Добавил выбор из кандидатов, если LLM не уверена в раскладке
This commit is contained in:
+1
-1
@@ -61,7 +61,7 @@ web_base_url = "" # напр. "http://umbar:8080" — для
|
|||||||
|
|
||||||
[http]
|
[http]
|
||||||
listen = ":8080"
|
listen = ":8080"
|
||||||
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
|
trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|||||||
@@ -84,9 +84,10 @@ SQLite; на старте `worker` сверяет категорию qBittorrent
|
|||||||
reject / defer / undo) — команды к `worker`:
|
reject / defer / undo) — команды к `worker`:
|
||||||
|
|
||||||
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
|
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
|
||||||
(server-rendered + htmx). В v1 **без авторизации** (доверенная LAN), с
|
(server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
|
||||||
опциональным allowlist подсетей (`http.trusted_subnets`). Защиту
|
`http.trusted_subnets` зарезервировано, но **пока не применяется**:
|
||||||
навесим позже — [drafts/ideas.md](../drafts/ideas.md).
|
деплой только в локальную сеть без доступа из интернета, поэтому
|
||||||
|
allowlist-middleware и авторизацию отложили — [drafts/ideas.md](../drafts/ideas.md).
|
||||||
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
|
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
|
||||||
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
|
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
|
||||||
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
|
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
|
||||||
@@ -189,7 +190,7 @@ allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
|||||||
|
|
||||||
[http]
|
[http]
|
||||||
listen = ":8080"
|
listen = ":8080"
|
||||||
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
|
trusted_subnets = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN)
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ type Telegram struct {
|
|||||||
// HTTP — параметры веб-сервера.
|
// HTTP — параметры веб-сервера.
|
||||||
type HTTP struct {
|
type HTTP struct {
|
||||||
Listen string `toml:"listen"`
|
Listen string `toml:"listen"`
|
||||||
|
// TrustedSubnets — allowlist подсетей. ПОКА НЕ ПРИМЕНЯЕТСЯ: деплой только
|
||||||
|
// в локальную сеть без доступа из интернета, поэтому middleware отложено
|
||||||
|
// (см. architecture.md). Поле сохранено под будущую реализацию.
|
||||||
TrustedSubnets []string `toml:"trusted_subnets"`
|
TrustedSubnets []string `toml:"trusted_subnets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
||||||
r.Post("/ui/downloads/{id}/type", s.handleSetType)
|
r.Post("/ui/downloads/{id}/type", s.handleSetType)
|
||||||
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
|
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}/defer", s.handleDefer)
|
||||||
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapi_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -187,9 +188,12 @@ type fakeReviewer struct {
|
|||||||
refined map[int64]string
|
refined map[int64]string
|
||||||
typed map[int64]string
|
typed map[int64]string
|
||||||
ignored map[int64]string
|
ignored map[int64]string
|
||||||
|
chosen map[int64]int64
|
||||||
|
providerSet map[int64]string
|
||||||
applied []int64
|
applied []int64
|
||||||
deferred []int64
|
deferred []int64
|
||||||
undone []int64
|
undone []int64
|
||||||
|
cleared []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
|
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)
|
f.undone = append(f.undone, id)
|
||||||
return nil
|
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 {
|
func seriesReviewData() *worker.ReviewData {
|
||||||
s, e := 2, 1
|
s, e := 2, 1
|
||||||
@@ -248,6 +270,11 @@ func seriesReviewData() *worker.ReviewData {
|
|||||||
Preview: []layout.Link{
|
Preview: []layout.Link{
|
||||||
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
|
{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{"второй сезон"},
|
Hints: []string{"второй сезон"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,13 +301,55 @@ func TestReviewRenders(t *testing.T) {
|
|||||||
t.Fatalf("status = %d", resp.StatusCode)
|
t.Fatalf("status = %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
||||||
"Season 02", "Применить", "Уточнить"} {
|
"Season 02", "Применить", "Уточнить",
|
||||||
|
"База метаданных", "269613", "выбрать", "Без базы"} {
|
||||||
if !strings.Contains(string(body), want) {
|
if !strings.Contains(string(body), want) {
|
||||||
t.Errorf("страница ревью не содержит %q", 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) {
|
func TestApplyRedirectsToIndex(t *testing.T) {
|
||||||
rv := &fakeReviewer{data: seriesReviewData()}
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ type Reviewer interface {
|
|||||||
IgnoreFile(ctx context.Context, id int64, src string) error
|
IgnoreFile(ctx context.Context, id int64, src string) error
|
||||||
Defer(ctx context.Context, id int64) error
|
Defer(ctx context.Context, id int64) error
|
||||||
Undo(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
|
Files []reviewFileView
|
||||||
Preview []string
|
Preview []string
|
||||||
HasPlan bool
|
HasPlan bool
|
||||||
|
NoBase bool // выбрано «без базы»
|
||||||
|
Candidates []candidateView
|
||||||
}
|
}
|
||||||
|
|
||||||
type reviewFileView struct {
|
type reviewFileView struct {
|
||||||
@@ -54,6 +59,15 @@ type reviewFileView struct {
|
|||||||
Ignored bool
|
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) {
|
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,9 +101,12 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
view.OriginalTitle = rd.Plan.OriginalTitle
|
view.OriginalTitle = rd.Plan.OriginalTitle
|
||||||
view.Year = rd.Plan.Year
|
view.Year = rd.Plan.Year
|
||||||
view.Reasons = rec.ReasonList()
|
view.Reasons = rec.ReasonList()
|
||||||
if rec.Provider.Valid && rec.Provider.String != "none" {
|
switch rd.Provider {
|
||||||
view.Provider = rec.Provider.String
|
case "", "none":
|
||||||
view.ProviderID = rec.ProviderID.String
|
view.NoBase = rd.Provider == "none"
|
||||||
|
default:
|
||||||
|
view.Provider = rd.Provider
|
||||||
|
view.ProviderID = rd.ProviderID
|
||||||
}
|
}
|
||||||
if rec.Confidence.Valid {
|
if rec.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
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
|
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 {
|
for _, l := range rd.Preview {
|
||||||
view.Preview = append(view.Preview, l.Dst)
|
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) {
|
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,14 +8,17 @@ import (
|
|||||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
// matchMetadata сверяет план с включёнными базами: ищет по названию+году и,
|
// maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
|
||||||
// если ровно один кандидат уверенно совпадает (название и год), возвращает
|
const maxCandidates = 8
|
||||||
// матч с официальным id и каноническим именем. Несколько кандидатов или их
|
|
||||||
// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не
|
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
||||||
// валят распознавание — просто нет матча.
|
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
||||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
|
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
||||||
|
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
|
||||||
|
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
||||||
|
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||||||
if len(r.providers) == 0 {
|
if len(r.providers) == 0 {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
mt := metadata.Movie
|
mt := metadata.Movie
|
||||||
if plan.Type == MediaSeries {
|
if plan.Type == MediaSeries {
|
||||||
@@ -28,35 +31,52 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
|
|||||||
}
|
}
|
||||||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
||||||
|
|
||||||
|
var match *Match
|
||||||
|
var candidates []metadata.Candidate
|
||||||
|
seen := map[string]bool{}
|
||||||
|
|
||||||
for _, p := range r.providers {
|
for _, p := range r.providers {
|
||||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
|
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
|
||||||
continue
|
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)
|
strong := strongMatches(cands, plan.Year, matchTitles)
|
||||||
if len(strong) != 1 {
|
if len(strong) != 1 {
|
||||||
continue
|
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
|
var counts map[int]int
|
||||||
if mt == metadata.Series {
|
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
|
counts = got
|
||||||
} else {
|
} else {
|
||||||
r.log.Warn("recognize: episode counts failed",
|
r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
|
||||||
"provider", p.Name(), "id", c.ID, "err", cerr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
prov, pid := CandidateTag(c)
|
||||||
// Провенанс и тег папки — по внешнему id, если провайдер его дал
|
|
||||||
// (TVMaze отдаёт TVDB/IMDb-id); иначе по самому провайдеру.
|
|
||||||
prov, pid := c.Provider, c.ID
|
|
||||||
if c.TagProvider != "" {
|
|
||||||
prov, pid = c.TagProvider, c.TagID
|
|
||||||
}
|
|
||||||
return &Match{
|
return &Match{
|
||||||
Provider: prov,
|
Provider: prov,
|
||||||
ProviderID: pid,
|
ProviderID: pid,
|
||||||
@@ -65,7 +85,15 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
|
|||||||
SeasonEpisodeCounts: counts,
|
SeasonEpisodeCounts: counts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
// 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 c.Provider, c.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestMatchMetadata_SingleStrong(t *testing.T) {
|
|||||||
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
|
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
|
||||||
}}
|
}}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
m := r.matchMetadata(context.Background(),
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
|
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
|
||||||
if m == nil {
|
if m == nil {
|
||||||
t.Fatal("expected match")
|
t.Fatal("expected match")
|
||||||
@@ -61,16 +61,60 @@ func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
|
|||||||
{ID: "2", Title: "Fargo", Year: 2014},
|
{ID: "2", Title: "Fargo", Year: 2014},
|
||||||
}}
|
}}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
if m := r.matchMetadata(context.Background(),
|
if m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
|
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
|
||||||
t.Errorf("ambiguous must not match, got %+v", m)
|
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) {
|
func TestMatchMetadata_YearMismatch(t *testing.T) {
|
||||||
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
|
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
if m := r.matchMetadata(context.Background(),
|
if m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
|
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
|
||||||
t.Errorf("year mismatch must not match, got %+v", m)
|
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},
|
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
|
||||||
}}
|
}}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
m := r.matchMetadata(context.Background(),
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
|
Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
|
||||||
if m == nil || m.ProviderID != "1" {
|
if m == nil || m.ProviderID != "1" {
|
||||||
t.Errorf("should match by original title, got %+v", m)
|
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},
|
counts: map[int]int{1: 10},
|
||||||
}
|
}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
m := r.matchMetadata(context.Background(),
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||||||
if m == nil {
|
if m == nil {
|
||||||
t.Fatal("expected match")
|
t.Fatal("expected match")
|
||||||
@@ -118,7 +162,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
|||||||
counts: map[int]int{1: 10, 2: 10},
|
counts: map[int]int{1: 10, 2: 10},
|
||||||
}
|
}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
m := r.matchMetadata(context.Background(),
|
m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||||||
if m == nil || m.SeasonEpisodeCounts[1] != 10 {
|
if m == nil || m.SeasonEpisodeCounts[1] != 10 {
|
||||||
t.Errorf("counts not fetched: %+v", m)
|
t.Errorf("counts not fetched: %+v", m)
|
||||||
@@ -128,7 +172,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
|||||||
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
||||||
p := &fakeProvider{searchErr: errors.New("upstream down")}
|
p := &fakeProvider{searchErr: errors.New("upstream down")}
|
||||||
r := recognizerWith(p)
|
r := recognizerWith(p)
|
||||||
if m := r.matchMetadata(context.Background(),
|
if m, _ := r.matchMetadata(context.Background(),
|
||||||
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
|
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
|
||||||
t.Errorf("provider error must yield no match, got %+v", m)
|
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) {
|
func TestMatchMetadata_Disabled(t *testing.T) {
|
||||||
r := recognizerWith(nil)
|
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)
|
t.Errorf("no providers → no match, got %+v", m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,9 +118,10 @@ type Result struct {
|
|||||||
Plan Plan
|
Plan Plan
|
||||||
PreParse PreParse
|
PreParse PreParse
|
||||||
Decision Decision
|
Decision Decision
|
||||||
Match *Match // подтверждённый матч в базе (nil — нет)
|
Match *Match // подтверждённый единичный матч (nil — нет)
|
||||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
|
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
|
||||||
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
|
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
|
||||||
|
Raw string // сырой ответ LLM последней попытки
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM — нужная recognize часть провайдера.
|
// LLM — нужная recognize часть провайдера.
|
||||||
@@ -234,8 +235,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
||||||
// в плане заменяем на каноничные.
|
// в плане заменяем на каноничные. Кандидаты копим для ручного выбора в
|
||||||
match := r.matchMetadata(ctx, plan)
|
// review, когда единичного сильного матча нет.
|
||||||
|
match, candidates := r.matchMetadata(ctx, plan)
|
||||||
if match != nil {
|
if match != nil {
|
||||||
plan.Title = match.Title
|
plan.Title = match.Title
|
||||||
if match.Year != 0 {
|
if match.Year != 0 {
|
||||||
@@ -247,12 +249,14 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
|||||||
r.log.Info("recognize: done",
|
r.log.Info("recognize: done",
|
||||||
"type", plan.Type, "title", plan.Title, "year", plan.Year,
|
"type", plan.Type, "title", plan.Title, "year", plan.Year,
|
||||||
"files", len(plan.Files), "attempts", attempts,
|
"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{
|
return Result{
|
||||||
Plan: plan,
|
Plan: plan,
|
||||||
PreParse: pre,
|
PreParse: pre,
|
||||||
Decision: dec,
|
Decision: dec,
|
||||||
Match: match,
|
Match: match,
|
||||||
|
Candidates: candidates,
|
||||||
Attempts: attempts,
|
Attempts: attempts,
|
||||||
Raw: raw,
|
Raw: raw,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -228,3 +228,92 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestLatestBatchID_None(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
dl := seedDownload(t, st)
|
dl := seedDownload(t, st)
|
||||||
|
|||||||
+162
-7
@@ -7,9 +7,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"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/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
@@ -19,6 +21,10 @@ import (
|
|||||||
const (
|
const (
|
||||||
ovrMediaType = "media_type"
|
ovrMediaType = "media_type"
|
||||||
ovrIgnoredFiles = "ignored_files"
|
ovrIgnoredFiles = "ignored_files"
|
||||||
|
ovrProvider = "provider" // выбранная база ("none" = без базы)
|
||||||
|
ovrProviderID = "provider_id" // id в выбранной базе
|
||||||
|
ovrTitle = "title" // запиненное каноническое название
|
||||||
|
ovrYear = "year" // запиненный год
|
||||||
)
|
)
|
||||||
|
|
||||||
// recognizePending распознаёт завершённые загрузки и перезапускает те, что
|
// recognizePending распознаёт завершённые загрузки и перезапускает те, что
|
||||||
@@ -158,10 +164,17 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
|||||||
"download_id", id, "state", d.State)
|
"download_id", id, "state", d.State)
|
||||||
return
|
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)
|
w.log.Error("recognize: persist", "download_id", id, "err", err)
|
||||||
return
|
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);
|
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
|
||||||
// иначе — review. Раскладчик может быть не сконфигурирован.
|
// иначе — review. Раскладчик может быть не сконфигурирован.
|
||||||
@@ -413,6 +426,94 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
|
|||||||
return d, nil
|
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 — всё, что нужно транспорту для отрисовки ревью.
|
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
|
||||||
@@ -421,6 +522,9 @@ type ReviewData struct {
|
|||||||
Recognition *store.Recognition
|
Recognition *store.Recognition
|
||||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||||
Preview []layout.Link // целевые пути (Src — относительный, для показа)
|
Preview []layout.Link // целевые пути (Src — относительный, для показа)
|
||||||
|
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||||
|
Provider string // эффективный провайдер (с учётом выбора)
|
||||||
|
ProviderID string // эффективный id в базе
|
||||||
Hints []string
|
Hints []string
|
||||||
Overrides map[string]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)
|
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 {
|
if rec != nil && rec.Plan.Valid {
|
||||||
var plan recognize.Plan
|
var plan recognize.Plan
|
||||||
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil {
|
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-тегом; ошибку
|
// Превью строим по относительным путям с provider-тегом; ошибку
|
||||||
// игнорируем — просто покажем причины без превью.
|
// игнорируем — просто покажем причины без превью.
|
||||||
if w.layouter != nil {
|
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 {
|
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
|
||||||
rd.Preview = links
|
rd.Preview = links
|
||||||
}
|
}
|
||||||
@@ -481,18 +594,27 @@ func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return recognize.Plan{}, "", err
|
return recognize.Plan{}, "", err
|
||||||
}
|
}
|
||||||
tag := providerTag(rec.Provider.String, rec.ProviderID.String)
|
prov, pid := effectiveProvider(rec, overrides)
|
||||||
return applyOverrides(plan, overrides), tag, nil
|
return applyOverrides(plan, overrides), providerTag(prov, pid), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Хелперы преобразования ---
|
// --- Хелперы преобразования ---
|
||||||
|
|
||||||
// applyOverrides применяет ручные правки к плану: форсит тип и помечает
|
// applyOverrides применяет ручные правки к плану: форсит тип, каноническое
|
||||||
// игнорируемые файлы ролью ignore (их раскладка пропустит).
|
// имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью
|
||||||
|
// ignore (их раскладка пропустит).
|
||||||
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
|
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
|
||||||
if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) {
|
if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) {
|
||||||
plan.Type = recognize.MediaType(mt)
|
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])
|
ignored := parseIgnored(overrides[ovrIgnoredFiles])
|
||||||
if len(ignored) > 0 {
|
if len(ignored) > 0 {
|
||||||
for i := range plan.Files {
|
for i := range plan.Files {
|
||||||
@@ -504,6 +626,39 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
|
|||||||
return plan
|
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-…"
|
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
|
||||||
// / "tvdbid-…". Пустой id (нет матча) → пустой тег.
|
// / "tvdbid-…". Пустой id (нет матча) → пустой тег.
|
||||||
func providerTag(provider, id string) string {
|
func providerTag(provider, id string) string {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package worker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
"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/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
@@ -78,6 +80,7 @@ type memStore struct {
|
|||||||
hints map[int64][]string
|
hints map[int64][]string
|
||||||
overrides map[int64]map[string]string
|
overrides map[int64]map[string]string
|
||||||
links []store.FileLink
|
links []store.FileLink
|
||||||
|
candidates []store.MetadataCandidate
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMemStore() *memStore {
|
func newMemStore() *memStore {
|
||||||
@@ -199,6 +202,40 @@ func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error
|
|||||||
return nil
|
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) {
|
func jsonMarshal(v any) (string, error) {
|
||||||
b, err := json.Marshal(v)
|
b, err := json.Marshal(v)
|
||||||
return string(b), err
|
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) {
|
func TestToLayoutPlan(t *testing.T) {
|
||||||
s, e := 1, 3
|
s, e := 1, 3
|
||||||
plan := recognize.Plan{
|
plan := recognize.Plan{
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ type Store interface {
|
|||||||
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
|
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
|
||||||
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
||||||
DeleteFileLinksByBatch(ctx context.Context, batchID string) 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.
|
// QBittorrent — нужная worker часть клиента qBittorrent.
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.F
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return 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 {
|
type fakeQbt struct {
|
||||||
torrents []qbt.Torrent
|
torrents []qbt.Torrent
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
|
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
|
||||||
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
|
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
|
||||||
{{if .Year}}· Год: <b>{{.Year}}</b>{{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>
|
</p>
|
||||||
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
|
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
|
||||||
Переключить тип:
|
Переключить тип:
|
||||||
@@ -67,6 +67,49 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<strong>Файлы → роль</strong>
|
<strong>Файлы → роль</strong>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
Reference in New Issue
Block a user