Реализовал выбор источника и предпросмотр в ревью (review-source-selection)

Экран ревью теперь показывает единый список источников совпадения: строка
«распознано нейронкой» наравне с кандидатами баз; выбор/переключение/снятие
в пользу нейронки; ручное добавление по id или URL (TMDB/IMDb — по URL,
TVDB — по числовому id); предпросмотр полей и целевых путей каждого источника
до применения (место под режиссёра зарезервировано). «Раскладка» осталась
отдельной секцией для активного источника, инлайн-превью неактивных — по клику.

Ядро: единая деривация «источник → overrides» (sourcePins), общая для превью
и коммита → preview == apply; заодно чинит латентный залипший override
title/year при переключении источника. Превью считается эфемерно, без записи
в БД; пользовательский URL только парсится (SSRF нет).

Ревью дизайна и кода пройдены; правки ревьюеров учтены (сообщение об ошибке
ручного ввода доходит до пользователя, URL без схемы принимается).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-03 10:29:27 +03:00
co-authored by Claude Opus 4.8
parent 3d3448d050
commit aac3ced262
10 changed files with 688 additions and 104 deletions
+220 -25
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"path/filepath"
"strconv"
"strings"
@@ -569,20 +570,99 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) er
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
}
return w.chooseCandidateLocked(ctx, id, d, rec, *cand)
}
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
if cand.Title.Valid && cand.Title.String != "" {
pins[ovrTitle] = cand.Title.String
// AddManualSource добавляет источник вручную по (provider, id) и выбирает его.
// Когда автопоиск промахнулся: сохраняем кандидата (дедуп по provider:id) и
// пиннит как выбранный. provider — из набора tmdb/tvdb/imdb.
func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) error {
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
case "tmdb", "tvdb", "imdb":
default:
return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb)", provider)
}
if providerID == "" {
return fmt.Errorf("add source: empty id")
}
w.mu.Lock()
defer w.mu.Unlock()
d, err := w.requireReviewable(ctx, id, "add source")
if err != nil {
return err
}
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return fmt.Errorf("add source: %w", err)
}
if rec == nil {
return fmt.Errorf("add source: no recognition")
}
cand, err := w.findOrCreateCandidate(ctx, rec.ID, provider, providerID)
if err != nil {
return fmt.Errorf("add source: %w", err)
}
return w.chooseCandidateLocked(ctx, id, d, rec, *cand)
}
// findOrCreateCandidate возвращает кандидата рекогниции по (provider, id),
// создавая его при отсутствии (дедуп по provider:id). Под mu.
func (w *Worker) findOrCreateCandidate(ctx context.Context, recognitionID, provider, providerID string) (*store.MetadataCandidate, error) {
cands, err := w.store.ListCandidatesByRecognition(ctx, recognitionID)
if err != nil {
return nil, err
}
if c := findCandidate(cands, provider, providerID); c != nil {
return c, nil
}
if err := w.store.CreateCandidates(ctx, []store.MetadataCandidate{{
RecognitionID: recognitionID,
Provider: provider,
ProviderID: providerID,
}}); err != nil {
return nil, err
}
cands, err = w.store.ListCandidatesByRecognition(ctx, recognitionID)
if err != nil {
return nil, err
}
if c := findCandidate(cands, provider, providerID); c != nil {
return c, nil
}
return nil, fmt.Errorf("candidate %s:%s not found after create", provider, providerID)
}
func findCandidate(cands []store.MetadataCandidate, provider, providerID string) *store.MetadataCandidate {
for i := range cands {
if cands[i].Provider == provider && cands[i].ProviderID == providerID {
return &cands[i]
}
}
return nil
}
// chooseCandidateLocked пиннит кандидата как выбранный источник. Пишет ПОЛНЫЙ
// самосогласованный набор пинов (provider/id/title/year): пустые title/year у
// кандидата очищают возможный унаследованный пин прежнего источника — иначе
// превью разошлось бы с применением (решение 1a). Под mu.
func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.Download, rec *store.Recognition, cand store.MetadataCandidate) error {
title := ""
if cand.Title.Valid {
title = cand.Title.String
}
year := 0
if cand.Year.Valid {
pins[ovrYear] = strconv.FormatInt(cand.Year.Int64, 10)
year = int(cand.Year.Int64)
}
for field, value := range pins {
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year) {
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 {
if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
@@ -609,18 +689,20 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
if 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)
// Полный набор пинов: id задан вручную, название/год берём из плана
// (очищаем возможный унаследованный пин прежнего источника).
for field, value := range sourcePins(provider, providerID, "", 0) {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("set provider: %w", err)
}
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
"provider", provider, "provider_id", providerID)
return nil
}
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
// ClearProvider — «без базы»: снимает матч (тег папки не ставится) и очищает
// пины названия/года (источник — распознавание нейронкой).
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -629,16 +711,33 @@ func (w *Worker) ClearProvider(ctx context.Context, id string) error {
if 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)
for field, value := range sourcePins("none", "", "", 0) {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared")
return nil
}
// sourcePins — полный самосогласованный набор пинов источника (решение 1a):
// title/year пишутся пустой строкой, если у источника их нет; в applyOverrides
// пустая строка трактуется как «нет override» → берётся значение плана. Так
// выбор любого источника даёт детерминированный эффективный план, а превью
// совпадает с применением. Используется и в коммите (SetOverride), и в превью.
func sourcePins(provider, providerID, title string, year int) map[string]string {
yr := ""
if year > 0 {
yr = strconv.Itoa(year)
}
return map[string]string{
ovrProvider: provider,
ovrProviderID: providerID,
ovrTitle: title,
ovrYear: yr,
}
}
// --- Данные для экрана ревью ---
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
@@ -646,14 +745,39 @@ type ReviewData struct {
Download store.Download
Recognition *store.Recognition
Plan recognize.Plan // эффективный (с применёнными правками)
Preview []layout.Link // целевые пути (Src — относительный, для показа)
Preview []layout.Link // целевые пути активного источника (Src — относительный)
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Sources []SourceOption // единый список источников совпадения (нейронка + кандидаты)
Provider string // эффективный провайдер (с учётом выбора)
ProviderID string // эффективный id в базе
Hints []string
Overrides map[string]string
}
// SourceKind — вид источника в едином списке ревью.
type SourceKind string
const (
SourceNeural SourceKind = "neural" // распознавание нейронкой (без базы)
SourceCandidate SourceKind = "candidate" // кандидат метабазы (в т.ч. добавленный вручную)
)
// SourceOption — источник совпадения в списке ревью: эффективные поля и
// предпросмотр целевых путей, посчитанные эфемерно (без записи overrides).
type SourceOption struct {
Kind SourceKind
CandidateID string // ULID кандидата (пусто для нейронки)
Provider string // "none" для нейронки
ProviderID string
URL string // ссылка кандидата на запись (если есть)
Title string // эффективное название для этого источника
Year int // эффективный год
Type string // "movie" | "series"
Active bool // текущий эффективный источник
Plan recognize.Plan // эффективный план (для показа файлов → раскладка)
Preview []layout.Link // целевые пути этого источника
}
// ReviewData собирает данные ревью по загрузке.
func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) {
d, err := w.store.GetDownload(ctx, id)
@@ -687,27 +811,98 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error)
}
}
if rec != nil && rec.Plan.Valid {
var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
var rawPlan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &rawPlan); err != nil {
log.Warn("review data unmarshal plan failed", "error", err)
} else {
plan = applyOverrides(plan, overrides)
rd.Plan = plan
// Превью строим по относительным путям с provider-тегом; ошибку
// логируем на Debug — просто покажем причины без превью.
rd.Plan = applyOverrides(rawPlan, overrides)
// Превью активного источника строим по относительным путям с
// provider-тегом; ошибку логируем на Debug — покажем без превью.
if w.layouter != nil {
tag := providerTag(prov, pid)
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag)); lerr == nil {
rd.Preview = links
} else {
log.Debug("review data build preview failed", "error", lerr)
}
}
// Единый список источников: нейронка + кандидаты, каждый с
// эфемерным превью из сырого плана (без записи overrides).
rd.Sources = w.buildSources(rawPlan, overrides, prov, pid, rd.Candidates)
}
}
return rd, nil
}
// buildSources собирает единый список источников: нейронка (первой) +
// кандидаты (дедуп по provider:id). Активным помечается текущий эффективный
// источник.
func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption {
neutral := prov == "" || prov == "none"
out := make([]SourceOption, 0, len(cands)+1)
out = append(out, w.sourceOption(SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral))
seen := map[string]bool{}
for _, c := range cands {
key := c.Provider + ":" + c.ProviderID
if seen[key] {
continue
}
seen[key] = true
title := ""
if c.Title.Valid {
title = c.Title.String
}
year := 0
if c.Year.Valid {
year = int(c.Year.Int64)
}
active := !neutral && c.Provider == prov && c.ProviderID == pid
out = append(out, w.sourceOption(SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active))
}
return out
}
// sourceOption строит один источник: накладывает его пины на неисточниковые
// overrides, считает эффективный план и предпросмотр путей — эфемерно, без
// записи. Гарантия preview == apply: тот же набор пинов запишет выбор.
func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption {
eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year)))
opt := SourceOption{
Kind: kind,
CandidateID: candID,
Provider: provider,
ProviderID: providerID,
URL: url,
Title: eff.Title,
Year: eff.Year,
Type: string(eff.Type),
Active: active,
Plan: eff,
}
if w.layouter != nil {
if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID))); err == nil {
opt.Preview = links
}
}
return opt
}
// mergeSourceOverrides накладывает пины источника (provider/id/title/year) на
// неисточниковые overrides (media_type, ignored_files, force_review, ...).
func mergeSourceOverrides(base, pins map[string]string) map[string]string {
m := make(map[string]string, len(base)+len(pins))
for k, v := range base {
switch k {
case ovrProvider, ovrProviderID, ovrTitle, ovrYear:
continue
default:
m[k] = v
}
}
maps.Copy(m, pins)
return m
}
// effectivePlan загружает текущий план, применяет правки и возвращает
// provider-тег для имени папки (под mu).
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
+152
View File
@@ -1179,6 +1179,158 @@ func TestClearProvider(t *testing.T) {
}
}
// TestClearProvider_ClearsInheritedTitle — регрессия на залипший override
// (решение 1a): после выбора титульного кандидата переключение на нейронку
// возвращает название/год плана, а не унаследованные от кандидата.
func TestClearProvider_ClearsInheritedTitle(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)
}
if err := w.ClearProvider(context.Background(), "1"); err != nil {
t.Fatalf("ClearProvider: %v", err)
}
// Название/год снова из плана распознавания, тег пуст.
plan, tag, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" || plan.Year != 2000 {
t.Errorf("plan = %q (%d), want plan-defaults", plan.Title, plan.Year)
}
if tag != "" {
t.Errorf("tag = %q, want empty", tag)
}
if ov := st.overrides["1"]; ov[ovrTitle] != "" || ov[ovrYear] != "" {
t.Errorf("title/year override not cleared: %v", ov)
}
}
// TestAddManualSource_ChoosesAndClears — ручной источник создаётся, выбирается
// и (будучи безтайтловым) очищает унаследованный пин названия.
func TestAddManualSource_ChoosesAndClears(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if err := w.AddManualSource(context.Background(), "1", "TMDB", " 603 "); err != nil {
t.Fatalf("AddManualSource: %v", err)
}
if len(st.candidates) != 2 {
t.Fatalf("candidates = %d, want 2 (manual added)", len(st.candidates))
}
plan, tag, err := w.effectivePlan(context.Background(), "1")
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Догадка" { // безтайтловый источник → название из плана
t.Errorf("plan title = %q, want plan-default", plan.Title)
}
if tag != "tmdbid-603" {
t.Errorf("tag = %q", tag)
}
}
func TestAddManualSource_DedupAndInvalid(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"})
// Дубль (provider:id уже есть) → не плодим строку, выбираем существующую.
if err := w.AddManualSource(context.Background(), "1", "tvdb", "269613"); err != nil {
t.Fatalf("AddManualSource dup: %v", err)
}
if len(st.candidates) != 1 {
t.Errorf("candidates = %d, want 1 (dedup)", len(st.candidates))
}
if !st.candidates[0].Chosen {
t.Error("существующий кандидат должен стать выбранным")
}
if err := w.AddManualSource(context.Background(), "1", "kinopoisk", "1"); err == nil {
t.Error("недопустимый провайдер должен отклоняться")
}
if err := w.AddManualSource(context.Background(), "1", "tmdb", " "); err == nil {
t.Error("пустой id должен отклоняться")
}
}
func TestReviewData_BuildsSources(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
})
rd, err := w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
// Построение источников (в т.ч. предпросмотр) не должно писать overrides.
if ov := st.overrides["1"]; len(ov) != 0 {
t.Errorf("ReviewData записал overrides: %v", ov)
}
if len(rd.Sources) != 2 {
t.Fatalf("sources = %d, want 2 (neural + candidate)", len(rd.Sources))
}
if rd.Sources[0].Kind != SourceNeural || !rd.Sources[0].Active {
t.Errorf("source[0] = %+v, want активная нейронка", rd.Sources[0])
}
if rd.Sources[0].Title != "Догадка" {
t.Errorf("neural title = %q, want plan title", rd.Sources[0].Title)
}
if rd.Sources[1].Kind != SourceCandidate || rd.Sources[1].Title != "Fargo" || rd.Sources[1].Active {
t.Errorf("source[1] = %+v, want неактивный кандидат Fargo", rd.Sources[1])
}
}
// TestReviewData_SourcePreviewMatchesChosen — предпросмотр источника совпадает
// с превью активного источника после его выбора (эфемерное превью == то, что
// применится). Требует реального layouter.
func TestReviewData_SourcePreviewMatchesChosen(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
_ = f.st.CreateCandidates(context.Background(), []store.MetadataCandidate{
{RecognitionID: "1", Provider: "tvdb", ProviderID: "269613"},
})
candID := f.st.candidates[0].ID
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
var srcPreview []layout.Link
for _, s := range rd.Sources {
if s.Kind == SourceCandidate && s.ProviderID == "269613" {
srcPreview = s.Preview
}
}
if len(srcPreview) == 0 {
t.Fatal("предпросмотр кандидата пуст")
}
if err := f.w.ChooseCandidate(context.Background(), "1", candID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
rd2, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if !sameLinks(srcPreview, rd2.Preview) {
t.Errorf("превью источника != превью активного:\n src=%v\n act=%v", srcPreview, rd2.Preview)
}
}
func sameLinks(a, b []layout.Link) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Src != b[i].Src || a[i].Dst != b[i].Dst {
return false
}
}
return true
}
func TestReviewData_IncludesCandidates(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),