package worker import ( "context" "database/sql" "encoding/json" "errors" "fmt" "path/filepath" "strconv" "strings" "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/metadata" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" ) // Поля override. const ( ovrMediaType = "media_type" ovrIgnoredFiles = "ignored_files" ovrProvider = "provider" // выбранная база ("none" = без базы) ovrProviderID = "provider_id" // id в выбранной базе ovrTitle = "title" // запиненное каноническое название ovrYear = "year" // запиненный год ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать ) // recognizePending распознаёт завершённые загрузки и перезапускает те, что // помечены к перераспознаванию (recognizing — например, после подсказки или // после рестарта сервиса). Выполняется последовательно в поллинг-горутине; // сам вызов LLM идёт вне блокировки, поэтому команды ревью не простаивают. func (w *Worker) recognizePending(ctx context.Context) { w.mu.Lock() pending, err := w.store.ListDownloadsByState(ctx, store.StateCompleted, store.StateRecognizing) w.mu.Unlock() if err != nil { w.log.Warn("recognize: list pending failed", "err", err) return } for _, d := range pending { w.recognizeOne(ctx, d.ID) } } // recognizeOne проводит одну загрузку через распознавание. Claim-паттерн: // под блокировкой переводим в recognizing, LLM зовём без блокировки, затем // под блокировкой фиксируем результат — но только если задачу за это время // не увели в другое состояние (cancel/defer). func (w *Worker) recognizeOne(ctx context.Context, id int64) { w.mu.Lock() d, err := w.store.GetDownload(ctx, id) if err != nil { w.mu.Unlock() w.log.Warn("recognize: get download", "download_id", id, "err", err) return } if d.State != store.StateCompleted && d.State != store.StateRecognizing { w.mu.Unlock() return } if d.State == store.StateCompleted { w.transition(ctx, *d, store.StateRecognizing, "", "") } w.mu.Unlock() result, savePath, err := w.runRecognize(ctx, *d) if err != nil { // Не смогли получить сигналы или вызвать LLM — уходим в review с // причиной, человек перезапустит подсказкой. result = recognize.Result{Decision: recognize.Decision{ Reasons: []string{"распознавание не удалось: " + err.Error()}, }} } w.finishRecognition(ctx, id, result, savePath) } // runRecognize собирает сигналы из qBittorrent и накопленные подсказки, // затем зовёт распознаватель. Возвращает также savePath для маппинга // относительных путей файлов в абсолютные при раскладке. func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) { if !d.Infohash.Valid { return recognize.Result{}, "", fmt.Errorf("no infohash") } t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String) if err != nil { return recognize.Result{}, "", err } if !ok { return recognize.Result{}, "", fmt.Errorf("torrent not found in qBittorrent") } files, err := w.qbt.Files(ctx, t.Hash) if err != nil { return recognize.Result{}, "", err } hints, err := w.store.ListHints(ctx, d.ID) if err != nil { return recognize.Result{}, "", err } in := recognize.Input{ Name: t.Name, Context: d.Context, Hints: hints, Files: make([]recognize.File, len(files)), } for i, f := range files { in.Files[i] = recognize.File{Path: f.Name, Size: f.Size} } savePath := translatePath(t.SavePath, w.cfg.PathMap) res, err := w.recognizer.Recognize(ctx, in) if err != nil { return recognize.Result{}, savePath, err } return res, savePath, nil } // finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3 // метабазы выключены → авто-раскладки не делаем, всегда уходим в review. 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(provider), ProviderID: store.NullString(providerID), Plan: store.NullString(string(planJSON)), RawLLM: store.NullString(res.Raw), } if res.Plan.OriginalTitle != "" { rec.OriginalTitle = store.NullString(res.Plan.OriginalTitle) } if res.Plan.Year != 0 { rec.Year = sql.NullInt64{Int64: int64(res.Plan.Year), Valid: true} } if res.Plan.Confidence != 0 { rec.Confidence = sql.NullFloat64{Float64: res.Plan.Confidence, Valid: true} } w.mu.Lock() defer w.mu.Unlock() d, err := w.store.GetDownload(ctx, id) if err != nil { w.log.Warn("recognize: reload download", "download_id", id, "err", err) return } if d.State != store.StateRecognizing { // За время вызова LLM задачу увели (cancel/defer) — результат не нужен. w.log.Info("recognize: result discarded, state changed", "download_id", id, "state", d.State) return } recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons) if err != nil { w.log.Error("recognize: persist", "download_id", id, "err", err) return } // Кандидаты базы — для ручного выбора в review. if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 { if err := w.store.CreateCandidates(ctx, cands); err != nil { w.log.Warn("recognize: persist candidates", "download_id", id, "err", err) } } // Авто-раскладка при подтверждённом матче и чистой валидации (Ф4); // иначе — review. Раскладчик может быть не сконфигурирован. При ручной // перепривязке (force_review) авто-раскладку не делаем — нужно явное // подтверждение человеком. overrides := w.overridesOrNil(ctx, id) forceReview := overrides[ovrForceReview] == "1" if res.Decision.Auto && !forceReview && w.layouter != nil { plan := applyOverrides(res.Plan, overrides) 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 создаёт хардлинки по текущему плану (с применёнными правками) и // переводит задачу в done. Коллизия цели → остаёмся в review с причиной. func (w *Worker) Apply(ctx context.Context, id int64) error { w.mu.Lock() defer w.mu.Unlock() if w.layouter == nil { return fmt.Errorf("apply: layouter not configured") } d, err := w.store.GetDownload(ctx, id) if err != nil { return fmt.Errorf("apply: %w", err) } if d.State != store.StateReview && d.State != store.StateDeferred { return fmt.Errorf("apply: download %d is in state %s (expected review/deferred)", id, d.State) } plan, tag, err := w.effectivePlan(ctx, id) if err != nil { return fmt.Errorf("apply: %w", err) } t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String) if err != nil || !ok { return fmt.Errorf("apply: torrent not found: %v", err) } w.transition(ctx, *d, store.StateLinking, "", "") if err := w.linkPlan(ctx, d, plan, tag, 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)) if err != nil { w.transition(ctx, *d, store.StateReview, "build", err.Error()) return fmt.Errorf("build links: %w", err) } batch := w.newID() results, applyErr := w.layouter.Apply(ctx, links) // Фиксируем то, что успели слинковать (идемпотентность повторного apply). fl := make([]store.FileLink, 0, len(results)) for _, r := range results { fl = append(fl, store.FileLink{ DownloadID: d.ID, ApplyBatchID: batch, SrcPath: r.Link.Src, DstPath: r.Link.Dst, Kind: string(r.Link.Kind), Status: string(r.Status), }) } if len(fl) > 0 { if err := w.store.CreateFileLinks(ctx, fl); err != nil { return fmt.Errorf("persist links: %w", err) } } if applyErr != nil { if errors.Is(applyErr, layout.ErrCollision) { w.transition(ctx, *d, store.StateReview, "collision", applyErr.Error()) return applyErr } w.transition(ctx, *d, store.StateFailed, "apply", applyErr.Error()) return applyErr } w.transition(ctx, *d, store.StateDone, "", "") w.log.Info("apply: linked", "download_id", d.ID, "batch", batch, "links", len(fl)) return nil } // Relink повторно привязывает откатанную задачу (reverted): возвращает её на // распознавание, и поллинг-цикл перезапустит recognize. Авто-раскладку при // этом не делаем — ручная перепривязка всегда проходит через ревью с // подтверждением (force_review). Источник (раздача в qBittorrent) для этого // должен быть на месте. func (w *Worker) Relink(ctx context.Context, id int64) error { w.mu.Lock() defer w.mu.Unlock() d, err := w.store.GetDownload(ctx, id) if err != nil { return fmt.Errorf("relink: %w", err) } if d.State != store.StateReverted { return fmt.Errorf("relink: download %d is in state %s (expected reverted)", id, d.State) } if !d.Infohash.Valid { return fmt.Errorf("relink: download %d has no infohash", id) } // Раздача должна ещё быть в qBittorrent — без неё распознавать нечего. if _, ok, terr := w.torrentByInfohash(ctx, d.Infohash.String); terr != nil { return fmt.Errorf("relink: %w", terr) } else if !ok { return fmt.Errorf("relink: торрент не найден в qBittorrent") } // Вернуть задачу в активную обработку можно, только если другой активной // задачи на этот infohash нет (partial unique index по idempotency_key). active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String) if err != nil { return fmt.Errorf("relink: %w", err) } if active != nil { return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID) } // Ручная перепривязка — всегда с подтверждением, без авто-раскладки. if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil { return fmt.Errorf("relink: %w", err) } w.transition(ctx, *d, store.StateRecognizing, "", "") w.log.Info("relink: re-recognizing reverted download", "download_id", id) return nil } // Refine добавляет подсказку и отправляет задачу на перераспознавание. func (w *Worker) Refine(ctx context.Context, id int64, hint string) error { hint = strings.TrimSpace(hint) if hint == "" { return fmt.Errorf("refine: empty hint") } w.mu.Lock() defer w.mu.Unlock() d, err := w.requireReviewable(ctx, id, "refine") if err != nil { return err } if err := w.store.AddHint(ctx, id, hint); err != nil { return fmt.Errorf("refine: %w", err) } w.log.Info("review: hint added, re-recognizing", "download_id", id, "hint", hint) w.transition(ctx, *d, store.StateRecognizing, "", "") return nil } // SetType фиксирует тип (override) и перезапускает распознавание с подсказкой // — чтобы LLM пересобрал роли файлов под новый тип. func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error { if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) { return fmt.Errorf("set type: invalid type %q", mediaType) } w.mu.Lock() defer w.mu.Unlock() d, err := w.requireReviewable(ctx, id, "set type") if err != nil { return err } if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil { return fmt.Errorf("set type: %w", err) } label := "фильм" if mediaType == string(recognize.MediaSeries) { label = "сериал" } if err := w.store.AddHint(ctx, id, "Тип точно: "+label+"."); err != nil { return fmt.Errorf("set type: %w", err) } w.transition(ctx, *d, store.StateRecognizing, "", "") return nil } // IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review; // превью пересчитается с учётом правки. func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error { src = strings.TrimSpace(src) if src == "" { return fmt.Errorf("ignore: empty path") } w.mu.Lock() defer w.mu.Unlock() if _, err := w.requireReviewable(ctx, id, "ignore"); err != nil { return err } overrides, err := w.store.ListOverrides(ctx, id) if err != nil { return fmt.Errorf("ignore: %w", err) } ignored := parseIgnored(overrides[ovrIgnoredFiles]) if !contains(ignored, src) { ignored = append(ignored, src) } b, _ := json.Marshal(ignored) if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil { return fmt.Errorf("ignore: %w", err) } w.log.Info("review: file ignored", "download_id", id, "src", src) return nil } // Defer паркует задачу в deferred (вернётся в ревью по действию). func (w *Worker) Defer(ctx context.Context, id int64) error { w.mu.Lock() defer w.mu.Unlock() d, err := w.store.GetDownload(ctx, id) if err != nil { return fmt.Errorf("defer: %w", err) } if d.State.IsTerminal() { return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State) } w.transition(ctx, *d, store.StateDeferred, "", "") return nil } // Undo снимает хардлинки последнего батча и переводит задачу в reverted. // Источник недосягаем (раскладчик удаляет только пути под библиотекой). func (w *Worker) Undo(ctx context.Context, id int64) error { w.mu.Lock() defer w.mu.Unlock() if w.layouter == nil { return fmt.Errorf("undo: layouter not configured") } d, err := w.store.GetDownload(ctx, id) if err != nil { return fmt.Errorf("undo: %w", err) } if d.State != store.StateDone { return fmt.Errorf("undo: download %d is in state %s (expected done)", id, d.State) } batch, err := w.store.LatestBatchID(ctx, id) if err != nil { return fmt.Errorf("undo: %w", err) } if batch == "" { return fmt.Errorf("undo: nothing to revert") } rows, err := w.store.ListFileLinksByBatch(ctx, batch) if err != nil { return fmt.Errorf("undo: %w", err) } links := make([]layout.Link, len(rows)) for i, r := range rows { links[i] = layout.Link{Src: r.SrcPath, Dst: r.DstPath, Kind: layout.Kind(r.Kind)} } n, err := w.layouter.Undo(ctx, links) if err != nil { return fmt.Errorf("undo: %w", err) } if err := w.store.DeleteFileLinksByBatch(ctx, batch); err != nil { return fmt.Errorf("undo: %w", err) } w.transition(ctx, *d, store.StateReverted, "", "") w.log.Info("undo: reverted", "download_id", id, "batch", batch, "removed", n) return nil } // requireReviewable проверяет, что задача в review/deferred. Вызывается под mu. func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*store.Download, error) { d, err := w.store.GetDownload(ctx, id) if err != nil { return nil, fmt.Errorf("%s: %w", op, err) } if d.State != store.StateReview && d.State != store.StateDeferred { return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred)", op, id, d.State) } return d, nil } // --- Выбор базы метаданных (пиннинг; остаёмся в review, применяет человек) --- // ChooseCandidate пиннит выбранного кандидата базы как override (провайдер, // id, каноническое имя/год). Раскладку не запускает — превью обновится, а // человек подтвердит «Применить». func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error { w.mu.Lock() defer w.mu.Unlock() if _, err := w.requireReviewable(ctx, id, "choose candidate"); err != nil { return err } rec, err := w.store.GetCurrentRecognition(ctx, id) if err != nil { return fmt.Errorf("choose candidate: %w", err) } cand, err := w.store.GetCandidate(ctx, candidateID) if err != nil { return fmt.Errorf("choose candidate: %w", err) } if rec == nil || cand == nil || cand.RecognitionID != rec.ID { return fmt.Errorf("choose candidate: candidate %d does not belong to the current recognition", candidateID) } pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID} if cand.Title.Valid && cand.Title.String != "" { pins[ovrTitle] = cand.Title.String } if cand.Year.Valid { pins[ovrYear] = strconv.FormatInt(cand.Year.Int64, 10) } for field, value := range pins { if err := w.store.SetOverride(ctx, id, field, value); err != nil { return fmt.Errorf("choose candidate: %w", err) } } if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil { return fmt.Errorf("choose candidate: %w", err) } w.log.Info("review: candidate chosen", "download_id", id, "provider", cand.Provider, "provider_id", cand.ProviderID) return nil } // SetProviderID пиннит провайдера и id вручную (без выбора из списка). func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error { provider = strings.TrimSpace(strings.ToLower(provider)) providerID = strings.TrimSpace(providerID) switch provider { case "tmdb", "tvdb", "imdb": default: return fmt.Errorf("set provider: invalid provider %q (tmdb/tvdb/imdb)", provider) } if providerID == "" { return fmt.Errorf("set provider: empty id") } w.mu.Lock() defer w.mu.Unlock() if _, err := w.requireReviewable(ctx, id, "set provider"); err != nil { return err } if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil { return fmt.Errorf("set provider: %w", err) } if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil { return fmt.Errorf("set provider: %w", err) } w.log.Info("review: provider set manually", "download_id", id, "provider", provider, "provider_id", providerID) return nil } // ClearProvider — «без базы»: снимает матч (тег папки не ставится). func (w *Worker) ClearProvider(ctx context.Context, id int64) error { w.mu.Lock() defer w.mu.Unlock() if _, err := w.requireReviewable(ctx, id, "clear provider"); err != nil { return err } if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil { return fmt.Errorf("clear provider: %w", err) } if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil { return fmt.Errorf("clear provider: %w", err) } w.log.Info("review: provider cleared (no metadata base)", "download_id", id) return nil } // --- Данные для экрана ревью --- // ReviewData — всё, что нужно транспорту для отрисовки ревью. type ReviewData struct { Download store.Download Recognition *store.Recognition Plan recognize.Plan // эффективный (с применёнными правками) Preview []layout.Link // целевые пути (Src — относительный, для показа) Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора Provider string // эффективный провайдер (с учётом выбора) ProviderID string // эффективный id в базе Hints []string Overrides map[string]string } // ReviewData собирает данные ревью по загрузке. func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error) { d, err := w.store.GetDownload(ctx, id) if err != nil { return nil, fmt.Errorf("review data: %w", err) } rec, err := w.store.GetCurrentRecognition(ctx, id) if err != nil { return nil, fmt.Errorf("review data: %w", err) } hints, err := w.store.ListHints(ctx, id) if err != nil { return nil, fmt.Errorf("review data: %w", err) } overrides, err := w.store.ListOverrides(ctx, id) if err != nil { return nil, fmt.Errorf("review data: %w", err) } prov, pid := effectiveProvider(rec, overrides) rd := &ReviewData{ Download: *d, Recognition: rec, Hints: hints, Overrides: overrides, Provider: prov, ProviderID: pid, } if rec != nil { if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil { rd.Candidates = cands } else { w.log.Debug("review data: list candidates failed (skipped)", "download_id", id, "err", cerr) } } if rec != nil && rec.Plan.Valid { var plan recognize.Plan if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil { w.log.Warn("review data: unmarshal plan failed", "download_id", id, "err", err) } else { plan = applyOverrides(plan, overrides) rd.Plan = plan // Превью строим по относительным путям с provider-тегом; ошибку // логируем на Debug — просто покажем причины без превью. if w.layouter != nil { tag := providerTag(prov, pid) if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil { rd.Preview = links } else { w.log.Debug("review data: build preview failed (skipped)", "download_id", id, "err", lerr) } } } } return rd, nil } // 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 } if rec == nil || !rec.Plan.Valid { 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) } overrides, err := w.store.ListOverrides(ctx, id) if err != nil { return recognize.Plan{}, "", err } prov, pid := effectiveProvider(rec, overrides) return applyOverrides(plan, overrides), providerTag(prov, pid), nil } // --- Хелперы преобразования --- // applyOverrides применяет ручные правки к плану: форсит тип, каноническое // имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью // ignore (их раскладка пропустит). func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan { if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) { plan.Type = recognize.MediaType(mt) } if t := overrides[ovrTitle]; t != "" { plan.Title = t } if y := overrides[ovrYear]; y != "" { if year, err := strconv.Atoi(y); err == nil { plan.Year = year } } ignored := parseIgnored(overrides[ovrIgnoredFiles]) if len(ignored) > 0 { for i := range plan.Files { if contains(ignored, plan.Files[i].Src) { plan.Files[i].Role = "ignore" } } } return plan } // effectiveProvider возвращает провайдера и id для тега папки с учётом // ручного выбора: запиненный override перекрывает распознанный матч. // override "none" означает явный отказ от базы. func effectiveProvider(rec *store.Recognition, overrides map[string]string) (provider, id string) { if p, ok := overrides[ovrProvider]; ok { return p, overrides[ovrProviderID] } if rec != nil { return rec.Provider.String, rec.ProviderID.String } return "", "" } // toStoreCandidates переводит кандидатов распознавания в строки БД, // подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.). func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate { out := make([]store.MetadataCandidate, 0, len(cands)) for _, c := range cands { prov, id := recognize.CandidateTag(c) mc := store.MetadataCandidate{ RecognitionID: recognitionID, Provider: prov, ProviderID: id, Title: store.NullString(c.Title), } if c.Year != 0 { mc.Year = sql.NullInt64{Int64: int64(c.Year), Valid: true} } out = append(out, mc) } return out } // ProviderTag — экспорт providerTag для диагностических команд (CLI // `jellybit recognize --dry-run`). 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) } // 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 case "imdb": return "imdbid-" + id default: return "" } } // toLayoutPlan переводит план распознавания в план раскладки. srcPrefix // (savePath) приклеивается к относительным путям файлов; пустой — оставляет // относительные (для превью). 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, ProviderTag: providerTag, } for _, f := range plan.Files { role, ok := mapRole(f.Role) if !ok { continue } src := f.Src if srcPrefix != "" { src = filepath.Join(srcPrefix, f.Src) } lp.Files = append(lp.Files, layout.PlanFile{ Src: src, Role: role, Season: f.Season, Episode: f.Episode, }) } return lp } func mapRole(r recognize.FileRole) (layout.Role, bool) { switch r { case recognize.RoleMain: return layout.RoleMain, true case recognize.RoleEpisode: return layout.RoleEpisode, true case recognize.RoleSubtitle: return layout.RoleSubtitle, true default: return "", false } } // torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ // торренты (а не только свою категорию): раздача могла быть усыновлена по // тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял // (как и в Poll, см. там же). func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) { torrents, err := w.qbt.Torrents(ctx, "") if err != nil { return qbt.Torrent{}, false, err } want := strings.ToLower(infohash) for _, t := range torrents { for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} { if h != "" && strings.ToLower(h) == want { return t, true, nil } } } return qbt.Torrent{}, false, nil } func parseIgnored(s string) []string { if s == "" { return nil } var out []string _ = json.Unmarshal([]byte(s), &out) return out } func contains(ss []string, s string) bool { for _, x := range ss { if x == s { return true } } return false }