Раскладка файлов после распознавния

This commit is contained in:
2026-06-14 14:53:40 +03:00
parent 91c501624a
commit 9c1b178e46
19 changed files with 3001 additions and 38 deletions
+34 -1
View File
@@ -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
+22 -1
View File
@@ -46,11 +46,13 @@ type Deps struct {
Ingestor Ingestor
Commander Commander
Reader Reader
Reviewer Reviewer
}
type server struct {
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)
@@ -104,6 +121,8 @@ type downloadView struct {
State string
Error string
Terminal bool
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -279,6 +298,8 @@ func toView(d store.Download) downloadView {
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,
}
}
+200
View File
@@ -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)
}
}
+202
View File
@@ -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)
}
-4
View File
@@ -1,4 +0,0 @@
// Package layout — конвенции Jellyfin, санитизация путей, хардлинкер, undo.
//
// Заглушка: реализация в фазе Ф3 (см. docs/specs/jellyfin-layout.md).
package layout
+315
View File
@@ -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)
}
}
+260
View File
@@ -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")
}
}
+97
View File
@@ -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))
}
+93
View File
@@ -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"},
{"a<b>c|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")
}
}
+30
View File
@@ -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
}
@@ -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;
+230
View File
@@ -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
}
+172
View File
@@ -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)
}
}
+537
View File
@@ -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
}
+550
View File
@@ -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)
}
}
+57 -4
View File
@@ -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 — параметры воркера.
@@ -45,16 +76,34 @@ type Config struct {
type Worker struct {
store Store
qbt QBittorrent
recognizer Recognizer
layouter Layouter
cfg Config
log *slog.Logger
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 и двигает их.
+27 -1
View File
@@ -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,
+18
View File
@@ -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; }
</style>
</head>
@@ -52,11 +60,21 @@
{{if .Error}}<br><small>{{.Error}}</small>{{end}}
</td>
<td>
<div class="actions">
{{if .Reviewable}}
<a class="button" href="/review/{{.ID}}">Ревью →</a>
{{end}}
{{if .Undoable}}
<form method="post" action="/ui/downloads/{{.ID}}/undo">
<button type="submit">Откатить</button>
</form>
{{end}}
{{if not .Terminal}}
<form method="post" action="/ui/downloads/{{.ID}}/cancel">
<button type="submit">Отклонить</button>
</form>
{{end}}
</div>
</td>
</tr>
{{else}}
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jellybit · ревью #{{.ID}}</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; max-width: 70rem; margin: 2rem auto; padding: 0 1rem; }
h1 { margin-bottom: .25rem; }
a { color: inherit; }
input, button, select, textarea { padding: .4rem .6rem; font-size: 1rem; font-family: inherit; }
table { width: 100%; border-collapse: collapse; margin: .5rem 0 1rem; }
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid #8884; vertical-align: top; }
td.src, li.path { font-family: monospace; font-size: .85rem; word-break: break-all; }
.err { color: #c0392b; }
.state { font-size: .8rem; padding: .1rem .5rem; border-radius: 1rem; background: #8883; }
.state-review { background: #f1c40f88; }
.state-recognizing { background: #9b59b655; }
.state-deferred { background: #f39c1255; }
section { border: 1px solid #8883; border-radius: .5rem; padding: .75rem 1rem; margin: 1rem 0; }
.reasons li { color: #b9770e; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
.actions { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1rem; }
.actions form { display: inline; }
.ignored td { opacity: .5; text-decoration: line-through; }
ul.preview { margin: 0; padding-left: 1.2rem; }
small { color: #8888; }
textarea { width: 100%; box-sizing: border-box; }
</style>
</head>
<body>
<p><a href="/">← к списку</a></p>
<h1>Ревью #{{.ID}} <span class="state state-{{.State}}">{{.State}}</span></h1>
<p class="src"><small>{{.Source}}</small></p>
{{if .Context}}<p>Контекст: «{{.Context}}»</p>{{end}}
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .StateError}}<p class="err">{{.StateError}}</p>{{end}}
{{if eq .State "recognizing"}}
<p>⏳ Идёт распознавание, обновите страницу через несколько секунд…</p>
<p><a href="/review/{{.ID}}">Обновить</a></p>
{{end}}
{{if .Reasons}}
<section>
<strong>Причины ревью</strong>{{if .Confidence}} · уверенность {{.Confidence}}{{end}}
<ul class="reasons">{{range .Reasons}}<li>{{.}}</li>{{end}}</ul>
</section>
{{end}}
{{if .HasPlan}}
<section>
<strong>Догадка</strong>
<p class="row">
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
</p>
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
Переключить тип:
<button name="type" value="movie" {{if not .IsSeries}}disabled{{end}}>фильм</button>
<button name="type" value="series" {{if .IsSeries}}disabled{{end}}>сериал</button>
<small>(пересоберёт план)</small>
</form>
</section>
<section>
<strong>Файлы → роль</strong>
<table>
<thead><tr><th>#</th><th>файл</th><th>роль</th><th>S</th><th>E</th><th></th></tr></thead>
<tbody>
{{range $i, $f := .Files}}
<tr {{if $f.Ignored}}class="ignored"{{end}}>
<td>{{add $i 1}}</td>
<td class="src">{{$f.Src}}</td>
<td>{{$f.Role}}</td>
<td>{{$f.Season}}</td>
<td>{{$f.Episode}}</td>
<td>
{{if not $f.Ignored}}
<form method="post" action="/ui/downloads/{{$.ID}}/ignore">
<input type="hidden" name="src" value="{{$f.Src}}">
<button type="submit">игнор</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{if .Preview}}
<section>
<strong>Превью раскладки</strong> <small>(будут созданы хардлинки)</small>
<ul class="preview">{{range .Preview}}<li class="path">{{.}}</li>{{end}}</ul>
</section>
{{end}}
{{end}}
<section>
<strong>Уточнить и перераспознать</strong>
<form method="post" action="/ui/downloads/{{.ID}}/refine">
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
<button type="submit">🔁 Уточнить</button>
</form>
{{if .Hints}}
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
{{end}}
</section>
<div class="actions">
{{if .Preview}}
<form method="post" action="/ui/downloads/{{.ID}}/apply"><button type="submit">✅ Применить</button></form>
{{end}}
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button type="submit">🕗 Позже</button></form>
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button type="submit">❌ Отклонить</button></form>
</div>
</body>
</html>