Раскладка файлов после распознавния
This commit is contained in:
+34
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Package layout — конвенции Jellyfin, санитизация путей, хардлинкер, undo.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф3 (см. docs/specs/jellyfin-layout.md).
|
||||
package layout
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 и двигает их.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user