Реализовал выбор источника и предпросмотр в ревью (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:
@@ -118,6 +118,7 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
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}/source", s.handleAddSource)
|
||||
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
|
||||
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
|
||||
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
||||
@@ -631,6 +632,11 @@ func classifyErr(err error) (int, string) {
|
||||
case errors.Is(err, worker.ErrConflict):
|
||||
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
||||
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
||||
case errors.Is(err, errManualSource):
|
||||
// Ошибка ручного ввода источника — показываем пользователю как есть.
|
||||
return http.StatusBadRequest, errManualSource.Error()
|
||||
case errors.Is(err, errInvalidCandidate):
|
||||
return http.StatusBadRequest, errInvalidCandidate.Error()
|
||||
default:
|
||||
return http.StatusInternalServerError, "внутренняя ошибка"
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -32,6 +32,45 @@ func TestProviderURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualSource(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provider string
|
||||
raw string
|
||||
wantProvider string
|
||||
wantID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"id с провайдером", "tmdb", "60622", "tmdb", "60622", false},
|
||||
{"id обрезается позже воркером", "TVDB", "269613", "tvdb", "269613", false},
|
||||
{"URL TMDB со slug", "", "https://www.themoviedb.org/tv/60622-fargo", "tmdb", "60622", false},
|
||||
{"URL TMDB без схемы", "", "themoviedb.org/tv/60622", "tmdb", "60622", false},
|
||||
{"URL TMDB movie", "", "https://www.themoviedb.org/movie/693134", "tmdb", "693134", false},
|
||||
{"URL IMDb", "", "https://www.imdb.com/title/tt0111161/", "imdb", "tt0111161", false},
|
||||
{"URL TVDB dereferrer", "", "https://www.thetvdb.com/dereferrer/series/269613", "tvdb", "269613", false},
|
||||
{"URL TVDB slug — без id → ошибка", "", "https://www.thetvdb.com/series/fargo", "", "", true},
|
||||
{"мусорный URL → ошибка", "", "https://example.com/foo/bar", "", "", true},
|
||||
{"пустой ввод → ошибка", "tmdb", " ", "", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p, id, err := parseManualSource(c.provider, c.raw)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("ожидалась ошибка, получено %q:%q", p, id)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("неожиданная ошибка: %v", err)
|
||||
}
|
||||
if p != c.wantProvider || id != c.wantID {
|
||||
t.Errorf("parseManualSource(%q,%q) = %q:%q, want %q:%q", c.provider, c.raw, p, id, c.wantProvider, c.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchURLNoLinkWhenUnbuildable — эффективный провайдер, для которого URL не
|
||||
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
|
||||
// матч текстом — сценарий «URL записи неизвестен»).
|
||||
|
||||
@@ -49,7 +49,10 @@ func (stubReviewer) Relink(context.Context, string) error
|
||||
func (stubReviewer) Rerecognize(context.Context, string) error { return nil }
|
||||
func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil }
|
||||
func (stubReviewer) SetProviderID(context.Context, string, string, string) error { return nil }
|
||||
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
|
||||
func (stubReviewer) AddManualSource(context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (stubReviewer) ClearProvider(context.Context, string) error { return nil }
|
||||
|
||||
// stubLive — заглушка источника живой телеметрии.
|
||||
type stubLive struct{ m map[string]worker.Live }
|
||||
|
||||
+127
-21
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -25,6 +26,7 @@ type Reviewer interface {
|
||||
Rerecognize(ctx context.Context, id string) error
|
||||
ChooseCandidate(ctx context.Context, id, candidateID string) error
|
||||
SetProviderID(ctx context.Context, id string, provider, providerID string) error
|
||||
AddManualSource(ctx context.Context, id, provider, providerID string) error
|
||||
ClearProvider(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -50,19 +52,24 @@ type reviewView struct {
|
||||
Hints []string
|
||||
Files []fileRow
|
||||
HasPlan bool
|
||||
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
||||
NoBase bool // выбрано «без базы»
|
||||
Candidates []candidateView
|
||||
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
||||
NoBase bool // выбрано «без базы»
|
||||
Sources []sourceView // единый список источников совпадения
|
||||
}
|
||||
|
||||
type candidateView struct {
|
||||
ID string
|
||||
Provider string
|
||||
ProviderID string
|
||||
Title string
|
||||
Year int
|
||||
URL string
|
||||
Chosen bool
|
||||
// sourceView — строка единого списка источников на экране ревью: нейронка или
|
||||
// кандидат базы, с эффективными полями и предпросмотром целевых путей.
|
||||
type sourceView struct {
|
||||
Kind string // "neural" | "candidate"
|
||||
CandidateID string
|
||||
Provider string
|
||||
ProviderID string
|
||||
Title string
|
||||
Year int
|
||||
IsSeries bool
|
||||
MatchURL string
|
||||
Active bool
|
||||
Files []fileRow // предпросмотр «файл → раскладка» этого источника
|
||||
}
|
||||
|
||||
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -113,16 +120,22 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
||||
view.HasPlan = len(rd.Plan.Files) > 0
|
||||
view.HasLinks = len(rd.Preview) > 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),
|
||||
URL: c.URL.String,
|
||||
Chosen: c.Chosen,
|
||||
})
|
||||
for _, src := range rd.Sources {
|
||||
sv := sourceView{
|
||||
Kind: string(src.Kind),
|
||||
CandidateID: src.CandidateID,
|
||||
Provider: src.Provider,
|
||||
ProviderID: src.ProviderID,
|
||||
Title: src.Title,
|
||||
Year: src.Year,
|
||||
IsSeries: src.Type == "series",
|
||||
Active: src.Active,
|
||||
Files: buildFileRows(src.Plan, src.Preview),
|
||||
}
|
||||
if src.Kind == worker.SourceCandidate {
|
||||
sv.MatchURL = sourceMatchURL(src)
|
||||
}
|
||||
view.Sources = append(view.Sources, sv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +211,101 @@ func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleAddSource добавляет источник вручную по id или URL записи метабазы и
|
||||
// выбирает его. Разбор ввода — на входной границе транспорта.
|
||||
func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
||||
_ = r.ParseForm()
|
||||
provider, providerID, err := parseManualSource(r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deps.Reviewer.AddManualSource(ctx, id, provider, providerID)
|
||||
})
|
||||
}
|
||||
|
||||
var errInvalidCandidate = errors.New("некорректный id кандидата")
|
||||
|
||||
var errManualSource = errors.New("не удалось разобрать id или URL записи (для TVDB — числовой id)")
|
||||
|
||||
// parseManualSource разбирает ручной ввод источника: либо id (с выбранным в
|
||||
// форме провайдером), либо URL записи метабазы (провайдер и id из URL).
|
||||
func parseManualSource(provider, raw string) (string, string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errManualSource
|
||||
}
|
||||
if looksLikeURL(raw) {
|
||||
// URL без схемы (`themoviedb.org/tv/1`) url.Parse кладёт в Path, а не в
|
||||
// Host → достраиваем схему, иначе валидный копипаст отвергнется.
|
||||
u := raw
|
||||
if !strings.Contains(u, "://") {
|
||||
u = "https://" + u
|
||||
}
|
||||
p, id, ok := parseProviderURL(u)
|
||||
if !ok {
|
||||
return "", "", errManualSource
|
||||
}
|
||||
return p, id, nil
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(provider)), raw, nil
|
||||
}
|
||||
|
||||
func looksLikeURL(s string) bool {
|
||||
return strings.Contains(s, "://") || strings.HasPrefix(s, "www.") ||
|
||||
strings.Contains(s, ".org/") || strings.Contains(s, ".com/")
|
||||
}
|
||||
|
||||
// parseProviderURL — обратная к providerURL: URL записи → (provider, id).
|
||||
// TMDB/IMDb извлекаются из URL; TVDB — только dereferrer с числовым id (URL
|
||||
// сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём).
|
||||
func parseProviderURL(raw string) (provider, id string, ok bool) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", "", false
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
switch {
|
||||
case strings.Contains(host, "themoviedb.org"):
|
||||
if len(parts) >= 2 && (parts[0] == "movie" || parts[0] == "tv") {
|
||||
if d := leadingDigits(parts[1]); d != "" {
|
||||
return "tmdb", d, true
|
||||
}
|
||||
}
|
||||
case strings.Contains(host, "imdb.com"):
|
||||
if len(parts) >= 2 && parts[0] == "title" && strings.HasPrefix(parts[1], "tt") {
|
||||
return "imdb", parts[1], true
|
||||
}
|
||||
case strings.Contains(host, "thetvdb.com"):
|
||||
if len(parts) >= 3 && parts[0] == "dereferrer" {
|
||||
if d := leadingDigits(parts[2]); d != "" {
|
||||
return "tvdb", d, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// leadingDigits возвращает ведущие цифры строки (TMDB-URL вида
|
||||
// `/movie/60622-fargo` → «60622»).
|
||||
func leadingDigits(s string) string {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
return s[:i]
|
||||
}
|
||||
|
||||
// sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если
|
||||
// есть, иначе канонический URL из provider/id (обратный порядок к matchURL).
|
||||
func sourceMatchURL(src worker.SourceOption) string {
|
||||
if src.URL != "" {
|
||||
return src.URL
|
||||
}
|
||||
return providerURL(src.Provider, src.ProviderID, src.Type)
|
||||
}
|
||||
|
||||
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user