Раскладка: сходимость папки сериала (второй сезон в ту же папку)

При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (provider, provider_id) вместо печати заново из выхода
LLM — так второй/последующий сезон ложится в ТУ ЖЕ папку, а не заводит
рядом почти одинаковую. Отдельная сущность «тайтл» не вводится.

- layout: Plan.FolderBase перекрывает базу в папке и именах файлов;
  TitleFolder разбирает dst_path в папку тайтла и базу (снятие тега).
- store: LiveTitleFolders — dst_path живых ссылок того же матча.
- worker: resolveFolderBase (живость якоря — по наличию папки на диске,
  os.Lstat, а не по статусу ссылки в БД) в linkPlan и в превью ревью
  (превью=применение); рассинхрон нескольких живых папок → review из
  linking (deferred→review в графе нет).

Схема БД не менялась. Change series-folder-convergence заархивирован,
требования влиты в openspec/specs/file-layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 13:50:49 +03:00
co-authored by Claude Opus 4.8
parent 3f1a928000
commit 5c3ef79496
20 changed files with 1031 additions and 56 deletions
+245 -10
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -557,6 +558,27 @@ func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID string, d
}
return nil
}
func (m *memStore) LiveTitleFolders(_ context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
if provider == "" || providerID == "" {
return nil, nil
}
match := map[string]bool{} // download_id с current recognition этого матча
for _, r := range m.recs {
if r.IsCurrent && r.Provider.String == provider && r.ProviderID.String == providerID {
match[r.DownloadID] = true
}
}
var out []string
for _, l := range m.links {
if l.DownloadID == excludeDownloadID || !match[l.DownloadID] {
continue
}
if isLaidOut(l.Status) {
out = append(out, l.DstPath)
}
}
return out, nil
}
func (m *memStore) LatestBatchID(_ context.Context, id string) (string, error) {
for i := len(m.links) - 1; i >= 0; i-- {
if m.links[i].DownloadID == id {
@@ -1016,6 +1038,219 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
}
}
// --- Правило сходимости папки (проблема второго сезона) ---
// matchCurrentRec помечает current recognition загрузки id подтверждённым
// матчем (для правила сходимости).
func matchCurrentRec(f applyFixture, id, provider, providerID string) {
for _, r := range f.st.recs {
if r.DownloadID == id && r.IsCurrent {
r.Provider = store.NullString(provider)
r.ProviderID = store.NullString(providerID)
}
}
}
// seedLiveAnchor заводит живой якорь тайтла: current recognition матча +
// разложенную ссылку на папку folder под series, и создаёт эту папку на диске.
func seedLiveAnchor(t *testing.T, f applyFixture, dlID, provider, providerID, folder string, onDisk bool) {
t.Helper()
dst := filepath.Join(f.series, folder, "Season 01", folderBaseTest(folder)+" S01E01.mkv")
if onDisk {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("anchor"), 0o644); err != nil {
t.Fatal(err)
}
}
f.st.recs = append(f.st.recs, &store.Recognition{
ID: "rec-" + dlID, DownloadID: dlID, IsCurrent: true,
Provider: store.NullString(provider), ProviderID: store.NullString(providerID),
})
f.st.links = append(f.st.links, store.FileLink{
DownloadID: dlID, ApplyBatchID: "anchor-" + dlID, SrcPath: "/anchor/e1.mkv",
DstPath: dst, Kind: "video", Status: "linked",
})
}
// folderBaseTest — «Фарго (2014)» из «Фарго (2014) [tvdbid-269613]» (для имён
// файлов якоря в тесте).
func folderBaseTest(folder string) string {
if i := strings.LastIndex(folder, " ["); i >= 0 {
return folder[:i]
}
return folder
}
func TestApply_FolderConvergence_SecondSeason(t *testing.T) {
// Текущая загрузка распознана как «Show (2006)», но матч tvdb=269613
// совпадает с живым якорем «Фарго (2014)» → наследуем его базу.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Второй сезон лёг в папку якоря, база — унаследованная и в имени файла.
want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("ожидали раскладку в папку якоря %q: %v", want, err)
}
// Своей папки «Show (2006)» быть не должно.
if _, err := os.Stat(filepath.Join(f.series, "Show (2006) [tvdbid-269613]")); err == nil {
t.Error("создана отдельная папка вместо сходимости")
}
}
func TestApply_FolderConvergence_NoAnchorPrintsFresh(t *testing.T) {
// Матч есть, но живого якоря нет → печатаем базу из распознавания.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("ожидали свежую папку из распознавания %q: %v", want, err)
}
}
func TestApply_FolderConvergence_ProviderMismatchNoInherit(t *testing.T) {
// Якорь заматчен другим провайдером (tmdb) → по (tvdb,269613) не находится,
// базу не наследуем.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tmdb", "60622", "Фарго (2014) [tmdbid-60622]", true)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("смена провайдера не должна склеивать; ожидали %q: %v", want, err)
}
}
func TestApply_FolderConvergence_RenamedAnchorPrintsFresh(t *testing.T) {
// Ссылка якоря живая в БД, но папки на диске нет (переименована) → не якорь.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", false) // onDisk=false
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("отсутствующая на диске папка не якорь; ожидали %q: %v", want, err)
}
}
func TestApply_FolderConvergence_DesyncGoesReview(t *testing.T) {
// Две разные живые папки одного матча → рассинхрон → review, без раскладки.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
err := f.w.Apply(context.Background(), "1")
if err == nil {
t.Fatal("ожидали ошибку рассинхрона")
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review", f.st.downloads["1"].State)
}
if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
}
// Раскладки не было.
if len(f.st.links) != 2 { // только два якоря
t.Errorf("file_links = %d, раскладка не должна была выполниться", len(f.st.links))
}
}
func TestApply_FolderConvergence_DesyncFromDeferredGoesReview(t *testing.T) {
// Регрессия: apply из deferred при рассинхроне. Уход в review идёт из linking
// (linking→review легален), а не прямым deferred→review (ребра в графе нет).
f := newApplyFixture(t, seriesResult().Plan)
f.st.downloads["1"].State = store.StateDeferred
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
if err := f.w.Apply(context.Background(), "1"); err == nil {
t.Fatal("ожидали ошибку рассинхрона")
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review", f.st.downloads["1"].State)
}
if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
}
}
func TestReviewData_PreviewMatchesConvergedApply(t *testing.T) {
// Превью активного источника наследует ту же базу, что даст применение.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if len(rd.Preview) == 0 {
t.Fatal("превью пусто")
}
// Превью ведёт в папку якоря (относительные пути, с тегом).
if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Фарго (2014) [tvdbid-269613]", "Season 02")) {
t.Errorf("превью = %q, ожидали папку якоря", rd.Preview[0].Dst)
}
}
func TestApply_FolderConvergence_NoMatchNoInherit(t *testing.T) {
// Матча нет (provider=none), но рядом есть живой якорь — наследования быть не
// должно (сходимость только при подтверждённом матче).
f := newApplyFixture(t, seriesResult().Plan) // current rec без provider
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Без матча тег пуст, база из распознавания.
want := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("без матча наследования нет; ожидали %q: %v", want, err)
}
}
func TestReviewData_PreviewDesyncPrintsFreshNoReview(t *testing.T) {
// Рассинхрон в превью НЕ переводит задачу в review (превью информационно):
// база печатается из распознавания, состояние остаётся review.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, показ превью не должен менять состояние", f.st.downloads["1"].State)
}
if len(rd.Preview) == 0 {
t.Fatal("превью пусто")
}
// База из распознавания (не унаследованная), тег текущего матча.
if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Show (2006) [tvdbid-269613]", "Season 02")) {
t.Errorf("превью при рассинхроне = %q, ожидали свежую базу из распознавания", rd.Preview[0].Dst)
}
}
func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
// Если файл прежней загрузки ВСЁ ЕЩЁ на месте — коллизия → review, владение
// не отбирается (supersede не срабатывает для не-разложенного пути).
@@ -1374,14 +1609,14 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
t.Error("кандидат не помечен выбранным")
}
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
plan, tag, err := w.effectivePlan(context.Background(), "1")
plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Fargo" || plan.Year != 2014 {
t.Errorf("plan = %q (%d)", plan.Title, plan.Year)
}
if tag != "tvdbid-269613" {
if tag := providerTag(prov, pid); tag != "tvdbid-269613" {
t.Errorf("tag = %q", tag)
}
}
@@ -1419,8 +1654,8 @@ func TestClearProvider(t *testing.T) {
t.Errorf("provider override = %q, want none", st.overrides["1"][ovrProvider])
}
// «Без базы» → пустой тег.
_, tag, _ := w.effectivePlan(context.Background(), "1")
if tag != "" {
_, prov, pid, _ := w.effectivePlan(context.Background(), "1")
if tag := providerTag(prov, pid); tag != "" {
t.Errorf("tag = %q, want empty", tag)
}
}
@@ -1441,14 +1676,14 @@ func TestClearProvider_ClearsInheritedTitle(t *testing.T) {
t.Fatalf("ClearProvider: %v", err)
}
// Название/год снова из плана распознавания, тег пуст.
plan, tag, err := w.effectivePlan(context.Background(), "1")
plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" || plan.Year != 2000 {
t.Errorf("plan = %q (%d), want plan-defaults", plan.Title, plan.Year)
}
if tag != "" {
if tag := providerTag(prov, pid); tag != "" {
t.Errorf("tag = %q, want empty", tag)
}
if ov := st.overrides["1"]; ov[ovrTitle] != "" || ov[ovrYear] != "" {
@@ -1472,14 +1707,14 @@ func TestAddManualSource_ChoosesAndClears(t *testing.T) {
if len(st.candidates) != 2 {
t.Fatalf("candidates = %d, want 2 (manual added)", len(st.candidates))
}
plan, tag, err := w.effectivePlan(context.Background(), "1")
plan, prov, pid, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" { // безтайтловый источник → название из плана
t.Errorf("plan title = %q, want plan-default", plan.Title)
}
if tag != "tmdbid-603" {
if tag := providerTag(prov, pid); tag != "tmdbid-603" {
t.Errorf("tag = %q", tag)
}
}
@@ -1627,7 +1862,7 @@ func TestToLayoutPlan(t *testing.T) {
{Src: "sample.mkv", Role: "sample"},
},
}
lp := toLayoutPlan(plan, "/d", "tmdbid-1")
lp := toLayoutPlan(plan, "/d", "tmdbid-1", "")
if len(lp.Files) != 1 {
t.Fatalf("want 1 linkable file, got %d", len(lp.Files))
}
@@ -1669,7 +1904,7 @@ func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) {
{Src: tc.src, Role: recognize.RoleMain, Season: &s, Episode: &e},
},
}
lp := toLayoutPlan(plan, savePath, "")
lp := toLayoutPlan(plan, savePath, "", "")
if len(lp.Files) != 1 {
t.Fatalf("want 1 file, got %d", len(lp.Files))
}