From 9c1b178e4687e524d5c604563fbf6882e0e691e6 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 14 Jun 2026 14:53:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=B2=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/jellybit/serve.go | 35 +- internal/httpapi/httpapi.go | 55 +- internal/httpapi/httpapi_test.go | 200 +++++++ internal/httpapi/review.go | 202 +++++++ internal/layout/doc.go | 4 - internal/layout/layout.go | 315 ++++++++++ internal/layout/layout_test.go | 260 +++++++++ internal/layout/name.go | 97 +++ internal/layout/name_test.go | 93 +++ internal/qbt/qbt.go | 30 + .../migrations/0002_recognition_plan.sql | 8 + internal/store/recognition.go | 230 ++++++++ internal/store/recognition_test.go | 172 ++++++ internal/worker/review.go | 537 +++++++++++++++++ internal/worker/review_test.go | 550 ++++++++++++++++++ internal/worker/worker.go | 73 ++- internal/worker/worker_test.go | 28 +- web/templates/index.html | 28 +- web/templates/review.html | 122 ++++ 19 files changed, 3001 insertions(+), 38 deletions(-) create mode 100644 internal/httpapi/review.go delete mode 100644 internal/layout/doc.go create mode 100644 internal/layout/layout.go create mode 100644 internal/layout/layout_test.go create mode 100644 internal/layout/name.go create mode 100644 internal/layout/name_test.go create mode 100644 internal/store/migrations/0002_recognition_plan.sql create mode 100644 internal/store/recognition.go create mode 100644 internal/store/recognition_test.go create mode 100644 internal/worker/review.go create mode 100644 internal/worker/review_test.go create mode 100644 web/templates/review.html diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index b28b205..b1f43af 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -13,8 +13,11 @@ import ( "git.vakhrushev.me/av/jellybit/internal/config" "git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/ingest" + "git.vakhrushev.me/av/jellybit/internal/layout" + "git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/logging" "git.vakhrushev.me/av/jellybit/internal/qbt" + "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) @@ -57,7 +60,36 @@ func runServe(args []string) error { SavePath: cfg.QBittorrent.SavePath, }, logger) - wrk := worker.New(st, qb, worker.Config{ + // Ф2/Ф3: распознаватель и раскладчик. Если LLM не сконфигурирован, + // сервис работает как в Ф1 (completed-задачи дальше не двигаются). + var recognizer worker.Recognizer + if cfg.LLM.Type != "" && cfg.LLM.BaseURL != "" { + provider, perr := llm.New(llm.Config{ + Type: cfg.LLM.Type, + BaseURL: cfg.LLM.BaseURL, + APIKey: cfg.LLM.APIKey, + Model: cfg.LLM.Model, + Proxy: cfg.LLM.Proxy, + Timeout: cfg.LLM.Timeout.Std(), + }) + if perr != nil { + return fmt.Errorf("llm provider: %w", perr) + } + recognizer = recognize.New(provider, recognize.Config{MaxRetries: cfg.LLM.MaxRetries}, logger) + logger.Info("recognizer ready", "model", cfg.LLM.Model) + } else { + logger.Warn("llm not configured, recognition disabled") + } + + layouter, err := layout.New(layout.Config{ + MoviesDir: cfg.Paths.Movies, + SeriesDir: cfg.Paths.Series, + }) + if err != nil { + return fmt.Errorf("layouter: %w", err) + } + + wrk := worker.New(st, qb, recognizer, layouter, worker.Config{ Category: cfg.QBittorrent.Category, SavePath: cfg.QBittorrent.SavePath, PollInterval: cfg.Worker.PollInterval.Std(), @@ -70,6 +102,7 @@ func runServe(args []string) error { Ingestor: ingestor, Commander: wrk, Reader: st, + Reviewer: wrk, }) if err != nil { return err diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index f065abb..045007d 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -46,11 +46,13 @@ type Deps struct { Ingestor Ingestor Commander Commander Reader Reader + Reviewer Reviewer } type server struct { - deps Deps - index *template.Template + deps Deps + index *template.Template + review *template.Template } // NewRouter собирает HTTP-обработчик сервиса. @@ -59,7 +61,13 @@ func NewRouter(d Deps) (http.Handler, error) { if err != nil { return nil, err } - s := &server{deps: d, index: index} + review, err := template.New("review.html"). + Funcs(template.FuncMap{"add": func(a, b int) int { return a + b }}). + ParseFS(web.FS, "templates/review.html") + if err != nil { + return nil, err + } + s := &server{deps: d, index: index, review: review} r := chi.NewRouter() r.Use(middleware.RequestID) @@ -73,6 +81,15 @@ func NewRouter(d Deps) (http.Handler, error) { r.Post("/ui/downloads", s.handleUIAdd) r.Post("/ui/downloads/{id}/cancel", s.handleUICancel) + // Веб-UI: ревью раскладки. + r.Get("/review/{id}", s.handleReview) + r.Post("/ui/downloads/{id}/apply", s.handleApply) + r.Post("/ui/downloads/{id}/refine", s.handleRefine) + r.Post("/ui/downloads/{id}/type", s.handleSetType) + r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) + r.Post("/ui/downloads/{id}/defer", s.handleDefer) + r.Post("/ui/downloads/{id}/undo", s.handleUndo) + // REST API. r.Route("/api", func(r chi.Router) { r.Get("/downloads", s.handleAPIList) @@ -97,13 +114,15 @@ type indexView struct { } type downloadView struct { - ID int64 - Source string - Infohash string - Context string - State string - Error string - Terminal bool + ID int64 + Source string + Infohash string + Context string + State string + Error string + Terminal bool + Reviewable bool // review/deferred — есть экран ревью + Undoable bool // done — можно откатить раскладку } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -272,13 +291,15 @@ func toDTO(d store.Download) downloadDTO { func toView(d store.Download) downloadView { return downloadView{ - ID: d.ID, - Source: shorten(d.SourceRef, 64), - Infohash: d.Infohash.String, - Context: d.Context, - State: string(d.State), - Error: d.ErrorMsg.String, - Terminal: d.State.IsTerminal(), + ID: d.ID, + Source: shorten(d.SourceRef, 64), + Infohash: d.Infohash.String, + Context: d.Context, + State: string(d.State), + Error: d.ErrorMsg.String, + Terminal: d.State.IsTerminal(), + Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, + Undoable: d.State == store.StateDone, } } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index ded9f8b..0d531f2 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -12,7 +12,10 @@ import ( "git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/ingest" + "git.vakhrushev.me/av/jellybit/internal/layout" + "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" + "git.vakhrushev.me/av/jellybit/internal/worker" ) type fakeIngestor struct { @@ -175,3 +178,200 @@ func TestIndexRenders(t *testing.T) { type ingestErr string func (e ingestErr) Error() string { return string(e) } + +// --- Ревью --- + +type fakeReviewer struct { + data *worker.ReviewData + applyErr error + refined map[int64]string + typed map[int64]string + ignored map[int64]string + applied []int64 + deferred []int64 + undone []int64 +} + +func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) { + return f.data, nil +} +func (f *fakeReviewer) Apply(_ context.Context, id int64) error { + if f.applyErr != nil { + return f.applyErr + } + f.applied = append(f.applied, id) + return nil +} +func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error { + if f.refined == nil { + f.refined = map[int64]string{} + } + f.refined[id] = hint + return nil +} +func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error { + if f.typed == nil { + f.typed = map[int64]string{} + } + f.typed[id] = t + return nil +} +func (f *fakeReviewer) IgnoreFile(_ context.Context, id int64, src string) error { + if f.ignored == nil { + f.ignored = map[int64]string{} + } + f.ignored[id] = src + return nil +} +func (f *fakeReviewer) Defer(_ context.Context, id int64) error { + f.deferred = append(f.deferred, id) + return nil +} +func (f *fakeReviewer) Undo(_ context.Context, id int64) error { + f.undone = append(f.undone, id) + return nil +} + +func seriesReviewData() *worker.ReviewData { + s, e := 2, 1 + return &worker.ReviewData{ + Download: store.Download{ID: 1, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"}, + Recognition: &store.Recognition{ + ID: 1, DownloadID: 1, IsCurrent: true, Reasons: `["нет матча в базе"]`, + }, + Plan: recognize.Plan{ + Type: recognize.MediaSeries, Title: "Фарго", Year: 2015, + Files: []recognize.PlanFile{ + {Src: "Fargo/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}, + }, + }, + Preview: []layout.Link{ + {Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"}, + }, + Hints: []string{"второй сезон"}, + } +} + +// noRedirectClient — не следует за 3xx, чтобы проверять Location. +func noRedirectClient() *http.Client { + return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} +} + +func TestReviewRenders(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + + resp, err := http.Get(srv.URL + "/review/1") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv", + "Season 02", "Применить", "Уточнить"} { + if !strings.Contains(string(body), want) { + t.Errorf("страница ревью не содержит %q", want) + } + } +} + +func TestApplyRedirectsToIndex(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + + resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/" { + t.Errorf("Location = %q, want /", loc) + } + if len(rv.applied) != 1 { + t.Errorf("Apply не вызван: %v", rv.applied) + } +} + +func TestApplyCollisionRedirectsToReview(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData(), applyErr: ingestErr("collision")} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + + resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/1/apply", "", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") { + t.Errorf("Location = %q, want /review/1?err=...", loc) + } +} + +func TestRefinePostsHint(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + + resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/refine", + map[string][]string{"hint": {"это второй сезон"}}) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if rv.refined[1] != "это второй сезон" { + t.Errorf("Refine получил %q", rv.refined[1]) + } + if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") { + t.Errorf("Location = %q", loc) + } +} + +func TestIgnoreAndType(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + cl := noRedirectClient() + + if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/ignore", + map[string][]string{"src": {"Fargo/sample.mkv"}}); err != nil { + t.Fatal(err) + } + if rv.ignored[1] != "Fargo/sample.mkv" { + t.Errorf("IgnoreFile получил %q", rv.ignored[1]) + } + + if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/type", + map[string][]string{"type": {"movie"}}); err != nil { + t.Fatal(err) + } + if rv.typed[1] != "movie" { + t.Errorf("SetType получил %q", rv.typed[1]) + } +} + +func TestUndoAndDefer(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + cl := noRedirectClient() + + if _, err := cl.Post(srv.URL+"/ui/downloads/1/undo", "", nil); err != nil { + t.Fatal(err) + } + if _, err := cl.Post(srv.URL+"/ui/downloads/1/defer", "", nil); err != nil { + t.Fatal(err) + } + if len(rv.undone) != 1 || len(rv.deferred) != 1 { + t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred) + } +} diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go new file mode 100644 index 0000000..b4581a3 --- /dev/null +++ b/internal/httpapi/review.go @@ -0,0 +1,202 @@ +package httpapi + +import ( + "context" + "database/sql" + "errors" + "net/http" + "net/url" + "strconv" + + "git.vakhrushev.me/av/jellybit/internal/worker" +) + +// Reviewer — операции ревью и раскладки (worker.Worker). +type Reviewer interface { + ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error) + Apply(ctx context.Context, id int64) error + Refine(ctx context.Context, id int64, hint string) error + SetType(ctx context.Context, id int64, mediaType string) error + IgnoreFile(ctx context.Context, id int64, src string) error + Defer(ctx context.Context, id int64) error + Undo(ctx context.Context, id int64) error +} + +// --- Представление страницы ревью --- + +type reviewView struct { + ID int64 + Source string + Context string + State string + Error string // из ?err= + StateError string // error_msg загрузки (напр. причина коллизии) + MediaType string + IsSeries bool + Title string + OriginalTitle string + Year int + Confidence string + Reasons []string + Hints []string + Files []reviewFileView + Preview []string + HasPlan bool +} + +type reviewFileView struct { + Src string + Role string + Season string + Episode string + Ignored bool +} + +func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + http.Error(w, "некорректный id", http.StatusBadRequest) + return + } + rd, err := s.deps.Reviewer.ReviewData(r.Context(), id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "задача не найдена", http.StatusNotFound) + return + } + s.deps.Logger.Error("review data", "id", id, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + view := reviewView{ + ID: id, + Source: shorten(rd.Download.SourceRef, 80), + Context: rd.Download.Context, + State: string(rd.Download.State), + Error: r.URL.Query().Get("err"), + StateError: rd.Download.ErrorMsg.String, + Hints: rd.Hints, + } + if rec := rd.Recognition; rec != nil { + view.MediaType = string(rd.Plan.Type) + view.IsSeries = rd.Plan.Type == "series" + view.Title = rd.Plan.Title + view.OriginalTitle = rd.Plan.OriginalTitle + view.Year = rd.Plan.Year + view.Reasons = rec.ReasonList() + if rec.Confidence.Valid { + view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64) + } + for _, f := range rd.Plan.Files { + view.Files = append(view.Files, reviewFileView{ + Src: f.Src, + Role: string(f.Role), + Season: intPtrStr(f.Season), + Episode: intPtrStr(f.Episode), + Ignored: f.Role == "ignore", + }) + } + view.HasPlan = len(rd.Plan.Files) > 0 + } + for _, l := range rd.Preview { + view.Preview = append(view.Preview, l.Dst) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.review.Execute(w, view); err != nil { + s.deps.Logger.Error("render review", "err", err) + } +} + +// --- Действия ревью (POST → redirect) --- + +func (s *server) handleApply(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + redirectErr(w, r, "некорректный id") + return + } + if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil { + redirectReview(w, r, id, err.Error()) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) { + s.reviewAction(w, r, func(ctx context.Context, id int64) error { + _ = r.ParseForm() + return s.deps.Reviewer.Refine(ctx, id, r.PostForm.Get("hint")) + }) +} + +func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) { + s.reviewAction(w, r, func(ctx context.Context, id int64) error { + _ = r.ParseForm() + return s.deps.Reviewer.SetType(ctx, id, r.PostForm.Get("type")) + }) +} + +func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) { + s.reviewAction(w, r, func(ctx context.Context, id int64) error { + _ = r.ParseForm() + return s.deps.Reviewer.IgnoreFile(ctx, id, r.PostForm.Get("src")) + }) +} + +func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + redirectErr(w, r, "некорректный id") + return + } + if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil { + redirectReview(w, r, id, err.Error()) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r) + if err != nil { + redirectErr(w, r, "некорректный id") + return + } + if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil { + redirectErr(w, r, err.Error()) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// reviewAction — общий помощник: выполнить действие и вернуться на страницу +// ревью (с ошибкой в ?err при неудаче). +func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, int64) error) { + id, err := pathID(r) + if err != nil { + redirectErr(w, r, "некорректный id") + return + } + if err := fn(r.Context(), id); err != nil { + redirectReview(w, r, id, err.Error()) + return + } + redirectReview(w, r, id, "") +} + +func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string) { + u := "/review/" + strconv.FormatInt(id, 10) + if msg != "" { + u += "?err=" + url.QueryEscape(msg) + } + http.Redirect(w, r, u, http.StatusSeeOther) +} + +func intPtrStr(p *int) string { + if p == nil { + return "—" + } + return strconv.Itoa(*p) +} diff --git a/internal/layout/doc.go b/internal/layout/doc.go deleted file mode 100644 index d444496..0000000 --- a/internal/layout/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package layout — конвенции Jellyfin, санитизация путей, хардлинкер, undo. -// -// Заглушка: реализация в фазе Ф3 (см. docs/specs/jellyfin-layout.md). -package layout diff --git a/internal/layout/layout.go b/internal/layout/layout.go new file mode 100644 index 0000000..7a7ea09 --- /dev/null +++ b/internal/layout/layout.go @@ -0,0 +1,315 @@ +// Package layout раскладывает распознанные файлы по конвенциям Jellyfin +// хардлинками, не трогая исходную раздачу (см. docs/specs/jellyfin-layout.md). +// +// Инварианты безопасности (см. architecture.md → «Раскладка файлов»): +// - линкуем только файлы; целевые каталоги создаём mkdir; +// - целевое имя санитизируется, итоговый путь обязан быть строго под +// paths.movies/paths.series — иначе отказ (защита от traversal); +// - существующее не перезаписываем: тот же inode → идемпотентно «готово», +// другой файл → коллизия (review); +// - источник неприкосновенен: undo удаляет только ссылки своего батча и +// только под библиотекой. +package layout + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" +) + +// MediaType — вид контента. +type MediaType string + +const ( + Movie MediaType = "movie" + Series MediaType = "series" +) + +// Role — роль файла. Линкуются только main/episode/subtitle; остальные +// (extra/sample/ignore) раскладка пропускает. +type Role string + +const ( + RoleMain Role = "main" + RoleEpisode Role = "episode" + RoleSubtitle Role = "subtitle" +) + +// Kind — вид целевой ссылки (для file_link.kind). +type Kind string + +const ( + KindVideo Kind = "video" + KindSubtitle Kind = "subtitle" +) + +// PlanFile — один файл к раскладке. +type PlanFile struct { + Src string // абсолютный путь источника (content dir + относительное имя) + Role Role + Season *int // для сериала + Episode *int // для сериала + EpisodeEnd *int // двойная серия SxxEyy-Ezz (опц.) + Lang string // язык субтитров (опц.) + Flags []string // флаги субтитров: forced/sdh/... (опц.) +} + +// Plan — что и куда раскладывать. +type Plan struct { + Type MediaType + Title string + Year int + ProviderTag string // напр. "tmdbid-693134"; пусто — без тега + Files []PlanFile +} + +// Link — посчитанная пара источник → цель. +type Link struct { + Src string + Dst string + Kind Kind +} + +// Config — корни библиотек и режим каталогов. +type Config struct { + MoviesDir string + SeriesDir string + DirMode os.FileMode // 0 → 0755 +} + +// Layouter строит и применяет раскладку. +type Layouter struct { + movies string + series string + dirMode os.FileMode +} + +// New собирает раскладчик. Корни нормализуются (filepath.Clean). +func New(cfg Config) (*Layouter, error) { + if cfg.MoviesDir == "" || cfg.SeriesDir == "" { + return nil, fmt.Errorf("layout: movies/series dirs required") + } + mode := cfg.DirMode + if mode == 0 { + mode = 0o755 + } + return &Layouter{ + movies: filepath.Clean(cfg.MoviesDir), + series: filepath.Clean(cfg.SeriesDir), + dirMode: mode, + }, nil +} + +// root возвращает корень библиотеки для типа. +func (l *Layouter) root(t MediaType) (string, error) { + switch t { + case Movie: + return l.movies, nil + case Series: + return l.series, nil + default: + return "", fmt.Errorf("layout: неизвестный тип %q", t) + } +} + +// BuildLinks вычисляет целевые пути (без обращения к ФС, кроме отсутствия — +// чистая функция от плана). Файлы-роли вне main/episode/subtitle +// пропускаются. Любая невалидность (пустое название, серия без номера, +// выход за пределы библиотеки) — ошибка целиком, частичную раскладку не +// начинаем. +func (l *Layouter) BuildLinks(p Plan) ([]Link, error) { + root, err := l.root(p.Type) + if err != nil { + return nil, err + } + base, err := titleYear(p.Title, p.Year) + if err != nil { + return nil, err + } + folder := folderName(base, p.ProviderTag) + + var links []Link + for i := range p.Files { + f := &p.Files[i] + var dst string + var kind Kind + var berr error + + switch p.Type { + case Movie: + dst, kind, berr = l.movieDst(root, folder, base, f) + case Series: + dst, kind, berr = l.seriesDst(root, folder, base, f) + } + if berr != nil { + return nil, berr + } + if dst == "" { + continue // роль не линкуется (extra/sample/ignore) + } + if !underRoot(root, dst) { + return nil, fmt.Errorf("layout: цель %q вне библиотеки %q (файл %q)", dst, root, f.Src) + } + links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind}) + } + if len(links) == 0 { + return nil, fmt.Errorf("layout: план не дал ни одной ссылки") + } + return links, nil +} + +func (l *Layouter) movieDst(root, folder, base string, f *PlanFile) (string, Kind, error) { + dir := filepath.Join(root, folder) + switch f.Role { + case RoleMain: + return filepath.Join(dir, base+ext(f.Src)), KindVideo, nil + case RoleSubtitle: + name := base + subtitleSuffix(f.Lang, f.Flags) + ext(f.Src) + return filepath.Join(dir, name), KindSubtitle, nil + default: + return "", "", nil + } +} + +func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Kind, error) { + if f.Role != RoleEpisode && f.Role != RoleSubtitle { + return "", "", nil + } + if f.Season == nil || f.Episode == nil { + return "", "", fmt.Errorf("layout: файл %q без season/episode", f.Src) + } + episodeEnd := 0 + if f.EpisodeEnd != nil { + episodeEnd = *f.EpisodeEnd + } + dir := filepath.Join(root, folder, seasonFolder(*f.Season)) + stem := episodeStem(base, *f.Season, *f.Episode, episodeEnd) + switch f.Role { + case RoleEpisode: + return filepath.Join(dir, stem+ext(f.Src)), KindVideo, nil + case RoleSubtitle: + name := stem + subtitleSuffix(f.Lang, f.Flags) + ext(f.Src) + return filepath.Join(dir, name), KindSubtitle, nil + default: + return "", "", nil + } +} + +// LinkStatus — исход создания одной ссылки. +type LinkStatus string + +const ( + StatusLinked LinkStatus = "linked" // ссылка создана + StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно + StatusCollision LinkStatus = "collision" // цель занята другим файлом +) + +// Result — итог по одной ссылке. +type Result struct { + Link Link + Status LinkStatus +} + +// ErrCollision — цель существует и это другой файл (нужен review). +var ErrCollision = errors.New("layout: target collision") + +// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя +// доводит начатое. При коллизии (цель занята чужим файлом) возвращает +// ErrCollision, не перезаписывая. EXDEV (разные ФС) — явная ошибка. +func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) { + results := make([]Result, 0, len(links)) + for _, ln := range links { + root := l.movies + if !underRoot(l.movies, ln.Dst) { + root = l.series + } + if !underRoot(root, ln.Dst) { + return results, fmt.Errorf("layout: цель %q вне библиотек", ln.Dst) + } + if err := os.MkdirAll(filepath.Dir(ln.Dst), l.dirMode); err != nil { + return results, fmt.Errorf("layout: mkdir %q: %w", filepath.Dir(ln.Dst), err) + } + + status, err := linkOne(ln.Src, ln.Dst) + if err != nil { + return results, err + } + results = append(results, Result{Link: ln, Status: status}) + } + return results, nil +} + +// linkOne создаёт одну ссылку, разбирая «уже существует». +func linkOne(src, dst string) (LinkStatus, error) { + err := os.Link(src, dst) + if err == nil { + return StatusLinked, nil + } + if errors.Is(err, fs.ErrExist) { + same, serr := sameFile(src, dst) + if serr != nil { + return "", fmt.Errorf("layout: stat existing %q: %w", dst, serr) + } + if same { + return StatusExists, nil // идемпотентно: тот же inode + } + return StatusCollision, fmt.Errorf("%w: %q занят другим файлом", ErrCollision, dst) + } + if errors.Is(err, syscall.EXDEV) { + return "", fmt.Errorf("layout: hardlink через границу ФС (%q → %q): %w", src, dst, err) + } + return "", fmt.Errorf("layout: link %q → %q: %w", src, dst, err) +} + +// sameFile сообщает, указывают ли src и dst на один inode. +func sameFile(src, dst string) (bool, error) { + si, err := os.Stat(src) + if err != nil { + return false, err + } + di, err := os.Stat(dst) + if err != nil { + return false, err + } + return os.SameFile(si, di), nil +} + +// Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути +// строго под библиотеками (источник недосягаем). Отсутствующая цель — не +// ошибка (идемпотентно). Возвращает число удалённых ссылок. +func (l *Layouter) Undo(_ context.Context, links []Link) (int, error) { + removed := 0 + for _, ln := range links { + root := l.movies + if !underRoot(l.movies, ln.Dst) { + root = l.series + } + if !underRoot(root, ln.Dst) { + return removed, fmt.Errorf("layout: undo вне библиотеки: %q", ln.Dst) + } + if err := os.Remove(ln.Dst); err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err) + } + removed++ + pruneEmptyDirs(filepath.Dir(ln.Dst), root) + } + return removed, nil +} + +// pruneEmptyDirs удаляет опустевшие каталоги вверх до (не включая) root. +// Ошибки игнорируются: непустой каталог os.Remove не удалит — это и нужно. +func pruneEmptyDirs(dir, root string) { + for dir != root && underRoot(root, dir) { + if err := os.Remove(dir); err != nil { + return // непустой или нет прав — останавливаемся + } + dir = filepath.Dir(dir) + } +} diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go new file mode 100644 index 0000000..73d8f52 --- /dev/null +++ b/internal/layout/layout_test.go @@ -0,0 +1,260 @@ +package layout + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func intp(n int) *int { return &n } + +// fixture создаёт раскладчик с временными корнями downloads/movies/series и +// одним исходным файлом. +type fixture struct { + l *Layouter + downloads string + movies string + series string +} + +func newFixture(t *testing.T) fixture { + t.Helper() + 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} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + l, err := New(Config{MoviesDir: movies, SeriesDir: series}) + if err != nil { + t.Fatal(err) + } + return fixture{l: l, downloads: downloads, movies: movies, series: series} +} + +func (f fixture) srcFile(t *testing.T, rel, content string) string { + t.Helper() + p := filepath.Join(f.downloads, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestBuildLinks_Movie(t *testing.T) { + f := newFixture(t) + src := f.srcFile(t, "The.Matrix/movie.mkv", "x") + sub := f.srcFile(t, "The.Matrix/movie.ru.srt", "y") + plan := Plan{ + Type: Movie, Title: "The Matrix", Year: 1999, ProviderTag: "tmdbid-603", + Files: []PlanFile{ + {Src: src, Role: RoleMain}, + {Src: sub, Role: RoleSubtitle, Lang: "ru"}, + {Src: f.srcFile(t, "The.Matrix/sample.mkv", "z"), Role: "sample"}, + }, + } + links, err := f.l.BuildLinks(plan) + if err != nil { + t.Fatalf("BuildLinks: %v", err) + } + if len(links) != 2 { // sample пропущен + t.Fatalf("want 2 links, got %d: %+v", len(links), links) + } + wantMain := filepath.Join(f.movies, "The Matrix (1999) [tmdbid-603]", "The Matrix (1999).mkv") + wantSub := filepath.Join(f.movies, "The Matrix (1999) [tmdbid-603]", "The Matrix (1999).ru.srt") + if links[0].Dst != wantMain || links[0].Kind != KindVideo { + t.Errorf("main = %+v, want %q", links[0], wantMain) + } + if links[1].Dst != wantSub || links[1].Kind != KindSubtitle { + t.Errorf("sub = %+v, want %q", links[1], wantSub) + } +} + +func TestBuildLinks_Series(t *testing.T) { + f := newFixture(t) + plan := Plan{ + Type: Series, Title: "Fargo", Year: 2015, + Files: []PlanFile{ + {Src: f.srcFile(t, "Fargo/e1.mkv", "1"), Role: RoleEpisode, Season: intp(2), Episode: intp(1)}, + {Src: f.srcFile(t, "Fargo/e2.mkv", "2"), Role: RoleEpisode, Season: intp(2), Episode: intp(2)}, + }, + } + links, err := f.l.BuildLinks(plan) + if err != nil { + t.Fatalf("BuildLinks: %v", err) + } + want := filepath.Join(f.series, "Fargo (2015)", "Season 02", "Fargo (2015) S02E01.mkv") + if links[0].Dst != want { + t.Errorf("ep1 = %q, want %q", links[0].Dst, want) + } +} + +func TestBuildLinks_SeriesEpisodeWithoutNumber(t *testing.T) { + f := newFixture(t) + plan := Plan{ + Type: Series, Title: "X", Year: 2020, + Files: []PlanFile{{Src: f.srcFile(t, "x/e.mkv", "1"), Role: RoleEpisode, Season: intp(1)}}, + } + if _, err := f.l.BuildLinks(plan); err == nil { + t.Fatal("want error for episode without number") + } +} + +func TestBuildLinks_EmptyPlanRejected(t *testing.T) { + f := newFixture(t) + plan := Plan{Type: Movie, Title: "X", Year: 2020, + Files: []PlanFile{{Src: "/x/sample.mkv", Role: "sample"}}} + if _, err := f.l.BuildLinks(plan); err == nil { + t.Fatal("want error when no linkable files") + } +} + +func TestBuildLinks_TraversalTitleStaysInside(t *testing.T) { + f := newFixture(t) + // Враждебное название не должно вывести за пределы библиотеки. + plan := Plan{Type: Movie, Title: "../../etc/passwd", Year: 2020, + Files: []PlanFile{{Src: f.srcFile(t, "m/f.mkv", "1"), Role: RoleMain}}} + links, err := f.l.BuildLinks(plan) + if err != nil { + t.Fatalf("BuildLinks: %v", err) + } + if !underRoot(f.movies, links[0].Dst) { + t.Errorf("dst escaped library: %q", links[0].Dst) + } +} + +func TestApply_CreatesHardlink(t *testing.T) { + f := newFixture(t) + src := f.srcFile(t, "m/film.mkv", "data") + links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, + Files: []PlanFile{{Src: src, Role: RoleMain}}}) + + res, err := f.l.Apply(context.Background(), links) + if err != nil { + t.Fatalf("Apply: %v", err) + } + if len(res) != 1 || res[0].Status != StatusLinked { + t.Fatalf("res = %+v", res) + } + // Тот же inode, источник цел. + si, _ := os.Stat(src) + di, _ := os.Stat(links[0].Dst) + if !os.SameFile(si, di) { + t.Error("dst is not a hardlink of src") + } + if _, err := os.Stat(src); err != nil { + t.Errorf("source must remain: %v", err) + } +} + +func TestApply_Idempotent(t *testing.T) { + f := newFixture(t) + src := f.srcFile(t, "m/film.mkv", "data") + links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, + Files: []PlanFile{{Src: src, Role: RoleMain}}}) + + if _, err := f.l.Apply(context.Background(), links); err != nil { + t.Fatalf("first apply: %v", err) + } + res, err := f.l.Apply(context.Background(), links) + if err != nil { + t.Fatalf("second apply: %v", err) + } + if res[0].Status != StatusExists { + t.Errorf("status = %q, want exists (idempotent)", res[0].Status) + } +} + +func TestApply_CollisionNotOverwritten(t *testing.T) { + f := newFixture(t) + src := f.srcFile(t, "m/film.mkv", "original") + links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, + Files: []PlanFile{{Src: src, Role: RoleMain}}}) + + // Занимаем цель посторонним файлом. + if err := os.MkdirAll(filepath.Dir(links[0].Dst), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(links[0].Dst, []byte("foreign"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := f.l.Apply(context.Background(), links) + if !errors.Is(err, ErrCollision) { + t.Fatalf("err = %v, want ErrCollision", err) + } + // Посторонний файл не тронут. + b, _ := os.ReadFile(links[0].Dst) + if string(b) != "foreign" { + t.Errorf("foreign file overwritten: %q", b) + } +} + +func TestUndo_RemovesLinksAndPrunesDirs(t *testing.T) { + f := newFixture(t) + links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021, + Files: []PlanFile{ + {Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, + }}) + if _, err := f.l.Apply(context.Background(), links); err != nil { + t.Fatal(err) + } + + n, err := f.l.Undo(context.Background(), links) + if err != nil { + t.Fatalf("Undo: %v", err) + } + if n != 1 { + t.Errorf("removed = %d, want 1", n) + } + if _, err := os.Stat(links[0].Dst); !errors.Is(err, os.ErrNotExist) { + t.Errorf("link still exists: %v", err) + } + // Пустые каталоги сезона и сериала подчищены, корень цел. + if _, err := os.Stat(filepath.Join(f.series, "Show (2021)")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("show dir not pruned: %v", err) + } + if _, err := os.Stat(f.series); err != nil { + t.Errorf("series root must remain: %v", err) + } + // Источник цел. + if _, err := os.Stat(links[0].Src); err != nil { + t.Errorf("source removed by undo: %v", err) + } +} + +func TestUndo_Idempotent(t *testing.T) { + f := newFixture(t) + links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, + Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "1"), Role: RoleMain}}}) + if _, err := f.l.Apply(context.Background(), links); err != nil { + t.Fatal(err) + } + if _, err := f.l.Undo(context.Background(), links); err != nil { + t.Fatal(err) + } + // Повторный undo — не ошибка (цель уже снята). + n, err := f.l.Undo(context.Background(), links) + if err != nil { + t.Fatalf("second undo: %v", err) + } + if n != 0 { + t.Errorf("removed = %d, want 0", n) + } +} + +func TestUndo_RefusesOutsideLibrary(t *testing.T) { + f := newFixture(t) + outside := filepath.Join(f.downloads, "victim.mkv") + if _, err := f.l.Undo(context.Background(), []Link{{Dst: outside}}); err == nil { + t.Fatal("undo must refuse paths outside libraries") + } +} diff --git a/internal/layout/name.go b/internal/layout/name.go new file mode 100644 index 0000000..bb38928 --- /dev/null +++ b/internal/layout/name.go @@ -0,0 +1,97 @@ +package layout + +import ( + "fmt" + "path/filepath" + "strings" +) + +// sanitizeComponent чистит один компонент пути (имя папки/файла): убирает +// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает +// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри +// сохраняются. Результат гарантированно не содержит '/', '\\', ".." целиком +// и не пуст (иначе ошибка у вызывающего). +func sanitizeComponent(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r < 0x20 || r == 0x7f: // управляющие + b.WriteByte(' ') + case strings.ContainsRune(`/\:*?"<>|`, r): // разделители и недопустимые в SMB/NTFS + b.WriteByte(' ') + default: + b.WriteRune(r) + } + } + out := strings.Join(strings.Fields(b.String()), " ") // схлопнуть пробелы + out = strings.Trim(out, " .") // края: ни точек, ни пробелов + return out +} + +// titleYear строит базу "Название (Год)" или "Название" при year == 0. +func titleYear(title string, year int) (string, error) { + t := sanitizeComponent(title) + if t == "" { + return "", fmt.Errorf("layout: пустое название после санитизации (%q)", title) + } + if year > 0 { + return fmt.Sprintf("%s (%d)", t, year), nil + } + return t, nil +} + +// folderName добавляет provider-тег к базе: "Название (Год) [tmdbid-123]". +func folderName(base, providerTag string) string { + tag := sanitizeComponent(providerTag) + if tag == "" { + return base + } + return fmt.Sprintf("%s [%s]", base, tag) +} + +// seasonFolder — "Season 00" (спецвыпуски) / "Season 01" / ... +func seasonFolder(season int) string { + return fmt.Sprintf("Season %02d", season) +} + +// episodeStem — "Название (Год) S01E02"; при двойной серии episodeEnd>episode +// даёт "S01E02-E03". +func episodeStem(base string, season, episode, episodeEnd int) string { + if episodeEnd > episode { + return fmt.Sprintf("%s S%02dE%02d-E%02d", base, season, episode, episodeEnd) + } + return fmt.Sprintf("%s S%02dE%02d", base, season, episode) +} + +// subtitleSuffix — ".ru", ".ru.forced" и т.п. (флаги после языка). +func subtitleSuffix(lang string, flags []string) string { + var b strings.Builder + if l := sanitizeComponent(lang); l != "" { + b.WriteByte('.') + b.WriteString(strings.ToLower(l)) + } + for _, f := range flags { + if f = sanitizeComponent(f); f != "" { + b.WriteByte('.') + b.WriteString(strings.ToLower(f)) + } + } + return b.String() +} + +// ext возвращает расширение файла источника в нижнем регистре (с точкой). +func ext(src string) string { + return strings.ToLower(filepath.Ext(src)) +} + +// underRoot сообщает, лежит ли clean-путь p строго под каталогом root +// (после filepath.Clean у обоих). Защита от traversal: даже если имя +// прошло санитизацию, итог обязан остаться внутри библиотеки. +func underRoot(root, p string) bool { + root = filepath.Clean(root) + p = filepath.Clean(p) + if p == root { + return false // сам корень — не цель для файла + } + return strings.HasPrefix(p, root+string(filepath.Separator)) +} diff --git a/internal/layout/name_test.go b/internal/layout/name_test.go new file mode 100644 index 0000000..f896676 --- /dev/null +++ b/internal/layout/name_test.go @@ -0,0 +1,93 @@ +package layout + +import "testing" + +func TestSanitizeComponent(t *testing.T) { + tests := []struct { + in, want string + }{ + {"Дюна Часть вторая", "Дюна Часть вторая"}, + {"a/b\\c", "a b c"}, + {"..", ""}, + {"../../etc/passwd", "etc passwd"}, + {" trailing. ", "trailing"}, + {"name: sub*title?", "name sub title"}, + {"multi space", "multi space"}, + {"tab\tand\nnewline", "tab and newline"}, + {".hidden", "hidden"}, + {"ac|d\"e", "a b c d e"}, + } + for _, tt := range tests { + if got := sanitizeComponent(tt.in); got != tt.want { + t.Errorf("sanitizeComponent(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestTitleYear(t *testing.T) { + got, err := titleYear("The Matrix", 1999) + if err != nil || got != "The Matrix (1999)" { + t.Errorf("got %q, %v", got, err) + } + got, err = titleYear("No Year", 0) + if err != nil || got != "No Year" { + t.Errorf("got %q, %v", got, err) + } + if _, err := titleYear("///", 2000); err == nil { + t.Error("want error for empty title after sanitize") + } +} + +func TestFolderName(t *testing.T) { + if got := folderName("Dune (2024)", "tmdbid-693134"); got != "Dune (2024) [tmdbid-693134]" { + t.Errorf("got %q", got) + } + if got := folderName("Dune (2024)", ""); got != "Dune (2024)" { + t.Errorf("got %q", got) + } +} + +func TestEpisodeStem(t *testing.T) { + if got := episodeStem("Fargo (2015)", 2, 1, 0); got != "Fargo (2015) S02E01" { + t.Errorf("got %q", got) + } + if got := episodeStem("Show", 1, 5, 6); got != "Show S01E05-E06" { + t.Errorf("got %q", got) + } +} + +func TestSeasonFolder(t *testing.T) { + if got := seasonFolder(0); got != "Season 00" { + t.Errorf("got %q", got) + } + if got := seasonFolder(12); got != "Season 12" { + t.Errorf("got %q", got) + } +} + +func TestSubtitleSuffix(t *testing.T) { + if got := subtitleSuffix("ru", nil); got != ".ru" { + t.Errorf("got %q", got) + } + if got := subtitleSuffix("RU", []string{"forced"}); got != ".ru.forced" { + t.Errorf("got %q", got) + } + if got := subtitleSuffix("", nil); got != "" { + t.Errorf("got %q", got) + } +} + +func TestUnderRoot(t *testing.T) { + if !underRoot("/srv/media/movies", "/srv/media/movies/Film (2020)/Film (2020).mkv") { + t.Error("want true for nested path") + } + if underRoot("/srv/media/movies", "/srv/media/movies") { + t.Error("root itself is not a valid target") + } + if underRoot("/srv/media/movies", "/srv/media/series/x.mkv") { + t.Error("sibling dir must be rejected") + } + if underRoot("/srv/media/movies", "/srv/media/movies/../series/x.mkv") { + t.Error("traversal must be rejected after clean") + } +} diff --git a/internal/qbt/qbt.go b/internal/qbt/qbt.go index c15a4a7..500ae3f 100644 --- a/internal/qbt/qbt.go +++ b/internal/qbt/qbt.go @@ -54,6 +54,13 @@ type Torrent struct { InfohashV2 string `json:"infohash_v2"` } +// File — элемент /torrents/files: путь файла относительно content_path и +// его размер. +type File struct { + Name string `json:"name"` + Size int64 `json:"size"` +} + // AddRequest — параметры добавления торрента. type AddRequest struct { URLs []string // magnet/URL-ссылки @@ -217,3 +224,26 @@ func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, erro } return ts, nil } + +// Files возвращает список файлов торрента (имена относительно content_path и +// размеры). Нужен распознаванию как один из сигналов. +func (c *Client) Files(ctx context.Context, hash string) ([]File, error) { + resp, err := c.do(ctx, func() (*http.Request, error) { + u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash)) + return http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + }) + if err != nil { + return nil, fmt.Errorf("qbittorrent files: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10)) + return nil, fmt.Errorf("qbittorrent files: status %d body %q", + resp.StatusCode, strings.TrimSpace(string(body))) + } + var fs []File + if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil { + return nil, fmt.Errorf("decode qbittorrent files: %w", err) + } + return fs, nil +} diff --git a/internal/store/migrations/0002_recognition_plan.sql b/internal/store/migrations/0002_recognition_plan.sql new file mode 100644 index 0000000..f5cd785 --- /dev/null +++ b/internal/store/migrations/0002_recognition_plan.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Структурированный план раскладки (файл → роль/сезон/серия) для превью и +-- применения до создания хардлинков. Плоские поля recognition (media_type, +-- title, year, …) остаются для списков; план — каноничный JSON recognize.Plan. +ALTER TABLE recognition ADD COLUMN plan TEXT; + +-- +goose Down +ALTER TABLE recognition DROP COLUMN plan; diff --git a/internal/store/recognition.go b/internal/store/recognition.go new file mode 100644 index 0000000..39b9fa6 --- /dev/null +++ b/internal/store/recognition.go @@ -0,0 +1,230 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" +) + +// Recognition — строка таблицы recognition (попытка распознавания). +type Recognition struct { + ID int64 `db:"id"` + DownloadID int64 `db:"download_id"` + AttemptNo int `db:"attempt_no"` + IsCurrent bool `db:"is_current"` + MediaType sql.NullString `db:"media_type"` + Title sql.NullString `db:"title"` + OriginalTitle sql.NullString `db:"original_title"` + Year sql.NullInt64 `db:"year"` + Provider sql.NullString `db:"provider"` + ProviderID sql.NullString `db:"provider_id"` + Confidence sql.NullFloat64 `db:"confidence"` + Reasons string `db:"reasons"` // JSON-массив строк + RawLLM sql.NullString `db:"raw_llm"` + Plan sql.NullString `db:"plan"` // JSON recognize.Plan + CreatedAt string `db:"created_at"` +} + +// ReasonList разбирает JSON-поле reasons в срез строк. +func (r Recognition) ReasonList() []string { + if r.Reasons == "" { + return nil + } + var out []string + _ = json.Unmarshal([]byte(r.Reasons), &out) + return out +} + +// CreateRecognition вставляет новую попытку распознавания, помечая прежние +// как неактуальные (is_current = 0) и проставляя следующий attempt_no. +// Возвращает id новой записи. reasons сериализуется в JSON. +func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (int64, error) { + reasonsJSON, err := json.Marshal(reasons) + if err != nil { + return 0, fmt.Errorf("marshal reasons: %w", err) + } + + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return 0, fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, + `UPDATE recognition SET is_current = 0 WHERE download_id = ?`, r.DownloadID); err != nil { + return 0, fmt.Errorf("clear current recognitions: %w", err) + } + + var nextAttempt int + if err := tx.GetContext(ctx, &nextAttempt, + `SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`, + r.DownloadID); err != nil { + return 0, fmt.Errorf("next attempt_no: %w", err) + } + + const q = ` +INSERT INTO recognition + (download_id, attempt_no, is_current, media_type, title, original_title, + year, provider, provider_id, confidence, reasons, raw_llm, plan) +VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + res, err := tx.ExecContext(ctx, q, + r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle, + r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan) + if err != nil { + return 0, fmt.Errorf("insert recognition: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("recognition last insert id: %w", err) + } + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit recognition: %w", err) + } + return id, nil +} + +// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки +// либо (nil, nil), если её ещё нет. +func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*Recognition, error) { + var r Recognition + err := s.DB.GetContext(ctx, &r, + `SELECT * FROM recognition WHERE download_id = ? AND is_current = 1 + ORDER BY attempt_no DESC LIMIT 1`, downloadID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get current recognition: %w", err) + } + return &r, nil +} + +// --- Подсказки (hint) --- + +// AddHint добавляет текстовую подсказку ревьюера к загрузке. +func (s *Store) AddHint(ctx context.Context, downloadID int64, text string) error { + if _, err := s.DB.ExecContext(ctx, + `INSERT INTO hint (download_id, text) VALUES (?, ?)`, downloadID, text); err != nil { + return fmt.Errorf("add hint: %w", err) + } + return nil +} + +// ListHints возвращает подсказки загрузки в хронологическом порядке. +func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, error) { + var out []string + if err := s.DB.SelectContext(ctx, &out, + `SELECT text FROM hint WHERE download_id = ? ORDER BY id`, downloadID); err != nil { + return nil, fmt.Errorf("list hints: %w", err) + } + return out, nil +} + +// --- Ручные правки (override) --- + +// SetOverride пиннит значение поля (upsert по (download_id, field)). +func (s *Store) SetOverride(ctx context.Context, downloadID int64, field, value string) error { + const q = ` +INSERT INTO override (download_id, field, value) VALUES (?, ?, ?) +ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value` + if _, err := s.DB.ExecContext(ctx, q, downloadID, field, value); err != nil { + return fmt.Errorf("set override %q: %w", field, err) + } + return nil +} + +// ListOverrides возвращает запиненные правки загрузки как map[field]value. +func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error) { + rows, err := s.DB.QueryxContext(ctx, + `SELECT field, value FROM override WHERE download_id = ?`, downloadID) + if err != nil { + return nil, fmt.Errorf("list overrides: %w", err) + } + defer func() { _ = rows.Close() }() + + out := map[string]string{} + for rows.Next() { + var field, value string + if err := rows.Scan(&field, &value); err != nil { + return nil, fmt.Errorf("scan override: %w", err) + } + out[field] = value + } + return out, rows.Err() +} + +// --- Ссылки файлов (file_link) --- + +// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка). +type FileLink struct { + ID int64 `db:"id"` + DownloadID int64 `db:"download_id"` + ApplyBatchID string `db:"apply_batch_id"` + SrcPath string `db:"src_path"` + DstPath string `db:"dst_path"` + Kind string `db:"kind"` + Status string `db:"status"` + CreatedAt string `db:"created_at"` +} + +// CreateFileLinks вставляет батч ссылок одной транзакцией. +func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error { + if len(links) == 0 { + return nil + } + tx, err := s.DB.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + const q = ` +INSERT INTO file_link (download_id, apply_batch_id, src_path, dst_path, kind, status) +VALUES (?, ?, ?, ?, ?, ?)` + for _, l := range links { + if _, err := tx.ExecContext(ctx, q, + l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil { + return fmt.Errorf("insert file_link: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit file_links: %w", err) + } + return nil +} + +// LatestBatchID возвращает apply_batch_id последнего применённого батча +// загрузки (для undo) либо пустую строку, если ссылок нет. +func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) { + var batch string + err := s.DB.GetContext(ctx, &batch, + `SELECT apply_batch_id FROM file_link WHERE download_id = ? + ORDER BY id DESC LIMIT 1`, downloadID) + if err == sql.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("latest batch id: %w", err) + } + return batch, nil +} + +// ListFileLinksByBatch возвращает ссылки батча. +func (s *Store) ListFileLinksByBatch(ctx context.Context, batchID string) ([]FileLink, error) { + var out []FileLink + if err := s.DB.SelectContext(ctx, &out, + `SELECT * FROM file_link WHERE apply_batch_id = ? ORDER BY id`, batchID); err != nil { + return nil, fmt.Errorf("list file_links by batch: %w", err) + } + return out, nil +} + +// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС). +func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error { + if _, err := s.DB.ExecContext(ctx, + `DELETE FROM file_link WHERE apply_batch_id = ?`, batchID); err != nil { + return fmt.Errorf("delete file_links by batch: %w", err) + } + return nil +} diff --git a/internal/store/recognition_test.go b/internal/store/recognition_test.go new file mode 100644 index 0000000..491bfe3 --- /dev/null +++ b/internal/store/recognition_test.go @@ -0,0 +1,172 @@ +package store + +import ( + "context" + "database/sql" + "testing" +) + +func seedDownload(t *testing.T, st *Store) int64 { + t.Helper() + id, err := st.CreateDownload(context.Background(), + newDownloading("aabbccddeeff00112233445566778899aabbccdd")) + if err != nil { + t.Fatalf("seed download: %v", err) + } + return id +} + +func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + dl := seedDownload(t, st) + + id1, err := st.CreateRecognition(ctx, &Recognition{ + DownloadID: dl, + MediaType: NullString("series"), + Title: NullString("Show"), + Year: sql.NullInt64{Int64: 2006, Valid: true}, + Plan: NullString(`{"type":"series"}`), + }, []string{"нет матча в базе"}) + if err != nil { + t.Fatalf("create #1: %v", err) + } + + id2, err := st.CreateRecognition(ctx, &Recognition{ + DownloadID: dl, + MediaType: NullString("movie"), + Title: NullString("Show v2"), + }, []string{"уточнено"}) + if err != nil { + t.Fatalf("create #2: %v", err) + } + if id2 == id1 { + t.Fatal("ids must differ") + } + + cur, err := st.GetCurrentRecognition(ctx, dl) + if err != nil { + t.Fatalf("get current: %v", err) + } + if cur.ID != id2 { + t.Errorf("current id = %d, want %d", cur.ID, id2) + } + if cur.AttemptNo != 2 { + t.Errorf("attempt_no = %d, want 2", cur.AttemptNo) + } + if !cur.IsCurrent { + t.Error("current recognition must have is_current = true") + } + if cur.Title.String != "Show v2" { + t.Errorf("title = %q", cur.Title.String) + } + if got := cur.ReasonList(); len(got) != 1 || got[0] != "уточнено" { + t.Errorf("reasons = %v", got) + } +} + +func TestGetCurrentRecognition_None(t *testing.T) { + st := newTestStore(t) + dl := seedDownload(t, st) + cur, err := st.GetCurrentRecognition(context.Background(), dl) + if err != nil { + t.Fatalf("get current: %v", err) + } + if cur != nil { + t.Errorf("want nil, got %+v", cur) + } +} + +func TestHints(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + dl := seedDownload(t, st) + + for _, h := range []string{"второй сезон", "рус+англ дорожки"} { + if err := st.AddHint(ctx, dl, h); err != nil { + t.Fatalf("add hint: %v", err) + } + } + got, err := st.ListHints(ctx, dl) + if err != nil { + t.Fatalf("list hints: %v", err) + } + if len(got) != 2 || got[0] != "второй сезон" || got[1] != "рус+англ дорожки" { + t.Errorf("hints = %v", got) + } +} + +func TestOverrides_Upsert(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + dl := seedDownload(t, st) + + if err := st.SetOverride(ctx, dl, "media_type", "series"); err != nil { + t.Fatalf("set override: %v", err) + } + if err := st.SetOverride(ctx, dl, "media_type", "movie"); err != nil { // перезапись + t.Fatalf("override upsert: %v", err) + } + if err := st.SetOverride(ctx, dl, "ignored_files", `["sample.mkv"]`); err != nil { + t.Fatalf("set override 2: %v", err) + } + + got, err := st.ListOverrides(ctx, dl) + if err != nil { + t.Fatalf("list overrides: %v", err) + } + if got["media_type"] != "movie" { + t.Errorf("media_type = %q, want movie (upsert)", got["media_type"]) + } + if got["ignored_files"] != `["sample.mkv"]` { + t.Errorf("ignored_files = %q", got["ignored_files"]) + } +} + +func TestFileLinks_BatchLifecycle(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + dl := seedDownload(t, st) + + batch := "batch-1" + links := []FileLink{ + {DownloadID: dl, ApplyBatchID: batch, SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "linked"}, + {DownloadID: dl, ApplyBatchID: batch, SrcPath: "/d/a.srt", DstPath: "/m/A.ru.srt", Kind: "subtitle", Status: "linked"}, + } + if err := st.CreateFileLinks(ctx, links); err != nil { + t.Fatalf("create links: %v", err) + } + + latest, err := st.LatestBatchID(ctx, dl) + if err != nil || latest != batch { + t.Fatalf("latest batch = %q, %v", latest, err) + } + + got, err := st.ListFileLinksByBatch(ctx, batch) + if err != nil { + t.Fatalf("list by batch: %v", err) + } + if len(got) != 2 || got[0].DstPath != "/m/A.mkv" { + t.Errorf("links = %+v", got) + } + + if err := st.DeleteFileLinksByBatch(ctx, batch); err != nil { + t.Fatalf("delete batch: %v", err) + } + after, _ := st.ListFileLinksByBatch(ctx, batch) + if len(after) != 0 { + t.Errorf("links remain after delete: %+v", after) + } +} + +func TestLatestBatchID_None(t *testing.T) { + st := newTestStore(t) + dl := seedDownload(t, st) + latest, err := st.LatestBatchID(context.Background(), dl) + if err != nil { + t.Fatalf("latest batch: %v", err) + } + if latest != "" { + t.Errorf("want empty, got %q", latest) + } +} diff --git a/internal/worker/review.go b/internal/worker/review.go new file mode 100644 index 0000000..c41f6e5 --- /dev/null +++ b/internal/worker/review.go @@ -0,0 +1,537 @@ +package worker + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + + "git.vakhrushev.me/av/jellybit/internal/layout" + "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" +) + +// 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("нет infohash") + } + t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String) + if err != nil { + return recognize.Result{}, "", err + } + if !ok { + return recognize.Result{}, "", fmt.Errorf("торрент не найден в 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} + } + + res, err := w.recognizer.Recognize(ctx, in) + if err != nil { + return recognize.Result{}, t.SavePath, err + } + return res, t.SavePath, nil +} + +// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3 +// метабазы выключены → авто-раскладки не делаем, всегда уходим в review. +func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, _ string) { + planJSON, err := json.Marshal(res.Plan) + if err != nil { + w.log.Error("recognize: marshal plan", "download_id", id, "err", err) + planJSON = []byte("{}") + } + + rec := &store.Recognition{ + DownloadID: id, + MediaType: store.NullString(string(res.Plan.Type)), + Title: store.NullString(res.Plan.Title), + Provider: store.NullString("none"), + 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 + } + if _, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons); err != nil { + w.log.Error("recognize: persist", "download_id", id, "err", err) + return + } + w.transition(ctx, *d, store.StateReview, "", "") +} + +// --- Команды ревью --- + +// 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: раскладчик не сконфигурирован") + } + + 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: задача %d в состоянии %s (ожидалось review/deferred)", id, d.State) + } + + plan, 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: торрент не найден: %v", err) + } + + links, err := w.layouter.BuildLinks(toLayoutPlan(plan, t.SavePath)) + if err != nil { + return fmt.Errorf("apply: построение ссылок: %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: 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("apply: запись ссылок: %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) + } + w.transition(ctx, *d, store.StateFailed, "apply", applyErr.Error()) + return fmt.Errorf("apply: %w", applyErr) + } + + w.transition(ctx, *d, store.StateDone, "", "") + w.log.Info("apply: linked", "download_id", id, "batch", batch, "links", len(fl)) + 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: пустая подсказка") + } + 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.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: недопустимый тип %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: пустой путь") + } + 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) + } + 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: задача %d терминальна (%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: раскладчик не сконфигурирован") + } + + 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: задача %d в состоянии %s (ожидалось 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: нечего откатывать") + } + 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: задача %d в состоянии %s (ожидалось review/deferred)", op, id, d.State) + } + return d, nil +} + +// --- Данные для экрана ревью --- + +// ReviewData — всё, что нужно транспорту для отрисовки ревью. +type ReviewData struct { + Download store.Download + Recognition *store.Recognition + Plan recognize.Plan // эффективный (с применёнными правками) + Preview []layout.Link // целевые пути (Src — относительный, для показа) + 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) + } + + rd := &ReviewData{Download: *d, Recognition: rec, Hints: hints, Overrides: overrides} + if rec != nil && rec.Plan.Valid { + var plan recognize.Plan + if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil { + plan = applyOverrides(plan, overrides) + rd.Plan = plan + // Превью строим по относительным путям; ошибку игнорируем — + // просто покажем причины без превью. + if w.layouter != nil { + if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "")); lerr == nil { + rd.Preview = links + } + } + } + } + return rd, nil +} + +// effectivePlan загружает текущий план и применяет правки (под mu). +func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, 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("нет плана распознавания") + } + var plan recognize.Plan + if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil { + return recognize.Plan{}, fmt.Errorf("разбор плана: %w", err) + } + overrides, err := w.store.ListOverrides(ctx, id) + if err != nil { + return recognize.Plan{}, err + } + return applyOverrides(plan, overrides), 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) + } + 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 +} + +// toLayoutPlan переводит план распознавания в план раскладки. srcPrefix +// (savePath) приклеивается к относительным путям файлов; пустой — оставляет +// относительные (для превью). Роли вне main/episode/subtitle отбрасываются. +func toLayoutPlan(plan recognize.Plan, srcPrefix string) layout.Plan { + lp := layout.Plan{ + Type: layout.MediaType(plan.Type), + Title: plan.Title, + Year: plan.Year, + } + 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). +func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) { + torrents, err := w.qbt.Torrents(ctx, w.cfg.Category) + 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 +} diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go new file mode 100644 index 0000000..e842745 --- /dev/null +++ b/internal/worker/review_test.go @@ -0,0 +1,550 @@ +package worker + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/layout" + "git.vakhrushev.me/av/jellybit/internal/qbt" + "git.vakhrushev.me/av/jellybit/internal/recognize" + "git.vakhrushev.me/av/jellybit/internal/store" +) + +// memStore — полноценный in-memory store для тестов Ф3. +type memStore struct { + downloads map[int64]*store.Download + recs []*store.Recognition + hints map[int64][]string + overrides map[int64]map[string]string + links []store.FileLink +} + +func newMemStore() *memStore { + return &memStore{ + downloads: map[int64]*store.Download{}, + hints: map[int64][]string{}, + overrides: map[int64]map[string]string{}, + } +} + +func (m *memStore) put(d *store.Download) { m.downloads[d.ID] = d } + +func (m *memStore) ListDownloadsByState(_ context.Context, states ...store.State) ([]store.Download, error) { + var out []store.Download + for _, d := range m.downloads { + for _, s := range states { + if d.State == s { + out = append(out, *d) + } + } + } + return out, nil +} + +func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) { + d, ok := m.downloads[id] + if !ok { + return nil, os.ErrNotExist + } + cp := *d + return &cp, nil +} + +func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error { + d := m.downloads[id] + d.State = st + d.ErrorCode = store.NullString(code) + d.ErrorMsg = store.NullString(msg) + return nil +} + +func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (int64, error) { + for _, e := range m.recs { + if e.DownloadID == r.DownloadID { + e.IsCurrent = false + } + } + cp := *r + cp.ID = int64(len(m.recs) + 1) + cp.IsCurrent = true + cp.AttemptNo = 1 + for _, e := range m.recs { + if e.DownloadID == r.DownloadID { + cp.AttemptNo++ + } + } + b, _ := jsonMarshal(reasons) + cp.Reasons = b + m.recs = append(m.recs, &cp) + return cp.ID, nil +} + +func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID int64) (*store.Recognition, error) { + for _, e := range m.recs { + if e.DownloadID == downloadID && e.IsCurrent { + cp := *e + return &cp, nil + } + } + return nil, nil +} + +func (m *memStore) AddHint(_ context.Context, id int64, text string) error { + m.hints[id] = append(m.hints[id], text) + return nil +} +func (m *memStore) ListHints(_ context.Context, id int64) ([]string, error) { return m.hints[id], nil } + +func (m *memStore) SetOverride(_ context.Context, id int64, field, value string) error { + if m.overrides[id] == nil { + m.overrides[id] = map[string]string{} + } + m.overrides[id][field] = value + return nil +} +func (m *memStore) ListOverrides(_ context.Context, id int64) (map[string]string, error) { + return m.overrides[id], nil +} + +func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) error { + m.links = append(m.links, links...) + return nil +} +func (m *memStore) LatestBatchID(_ context.Context, id int64) (string, error) { + for i := len(m.links) - 1; i >= 0; i-- { + if m.links[i].DownloadID == id { + return m.links[i].ApplyBatchID, nil + } + } + return "", nil +} +func (m *memStore) ListFileLinksByBatch(_ context.Context, batch string) ([]store.FileLink, error) { + var out []store.FileLink + for _, l := range m.links { + if l.ApplyBatchID == batch { + out = append(out, l) + } + } + return out, nil +} +func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error { + kept := m.links[:0] + for _, l := range m.links { + if l.ApplyBatchID != batch { + kept = append(kept, l) + } + } + m.links = kept + return nil +} + +func jsonMarshal(v any) (string, error) { + b, err := json.Marshal(v) + return string(b), err +} + +// fakeRecognizer возвращает заданный результат; onCall — побочный эффект для +// симуляции гонок (напр. отмена во время вызова LLM). +type fakeRecognizer struct { + result recognize.Result + err error + onCall func() + calls int +} + +func (f *fakeRecognizer) Recognize(_ context.Context, _ recognize.Input) (recognize.Result, error) { + f.calls++ + if f.onCall != nil { + f.onCall() + } + return f.result, f.err +} + +func testWorkerWith(st Store, qb QBittorrent, rec Recognizer, lay Layouter) *Worker { + w := New(st, qb, rec, lay, Config{Category: "jellybit"}, + slog.New(slog.NewTextHandler(io.Discard, nil))) + n := 0 + w.newID = func() string { n++; return "batch-" + itoa(n) } + return w +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +const ihTest = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" + +func completedDownload(id int64) *store.Download { + return &store.Download{ + ID: id, State: store.StateCompleted, SourceType: store.SourceMagnet, + SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohash: store.NullString(ihTest), + Context: "ctx", + } +} + +func seriesResult() recognize.Result { + s, e1, e2 := 2, 1, 2 + return recognize.Result{ + Plan: recognize.Plan{ + Type: recognize.MediaSeries, Title: "Show", Year: 2006, Confidence: 0.7, + Files: []recognize.PlanFile{ + {Src: "Show/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e1}, + {Src: "Show/e2.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e2}, + }, + }, + Decision: recognize.Decision{Reasons: []string{"нет матча в базе"}}, + Raw: `{"type":"series"}`, + } +} + +func TestRecognizeOne_CompletedToReview(t *testing.T) { + st := newMemStore() + st.put(completedDownload(1)) + qb := &fakeQbt{ + torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", Category: "jellybit"}}, + files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}, {Name: "Show/e2.mkv", Size: 100}}, + } + rec := &fakeRecognizer{result: seriesResult()} + w := testWorkerWith(st, qb, rec, nil) + + w.recognizeOne(context.Background(), 1) + + if st.downloads[1].State != store.StateReview { + t.Fatalf("state = %q, want review", st.downloads[1].State) + } + cur, _ := st.GetCurrentRecognition(context.Background(), 1) + if cur == nil || cur.Title.String != "Show" { + t.Fatalf("recognition = %+v", cur) + } + if !cur.Plan.Valid { + t.Error("plan must be persisted") + } +} + +func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) { + st := newMemStore() + st.put(completedDownload(1)) + qb := &fakeQbt{ + torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}, + files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}}, + } + // Во время вызова LLM задачу отменяют. + rec := &fakeRecognizer{result: seriesResult(), onCall: func() { + st.downloads[1].State = store.StateCancelled + }} + w := testWorkerWith(st, qb, rec, nil) + + w.recognizeOne(context.Background(), 1) + + if st.downloads[1].State != store.StateCancelled { + t.Errorf("state = %q, want cancelled (result discarded)", st.downloads[1].State) + } + if cur, _ := st.GetCurrentRecognition(context.Background(), 1); cur != nil { + t.Error("recognition must not be persisted after discard") + } +} + +func TestRecognizeOne_SignalsErrorToReview(t *testing.T) { + st := newMemStore() + st.put(completedDownload(1)) + qb := &fakeQbt{torrents: nil} // торрент пропал + rec := &fakeRecognizer{result: seriesResult()} + w := testWorkerWith(st, qb, rec, nil) + + w.recognizeOne(context.Background(), 1) + + if st.downloads[1].State != store.StateReview { + t.Fatalf("state = %q, want review", st.downloads[1].State) + } + cur, _ := st.GetCurrentRecognition(context.Background(), 1) + if cur == nil || len(cur.ReasonList()) == 0 { + t.Fatal("expected review with reason") + } +} + +func TestRefine_AddsHintAndRerecognizes(t *testing.T) { + st := newMemStore() + d := completedDownload(1) + d.State = store.StateReview + st.put(d) + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + + if err := w.Refine(context.Background(), 1, "это второй сезон"); err != nil { + t.Fatalf("Refine: %v", err) + } + if st.downloads[1].State != store.StateRecognizing { + t.Errorf("state = %q, want recognizing", st.downloads[1].State) + } + if h := st.hints[1]; len(h) != 1 || h[0] != "это второй сезон" { + t.Errorf("hints = %v", h) + } + if err := w.Refine(context.Background(), 1, " "); err == nil { + t.Error("empty hint must be rejected") + } +} + +func TestSetType(t *testing.T) { + st := newMemStore() + d := completedDownload(1) + d.State = store.StateReview + st.put(d) + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + + if err := w.SetType(context.Background(), 1, "series"); err != nil { + t.Fatalf("SetType: %v", err) + } + if st.overrides[1][ovrMediaType] != "series" { + t.Errorf("override = %v", st.overrides[1]) + } + if st.downloads[1].State != store.StateRecognizing { + t.Errorf("state = %q, want recognizing", st.downloads[1].State) + } + if err := w.SetType(context.Background(), 1, "cartoon"); err == nil { + t.Error("invalid type must be rejected") + } +} + +func TestIgnoreFile(t *testing.T) { + st := newMemStore() + d := completedDownload(1) + d.State = store.StateReview + st.put(d) + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + + if err := w.IgnoreFile(context.Background(), 1, "Show/sample.mkv"); err != nil { + t.Fatalf("IgnoreFile: %v", err) + } + if err := w.IgnoreFile(context.Background(), 1, "Show/sample.mkv"); err != nil { // повтор не дублирует + t.Fatalf("IgnoreFile repeat: %v", err) + } + ignored := parseIgnored(st.overrides[1][ovrIgnoredFiles]) + if len(ignored) != 1 || ignored[0] != "Show/sample.mkv" { + t.Errorf("ignored = %v", ignored) + } + if st.downloads[1].State != store.StateReview { + t.Errorf("ignore must keep review, got %q", st.downloads[1].State) + } +} + +func TestDefer(t *testing.T) { + st := newMemStore() + d := completedDownload(1) + d.State = store.StateReview + st.put(d) + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + + if err := w.Defer(context.Background(), 1); err != nil { + t.Fatalf("Defer: %v", err) + } + if st.downloads[1].State != store.StateDeferred { + t.Errorf("state = %q, want deferred", st.downloads[1].State) + } +} + +// applyFixture — реальный layouter с temp-библиотеками и исходными файлами. +type applyFixture struct { + w *Worker + st *memStore + downloads string + movies string + series string +} + +// newApplyFixture готовит worker с реальным layouter: исходные файлы лежат в +// downloads (он же savePath торрента), библиотеки — movies/series. +func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture { + t.Helper() + 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) + } + for _, f := range plan.Files { + p := filepath.Join(downloads, f.Src) + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte("data-"+f.Src), 0o644); err != nil { + t.Fatal(err) + } + } + lay, err := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}) + if err != nil { + t.Fatal(err) + } + + st := newMemStore() + d := completedDownload(1) + d.State = store.StateReview + st.put(d) + planJSON, _ := json.Marshal(plan) + st.recs = append(st.recs, &store.Recognition{ + ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)), + }) + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit"}}} + w := testWorkerWith(st, qb, &fakeRecognizer{}, lay) + + return applyFixture{w: w, st: st, downloads: downloads, movies: movies, series: series} +} + +func TestApply_LinksAndDone(t *testing.T) { + f := newApplyFixture(t, seriesResult().Plan) + + if err := f.w.Apply(context.Background(), 1); err != nil { + t.Fatalf("Apply: %v", err) + } + if f.st.downloads[1].State != store.StateDone { + t.Fatalf("state = %q, want done", f.st.downloads[1].State) + } + if len(f.st.links) != 2 { + t.Fatalf("file_links = %d, want 2", len(f.st.links)) + } + want := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Errorf("expected hardlink %q: %v", want, err) + } + if _, err := os.Stat(filepath.Join(f.downloads, "Show/e1.mkv")); err != nil { + t.Errorf("source must remain: %v", err) + } +} + +func TestApply_IgnoredFileSkipped(t *testing.T) { + plan := seriesResult().Plan + s, e := 2, 9 + plan.Files = append(plan.Files, recognize.PlanFile{ + Src: "Show/sample.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e, + }) + f := newApplyFixture(t, plan) + _ = f.st.SetOverride(context.Background(), 1, ovrIgnoredFiles, `["Show/sample.mkv"]`) + + if err := f.w.Apply(context.Background(), 1); err != nil { + t.Fatalf("Apply: %v", err) + } + if len(f.st.links) != 2 { // sample пропущен + t.Errorf("file_links = %d, want 2 (sample ignored)", len(f.st.links)) + } +} + +func TestApply_CollisionStaysReview(t *testing.T) { + plan := seriesResult().Plan + f := newApplyFixture(t, plan) + // Занимаем цель первой серии чужим файлом. + dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") + _ = os.MkdirAll(filepath.Dir(dst), 0o755) + _ = os.WriteFile(dst, []byte("foreign"), 0o644) + + err := f.w.Apply(context.Background(), 1) + if err == nil { + t.Fatal("want collision error") + } + if f.st.downloads[1].State != store.StateReview { + t.Errorf("state = %q, want review after collision", f.st.downloads[1].State) + } + b, _ := os.ReadFile(dst) + if string(b) != "foreign" { + t.Errorf("foreign file overwritten: %q", b) + } +} + +func TestUndo_RevertsLinks(t *testing.T) { + plan := seriesResult().Plan + f := newApplyFixture(t, plan) + if err := f.w.Apply(context.Background(), 1); err != nil { + t.Fatalf("Apply: %v", err) + } + dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") + if _, err := os.Stat(dst); err != nil { + t.Fatalf("precondition: link must exist: %v", err) + } + + if err := f.w.Undo(context.Background(), 1); err != nil { + t.Fatalf("Undo: %v", err) + } + if f.st.downloads[1].State != store.StateReverted { + t.Errorf("state = %q, want reverted", f.st.downloads[1].State) + } + if _, err := os.Stat(dst); !os.IsNotExist(err) { + t.Errorf("link must be removed: %v", err) + } + if len(f.st.links) != 0 { + t.Errorf("file_links must be deleted, got %d", len(f.st.links)) + } + // Источник цел. + if _, err := os.Stat(filepath.Join(f.downloads, "Show/e1.mkv")); err != nil { + t.Errorf("source removed by undo: %v", err) + } +} + +func TestReviewData(t *testing.T) { + plan := seriesResult().Plan + f := newApplyFixture(t, plan) + _ = f.st.AddHint(context.Background(), 1, "подсказка") + + rd, err := f.w.ReviewData(context.Background(), 1) + if err != nil { + t.Fatalf("ReviewData: %v", err) + } + if rd.Recognition == nil || len(rd.Plan.Files) != 2 { + t.Fatalf("plan files = %+v", rd.Plan) + } + if len(rd.Preview) != 2 { + t.Errorf("preview links = %d, want 2", len(rd.Preview)) + } + if len(rd.Hints) != 1 { + t.Errorf("hints = %v", rd.Hints) + } +} + +func TestApplyOverrides(t *testing.T) { + plan := recognize.Plan{ + Type: recognize.MediaMovie, + Files: []recognize.PlanFile{ + {Src: "a.mkv", Role: recognize.RoleMain}, + {Src: "b.mkv", Role: recognize.RoleEpisode}, + }, + } + out := applyOverrides(plan, map[string]string{ + ovrMediaType: "series", + ovrIgnoredFiles: `["a.mkv"]`, + }) + if out.Type != recognize.MediaSeries { + t.Errorf("type = %q, want series", out.Type) + } + if out.Files[0].Role != "ignore" { + t.Errorf("a.mkv role = %q, want ignore", out.Files[0].Role) + } +} + +func TestToLayoutPlan(t *testing.T) { + s, e := 1, 3 + plan := recognize.Plan{ + Type: recognize.MediaSeries, Title: "X", Year: 2020, + Files: []recognize.PlanFile{ + {Src: "e.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}, + {Src: "sample.mkv", Role: "sample"}, + }, + } + lp := toLayoutPlan(plan, "/d") + if len(lp.Files) != 1 { + t.Fatalf("want 1 linkable file, got %d", len(lp.Files)) + } + if lp.Files[0].Src != filepath.Join("/d", "e.mkv") { + t.Errorf("src = %q", lp.Files[0].Src) + } + if lp.Files[0].Role != layout.RoleEpisode { + t.Errorf("role = %q", lp.Files[0].Role) + } +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 74f472a..b5f0893 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -4,7 +4,11 @@ // состояние. // // Ф1 ведёт задачу downloading → completed, плюс stuck/failed по таймаутам и -// ошибкам qBittorrent. Распознавание и раскладка (completed →) — Ф2+. +// ошибкам qBittorrent. Ф3 продолжает: completed → recognizing (вызов +// recognize) → review; команды ревью (apply/refine/reject/defer/undo, +// переключение типа, пометка «игнор») раскладывают файлы хардлинками через +// layout. Распознавание зовётся в поллинг-цикле, команды — из транспортов; +// всё под per-download блокировкой w.mu. package worker import ( @@ -15,7 +19,9 @@ import ( "sync" "time" + "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/qbt" + "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" ) @@ -24,12 +30,37 @@ type Store interface { ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error) GetDownload(ctx context.Context, id int64) (*store.Download, error) SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error + + // Ф3: распознавание, ревью, раскладка. + CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error) + GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error) + AddHint(ctx context.Context, downloadID int64, text string) error + ListHints(ctx context.Context, downloadID int64) ([]string, error) + SetOverride(ctx context.Context, downloadID int64, field, value string) error + ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error) + CreateFileLinks(ctx context.Context, links []store.FileLink) error + LatestBatchID(ctx context.Context, downloadID int64) (string, error) + ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error) + DeleteFileLinksByBatch(ctx context.Context, batchID string) error } // QBittorrent — нужная worker часть клиента qBittorrent. type QBittorrent interface { Torrents(ctx context.Context, category string) ([]qbt.Torrent, error) Add(ctx context.Context, ar qbt.AddRequest) error + Files(ctx context.Context, hash string) ([]qbt.File, error) +} + +// Recognizer — распознаватель (recognize.Recognizer). +type Recognizer interface { + Recognize(ctx context.Context, in recognize.Input) (recognize.Result, error) +} + +// Layouter — раскладчик хардлинками (layout.Layouter). +type Layouter interface { + BuildLinks(p layout.Plan) ([]layout.Link, error) + Apply(ctx context.Context, links []layout.Link) ([]layout.Result, error) + Undo(ctx context.Context, links []layout.Link) (int, error) } // Config — параметры воркера. @@ -43,18 +74,36 @@ type Config struct { // Worker — поллер и владелец переходов. type Worker struct { - store Store - qbt QBittorrent - cfg Config - log *slog.Logger + store Store + qbt QBittorrent + recognizer Recognizer + layouter Layouter + cfg Config + log *slog.Logger - mu sync.Mutex // сериализует переходы (поллинг + команды) - now func() time.Time // подменяется в тестах + mu sync.Mutex // сериализует переходы (поллинг + команды) + now func() time.Time // подменяется в тестах + newID func() string // генератор apply_batch_id (подменяется в тестах) } -// New собирает воркер. -func New(st Store, qb QBittorrent, cfg Config, log *slog.Logger) *Worker { - return &Worker{store: st, qbt: qb, cfg: cfg, log: log, now: time.Now} +// New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней +// распознавания и раскладки) — тогда completed-задачи не двигаются дальше. +func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker { + return &Worker{ + store: st, + qbt: qb, + recognizer: rec, + layouter: lay, + cfg: cfg, + log: log, + now: time.Now, + newID: defaultBatchID, + } +} + +// defaultBatchID — уникальный идентификатор батча раскладки. +func defaultBatchID() string { + return fmt.Sprintf("b-%d", time.Now().UnixNano()) } // Run крутит цикл поллинга до отмены ctx. @@ -79,6 +128,10 @@ func (w *Worker) pollOnce(ctx context.Context) { if err := w.Poll(ctx); err != nil { w.log.Warn("poll failed", "err", err) } + // Ф3: распознаём завершённые загрузки (и перезапускаем по подсказке). + if w.recognizer != nil { + w.recognizePending(ctx) + } } // Poll сверяет активные задачи с состоянием qBittorrent и двигает их. diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 90e1c04..d7633cf 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -63,9 +63,31 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State return nil } +// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) --- + +func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) { + return 0, nil +} +func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ int64) (*store.Recognition, error) { + return nil, nil +} +func (f *fakeStore) AddHint(_ context.Context, _ int64, _ string) error { return nil } +func (f *fakeStore) ListHints(_ context.Context, _ int64) ([]string, error) { return nil, nil } +func (f *fakeStore) SetOverride(_ context.Context, _ int64, _, _ string) error { return nil } +func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string, error) { + return nil, nil +} +func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil } +func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil } +func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) { + return nil, nil +} +func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil } + type fakeQbt struct { torrents []qbt.Torrent added []qbt.AddRequest + files []qbt.File } func (f *fakeQbt) Torrents(_ context.Context, _ string) ([]qbt.Torrent, error) { @@ -77,8 +99,12 @@ func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error { return nil } +func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) { + return f.files, nil +} + func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker { - w := New(st, qb, Config{ + w := New(st, qb, nil, nil, Config{ Category: "jellybit", SavePath: "/srv/media/downloads", MagnetTimeout: 30 * time.Minute, diff --git a/web/templates/index.html b/web/templates/index.html index 5cc8a23..a07ebe0 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -19,9 +19,17 @@ .state { font-size: .8rem; padding: .1rem .5rem; border-radius: 1rem; background: #8883; white-space: nowrap; } .state-completed { background: #2ecc7155; } .state-downloading { background: #3498db55; } + .state-recognizing { background: #9b59b655; } + .state-review { background: #f1c40f88; } + .state-deferred { background: #f39c1255; } + .state-linking { background: #1abc9c55; } + .state-done { background: #2ecc7188; } .state-stuck { background: #f39c1255; } .state-failed { background: #e74c3c55; } .state-cancelled { background: #95a5a655; } + .state-reverted { background: #95a5a655; } + .actions { display: flex; gap: .4rem; flex-wrap: wrap; } + a.button { display: inline-block; padding: .35rem .6rem; border: 1px solid #8886; border-radius: .3rem; text-decoration: none; } small { color: #8888; } @@ -52,11 +60,21 @@ {{if .Error}}
{{.Error}}{{end}} - {{if not .Terminal}} -
- -
- {{end}} +
+ {{if .Reviewable}} + Ревью → + {{end}} + {{if .Undoable}} +
+ +
+ {{end}} + {{if not .Terminal}} +
+ +
+ {{end}} +
{{else}} diff --git a/web/templates/review.html b/web/templates/review.html new file mode 100644 index 0000000..eac62b7 --- /dev/null +++ b/web/templates/review.html @@ -0,0 +1,122 @@ + + + + + + jellybit · ревью #{{.ID}} + + + +

← к списку

+

Ревью #{{.ID}} {{.State}}

+

{{.Source}}

+ {{if .Context}}

Контекст: «{{.Context}}»

{{end}} + + {{if .Error}}

{{.Error}}

{{end}} + {{if .StateError}}

{{.StateError}}

{{end}} + + {{if eq .State "recognizing"}} +

⏳ Идёт распознавание, обновите страницу через несколько секунд…

+

Обновить

+ {{end}} + + {{if .Reasons}} +
+ Причины ревью{{if .Confidence}} · уверенность {{.Confidence}}{{end}} +
    {{range .Reasons}}
  • {{.}}
  • {{end}}
+
+ {{end}} + + {{if .HasPlan}} +
+ Догадка +

+ Тип: {{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}} + · Название: {{.Title}}{{if .OriginalTitle}} ({{.OriginalTitle}}){{end}} + {{if .Year}}· Год: {{.Year}}{{end}} +

+
+ Переключить тип: + + + (пересоберёт план) +
+
+ +
+ Файлы → роль + + + + {{range $i, $f := .Files}} + + + + + + + + + {{end}} + +
#файлрольSE
{{add $i 1}}{{$f.Src}}{{$f.Role}}{{$f.Season}}{{$f.Episode}} + {{if not $f.Ignored}} +
+ + +
+ {{end}} +
+
+ + {{if .Preview}} +
+ Превью раскладки (будут созданы хардлинки) +
    {{range .Preview}}
  • {{.}}
  • {{end}}
+
+ {{end}} + {{end}} + +
+ Уточнить и перераспознать +
+

+ +
+ {{if .Hints}} +

Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}

+ {{end}} +
+ +
+ {{if .Preview}} +
+ {{end}} +
+
+
+ +