Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой папки-якоря того же (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:
@@ -20,6 +20,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
@@ -68,7 +69,13 @@ type Plan struct {
|
||||
Title string
|
||||
Year int
|
||||
ProviderTag string // напр. "tmdbid-693134"; пусто — без тега
|
||||
Files []PlanFile
|
||||
// FolderBase — унаследованная от живого якоря база имени ("Название (Год)").
|
||||
// Пусто → база печатается из Title/Year (первая загрузка тайтла). Непусто →
|
||||
// перекрывает Title/Year и идёт и в папку, и в имена файлов (правило
|
||||
// сходимости папки, см. file-layout spec). Provider-тег добавляется отдельно
|
||||
// из ProviderTag.
|
||||
FolderBase string
|
||||
Files []PlanFile
|
||||
}
|
||||
|
||||
// Link — посчитанная пара источник → цель.
|
||||
@@ -136,7 +143,7 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err := titleYear(p.Title, p.Year)
|
||||
base, err := planBase(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -209,6 +216,36 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
|
||||
}
|
||||
}
|
||||
|
||||
// TitleFolder разбирает целевой путь dst живой ссылки в папку тайтла и её базу
|
||||
// имени для правила сходимости (см. file-layout spec). Возвращает абсолютный
|
||||
// путь папки тайтла (первый сегмент под корнем библиотеки типа t), её базу
|
||||
// (снят хвостовой provider-тег) и ok. ok=false, если dst не под корнем нужной
|
||||
// библиотеки или база пуста — вызывающий трактует как «нет якоря». Существование
|
||||
// папки на диске здесь НЕ проверяется (это делает worker через os.Lstat).
|
||||
func (l *Layouter) TitleFolder(t MediaType, dst string) (dir, base string, ok bool) {
|
||||
root, err := l.root(t)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
dst = filepath.Clean(dst)
|
||||
if !underRoot(root, dst) {
|
||||
return "", "", false
|
||||
}
|
||||
rel, err := filepath.Rel(root, dst)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
first, _, _ := strings.Cut(rel, string(filepath.Separator))
|
||||
if first == "" || first == "." {
|
||||
return "", "", false
|
||||
}
|
||||
base = folderBase(first)
|
||||
if base == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return filepath.Join(root, first), base, true
|
||||
}
|
||||
|
||||
// LinkStatus — исход создания одной ссылки.
|
||||
type LinkStatus string
|
||||
|
||||
|
||||
@@ -98,6 +98,78 @@ func TestBuildLinks_Series(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_FolderBaseOverridesSeries(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// LLM дал «Fargo» 2017, но живой якорь — «Фарго (2014)». FolderBase должна
|
||||
// перекрыть Title/Year и в папке, И в имени файла.
|
||||
plan := Plan{
|
||||
Type: Series, Title: "Fargo", Year: 2017, ProviderTag: "tvdbid-269613",
|
||||
FolderBase: "Фарго (2014)",
|
||||
Files: []PlanFile{
|
||||
{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(2), Episode: intp(1)},
|
||||
},
|
||||
}
|
||||
links, err := f.l.BuildLinks(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinks: %v", err)
|
||||
}
|
||||
want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv")
|
||||
if links[0].Dst != want {
|
||||
t.Errorf("ep = %q, want %q", links[0].Dst, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_FolderBaseOverridesMovie(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
plan := Plan{
|
||||
Type: Movie, Title: "Dune", Year: 2021, ProviderTag: "tmdbid-693134",
|
||||
FolderBase: "Дюна Часть вторая (2024)",
|
||||
Files: []PlanFile{{Src: f.srcFile(t, "m/f.mkv", "1"), Role: RoleMain}},
|
||||
}
|
||||
links, err := f.l.BuildLinks(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinks: %v", err)
|
||||
}
|
||||
want := filepath.Join(f.movies, "Дюна Часть вторая (2024) [tmdbid-693134]", "Дюна Часть вторая (2024).mkv")
|
||||
if links[0].Dst != want {
|
||||
t.Errorf("main = %q, want %q", links[0].Dst, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleFolder(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
epDst := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 01", "Фарго (2014) S01E01.mkv")
|
||||
dir, base, ok := f.l.TitleFolder(Series, epDst)
|
||||
if !ok {
|
||||
t.Fatal("want ok for path under series root")
|
||||
}
|
||||
if wantDir := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]"); dir != wantDir {
|
||||
t.Errorf("dir = %q, want %q", dir, wantDir)
|
||||
}
|
||||
if base != "Фарго (2014)" {
|
||||
t.Errorf("base = %q, want %q", base, "Фарго (2014)")
|
||||
}
|
||||
|
||||
// Фильм: папка тайтла — первый сегмент, база без тега.
|
||||
mvDst := filepath.Join(f.movies, "Dune (2024) [tmdbid-1]", "Dune (2024).mkv")
|
||||
if dir, base, ok := f.l.TitleFolder(Movie, mvDst); !ok ||
|
||||
dir != filepath.Join(f.movies, "Dune (2024) [tmdbid-1]") || base != "Dune (2024)" {
|
||||
t.Errorf("movie: dir=%q base=%q ok=%v", dir, base, ok)
|
||||
}
|
||||
|
||||
// Путь не под корнем нужной библиотеки → not ok.
|
||||
if _, _, ok := f.l.TitleFolder(Series, mvDst); ok {
|
||||
t.Error("movie path must not resolve under series root")
|
||||
}
|
||||
if _, _, ok := f.l.TitleFolder(Movie, "/etc/passwd"); ok {
|
||||
t.Error("path outside library must be rejected")
|
||||
}
|
||||
// Сам корень (нет сегмента папки) → not ok.
|
||||
if _, _, ok := f.l.TitleFolder(Movie, f.movies); ok {
|
||||
t.Error("root itself has no title folder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_SeriesEpisodeWithoutNumber(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
plan := Plan{
|
||||
|
||||
@@ -40,6 +40,20 @@ func titleYear(title string, year int) (string, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// planBase выбирает базу имени плана: унаследованная от живого якоря
|
||||
// FolderBase (правило сходимости папки) перекрывает Title/Year. База в любом
|
||||
// случае санитизируется (FolderBase пришла с диска — прогон идемпотентен).
|
||||
func planBase(p Plan) (string, error) {
|
||||
if p.FolderBase != "" {
|
||||
b := sanitizeComponent(p.FolderBase)
|
||||
if b == "" {
|
||||
return "", fmt.Errorf("layout: empty folder base after sanitization (%q)", p.FolderBase)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return titleYear(p.Title, p.Year)
|
||||
}
|
||||
|
||||
// folderName добавляет provider-тег к базе: "Название (Год) [tmdbid-123]".
|
||||
func folderName(base, providerTag string) string {
|
||||
tag := sanitizeComponent(providerTag)
|
||||
@@ -49,6 +63,19 @@ func folderName(base, providerTag string) string {
|
||||
return fmt.Sprintf("%s [%s]", base, tag)
|
||||
}
|
||||
|
||||
// folderBase восстанавливает базу имени ("Название (Год)") из имени папки
|
||||
// тайтла, снимая хвостовой provider-тег " [...]" (любой, а не только текущий:
|
||||
// у переоценённого якоря тег мог остаться старым). Тег без пробела перед "["
|
||||
// или незакрытый — не трогаем. Результат санитизируется; пустой → "" (вызывающий
|
||||
// трактует как «нет якоря»).
|
||||
func folderBase(folder string) string {
|
||||
base := folder
|
||||
if i := strings.LastIndex(folder, " ["); i >= 0 && strings.HasSuffix(folder, "]") {
|
||||
base = folder[:i]
|
||||
}
|
||||
return sanitizeComponent(base)
|
||||
}
|
||||
|
||||
// seasonFolder — "Season 00" (спецвыпуски) / "Season 01" / ...
|
||||
func seasonFolder(season int) string {
|
||||
return fmt.Sprintf("Season %02d", season)
|
||||
|
||||
@@ -47,6 +47,25 @@ func TestFolderName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderBase(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"Фарго (2014) [tvdbid-269613]", "Фарго (2014)"}, // текущий тег
|
||||
{"Fargo (2017) [tmdbid-123]", "Fargo (2017)"}, // чужой/старый тег снимается так же
|
||||
{"No Tag (2020)", "No Tag (2020)"}, // без тега — как есть
|
||||
{"Bare Name", "Bare Name"}, // без года и тега
|
||||
{"Weird [not a tag", "Weird [not a tag"}, // незакрытый — не трогаем
|
||||
{"[tvdbid-1]", "[tvdbid-1]"}, // нет " [" с пробелом — не тег
|
||||
{"Movie (2020) [edition-Director's Cut]", "Movie (2020)"}, // снимается любой хвостовой [...]
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := folderBase(tt.in); got != tt.want {
|
||||
t.Errorf("folderBase(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodeStem(t *testing.T) {
|
||||
if got := episodeStem("Fargo (2015)", 2, 1, 0); got != "Fargo (2015) S02E01" {
|
||||
t.Errorf("got %q", got)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+101
-29
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -132,10 +133,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize
|
||||
planJSON = []byte("{}")
|
||||
}
|
||||
|
||||
provider, providerID, tag := "none", "", ""
|
||||
provider, providerID := "none", ""
|
||||
if res.Match != nil {
|
||||
provider, providerID = res.Match.Provider, res.Match.ProviderID
|
||||
tag = providerTag(res.Match.Provider, res.Match.ProviderID)
|
||||
}
|
||||
|
||||
rec := &store.Recognition{
|
||||
@@ -197,11 +197,13 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize
|
||||
// Claim перехода в linking должен закоммититься до хардлинков (MINOR-7):
|
||||
// при провале записи не линкуем — задача остаётся в recognizing, и
|
||||
// поллинг-цикл (recognizePending) повторит распознавание/авто-раскладку.
|
||||
// Правило сходимости папки (и уход в review при рассинхроне) — внутри
|
||||
// linkPlan, из состояния linking.
|
||||
if err := w.transitionErr(lctx, *d, store.StateLinking, "", ""); err != nil {
|
||||
logctx.From(lctx).Warn("auto-apply claim failed, left for recognizing", "error", err)
|
||||
return
|
||||
}
|
||||
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
|
||||
if err := w.linkPlan(lctx, d, plan, provider, providerID, savePath); err != nil {
|
||||
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
|
||||
}
|
||||
return
|
||||
@@ -239,7 +241,7 @@ func (w *Worker) Apply(ctx context.Context, id string) error {
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
|
||||
plan, tag, err := w.effectivePlan(ctx, id)
|
||||
plan, prov, pid, err := w.effectivePlan(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
@@ -262,21 +264,36 @@ func (w *Worker) Apply(ctx context.Context, id string) error {
|
||||
// Claim перехода в linking ОБЯЗАН закоммититься до создания хардлинков: при
|
||||
// провале записи не линкуем (иначе ссылки лягут при задаче в review, а
|
||||
// финальный linking→done граф отклонит — MINOR-7). Задача остаётся в
|
||||
// review/deferred, повтор безопасен.
|
||||
// review/deferred, повтор безопасен. Правило сходимости папки (и уход в
|
||||
// review при рассинхроне) — уже внутри linkPlan, из состояния linking.
|
||||
if err := w.transitionErr(ctx, *d, store.StateLinking, "", ""); err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
if err := w.linkPlan(ctx, d, plan, tag, translatePath(t.SavePath, w.cfg.PathMap)); err != nil {
|
||||
if err := w.linkPlan(ctx, d, plan, prov, pid, translatePath(t.SavePath, w.cfg.PathMap)); err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// linkPlan строит и создаёт хардлинки по плану, фиксирует батч ссылок и
|
||||
// двигает задачу: done при успехе, review при коллизии/невалидном плане,
|
||||
// failed при иной ошибке ФС. Идемпотентен (повтор доводит начатое). Под mu.
|
||||
func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, providerTag, savePath string) error {
|
||||
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag))
|
||||
// двигает задачу: done при успехе, review при коллизии/невалидном плане/
|
||||
// рассинхроне папок, failed при иной ошибке ФС. Идемпотентен (повтор доводит
|
||||
// начатое). Под mu; вызывается уже в состоянии linking (claim закоммичен) —
|
||||
// поэтому уход в review здесь легален (linking→review), как для коллизии.
|
||||
func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, provider, providerID, savePath string) error {
|
||||
// Правило сходимости папки: при подтверждённом матче база наследуется от
|
||||
// живого якоря; рассинхрон (несколько разных живых папок) → review.
|
||||
folderBase, desync, err := w.resolveFolderBase(ctx, d.ID, provider, providerID, layout.MediaType(plan.Type))
|
||||
if err != nil {
|
||||
w.transition(ctx, *d, store.StateReview, "resolve", err.Error())
|
||||
return fmt.Errorf("link plan: %w", err)
|
||||
}
|
||||
if desync {
|
||||
w.transition(ctx, *d, store.StateReview, reasonTitleFolderDesync, "несколько живых папок тайтла с одним матчем")
|
||||
return fmt.Errorf("рассинхрон папок тайтла: %w", ErrConflict)
|
||||
}
|
||||
|
||||
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase))
|
||||
if err != nil {
|
||||
w.transition(ctx, *d, store.StateReview, "build", err.Error())
|
||||
return fmt.Errorf("build links: %w", err)
|
||||
@@ -916,9 +933,15 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
rd.Plan = applyOverrides(rawPlan, overrides)
|
||||
// Превью активного источника строим по относительным путям с
|
||||
// provider-тегом; ошибку логируем на Debug — покажем без превью.
|
||||
// База наследуется тем же правилом сходимости, что и применение
|
||||
// (превью=применение); рассинхрон в превью не переводит в review.
|
||||
if w.layouter != nil {
|
||||
tag := providerTag(prov, pid)
|
||||
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag)); lerr == nil {
|
||||
base, _, berr := w.resolveFolderBase(ctx, id, prov, pid, layout.MediaType(rd.Plan.Type))
|
||||
if berr != nil {
|
||||
log.Debug("review data resolve folder base failed", "error", berr)
|
||||
}
|
||||
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag, base)); lerr == nil {
|
||||
rd.Preview = links
|
||||
} else {
|
||||
log.Debug("review data build preview failed", "error", lerr)
|
||||
@@ -926,7 +949,7 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
}
|
||||
// Единый список источников: нейронка + кандидаты, каждый с
|
||||
// эфемерным превью из сырого плана (без записи overrides).
|
||||
rd.Sources = w.buildSources(rawPlan, overrides, prov, pid, rd.Candidates)
|
||||
rd.Sources = w.buildSources(ctx, id, rawPlan, overrides, prov, pid, rd.Candidates)
|
||||
}
|
||||
}
|
||||
return rd, nil
|
||||
@@ -935,10 +958,10 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
|
||||
// buildSources собирает единый список источников: нейронка (первой) +
|
||||
// кандидаты (дедуп по provider:id). Активным помечается текущий эффективный
|
||||
// источник.
|
||||
func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption {
|
||||
func (w *Worker) buildSources(ctx context.Context, downloadID string, rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption {
|
||||
neutral := prov == "" || prov == "none"
|
||||
out := make([]SourceOption, 0, len(cands)+1)
|
||||
out = append(out, w.sourceOption(SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral))
|
||||
out = append(out, w.sourceOption(ctx, downloadID, SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral))
|
||||
seen := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
key := c.Provider + ":" + c.ProviderID
|
||||
@@ -955,7 +978,7 @@ func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]strin
|
||||
year = int(c.Year.Int64)
|
||||
}
|
||||
active := !neutral && c.Provider == prov && c.ProviderID == pid
|
||||
out = append(out, w.sourceOption(SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active))
|
||||
out = append(out, w.sourceOption(ctx, downloadID, SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -963,7 +986,7 @@ func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]strin
|
||||
// sourceOption строит один источник: накладывает его пины на неисточниковые
|
||||
// overrides, считает эффективный план и предпросмотр путей — эфемерно, без
|
||||
// записи. Гарантия preview == apply: тот же набор пинов запишет выбор.
|
||||
func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
|
||||
func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
|
||||
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year)))
|
||||
opt := SourceOption{
|
||||
Kind: kind,
|
||||
@@ -978,7 +1001,10 @@ func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[
|
||||
Plan: eff,
|
||||
}
|
||||
if w.layouter != nil {
|
||||
if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID))); err == nil {
|
||||
// Превью источника наследует базу тем же правилом сходимости, что и
|
||||
// применение (превью=применение); рассинхрон в превью не переводит в review.
|
||||
folderBase, _, _ := w.resolveFolderBase(ctx, downloadID, provider, providerID, layout.MediaType(eff.Type))
|
||||
if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID), folderBase)); err == nil {
|
||||
opt.Preview = links
|
||||
}
|
||||
}
|
||||
@@ -1002,25 +1028,68 @@ func mergeSourceOverrides(base, pins map[string]string) map[string]string {
|
||||
}
|
||||
|
||||
// effectivePlan загружает текущий план, применяет правки и возвращает
|
||||
// provider-тег для имени папки (под mu).
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
|
||||
// эффективные provider/provider_id (для тега папки и правила сходимости) (под mu).
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.Plan, provider, providerID string, err error) {
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return recognize.Plan{}, "", err
|
||||
return recognize.Plan{}, "", "", err
|
||||
}
|
||||
if rec == nil || !rec.Plan.Valid {
|
||||
return recognize.Plan{}, "", fmt.Errorf("no recognition plan")
|
||||
return recognize.Plan{}, "", "", fmt.Errorf("no recognition plan")
|
||||
}
|
||||
var plan recognize.Plan
|
||||
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
|
||||
return recognize.Plan{}, "", fmt.Errorf("parse plan: %w", err)
|
||||
return recognize.Plan{}, "", "", fmt.Errorf("parse plan: %w", err)
|
||||
}
|
||||
overrides, err := w.store.ListOverrides(ctx, id)
|
||||
if err != nil {
|
||||
return recognize.Plan{}, "", err
|
||||
return recognize.Plan{}, "", "", err
|
||||
}
|
||||
prov, pid := effectiveProvider(rec, overrides)
|
||||
return applyOverrides(plan, overrides), providerTag(prov, pid), nil
|
||||
return applyOverrides(plan, overrides), prov, pid, nil
|
||||
}
|
||||
|
||||
// reasonTitleFolderDesync — код причины ухода в review, когда у тайтла нашлось
|
||||
// несколько разных живых папок с одним матчем (правило сходимости папки).
|
||||
const reasonTitleFolderDesync = "title_folder_desync"
|
||||
|
||||
// resolveFolderBase применяет правило сходимости папки (см. file-layout spec):
|
||||
// при подтверждённом матче наследует базу имени от живой папки-якоря того же
|
||||
// (provider, providerID), кроме самой загрузки downloadID. Возвращает базу для
|
||||
// layout.Plan.FolderBase (пусто → печатать из распознавания), флаг рассинхрона
|
||||
// (≥2 разных живых папок) и ошибку. Чистая (только чтение БД+ФС), без побочных
|
||||
// эффектов — переиспользуется применением и превью. Живость якоря определяется
|
||||
// существованием папки на диске (os.Lstat), а не статусом ссылки в БД.
|
||||
func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, providerID string, mt layout.MediaType) (base string, desync bool, err error) {
|
||||
if provider == "" || provider == "none" || providerID == "" || w.layouter == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
paths, err := w.store.LiveTitleFolders(ctx, provider, providerID, downloadID)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("resolve folder base: %w", err)
|
||||
}
|
||||
bases := make(map[string]string, 2) // абсолютная папка тайтла → база имени
|
||||
for _, p := range paths {
|
||||
dir, b, ok := w.layouter.TitleFolder(mt, p)
|
||||
if !ok {
|
||||
continue // не под корнем / не разобрать — не якорь
|
||||
}
|
||||
if _, seen := bases[dir]; seen {
|
||||
continue
|
||||
}
|
||||
if _, serr := os.Lstat(dir); serr != nil {
|
||||
continue // папки на диске нет (переименована/удалена) — не якорь
|
||||
}
|
||||
bases[dir] = b
|
||||
}
|
||||
switch len(bases) {
|
||||
case 0:
|
||||
return "", false, nil
|
||||
case 1:
|
||||
for _, b := range bases {
|
||||
return b, false, nil
|
||||
}
|
||||
}
|
||||
return "", true, nil // рассинхрон: несколько разных живых папок
|
||||
}
|
||||
|
||||
// --- Хелперы преобразования ---
|
||||
@@ -1093,7 +1162,7 @@ func ProviderTag(provider, id string) string { return providerTag(provider, id)
|
||||
|
||||
// ToLayoutPlan — экспорт toLayoutPlan для диагностических команд.
|
||||
func ToLayoutPlan(p recognize.Plan, srcPrefix, providerTag string) layout.Plan {
|
||||
return toLayoutPlan(p, srcPrefix, providerTag)
|
||||
return toLayoutPlan(p, srcPrefix, providerTag, "")
|
||||
}
|
||||
|
||||
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
|
||||
@@ -1116,14 +1185,17 @@ func providerTag(provider, id string) string {
|
||||
|
||||
// toLayoutPlan переводит план распознавания в план раскладки. srcPrefix
|
||||
// (savePath) приклеивается к относительным путям файлов; пустой — оставляет
|
||||
// относительные (для превью). providerTag добавляется к имени папки. Роли
|
||||
// вне main/episode/subtitle отбрасываются.
|
||||
func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag string) layout.Plan {
|
||||
// относительные (для превью). providerTag добавляется к имени папки. folderBase
|
||||
// (непустой) — унаследованная от живого якоря база имени (правило сходимости):
|
||||
// перекрывает Title/Year в папке и в именах файлов. Роли вне
|
||||
// main/episode/subtitle отбрасываются.
|
||||
func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag, folderBase string) layout.Plan {
|
||||
lp := layout.Plan{
|
||||
Type: layout.MediaType(plan.Type),
|
||||
Title: plan.Title,
|
||||
Year: plan.Year,
|
||||
ProviderTag: providerTag,
|
||||
FolderBase: folderBase,
|
||||
}
|
||||
for _, f := range plan.Files {
|
||||
role, ok := mapRole(f.Role)
|
||||
|
||||
+245
-10
@@ -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))
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ type Store interface {
|
||||
ListOverrides(ctx context.Context, downloadID string) (map[string]string, error)
|
||||
CreateFileLinks(ctx context.Context, links []store.FileLink) error
|
||||
SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error
|
||||
// LiveTitleFolders — dst_path живых ссылок загрузок того же (provider,
|
||||
// provider_id), кроме excludeDownloadID (правило сходимости папки).
|
||||
LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error)
|
||||
LatestBatchID(ctx context.Context, downloadID string) (string, error)
|
||||
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
||||
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
|
||||
@@ -110,6 +113,9 @@ type Layouter interface {
|
||||
Apply(ctx context.Context, links []layout.Link) ([]layout.Result, error)
|
||||
Undo(ctx context.Context, links []layout.Link) (int, error)
|
||||
Remove(ctx context.Context, links []layout.Link) (int, error)
|
||||
// TitleFolder разбирает dst_path в папку тайтла и базу имени (правило
|
||||
// сходимости папки). ok=false, если путь не под корнем нужной библиотеки.
|
||||
TitleFolder(t layout.MediaType, dst string) (dir, base string, ok bool)
|
||||
}
|
||||
|
||||
// NotifyEvent — повод позвать пользователя.
|
||||
|
||||
@@ -235,6 +235,9 @@ func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]strin
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
|
||||
func (f *fakeStore) LiveTitleFolders(_ context.Context, _, _, _ string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user