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

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)