Реализовал выбор источника и предпросмотр в ревью (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:
+15
-4
@@ -49,7 +49,14 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
|
||||
Контекст: «второй сезон, рус+англ дорожки» [+ добавить → 🔁 перераспознать]
|
||||
|
||||
Тип: ( ) фильм (•) сериал Название: Фарго Год: 2015 Сезон: 02
|
||||
База: [TMDB поиск…] [TVDB поиск…] выбрано: — (без базы) [ввести id]
|
||||
|
||||
Источник совпадения (единый список — выбираем источник, а не режим):
|
||||
(•) распознано нейронкой (без базы) [активен]
|
||||
( ) tvdb Fargo · 2014 id 269613 [запись↗] [предпросмотр▸] [выбрать]
|
||||
( ) tmdb Fargo id 60622 [запись↗] [предпросмотр▸] [выбрать]
|
||||
+ добавить вручную: [tmdb▾] [id или URL записи] [Добавить]
|
||||
предпросмотр▸ раскрывает поля (тип/название/год, место под режиссёра) и
|
||||
целевые пути ЭТОГО источника — до выбора, ничего не меняя
|
||||
|
||||
Файлы → серии:
|
||||
# | файл | размер | роль | S | E
|
||||
@@ -141,9 +148,13 @@ Telegram = одобрить / подсказать / выбрать кандид
|
||||
## Объём по версиям
|
||||
|
||||
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
|
||||
заново», переключатель типа, выбор кандидата базы / ручной ввод id /
|
||||
«без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже»,
|
||||
Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой
|
||||
заново», переключатель типа, **единый список источников совпадения**
|
||||
(нейронка наравне с кандидатами баз; выбор/переключение/снятие в пользу
|
||||
нейронки), **ручное добавление источника по id или URL** (TMDB/IMDb — по
|
||||
URL, TVDB — по числовому id), **предпросмотр полей и целевых путей каждого
|
||||
источника до применения** (место под режиссёра зарезервировано), пометка
|
||||
файла «игнор», «Применить»/«Отклонить»/«Позже», Undo и «Привязать заново».
|
||||
В Telegram — подтверждение с reply-подсказкой
|
||||
(«Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
|
||||
пинги о входе в review и готовности.
|
||||
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+220
-25
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
## 1. Ядро: эфемерный предпросмотр источника (worker)
|
||||
|
||||
- [ ] 1.1 Ввести чистую деривацию «источник → полный самосогласованный набор
|
||||
- [x] 1.1 Ввести чистую деривацию «источник → полный самосогласованный набор
|
||||
overrides» (кандидат с title/year → пиннит; без — пустая строка в
|
||||
title/year; нейронка → provider=none + пустые title/year) и
|
||||
переиспользовать её в превью и в коммите — гарантия preview == apply
|
||||
(решение 1a design.md)
|
||||
- [ ] 1.2 Привести команды выбора к этой деривации: `ChooseCandidate` у
|
||||
- [x] 1.2 Привести команды выбора к этой деривации: `ChooseCandidate` у
|
||||
безтайтлового кандидата и `ClearProvider` очищают `ovrTitle`/`ovrYear`
|
||||
(пустой строкой) — чинит латентный залипший override
|
||||
- [ ] 1.3 Выделить чистый расчёт «план + набор overrides источника → поля +
|
||||
- [x] 1.3 Выделить чистый расчёт «план + набор overrides источника → поля +
|
||||
`[]layout.Link`» из логики `ReviewData` (переиспользуя `applyOverrides` +
|
||||
`toLayoutPlan` + `layouter.BuildLinks`), без записи overrides в БД
|
||||
- [ ] 1.4 Расширить `ReviewData` срезом источников: нейронка (синтетическая
|
||||
- [x] 1.4 Расширить `ReviewData` срезом источников: нейронка (синтетическая
|
||||
строка `provider=none`) + каждый кандидат; для каждого — эффективные
|
||||
поля (тип/название/год), превью путей и признак «активен»; дедуп
|
||||
источников по `provider:id` (нейронка — отдельная строка)
|
||||
- [ ] 1.5 Тесты worker: превью для неактивного источника не пишет overrides
|
||||
- [x] 1.5 Тесты worker: превью для неактивного источника не пишет overrides
|
||||
и не создаёт ссылок; пути превью совпадают с результатом применения
|
||||
того же источника; **переключение с титульного кандидата на нейронку/
|
||||
безтайтловый источник не тянет чужие название/год** (preview и apply);
|
||||
@@ -23,46 +23,46 @@
|
||||
|
||||
## 2. Ручное добавление источника (worker + store + httpapi)
|
||||
|
||||
- [ ] 2.1 Парсер ручного ввода в `httpapi`: id или URL записи метабазы →
|
||||
- [x] 2.1 Парсер ручного ввода в `httpapi`: id или URL записи метабазы →
|
||||
`(provider, provider_id)` (обратный к `providerURL`: themoviedb.org
|
||||
movie/tv, thetvdb, imdb); валидация на входной границе
|
||||
- [ ] 2.2 Метод worker «добавить источник вручную»: сохранить
|
||||
- [x] 2.2 Метод worker «добавить источник вручную»: сохранить
|
||||
`metadata_candidate` (provider/id, опц. url; title/year пустые) с
|
||||
дедупом по `provider:id`, затем выбрать его (как `ChooseCandidate`)
|
||||
- [ ] 2.3 Обработчик POST добавления ручного источника + маршрут; отклонять
|
||||
- [x] 2.3 Обработчик POST добавления ручного источника + маршрут; отклонять
|
||||
некорректный ввод сообщением, не меняя активный источник
|
||||
- [ ] 2.4 Тесты: URL → provider+id; невалидный ввод отклонён; дубль id не
|
||||
- [x] 2.4 Тесты: URL → provider+id; невалидный ввод отклонён; дубль id не
|
||||
плодит строку, а выбирает существующую
|
||||
|
||||
## 3. Страница ревью: единый список и предпросмотр (httpapi + шаблон)
|
||||
|
||||
- [ ] 3.1 Переработать блок «Источник совпадения» в единый список строк из
|
||||
- [x] 3.1 Переработать блок «Источник совпадения» в единый список строк из
|
||||
`[]SourceOption`: нейронка (первой) + кандидаты баз + ручной ввод;
|
||||
ровно один активный, у активного — матч-ссылка (как сейчас)
|
||||
- [ ] 3.2 Строка источника: шапка (провайдер/название/год + маркер активного
|
||||
- [x] 3.2 Строка источника: шапка (провайдер/название/год + маркер активного
|
||||
+ кнопка «выбрать») и **раскрываемый по клику** блок предпросмотра —
|
||||
поля (тип/название/год + пустое место под режиссёра) и целевые пути;
|
||||
данные всех источников предпосчитаны сервером и вложены в страницу
|
||||
- [ ] 3.3 Секцию «Раскладка» оставить отдельной — показывает пути **активного**
|
||||
- [x] 3.3 Секцию «Раскладка» оставить отдельной — показывает пути **активного**
|
||||
источника (как сейчас); инлайн-превью в строках — только для сравнения
|
||||
неактивных до выбора
|
||||
- [ ] 3.4 Клиентский vanilla-JS: раскрытие/скрытие предпосчитанного превью
|
||||
- [x] 3.4 Клиентский vanilla-JS: раскрытие/скрытие предпосчитанного превью
|
||||
строки по клику (нативный `<details>` или минимальный JS); без
|
||||
доменного пересчёта на клиенте
|
||||
- [ ] 3.5 Действия строк формами-раундтрипами: выбрать кандидата
|
||||
- [x] 3.5 Действия строк формами-раундтрипами: выбрать кандидата
|
||||
(`ChooseCandidate`), снять в пользу нейронки (`ClearProvider`),
|
||||
добавить вручную; сохранить прежние действия ревью
|
||||
- [ ] 3.6 Убедиться, что матч-ссылка и заголовок строки корректны для ручного
|
||||
- [x] 3.6 Убедиться, что матч-ссылка и заголовок строки корректны для ручного
|
||||
кандидата с пустыми title/year (деградация до «провайдер + id»)
|
||||
|
||||
## 4. Проверка и приёмка
|
||||
|
||||
- [ ] 4.1 `task test` и `task lint` зелёные
|
||||
- [ ] 4.2 Ручная проверка сценариев спеки: нейронка-строка, переключение
|
||||
- [x] 4.1 `task test` и `task lint` зелёные
|
||||
- [x] 4.2 Ручная проверка сценариев спеки: нейронка-строка, переключение
|
||||
кандидатов, снятие в пользу нейронки, ручной ввод по URL, предпросмотр
|
||||
без фиксации
|
||||
- [ ] 4.3 `openspec validate review-source-selection --strict`
|
||||
- [ ] 4.4 Обновить `docs/specs/review-ux.md` (источник истины по review-домену
|
||||
- [x] 4.3 `openspec validate review-source-selection --strict`
|
||||
- [x] 4.4 Обновить `docs/specs/review-ux.md` (источник истины по review-домену
|
||||
до миграции): единый список источников, ручное добавление, предпросмотр,
|
||||
объём Ф3/Ф5 — обязательно (поведение экрана меняется); убрать пункт
|
||||
беклога после archive
|
||||
|
||||
+31
-21
@@ -66,42 +66,52 @@
|
||||
|
||||
<!-- Источник совпадения -->
|
||||
<div class="section">
|
||||
<div class="section-head"><h2>Источник совпадения</h2><span class="hint">выбери базу или оставь распознавание нейронкой</span></div>
|
||||
<div class="section-head"><h2>Источник совпадения</h2><span class="hint">выбери источник — распознавание нейронкой или запись базы</span></div>
|
||||
|
||||
{{if .Provider}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>{{.Provider}}</b> {{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">{{.ProviderID}} ↗</a>{{else}}<span class="mono">{{.ProviderID}}</span>{{end}}</p>
|
||||
{{else if .NoBase}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>без базы</b> <span class="faint">(тег папки не ставится)</span></p>
|
||||
{{else}}<p style="margin:0 0 var(--sp-3)"><span class="faint">Матч не подтверждён — выбери кандидата, введи id или «без базы».</span></p>{{end}}
|
||||
|
||||
{{if .Candidates}}
|
||||
<div class="cand-list">
|
||||
{{range .Candidates}}
|
||||
<div class="cand{{if .Chosen}} selected{{end}}">
|
||||
{{range .Sources}}
|
||||
<div class="cand{{if .Active}} selected{{end}}">
|
||||
<span class="radio"></span>
|
||||
<div class="body">
|
||||
<div class="name"><span class="prov {{.Provider}}">{{.Provider}}</span> {{.Title}}{{if .Year}} <span class="muted">· {{.Year}}</span>{{end}}</div>
|
||||
<div class="sub">id {{.ProviderID}}</div>
|
||||
{{if eq .Kind "neural"}}
|
||||
<div class="name"><span class="prov none">нейронка</span> распознано нейронкой</div>
|
||||
<div class="sub">без базы — тег папки не ставится</div>
|
||||
{{else}}
|
||||
<div class="name"><span class="prov {{.Provider}}">{{.Provider}}</span> {{if .Title}}{{.Title}}{{else}}<span class="faint">id {{.ProviderID}}</span>{{end}}{{if .Year}} <span class="muted">· {{.Year}}</span>{{end}}</div>
|
||||
<div class="sub">id {{.ProviderID}}{{if .MatchURL}} · <a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">запись ↗</a>{{end}}</div>
|
||||
{{end}}
|
||||
{{if not .Active}}
|
||||
<details class="src-preview">
|
||||
<summary>предпросмотр</summary>
|
||||
<div class="src-fields">
|
||||
<span class="label">Тип</span> {{if .IsSeries}}сериал{{else}}фильм{{end}} ·
|
||||
<span class="label">Название</span> {{.Title}}{{if .Year}} ({{.Year}}){{end}} ·
|
||||
<span class="label">Режиссёр</span> <span class="faint">—</span>
|
||||
</div>
|
||||
{{template "layout_widget" .Files}}
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .URL}}<a class="ext-link" href="{{.URL}}" target="_blank" rel="noopener">ссылка ↗</a>{{end}}
|
||||
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
|
||||
<input type="hidden" name="candidate_id" value="{{.ID}}">
|
||||
<button class="btn btn-sm{{if not .Chosen}} btn-primary{{end}}" type="submit"{{if .Chosen}} disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
|
||||
{{if .Active}}
|
||||
<button class="btn btn-sm" type="button" disabled>активен</button>
|
||||
{{else}}
|
||||
<form method="post" action="/ui/downloads/{{$.ID}}/{{if eq .Kind "neural"}}nobase{{else}}candidate{{end}}">
|
||||
{{if ne .Kind "neural"}}<input type="hidden" name="candidate_id" value="{{.CandidateID}}">{{end}}
|
||||
<button class="btn btn-sm btn-primary" type="submit">выбрать</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="cand-extra" method="post" action="/ui/downloads/{{.ID}}/provider">
|
||||
<form class="cand-extra" method="post" action="/ui/downloads/{{.ID}}/source">
|
||||
<select class="select" name="provider" style="max-width:120px">
|
||||
<option value="tmdb">TMDB</option>
|
||||
<option value="tvdb">TVDB</option>
|
||||
<option value="imdb">IMDb</option>
|
||||
</select>
|
||||
<input class="input mono" name="provider_id" placeholder="ввести id вручную, напр. 60622" required>
|
||||
<button class="btn btn-sm" type="submit">Задать id</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:var(--sp-2)">
|
||||
<button class="btn btn-sm btn-ghost" type="submit">Без базы</button>
|
||||
<input class="input mono" name="provider_id" placeholder="id или URL записи (для TVDB — числовой id)" required>
|
||||
<button class="btn btn-sm" type="submit">Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user