Добавил поиск метаданных по каталогам

This commit is contained in:
2026-06-14 15:21:01 +03:00
parent 9c1b178e46
commit 5087f35861
21 changed files with 1435 additions and 72 deletions
+87 -25
View File
@@ -112,18 +112,25 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, _ string) {
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
planJSON, err := json.Marshal(res.Plan)
if err != nil {
w.log.Error("recognize: marshal plan", "download_id", id, "err", err)
planJSON = []byte("{}")
}
provider, providerID, tag := "none", "", ""
if res.Match != nil {
provider, providerID = res.Match.Provider, res.Match.ProviderID
tag = providerTag(res.Match.Provider, res.Match.ProviderID)
}
rec := &store.Recognition{
DownloadID: id,
MediaType: store.NullString(string(res.Plan.Type)),
Title: store.NullString(res.Plan.Title),
Provider: store.NullString("none"),
Provider: store.NullString(provider),
ProviderID: store.NullString(providerID),
Plan: store.NullString(string(planJSON)),
RawLLM: store.NullString(res.Raw),
}
@@ -155,9 +162,31 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
w.log.Error("recognize: persist", "download_id", id, "err", err)
return
}
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
// иначе — review. Раскладчик может быть не сконфигурирован.
if res.Decision.Auto && w.layouter != nil {
plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id))
w.transition(ctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil {
w.log.Warn("recognize: auto-apply failed, left for review",
"download_id", id, "err", err)
}
return
}
w.transition(ctx, *d, store.StateReview, "", "")
}
// overridesOrNil читает правки, проглатывая ошибку (для авто-пути).
func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
o, err := w.store.ListOverrides(ctx, id)
if err != nil {
w.log.Warn("recognize: list overrides", "download_id", id, "err", err)
return nil
}
return o
}
// --- Команды ревью ---
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
@@ -177,7 +206,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: задача %d в состоянии %s (ожидалось review/deferred)", id, d.State)
}
plan, err := w.effectivePlan(ctx, id)
plan, tag, err := w.effectivePlan(ctx, id)
if err != nil {
return fmt.Errorf("apply: %w", err)
}
@@ -186,9 +215,21 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: торрент не найден: %v", err)
}
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, t.SavePath))
w.transition(ctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(ctx, d, plan, tag, t.SavePath); 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))
if err != nil {
return fmt.Errorf("apply: построение ссылок: %w", err)
w.transition(ctx, *d, store.StateReview, "build", err.Error())
return fmt.Errorf("построение ссылок: %w", err)
}
batch := w.newID()
@@ -198,7 +239,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
fl := make([]store.FileLink, 0, len(results))
for _, r := range results {
fl = append(fl, store.FileLink{
DownloadID: id,
DownloadID: d.ID,
ApplyBatchID: batch,
SrcPath: r.Link.Src,
DstPath: r.Link.Dst,
@@ -208,21 +249,21 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
}
if len(fl) > 0 {
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
return fmt.Errorf("apply: запись ссылок: %w", err)
return fmt.Errorf("запись ссылок: %w", err)
}
}
if applyErr != nil {
if errors.Is(applyErr, layout.ErrCollision) {
w.transition(ctx, *d, store.StateReview, "collision", applyErr.Error())
return fmt.Errorf("apply: %w", applyErr)
return applyErr
}
w.transition(ctx, *d, store.StateFailed, "apply", applyErr.Error())
return fmt.Errorf("apply: %w", applyErr)
return applyErr
}
w.transition(ctx, *d, store.StateDone, "", "")
w.log.Info("apply: linked", "download_id", id, "batch", batch, "links", len(fl))
w.log.Info("apply: linked", "download_id", d.ID, "batch", batch, "links", len(fl))
return nil
}
@@ -409,10 +450,11 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil {
plan = applyOverrides(plan, overrides)
rd.Plan = plan
// Превью строим по относительным путям; ошибку игнорируем —
// просто покажем причины без превью.
// Превью строим по относительным путям с provider-тегом; ошибку
// игнорируем — просто покажем причины без превью.
if w.layouter != nil {
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "")); lerr == nil {
tag := providerTag(rec.Provider.String, rec.ProviderID.String)
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
rd.Preview = links
}
}
@@ -421,24 +463,26 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
return rd, nil
}
// effectivePlan загружает текущий план и применяет правки (под mu).
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, error) {
// effectivePlan загружает текущий план, применяет правки и возвращает
// provider-тег для имени папки (под mu).
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, 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("нет плана распознавания")
return recognize.Plan{}, "", fmt.Errorf("нет плана распознавания")
}
var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
return recognize.Plan{}, fmt.Errorf("разбор плана: %w", err)
return recognize.Plan{}, "", fmt.Errorf("разбор плана: %w", err)
}
overrides, err := w.store.ListOverrides(ctx, id)
if err != nil {
return recognize.Plan{}, err
return recognize.Plan{}, "", err
}
return applyOverrides(plan, overrides), nil
tag := providerTag(rec.Provider.String, rec.ProviderID.String)
return applyOverrides(plan, overrides), tag, nil
}
// --- Хелперы преобразования ---
@@ -460,14 +504,32 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
return plan
}
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
// / "tvdbid-…". Пустой id (нет матча) → пустой тег.
func providerTag(provider, id string) string {
if id == "" {
return ""
}
switch provider {
case "tmdb":
return "tmdbid-" + id
case "tvdb":
return "tvdbid-" + id
default:
return ""
}
}
// toLayoutPlan переводит план распознавания в план раскладки. srcPrefix
// (savePath) приклеивается к относительным путям файлов; пустой — оставляет
// относительные (для превью). Роли вне main/episode/subtitle отбрасываются.
func toLayoutPlan(plan recognize.Plan, srcPrefix string) layout.Plan {
// относительные (для превью). providerTag добавляется к имени папки. Роли
// вне main/episode/subtitle отбрасываются.
func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag string) layout.Plan {
lp := layout.Plan{
Type: layout.MediaType(plan.Type),
Title: plan.Title,
Year: plan.Year,
Type: layout.MediaType(plan.Type),
Title: plan.Title,
Year: plan.Year,
ProviderTag: providerTag,
}
for _, f := range plan.Files {
role, ok := mapRole(f.Role)
+78 -1
View File
@@ -528,6 +528,80 @@ func TestApplyOverrides(t *testing.T) {
}
}
func TestRecognizeOne_AutoApplies(t *testing.T) {
root := t.TempDir()
downloads := filepath.Join(root, "downloads")
movies := filepath.Join(root, "movies")
series := filepath.Join(root, "series")
for _, d := range []string{downloads, movies, series} {
_ = os.MkdirAll(d, 0o755)
}
plan := seriesResult().Plan
plan.Confidence = 0.95
for _, f := range plan.Files {
p := filepath.Join(downloads, f.Src)
_ = os.MkdirAll(filepath.Dir(p), 0o755)
_ = os.WriteFile(p, []byte("x"), 0o644)
}
lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series})
st := newMemStore()
st.put(completedDownload(1))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: downloads, Category: "jellybit"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}, {Name: "Show/e2.mkv", Size: 1}},
}
rec := &fakeRecognizer{result: recognize.Result{
Plan: plan,
Decision: recognize.Decision{Auto: true},
Match: &recognize.Match{Provider: "tmdb", ProviderID: "42", Title: "Show", Year: 2006},
}}
w := testWorkerWith(st, qb, rec, lay)
w.recognizeOne(context.Background(), 1)
if st.downloads[1].State != store.StateDone {
t.Fatalf("state = %q, want done (auto)", st.downloads[1].State)
}
// Provider-тег попал в имя папки.
want := filepath.Join(series, "Show (2006) [tmdbid-42]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("expected auto-linked file %q: %v", want, err)
}
if len(st.links) != 2 {
t.Errorf("file_links = %d, want 2", len(st.links))
}
}
func TestApply_UsesProviderTag(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
f.st.recs[0].Provider = store.NullString("tmdb")
f.st.recs[0].ProviderID = store.NullString("603")
if err := f.w.Apply(context.Background(), 1); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tmdbid-603]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("expected tagged path %q: %v", want, err)
}
}
func TestProviderTag(t *testing.T) {
cases := []struct{ provider, id, want string }{
{"tmdb", "603", "tmdbid-603"},
{"tvdb", "123", "tvdbid-123"},
{"none", "", ""},
{"tmdb", "", ""},
{"weird", "1", ""},
}
for _, c := range cases {
if got := providerTag(c.provider, c.id); got != c.want {
t.Errorf("providerTag(%q,%q) = %q, want %q", c.provider, c.id, got, c.want)
}
}
}
func TestToLayoutPlan(t *testing.T) {
s, e := 1, 3
plan := recognize.Plan{
@@ -537,7 +611,7 @@ func TestToLayoutPlan(t *testing.T) {
{Src: "sample.mkv", Role: "sample"},
},
}
lp := toLayoutPlan(plan, "/d")
lp := toLayoutPlan(plan, "/d", "tmdbid-1")
if len(lp.Files) != 1 {
t.Fatalf("want 1 linkable file, got %d", len(lp.Files))
}
@@ -547,4 +621,7 @@ func TestToLayoutPlan(t *testing.T) {
if lp.Files[0].Role != layout.RoleEpisode {
t.Errorf("role = %q", lp.Files[0].Role)
}
if lp.ProviderTag != "tmdbid-1" {
t.Errorf("provider tag = %q", lp.ProviderTag)
}
}