Добавил выбор из кандидатов, если LLM не уверена в раскладке

This commit is contained in:
2026-06-14 16:43:50 +03:00
parent 4af3ad2dde
commit 7f7f5f69d4
16 changed files with 831 additions and 88 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ web_base_url = "" # напр. "http://umbar:8080" — для
[http]
listen = ":8080"
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано
[log]
level = "info"
+5 -4
View File
@@ -84,9 +84,10 @@ SQLite; на старте `worker` сверяет категорию qBittorrent
reject / defer / undo) — команды к `worker`:
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
(server-rendered + htmx). В v1 **без авторизации** (доверенная LAN), с
опциональным allowlist подсетей (`http.trusted_subnets`). Защиту
навесим позже — [drafts/ideas.md](../drafts/ideas.md).
(server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
`http.trusted_subnets` зарезервировано, но **пока не применяется**:
деплой только в локальную сеть без доступа из интернета, поэтому
allowlist-middleware и авторизацию отложили — [drafts/ideas.md](../drafts/ideas.md).
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
@@ -189,7 +190,7 @@ allowed_user_ids = [] # пусто = запрет всем (fail-closed)
[http]
listen = ":8080"
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
trusted_subnets = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN)
[log]
level = "info"
+3
View File
@@ -96,6 +96,9 @@ type Telegram struct {
// HTTP — параметры веб-сервера.
type HTTP struct {
Listen string `toml:"listen"`
// TrustedSubnets — allowlist подсетей. ПОКА НЕ ПРИМЕНЯЕТСЯ: деплой только
// в локальную сеть без доступа из интернета, поэтому middleware отложено
// (см. architecture.md). Поле сохранено под будущую реализацию.
TrustedSubnets []string `toml:"trusted_subnets"`
}
+3
View File
@@ -87,6 +87,9 @@ func NewRouter(d Deps) (http.Handler, error) {
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}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
+70 -1
View File
@@ -2,6 +2,7 @@ package httpapi_test
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
@@ -187,9 +188,12 @@ type fakeReviewer struct {
refined map[int64]string
typed map[int64]string
ignored map[int64]string
chosen map[int64]int64
providerSet map[int64]string
applied []int64
deferred []int64
undone []int64
cleared []int64
}
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
@@ -231,6 +235,24 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
f.undone = append(f.undone, id)
return nil
}
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
if f.chosen == nil {
f.chosen = map[int64]int64{}
}
f.chosen[id] = candidateID
return nil
}
func (f *fakeReviewer) SetProviderID(_ context.Context, id int64, provider, providerID string) error {
if f.providerSet == nil {
f.providerSet = map[int64]string{}
}
f.providerSet[id] = provider + ":" + providerID
return nil
}
func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
f.cleared = append(f.cleared, id)
return nil
}
func seriesReviewData() *worker.ReviewData {
s, e := 2, 1
@@ -248,6 +270,11 @@ func seriesReviewData() *worker.ReviewData {
Preview: []layout.Link{
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
},
Candidates: []store.MetadataCandidate{
{ID: 10, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
Year: sql.NullInt64{Int64: 2014, Valid: true}},
{ID: 11, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
},
Hints: []string{"второй сезон"},
}
}
@@ -274,13 +301,55 @@ func TestReviewRenders(t *testing.T) {
t.Fatalf("status = %d", resp.StatusCode)
}
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
"Season 02", "Применить", "Уточнить"} {
"Season 02", "Применить", "Уточнить",
"База метаданных", "269613", "выбрать", "Без базы"} {
if !strings.Contains(string(body), want) {
t.Errorf("страница ревью не содержит %q", want)
}
}
}
func TestChooseCandidate(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/candidate",
map[string][]string{"candidate_id": {"10"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if rv.chosen[1] != 10 {
t.Errorf("ChooseCandidate получил %d", rv.chosen[1])
}
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
t.Errorf("Location = %q", loc)
}
}
func TestSetProviderAndNoBase(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/provider",
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
t.Fatal(err)
}
if rv.providerSet[1] != "tvdb:269613" {
t.Errorf("SetProviderID получил %q", rv.providerSet[1])
}
if _, err := cl.Post(srv.URL+"/ui/downloads/1/nobase", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.cleared) != 1 || rv.cleared[0] != 1 {
t.Errorf("ClearProvider = %v", rv.cleared)
}
}
func TestApplyRedirectsToIndex(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
+56 -3
View File
@@ -20,6 +20,9 @@ type Reviewer interface {
IgnoreFile(ctx context.Context, id int64, src string) error
Defer(ctx context.Context, id int64) error
Undo(ctx context.Context, id int64) error
ChooseCandidate(ctx context.Context, id, candidateID int64) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
ClearProvider(ctx context.Context, id int64) error
}
// --- Представление страницы ревью ---
@@ -44,6 +47,8 @@ type reviewView struct {
Files []reviewFileView
Preview []string
HasPlan bool
NoBase bool // выбрано «без базы»
Candidates []candidateView
}
type reviewFileView struct {
@@ -54,6 +59,15 @@ type reviewFileView struct {
Ignored bool
}
type candidateView struct {
ID int64
Provider string
ProviderID string
Title string
Year int
Chosen bool
}
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
@@ -87,9 +101,12 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
view.OriginalTitle = rd.Plan.OriginalTitle
view.Year = rd.Plan.Year
view.Reasons = rec.ReasonList()
if rec.Provider.Valid && rec.Provider.String != "none" {
view.Provider = rec.Provider.String
view.ProviderID = rec.ProviderID.String
switch rd.Provider {
case "", "none":
view.NoBase = rd.Provider == "none"
default:
view.Provider = rd.Provider
view.ProviderID = rd.ProviderID
}
if rec.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
@@ -104,6 +121,16 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
})
}
view.HasPlan = len(rd.Plan.Files) > 0
for _, c := range rd.Candidates {
view.Candidates = append(view.Candidates, candidateView{
ID: c.ID,
Provider: c.Provider,
ProviderID: c.ProviderID,
Title: c.Title.String,
Year: int(c.Year.Int64),
Chosen: c.Chosen,
})
}
}
for _, l := range rd.Preview {
view.Preview = append(view.Preview, l.Dst)
@@ -151,6 +178,32 @@ func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
})
}
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm()
candidateID, err := strconv.ParseInt(r.PostForm.Get("candidate_id"), 10, 64)
if err != nil {
return errInvalidCandidate
}
return s.deps.Reviewer.ChooseCandidate(ctx, id, candidateID)
})
}
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm()
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
})
}
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
return s.deps.Reviewer.ClearProvider(ctx, id)
})
}
var errInvalidCandidate = errors.New("некорректный id кандидата")
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
+48 -20
View File
@@ -8,14 +8,17 @@ import (
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// matchMetadata сверяет план с включёнными базами: ищет по названию+году и,
// если ровно один кандидат уверенно совпадает (название и год), возвращает
// матч с официальным id и каноническим именем. Несколько кандидатов или их
// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не
// валят распознавание — просто нет матча.
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
// maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
const maxCandidates = 8
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
// сильный матч — ровно один кандидат с совпадением названия и года (для него
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
if len(r.providers) == 0 {
return nil
return nil, nil
}
mt := metadata.Movie
if plan.Type == MediaSeries {
@@ -28,35 +31,52 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
}
matchTitles := normSet(plan.Title, plan.OriginalTitle)
var match *Match
var candidates []metadata.Candidate
seen := map[string]bool{}
for _, p := range r.providers {
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
if err != nil {
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
continue
}
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
for _, c := range cands {
key := c.Provider + ":" + c.ID
if seen[key] || len(candidates) >= maxCandidates {
continue
}
seen[key] = true
candidates = append(candidates, c)
}
// Единичный сильный матч ищем у первого подходящего провайдера.
if match != nil {
continue
}
strong := strongMatches(cands, plan.Year, matchTitles)
if len(strong) != 1 {
continue
}
c := strong[0]
match = r.buildMatch(ctx, p, strong[0], mt)
}
return match, candidates
}
// Число серий тянем по нативному id провайдера.
// buildMatch тянет число серий (по нативному id) и собирает Match с
// тег-предпочтительным провенансом.
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
var counts map[int]int
if mt == metadata.Series {
if got, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
counts = got
} else {
r.log.Warn("recognize: episode counts failed",
"provider", p.Name(), "id", c.ID, "err", cerr)
r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
}
}
// Провенанс и тег папки — по внешнему id, если провайдер его дал
// (TVMaze отдаёт TVDB/IMDb-id); иначе по самому провайдеру.
prov, pid := c.Provider, c.ID
if c.TagProvider != "" {
prov, pid = c.TagProvider, c.TagID
}
prov, pid := CandidateTag(c)
return &Match{
Provider: prov,
ProviderID: pid,
@@ -64,8 +84,16 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
Year: c.Year,
SeasonEpisodeCounts: counts,
}
}
// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
// поиска. Используется и в матче, и при сохранении кандидатов.
func CandidateTag(c metadata.Candidate) (provider, id string) {
if c.TagProvider != "" {
return c.TagProvider, c.TagID
}
return nil
return c.Provider, c.ID
}
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
+52 -8
View File
@@ -44,7 +44,7 @@ func TestMatchMetadata_SingleStrong(t *testing.T) {
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
}}
r := recognizerWith(p)
m := r.matchMetadata(context.Background(),
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
if m == nil {
t.Fatal("expected match")
@@ -61,16 +61,60 @@ func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
{ID: "2", Title: "Fargo", Year: 2014},
}}
r := recognizerWith(p)
if m := r.matchMetadata(context.Background(),
if m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
t.Errorf("ambiguous must not match, got %+v", m)
}
}
func TestMatchMetadata_ReturnsCandidates(t *testing.T) {
// Нет сильного матча (разные названия), но кандидаты собраны для выбора.
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
{Provider: "tvmaze", ID: "2", Title: "Fargo Idaho", Year: 2010},
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014}, // дубль по id
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Совсем другое", Year: 2014})
if m != nil {
t.Errorf("strong match не ожидался: %+v", m)
}
if len(cands) != 2 { // дубль отброшен
t.Fatalf("candidates = %d, want 2: %+v", len(cands), cands)
}
// CandidateTag даёт внешний TVDB-id для первого.
prov, id := CandidateTag(cands[0])
if prov != "tvdb" || id != "269613" {
t.Errorf("tag = %s/%s", prov, id)
}
}
func TestRecognize_PopulatesCandidates(t *testing.T) {
in := Input{Name: "Show.S01", Files: []File{{Path: "e1.mkv", Size: 1}}}
resp := `{"type":"series","title":"Show","year":2020,"confidence":0.9,
"files":[{"src":"e1.mkv","role":"episode","season":1,"episode":1}]}`
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Show One", Year: 2020},
{Provider: "tvmaze", ID: "2", Title: "Show Two", Year: 2019},
}}
r := New(&fakeLLM{responses: []string{resp}}, []metadata.Provider{p}, Config{}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
t.Fatalf("Recognize: %v", err)
}
if res.Match != nil {
t.Errorf("strong match не ожидался")
}
if len(res.Candidates) != 2 {
t.Errorf("Result.Candidates = %d, want 2", len(res.Candidates))
}
}
func TestMatchMetadata_YearMismatch(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
r := recognizerWith(p)
if m := r.matchMetadata(context.Background(),
if m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
t.Errorf("year mismatch must not match, got %+v", m)
}
@@ -81,7 +125,7 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
}}
r := recognizerWith(p)
m := r.matchMetadata(context.Background(),
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
if m == nil || m.ProviderID != "1" {
t.Errorf("should match by original title, got %+v", m)
@@ -98,7 +142,7 @@ func TestMatchMetadata_TagFromExternal(t *testing.T) {
counts: map[int]int{1: 10},
}
r := recognizerWith(p)
m := r.matchMetadata(context.Background(),
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
if m == nil {
t.Fatal("expected match")
@@ -118,7 +162,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
counts: map[int]int{1: 10, 2: 10},
}
r := recognizerWith(p)
m := r.matchMetadata(context.Background(),
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
if m == nil || m.SeasonEpisodeCounts[1] != 10 {
t.Errorf("counts not fetched: %+v", m)
@@ -128,7 +172,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
p := &fakeProvider{searchErr: errors.New("upstream down")}
r := recognizerWith(p)
if m := r.matchMetadata(context.Background(),
if m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
t.Errorf("provider error must yield no match, got %+v", m)
}
@@ -136,7 +180,7 @@ func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
func TestMatchMetadata_Disabled(t *testing.T) {
r := recognizerWith(nil)
if m := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
if m, _ := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
t.Errorf("no providers → no match, got %+v", m)
}
}
+10 -6
View File
@@ -118,9 +118,10 @@ type Result struct {
Plan Plan
PreParse PreParse
Decision Decision
Match *Match // подтверждённый матч в базе (nil — нет)
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
Match *Match // подтверждённый единичный матч (nil — нет)
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
Raw string // сырой ответ LLM последней попытки
}
// LLM — нужная recognize часть провайдера.
@@ -234,8 +235,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
}
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
// в плане заменяем на каноничные.
match := r.matchMetadata(ctx, plan)
// в плане заменяем на каноничные. Кандидаты копим для ручного выбора в
// review, когда единичного сильного матча нет.
match, candidates := r.matchMetadata(ctx, plan)
if match != nil {
plan.Title = match.Title
if match.Year != 0 {
@@ -247,12 +249,14 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
r.log.Info("recognize: done",
"type", plan.Type, "title", plan.Title, "year", plan.Year,
"files", len(plan.Files), "attempts", attempts,
"matched", match != nil, "auto", dec.Auto, "reasons", len(dec.Reasons))
"matched", match != nil, "candidates", len(candidates),
"auto", dec.Auto, "reasons", len(dec.Reasons))
return Result{
Plan: plan,
PreParse: pre,
Decision: dec,
Match: match,
Candidates: candidates,
Attempts: attempts,
Raw: raw,
}, nil
+89
View File
@@ -228,3 +228,92 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
}
return nil
}
// --- Кандидаты базы метаданных (metadata_candidate) ---
// MetadataCandidate — строка таблицы metadata_candidate. provider/provider_id
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
// см. recognize), а не обязательно нативный id провайдера поиска.
type MetadataCandidate struct {
ID int64 `db:"id"`
RecognitionID int64 `db:"recognition_id"`
Provider string `db:"provider"`
ProviderID string `db:"provider_id"`
Title sql.NullString `db:"title"`
Year sql.NullInt64 `db:"year"`
Chosen bool `db:"chosen"`
CreatedAt string `db:"created_at"`
}
// CreateCandidates вставляет кандидатов распознавания одной транзакцией.
func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate) error {
if len(cands) == 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 metadata_candidate (recognition_id, provider, provider_id, title, year)
VALUES (?, ?, ?, ?, ?)`
for _, c := range cands {
if _, err := tx.ExecContext(ctx, q,
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year); err != nil {
return fmt.Errorf("insert candidate: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit candidates: %w", err)
}
return nil
}
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]MetadataCandidate, error) {
var out []MetadataCandidate
if err := s.DB.SelectContext(ctx, &out,
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
return nil, fmt.Errorf("list candidates: %w", err)
}
return out, nil
}
// GetCandidate возвращает кандидата по id либо (nil, nil).
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
var c MetadataCandidate
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get candidate %d: %w", id, err)
}
return &c, nil
}
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
// той же попытке распознавания.
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`UPDATE metadata_candidate SET chosen = 0 WHERE recognition_id = ?`, recognitionID); err != nil {
return fmt.Errorf("clear chosen: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE metadata_candidate SET chosen = 1 WHERE id = ? AND recognition_id = ?`,
candidateID, recognitionID); err != nil {
return fmt.Errorf("set chosen: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit chosen: %w", err)
}
return nil
}
+61
View File
@@ -159,6 +159,67 @@ func TestFileLinks_BatchLifecycle(t *testing.T) {
}
}
func TestCandidates_Lifecycle(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
recID, err := st.CreateRecognition(ctx, &Recognition{DownloadID: dl}, nil)
if err != nil {
t.Fatalf("create recognition: %v", err)
}
cands := []MetadataCandidate{
{RecognitionID: recID, Provider: "tvdb", ProviderID: "269613",
Title: NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true}},
{RecognitionID: recID, Provider: "tmdb", ProviderID: "60622",
Title: NullString("Fargo")},
}
if err := st.CreateCandidates(ctx, cands); err != nil {
t.Fatalf("create candidates: %v", err)
}
got, err := st.ListCandidatesByRecognition(ctx, recID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 || got[0].Provider != "tvdb" || got[0].ProviderID != "269613" {
t.Fatalf("candidates = %+v", got)
}
chosenID := got[0].ID
if err := st.SetCandidateChosen(ctx, recID, chosenID); err != nil {
t.Fatalf("set chosen: %v", err)
}
got, _ = st.ListCandidatesByRecognition(ctx, recID)
for _, c := range got {
want := c.ID == chosenID
if c.Chosen != want {
t.Errorf("candidate %d chosen = %v, want %v", c.ID, c.Chosen, want)
}
}
// GetCandidate + переотметка.
single, err := st.GetCandidate(ctx, got[1].ID)
if err != nil || single == nil || single.Provider != "tmdb" {
t.Fatalf("get candidate = %+v, %v", single, err)
}
if err := st.SetCandidateChosen(ctx, recID, got[1].ID); err != nil {
t.Fatal(err)
}
got, _ = st.ListCandidatesByRecognition(ctx, recID)
if got[0].Chosen || !got[1].Chosen {
t.Errorf("re-choose failed: %+v", got)
}
}
func TestGetCandidate_None(t *testing.T) {
st := newTestStore(t)
c, err := st.GetCandidate(context.Background(), 999)
if err != nil || c != nil {
t.Errorf("want nil,nil; got %+v, %v", c, err)
}
}
func TestLatestBatchID_None(t *testing.T) {
st := newTestStore(t)
dl := seedDownload(t, st)
+162 -7
View File
@@ -7,9 +7,11 @@ import (
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/metadata"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
@@ -19,6 +21,10 @@ import (
const (
ovrMediaType = "media_type"
ovrIgnoredFiles = "ignored_files"
ovrProvider = "provider" // выбранная база ("none" = без базы)
ovrProviderID = "provider_id" // id в выбранной базе
ovrTitle = "title" // запиненное каноническое название
ovrYear = "year" // запиненный год
)
// recognizePending распознаёт завершённые загрузки и перезапускает те, что
@@ -158,10 +164,17 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
"download_id", id, "state", d.State)
return
}
if _, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons); err != nil {
recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons)
if err != nil {
w.log.Error("recognize: persist", "download_id", id, "err", err)
return
}
// Кандидаты базы — для ручного выбора в review.
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
if err := w.store.CreateCandidates(ctx, cands); err != nil {
w.log.Warn("recognize: persist candidates", "download_id", id, "err", err)
}
}
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
// иначе — review. Раскладчик может быть не сконфигурирован.
@@ -413,6 +426,94 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
return d, nil
}
// --- Выбор базы метаданных (пиннинг; остаёмся в review, применяет человек) ---
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
// человек подтвердит «Применить».
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
w.mu.Lock()
defer w.mu.Unlock()
if _, err := w.requireReviewable(ctx, id, "choose candidate"); err != nil {
return err
}
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
cand, err := w.store.GetCandidate(ctx, candidateID)
if err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
return fmt.Errorf("choose candidate: кандидат %d не относится к текущему распознаванию", candidateID)
}
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
if cand.Title.Valid && cand.Title.String != "" {
pins[ovrTitle] = cand.Title.String
}
if cand.Year.Valid {
pins[ovrYear] = strconv.FormatInt(cand.Year.Int64, 10)
}
for field, value := range pins {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
}
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
w.log.Info("review: candidate chosen",
"download_id", id, "provider", cand.Provider, "provider_id", cand.ProviderID)
return nil
}
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error {
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
case "tmdb", "tvdb", "imdb":
default:
return fmt.Errorf("set provider: недопустимый провайдер %q (tmdb/tvdb/imdb)", provider)
}
if providerID == "" {
return fmt.Errorf("set provider: пустой id")
}
w.mu.Lock()
defer w.mu.Unlock()
if _, err := w.requireReviewable(ctx, id, "set provider"); err != nil {
return err
}
if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil {
return fmt.Errorf("set provider: %w", err)
}
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
return fmt.Errorf("set provider: %w", err)
}
return nil
}
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
w.mu.Lock()
defer w.mu.Unlock()
if _, err := w.requireReviewable(ctx, id, "clear provider"); err != nil {
return err
}
if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
return nil
}
// --- Данные для экрана ревью ---
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
@@ -421,6 +522,9 @@ type ReviewData struct {
Recognition *store.Recognition
Plan recognize.Plan // эффективный (с применёнными правками)
Preview []layout.Link // целевые пути (Src — относительный, для показа)
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Provider string // эффективный провайдер (с учётом выбора)
ProviderID string // эффективный id в базе
Hints []string
Overrides map[string]string
}
@@ -444,7 +548,16 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
return nil, fmt.Errorf("review data: %w", err)
}
rd := &ReviewData{Download: *d, Recognition: rec, Hints: hints, Overrides: overrides}
prov, pid := effectiveProvider(rec, overrides)
rd := &ReviewData{
Download: *d, Recognition: rec, Hints: hints, Overrides: overrides,
Provider: prov, ProviderID: pid,
}
if rec != nil {
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
rd.Candidates = cands
}
}
if rec != nil && rec.Plan.Valid {
var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil {
@@ -453,7 +566,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
// Превью строим по относительным путям с provider-тегом; ошибку
// игнорируем — просто покажем причины без превью.
if w.layouter != nil {
tag := providerTag(rec.Provider.String, rec.ProviderID.String)
tag := providerTag(prov, pid)
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
rd.Preview = links
}
@@ -481,18 +594,27 @@ func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, s
if err != nil {
return recognize.Plan{}, "", err
}
tag := providerTag(rec.Provider.String, rec.ProviderID.String)
return applyOverrides(plan, overrides), tag, nil
prov, pid := effectiveProvider(rec, overrides)
return applyOverrides(plan, overrides), providerTag(prov, pid), nil
}
// --- Хелперы преобразования ---
// applyOverrides применяет ручные правки к плану: форсит тип и помечает
// игнорируемые файлы ролью ignore (их раскладка пропустит).
// applyOverrides применяет ручные правки к плану: форсит тип, каноническое
// имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью
// ignore (их раскладка пропустит).
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) {
plan.Type = recognize.MediaType(mt)
}
if t := overrides[ovrTitle]; t != "" {
plan.Title = t
}
if y := overrides[ovrYear]; y != "" {
if year, err := strconv.Atoi(y); err == nil {
plan.Year = year
}
}
ignored := parseIgnored(overrides[ovrIgnoredFiles])
if len(ignored) > 0 {
for i := range plan.Files {
@@ -504,6 +626,39 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
return plan
}
// effectiveProvider возвращает провайдера и id для тега папки с учётом
// ручного выбора: запиненный override перекрывает распознанный матч.
// override "none" означает явный отказ от базы.
func effectiveProvider(rec *store.Recognition, overrides map[string]string) (provider, id string) {
if p, ok := overrides[ovrProvider]; ok {
return p, overrides[ovrProviderID]
}
if rec != nil {
return rec.Provider.String, rec.ProviderID.String
}
return "", ""
}
// toStoreCandidates переводит кандидатов распознавания в строки БД,
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
out := make([]store.MetadataCandidate, 0, len(cands))
for _, c := range cands {
prov, id := recognize.CandidateTag(c)
mc := store.MetadataCandidate{
RecognitionID: recognitionID,
Provider: prov,
ProviderID: id,
Title: store.NullString(c.Title),
}
if c.Year != 0 {
mc.Year = sql.NullInt64{Int64: int64(c.Year), Valid: true}
}
out = append(out, mc)
}
return out
}
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
// / "tvdbid-…". Пустой id (нет матча) → пустой тег.
func providerTag(provider, id string) string {
+174
View File
@@ -2,6 +2,7 @@ package worker
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
@@ -11,6 +12,7 @@ import (
"time"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/metadata"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
@@ -78,6 +80,7 @@ type memStore struct {
hints map[int64][]string
overrides map[int64]map[string]string
links []store.FileLink
candidates []store.MetadataCandidate
}
func newMemStore() *memStore {
@@ -199,6 +202,40 @@ func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error
return nil
}
func (m *memStore) CreateCandidates(_ context.Context, cands []store.MetadataCandidate) error {
for _, c := range cands {
c.ID = int64(len(m.candidates) + 1)
m.candidates = append(m.candidates, c)
}
return nil
}
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) ([]store.MetadataCandidate, error) {
var out []store.MetadataCandidate
for _, c := range m.candidates {
if c.RecognitionID == recID {
out = append(out, c)
}
}
return out, nil
}
func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCandidate, error) {
for i := range m.candidates {
if m.candidates[i].ID == id {
cp := m.candidates[i]
return &cp, nil
}
}
return nil, nil
}
func (m *memStore) SetCandidateChosen(_ context.Context, recID, id int64) error {
for i := range m.candidates {
if m.candidates[i].RecognitionID == recID {
m.candidates[i].Chosen = m.candidates[i].ID == id
}
}
return nil
}
func jsonMarshal(v any) (string, error) {
b, err := json.Marshal(v)
return string(b), err
@@ -659,6 +696,143 @@ func TestProviderTag(t *testing.T) {
}
}
// reviewWithCandidate готовит memStore: задача в review, одна попытка
// распознавания с одним кандидатом базы.
func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore) {
t.Helper()
st := newMemStore()
d := completedDownload(1)
d.State = store.StateReview
st.put(d)
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaSeries, Title: "Догадка", Year: 2000})
st.recs = append(st.recs, &store.Recognition{
ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)),
Provider: store.NullString("none"),
})
cand.RecognitionID = 1
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
return w, st
}
func TestRecognizeOne_PersistsCandidates(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "e1.mkv", Size: 1}},
}
res := seriesResult()
res.Candidates = []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Show A", Year: 2006, TagProvider: "tvdb", TagID: "269613"},
{Provider: "tvmaze", ID: "2", Title: "Show B", Year: 2007},
}
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
w.recognizeOne(context.Background(), 1)
if len(st.candidates) != 2 {
t.Fatalf("candidates = %d, want 2", len(st.candidates))
}
// Тег-предпочтительный provider/id сохранён (TVMaze → tvdb).
if st.candidates[0].Provider != "tvdb" || st.candidates[0].ProviderID != "269613" {
t.Errorf("candidate[0] = %+v", st.candidates[0])
}
}
func TestChooseCandidate_PinsOverrides(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true},
})
candID := st.candidates[0].ID
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
ov := st.overrides[1]
if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" ||
ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" {
t.Errorf("overrides = %v", ov)
}
if !st.candidates[0].Chosen {
t.Error("кандидат не помечен выбранным")
}
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
plan, tag, err := w.effectivePlan(context.Background(), 1)
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Fargo" || plan.Year != 2014 {
t.Errorf("plan = %q (%d)", plan.Title, plan.Year)
}
if tag != "tvdbid-269613" {
t.Errorf("tag = %q", tag)
}
}
func TestChooseCandidate_RejectsForeign(t *testing.T) {
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.ChooseCandidate(context.Background(), 1, 999); err == nil {
t.Error("чужой кандидат должен отклоняться")
}
}
func TestSetProviderID(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.SetProviderID(context.Background(), 1, "TMDB", " 603 "); err != nil {
t.Fatalf("SetProviderID: %v", err)
}
if st.overrides[1][ovrProvider] != "tmdb" || st.overrides[1][ovrProviderID] != "603" {
t.Errorf("overrides = %v", st.overrides[1])
}
if err := w.SetProviderID(context.Background(), 1, "kinopoisk", "1"); err == nil {
t.Error("недопустимый провайдер должен отклоняться")
}
if err := w.SetProviderID(context.Background(), 1, "tmdb", ""); err == nil {
t.Error("пустой id должен отклоняться")
}
}
func TestClearProvider(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
_ = st.SetOverride(context.Background(), 1, ovrProvider, "tvdb")
if err := w.ClearProvider(context.Background(), 1); err != nil {
t.Fatalf("ClearProvider: %v", err)
}
if st.overrides[1][ovrProvider] != "none" {
t.Errorf("provider override = %q, want none", st.overrides[1][ovrProvider])
}
// «Без базы» → пустой тег.
_, tag, _ := w.effectivePlan(context.Background(), 1)
if tag != "" {
t.Errorf("tag = %q, want empty", tag)
}
}
func TestReviewData_IncludesCandidates(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
})
candID := st.candidates[0].ID
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
t.Fatal(err)
}
rd, err := w.ReviewData(context.Background(), 1)
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if len(rd.Candidates) != 1 {
t.Fatalf("candidates = %d", len(rd.Candidates))
}
if rd.Provider != "tvdb" || rd.ProviderID != "269613" {
t.Errorf("eff provider = %s/%s", rd.Provider, rd.ProviderID)
}
if rd.Plan.Title != "Fargo" {
t.Errorf("plan title = %q", rd.Plan.Title)
}
}
func TestToLayoutPlan(t *testing.T) {
s, e := 1, 3
plan := recognize.Plan{
+6
View File
@@ -42,6 +42,12 @@ type Store interface {
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
// Кандидаты базы метаданных (ручной выбор в review).
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
}
// QBittorrent — нужная worker часть клиента qBittorrent.
+10
View File
@@ -83,6 +83,16 @@ func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.F
return nil, nil
}
func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil }
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
return nil
}
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
type fakeQbt struct {
torrents []qbt.Torrent
+44 -1
View File
@@ -57,7 +57,7 @@
Тип: <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}}
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{end}}
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{else if .NoBase}}· База: <b>без базы</b>{{end}}
</p>
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
Переключить тип:
@@ -67,6 +67,49 @@
</form>
</section>
<section>
<strong>База метаданных</strong>
{{if .Provider}}<p>Выбрано: <b>{{.Provider}}</b> {{.ProviderID}}</p>
{{else if .NoBase}}<p>Выбрано: <b>без базы</b> (тег папки не ставится)</p>
{{else}}<p><small>Матч не подтверждён — выберите кандидата, введите id или «без базы».</small></p>{{end}}
{{if .Candidates}}
<table>
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th></th></tr></thead>
<tbody>
{{range .Candidates}}
<tr>
<td>{{.Provider}}</td>
<td>{{.Title}}</td>
<td>{{if .Year}}{{.Year}}{{end}}</td>
<td class="src">{{.ProviderID}}</td>
<td>
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
<input type="hidden" name="candidate_id" value="{{.ID}}">
<button type="submit" {{if .Chosen}}disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<form class="row" method="post" action="/ui/downloads/{{.ID}}/provider">
Вручную:
<select name="provider">
<option value="tvdb">tvdb</option>
<option value="tmdb">tmdb</option>
<option value="imdb">imdb</option>
</select>
<input type="text" name="provider_id" placeholder="id (напр. 269613)" required>
<button type="submit">задать id</button>
</form>
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:.4rem">
<button type="submit">Без базы</button>
</form>
</section>
<section>
<strong>Файлы → роль</strong>
<table>