Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой папки-якоря того же (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:
+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)
|
||||
|
||||
Reference in New Issue
Block a user