Реализовал выбор источника и предпросмотр в ревью (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
+75 -13
View File
@@ -399,6 +399,7 @@ type fakeReviewer struct {
ignored map[string]string
chosen map[string]string
providerSet map[string]string
sourceAdded map[string]string
applied []string
deferred []string
undone []string
@@ -468,6 +469,13 @@ func (f *fakeReviewer) SetProviderID(_ context.Context, id string, provider, pro
f.providerSet[id] = provider + ":" + providerID
return nil
}
func (f *fakeReviewer) AddManualSource(_ context.Context, id, provider, providerID string) error {
if f.sourceAdded == nil {
f.sourceAdded = map[string]string{}
}
f.sourceAdded[id] = provider + ":" + providerID
return nil
}
func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
f.cleared = append(f.cleared, id)
return nil
@@ -475,25 +483,37 @@ func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
func seriesReviewData() *worker.ReviewData {
s, e := 2, 1
plan := recognize.Plan{
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
Files: []recognize.PlanFile{
{Src: "Fargo/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e},
},
}
preview := []layout.Link{
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
}
return &worker.ReviewData{
Download: store.Download{ID: tid, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
Recognition: &store.Recognition{
ID: "1", DownloadID: tid, IsCurrent: true, Reasons: `["нет матча в базе"]`,
},
Plan: recognize.Plan{
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
Files: []recognize.PlanFile{
{Src: "Fargo/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e},
},
},
Preview: []layout.Link{
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
},
Plan: plan,
Preview: preview,
Candidates: []store.MetadataCandidate{
{ID: cid, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
Year: sql.NullInt64{Int64: 2014, Valid: true}},
{ID: cid2, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
},
// Единый список источников: нейронка активна (матча нет) + кандидаты.
Sources: []worker.SourceOption{
{Kind: worker.SourceNeural, Provider: "none", Title: "Фарго", Year: 2015,
Type: "series", Active: true, Plan: plan, Preview: preview},
{Kind: worker.SourceCandidate, CandidateID: cid, Provider: "tvdb", ProviderID: "269613",
URL: "https://www.thetvdb.com/dereferrer/series/269613", Title: "Fargo", Year: 2014,
Type: "series", Plan: plan, Preview: preview},
{Kind: worker.SourceCandidate, CandidateID: cid2, Provider: "tmdb", ProviderID: "60622",
Title: "Fargo", Type: "series", Plan: plan, Preview: preview},
},
Hints: []string{"второй сезон"},
}
}
@@ -521,7 +541,7 @@ func TestReviewRenders(t *testing.T) {
}
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
"Season 02", "Применить", "Уточнить",
"Источник совпадения", "269613", "выбрать", "Без базы"} {
"Источник совпадения", "269613", "выбрать", "распознано нейронкой", "Добавить"} {
if !strings.Contains(string(body), want) {
t.Errorf("страница ревью не содержит %q", want)
}
@@ -529,12 +549,13 @@ func TestReviewRenders(t *testing.T) {
}
func TestReviewShowsMatchLink(t *testing.T) {
// Подтверждённый матч в блоке «Источник совпадения» — ссылкой на запись.
// Подтверждённый матч в списке источников — ссылкой на запись.
rd := seriesReviewData()
rd.Provider = "tvdb"
rd.ProviderID = "269613"
rd.Candidates[0].Chosen = true // tvdb 269613 в seriesReviewData
rd.Candidates[0].URL = store.NullString("https://www.thetvdb.com/dereferrer/series/269613")
// Активен кандидат tvdb (индекс 1: нейронка, tvdb, tmdb).
rd.Sources[0].Active = false
rd.Sources[1].Active = true
rv := &fakeReviewer{data: rd}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
@@ -591,6 +612,47 @@ func TestSetProviderAndNoBase(t *testing.T) {
}
}
func TestAddManualSource(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
// URL записи → провайдер+id разбираются на границе, воркер получает их.
if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/source",
map[string][]string{"provider": {"tmdb"}, "provider_id": {"https://www.themoviedb.org/tv/60622-fargo"}}); err != nil {
t.Fatal(err)
}
if rv.sourceAdded[tid] != "tmdb:60622" {
t.Errorf("AddManualSource получил %q, want tmdb:60622", rv.sourceAdded[tid])
}
}
func TestAddManualSource_RejectsBadURL(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
// URL TVDB со slug (без числового id) не разбирается → отклонён, источник
// не добавлен, пользователю уходит сообщение (не «внутренняя ошибка»).
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/source",
map[string][]string{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if _, called := rv.sourceAdded[tid]; called {
t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, "/review/"+tid) || !strings.Contains(loc, "err=") {
t.Errorf("Location = %q, want редирект на ревью с ?err=", loc)
}
if strings.Contains(loc, "%D0%B2%D0%BD%D1%83%D1%82%D1%80%D0%B5%D0%BD") { // «внутрен…»
t.Errorf("ошибка ввода подана как внутренняя: %q", loc)
}
}
func TestApplyRedirectsToIndex(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},