Добавил поиск метаданных по каталогам
This commit is contained in:
+87
-25
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user