Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой папки-якоря того же (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:
@@ -293,6 +293,32 @@ func (s *Store) LayoutSizeByDownload(ctx context.Context, ids []string) (map[str
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LiveTitleFolders возвращает целевые пути (dst_path) живых ссылок
|
||||
// (`status IN linked/copied/exists`) загрузок, чьё current recognition имеет тот
|
||||
// же (provider, provider_id), КРОМЕ excludeDownloadID. Питает правило сходимости
|
||||
// папки (см. file-layout): вызывающий сводит пути к папкам тайтла, проверяет их
|
||||
// существование на диске и наследует базу имени. Пустой provider/id → пусто (без
|
||||
// подтверждённого матча якоря нет). Проверка живости папки на диске —
|
||||
// ответственность вызывающего (статус ссылки не гарантирует наличие папки).
|
||||
func (s *Store) LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
|
||||
if provider == "" || providerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
const q = `
|
||||
SELECT fl.dst_path
|
||||
FROM file_link fl
|
||||
JOIN recognition r ON r.download_id = fl.download_id AND r.is_current = 1
|
||||
WHERE fl.status IN ('linked', 'copied', 'exists')
|
||||
AND fl.download_id != ?
|
||||
AND r.provider = ?
|
||||
AND r.provider_id = ?`
|
||||
var out []string
|
||||
if err := s.DB.SelectContext(ctx, &out, q, excludeDownloadID, provider, providerID); err != nil {
|
||||
return nil, fmt.Errorf("live title folders: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
|
||||
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
|
||||
if _, err := s.DB.ExecContext(ctx,
|
||||
|
||||
@@ -289,6 +289,66 @@ func TestCandidates_Lifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveTitleFolders(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// anchor и other сматчены на один (tvdb, 269613); alien — на другой матч.
|
||||
anchor := mustCreate(t, st, "1100ccddeeff00112233445566778899aabbccdd")
|
||||
other := mustCreate(t, st, "2200ccddeeff00112233445566778899aabbccdd")
|
||||
alien := mustCreate(t, st, "3300ccddeeff00112233445566778899aabbccdd")
|
||||
|
||||
seedMatch := func(dl, provider, id string) {
|
||||
if _, err := st.CreateRecognition(ctx, &Recognition{
|
||||
DownloadID: dl, Provider: NullString(provider), ProviderID: NullString(id),
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("seed recognition: %v", err)
|
||||
}
|
||||
}
|
||||
seedMatch(anchor, "tvdb", "269613")
|
||||
seedMatch(other, "tvdb", "269613")
|
||||
seedMatch(alien, "tvdb", "999")
|
||||
|
||||
if err := st.CreateFileLinks(ctx, []FileLink{
|
||||
{DownloadID: anchor, ApplyBatchID: "a", SrcPath: "/d/a.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 01/e.mkv", Kind: "video", Status: "linked"},
|
||||
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/o.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv", Kind: "video", Status: "exists"},
|
||||
// superseded/collision у other — не живые, не должны попасть.
|
||||
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/x.mkv", DstPath: "/s/Old/e.mkv", Kind: "video", Status: "superseded"},
|
||||
{DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/y.mkv", DstPath: "/s/Coll/e.mkv", Kind: "video", Status: "collision"},
|
||||
// alien — другой матч, мимо.
|
||||
{DownloadID: alien, ApplyBatchID: "z", SrcPath: "/d/z.mkv", DstPath: "/s/Other/e.mkv", Kind: "video", Status: "linked"},
|
||||
}); err != nil {
|
||||
t.Fatalf("create links: %v", err)
|
||||
}
|
||||
|
||||
// Из позиции anchor: видим только живую ссылку other (себя исключаем).
|
||||
got, err := st.LiveTitleFolders(ctx, "tvdb", "269613", anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("live title folders: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv" {
|
||||
t.Errorf("got %v, want только живую ссылку other", got)
|
||||
}
|
||||
|
||||
// Без исключения — обе живые ссылки матча (anchor + other), но не superseded/
|
||||
// collision/alien.
|
||||
got, err = st.LiveTitleFolders(ctx, "tvdb", "269613", "")
|
||||
if err != nil {
|
||||
t.Fatalf("live title folders: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("got %v, want 2 живые ссылки", got)
|
||||
}
|
||||
|
||||
// Пустой provider/id → пусто (нет подтверждённого матча).
|
||||
if got, _ := st.LiveTitleFolders(ctx, "", "269613", ""); got != nil {
|
||||
t.Errorf("пустой provider → пусто, got %v", got)
|
||||
}
|
||||
if got, _ := st.LiveTitleFolders(ctx, "tvdb", "", ""); got != nil {
|
||||
t.Errorf("пустой id → пусто, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCandidate_None(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
c, err := st.GetCandidate(context.Background(), "01hzzzzzzzzzzzzzzzzzzzzzzz")
|
||||
|
||||
Reference in New Issue
Block a user