layout: непомещающееся целевое имя уводит задачу в review вместо failed
- предел длины компонента (255 байт) проверяется в BuildLinks до первой операции с ФС: ни каталога, ни ссылки при отказе не создаётся - причина пустого предпросмотра считается на показе (ReviewData.PreviewError) и печатается в панели действий и в карточке Telegram: у задачи без записанной причины взять её больше неоткуда
This commit is contained in:
+30
-10
@@ -325,7 +325,15 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
|
||||
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase))
|
||||
if err != nil {
|
||||
w.transition(ctx, *d, store.StateReview, reasonBuild, err.Error())
|
||||
// Непомещающееся имя — свой код причины: этот отказ человек чинит
|
||||
// подсказкой названия, а прочие отказы построения (пустое название,
|
||||
// серия без номера) — нет, и слепить их в один код значило бы потерять
|
||||
// эту разницу.
|
||||
code := reasonBuild
|
||||
if errors.Is(err, layout.ErrNameTooLong) {
|
||||
code = reasonNameTooLong
|
||||
}
|
||||
w.transition(ctx, *d, store.StateReview, code, err.Error())
|
||||
return fmt.Errorf("build links: %w", err)
|
||||
}
|
||||
|
||||
@@ -953,14 +961,21 @@ func candMediaType(rec *store.Recognition) recognize.MediaType {
|
||||
type ReviewData struct {
|
||||
Download store.Download
|
||||
Recognition *store.Recognition
|
||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||
Preview []layout.Link // целевые пути активного источника (Src — относительный)
|
||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||
Provider string // эффективный провайдер (с учётом выбора)
|
||||
ProviderID string // эффективный id в базе
|
||||
Hints []string
|
||||
Overrides map[string]string
|
||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||
Preview []layout.Link // целевые пути активного источника (Src — относительный)
|
||||
// PreviewError — почему предпросмотр не построился, человекочитаемо (пусто —
|
||||
// построился либо строить было нечего). Считается НА ПОКАЗЕ, поэтому всегда
|
||||
// относится к текущему эффективному плану — в отличие от error_msg загрузки,
|
||||
// который остался от последнего перехода и после смены источника устаревает.
|
||||
// Задача в review без записанной причины (обычный «нет матча») иначе оставила
|
||||
// бы экран без объяснения, почему пропала кнопка «Применить».
|
||||
PreviewError string
|
||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
|
||||
Provider string // эффективный провайдер (с учётом выбора)
|
||||
ProviderID string // эффективный id в базе
|
||||
Hints []string
|
||||
Overrides map[string]string
|
||||
}
|
||||
|
||||
// MatchURL — ссылка на подтверждённую запись метабазы для этой загрузки. Общий
|
||||
@@ -1082,8 +1097,12 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
rd.Preview = links
|
||||
} else {
|
||||
// Видимая деградация: без превью на экране ревью пропадает
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN.
|
||||
// кнопка «Применить» — не рядовой Debug, а WARN. Причину
|
||||
// отдаём транспорту: молча пропавшая кнопка неотличима от
|
||||
// поломки, а у задачи без записанной причины взять её больше
|
||||
// неоткуда.
|
||||
log.Warn("review data build preview failed", "error", lerr)
|
||||
rd.PreviewError = lerr.Error()
|
||||
}
|
||||
}
|
||||
// Единый список источников: нейронка + кандидаты, каждый с
|
||||
@@ -1267,6 +1286,7 @@ const (
|
||||
reasonBuild = "build" // не удалось построить план ссылок
|
||||
reasonPersist = "persist" // ссылки на диске, но учёт не записан
|
||||
reasonCollision = "collision" // целевой путь уже занят (layout.ErrCollision)
|
||||
reasonNameTooLong = "name_too_long" // компонент целевого пути длиннее предела (layout.ErrNameTooLong)
|
||||
reasonTitleFolderDesync = "title_folder_desync" // ≥2 разных живых папок тайтла с одним матчем
|
||||
)
|
||||
|
||||
|
||||
@@ -2406,3 +2406,142 @@ func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
|
||||
t.Errorf("plan.Title = %q, want название распознавания", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// Раскладка с непомещающимся именем уводит задачу в review со своим кодом
|
||||
// причины, а не в failed с текстом ядра. Каталог библиотеки при этом пуст:
|
||||
// проверка стоит до первой операции с ФС (критерии приёмки A1, A2, A4).
|
||||
func TestApply_NameTooLongGoesReview(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
plan.Title = strings.Repeat("ы", 200) // 400 байт — не помещается ни в имя, ни в папку
|
||||
f := newApplyFixture(t, plan)
|
||||
|
||||
err := f.w.Apply(context.Background(), "1")
|
||||
if !errors.Is(err, layout.ErrNameTooLong) {
|
||||
t.Fatalf("Apply err = %v, want ErrNameTooLong", err)
|
||||
}
|
||||
d := f.st.downloads["1"]
|
||||
if d.State != store.StateReview {
|
||||
t.Errorf("state = %q, want review (а не failed)", d.State)
|
||||
}
|
||||
if d.ErrorCode.String != reasonNameTooLong {
|
||||
t.Errorf("error_code = %q, want %q", d.ErrorCode.String, reasonNameTooLong)
|
||||
}
|
||||
// A3: причина — наш доменный текст, а не текст ядра.
|
||||
if msg := d.ErrorMsg.String; strings.Contains(msg, "file name too long") ||
|
||||
strings.Contains(msg, "ENAMETOOLONG") {
|
||||
t.Errorf("error_msg несёт текст системной ошибки: %q", msg)
|
||||
}
|
||||
if !strings.Contains(d.ErrorMsg.String, "не помещается") {
|
||||
t.Errorf("error_msg не называет причину по-человечески: %q", d.ErrorMsg.String)
|
||||
}
|
||||
// A2/A4: под библиотекой не появилось ничего.
|
||||
entries, rerr := os.ReadDir(f.series)
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("под series появилось %d записей, ожидалось 0", len(entries))
|
||||
}
|
||||
if len(f.st.links) != 0 {
|
||||
t.Errorf("file_links = %d, want 0 (частичной раскладки не остаётся)", len(f.st.links))
|
||||
}
|
||||
}
|
||||
|
||||
// Прочие отказы построения плана свой код сохраняют: непомещающееся имя не
|
||||
// поглотило соседний случай.
|
||||
func TestApply_EmptyTitleKeepsBuildReason(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
plan.Title = "..." // санитизация схлопывает в пустое
|
||||
f := newApplyFixture(t, plan)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err == nil {
|
||||
t.Fatal("want build error")
|
||||
}
|
||||
if got := f.st.downloads["1"].ErrorCode.String; got != reasonBuild {
|
||||
t.Errorf("error_code = %q, want %q", got, reasonBuild)
|
||||
}
|
||||
}
|
||||
|
||||
// Экран ревью обязан назвать причину, даже когда в состоянии её нет. Самый
|
||||
// частый вход в review — распознавание без подтверждённого матча — приходит
|
||||
// туда с пустым error_msg и до раскладки не доходит, поэтому единственный
|
||||
// источник причины здесь — построение предпросмотра.
|
||||
func TestReviewData_PreviewErrorWhenStateHasNoReason(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
plan.Title = strings.Repeat("ы", 200)
|
||||
f := newApplyFixture(t, plan)
|
||||
// Состояние без записанной причины: так задачу оставляет finishRecognition.
|
||||
d := f.st.downloads["1"]
|
||||
d.ErrorCode = store.NullString("")
|
||||
d.ErrorMsg = store.NullString("")
|
||||
f.st.put(d)
|
||||
|
||||
rd, err := f.w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
if len(rd.Preview) != 0 {
|
||||
t.Fatalf("предпросмотр = %d ссылок, ожидался пустой", len(rd.Preview))
|
||||
}
|
||||
if rd.PreviewError == "" {
|
||||
t.Fatal("предпросмотр пуст, а причины нет — экран промолчит о том, почему пропала кнопка")
|
||||
}
|
||||
if !strings.Contains(rd.PreviewError, "не помещается") {
|
||||
t.Errorf("PreviewError = %q, ожидалась причина по длине имени", rd.PreviewError)
|
||||
}
|
||||
}
|
||||
|
||||
// Сценарий спеки «Подсказка человека чинит случай, когда база печатается из
|
||||
// распознавания»: короткое название доводит задачу до done.
|
||||
func TestApply_ShorterTitleFixesNameTooLong(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
plan.Title = strings.Repeat("ы", 200)
|
||||
f := newApplyFixture(t, plan)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); !errors.Is(err, layout.ErrNameTooLong) {
|
||||
t.Fatalf("подготовка: ожидался отказ по длине, got %v", err)
|
||||
}
|
||||
// Человек задаёт название короче (жёсткая правка поля — override).
|
||||
if err := f.st.SetOverride(context.Background(), "1", ovrTitle, "Шоу"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply после укорачивания: %v", err)
|
||||
}
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Errorf("state = %q, want done", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Унаследованная база отказа по длине дать не может, и это свойство арифметики,
|
||||
// а не совпадение: папка-якоря лежит на диске, значит её имя <= предела, а хвост
|
||||
// имени файла (" S02E01" + расширение, 11 байт) не длиннее хвоста папки
|
||||
// (" [" + provider-тег + "]", минимум 11 байт). Тест держит это свойство: если
|
||||
// суффиксы имён вырастут (язык и флаги субтитров, двойные серии), он покраснеет
|
||||
// раньше, чем задача упрётся в тупик, из которого подсказка не выводит.
|
||||
func TestApply_InheritedBaseAtLimitStillFits(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
f := newApplyFixture(t, plan)
|
||||
prov, pid := "tvdb", "269613"
|
||||
tag := prov + "id-" + pid
|
||||
|
||||
// Папка якоря ровно в предел: длиннее её на диске быть не может.
|
||||
base := strings.Repeat("a", 255-len(" [")-len(tag)-len("]"))
|
||||
anchorDir := filepath.Join(f.series, base+" ["+tag+"]")
|
||||
if err := os.MkdirAll(filepath.Join(anchorDir, "Season 01"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.st.links = append(f.st.links, store.FileLink{
|
||||
DownloadID: "other", DstPath: filepath.Join(anchorDir, "Season 01", base+" S01E01.mkv"),
|
||||
Status: string(layout.StatusLinked),
|
||||
})
|
||||
_ = f.st.SetOverride(context.Background(), "1", ovrProvider, prov)
|
||||
_ = f.st.SetOverride(context.Background(), "1", ovrProviderID, pid)
|
||||
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("унаследованная база в предел должна раскладываться: %v", err)
|
||||
}
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Errorf("state = %q, want done", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,7 +961,8 @@ func (w *Worker) logCmd(ctx context.Context, cmd, id string, err error) {
|
||||
log := logctx.FromOr(ctx, w.log)
|
||||
switch {
|
||||
case errors.Is(err, ErrConflict), errors.Is(err, ErrNotReady), errors.Is(err, ErrInvalidInput),
|
||||
errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision):
|
||||
errors.Is(err, store.ErrNotFound), errors.Is(err, layout.ErrCollision),
|
||||
errors.Is(err, layout.ErrNameTooLong):
|
||||
log.Debug("command rejected", "command", cmd, "download_id", id, "error", err)
|
||||
default:
|
||||
log.Error("command failed", "command", cmd, "download_id", id, "error", err)
|
||||
|
||||
Reference in New Issue
Block a user