831 lines
30 KiB
Go
831 lines
30 KiB
Go
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
|
|
}
|