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}}
{{.Source}}
+ {{if .Context}}Контекст: «{{.Context}}»
{{end}} + + {{if .Error}}{{.Error}}
{{end}} + {{if .StateError}}{{.StateError}}
{{end}} + + {{if eq .State "recognizing"}} +⏳ Идёт распознавание, обновите страницу через несколько секунд…
+ + {{end}} + + {{if .Reasons}} ++ Тип: {{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}} + · Название: {{.Title}}{{if .OriginalTitle}} ({{.OriginalTitle}}){{end}} + {{if .Year}}· Год: {{.Year}}{{end}} +
+ +| # | файл | роль | S | E | |
|---|---|---|---|---|---|
| {{add $i 1}} | +{{$f.Src}} | +{{$f.Role}} | +{{$f.Season}} | +{{$f.Episode}} | ++ {{if not $f.Ignored}} + + {{end}} + | +
Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}
+ {{end}} +