From aac3ced2626aee8e31763958341f2f90f21ed245 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 3 Jul 2026 10:29:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B4=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE?= =?UTF-8?q?=D1=82=D1=80=20=D0=B2=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20(revie?= =?UTF-8?q?w-source-selection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экран ревью теперь показывает единый список источников совпадения: строка «распознано нейронкой» наравне с кандидатами баз; выбор/переключение/снятие в пользу нейронки; ручное добавление по 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) --- docs/specs/review-ux.md | 19 +- internal/httpapi/httpapi.go | 6 + internal/httpapi/httpapi_test.go | 88 ++++++- internal/httpapi/providerurl_test.go | 39 +++ internal/httpapi/render_test.go | 5 +- internal/httpapi/review.go | 148 +++++++++-- internal/worker/review.go | 245 ++++++++++++++++-- internal/worker/review_test.go | 152 +++++++++++ .../changes/review-source-selection/tasks.md | 38 +-- web/templates/review.html | 52 ++-- 10 files changed, 688 insertions(+), 104 deletions(-) diff --git a/docs/specs/review-ux.md b/docs/specs/review-ux.md index 6469b60..96a1c85 100644 --- a/docs/specs/review-ux.md +++ b/docs/specs/review-ux.md @@ -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 (на будущее):** полный редактор маппинга «файл → серия» diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 5316198..e87d1da 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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, "внутренняя ошибка" } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 9188311..574d246 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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{}, diff --git a/internal/httpapi/providerurl_test.go b/internal/httpapi/providerurl_test.go index 136a519..d15967f 100644 --- a/internal/httpapi/providerurl_test.go +++ b/internal/httpapi/providerurl_test.go @@ -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 записи неизвестен»). diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index ae9eb03..a0fafc9 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -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 } diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index b994bbd..eddb32b 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -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 { diff --git a/internal/worker/review.go b/internal/worker/review.go index 02b8f98..2e600a5 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -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) { diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index f65694a..aed7b3a 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -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"), diff --git a/openspec/changes/review-source-selection/tasks.md b/openspec/changes/review-source-selection/tasks.md index 932cf92..41c6a96 100644 --- a/openspec/changes/review-source-selection/tasks.md +++ b/openspec/changes/review-source-selection/tasks.md @@ -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: раскрытие/скрытие предпосчитанного превью строки по клику (нативный `
` или минимальный 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 diff --git a/web/templates/review.html b/web/templates/review.html index d96ad99..ce34ba2 100644 --- a/web/templates/review.html +++ b/web/templates/review.html @@ -66,42 +66,52 @@
-

Источник совпадения

выбери базу или оставь распознавание нейронкой
+

Источник совпадения

выбери источник — распознавание нейронкой или запись базы
- {{if .Provider}}

Выбрано: {{.Provider}} {{if .MatchURL}}{{.ProviderID}} ↗{{else}}{{.ProviderID}}{{end}}

- {{else if .NoBase}}

Выбрано: без базы (тег папки не ставится)

- {{else}}

Матч не подтверждён — выбери кандидата, введи id или «без базы».

{{end}} - - {{if .Candidates}}
- {{range .Candidates}} -
+ {{range .Sources}} +
-
{{.Provider}} {{.Title}}{{if .Year}} · {{.Year}}{{end}}
-
id {{.ProviderID}}
+ {{if eq .Kind "neural"}} +
нейронка распознано нейронкой
+
без базы — тег папки не ставится
+ {{else}} +
{{.Provider}} {{if .Title}}{{.Title}}{{else}}id {{.ProviderID}}{{end}}{{if .Year}} · {{.Year}}{{end}}
+
id {{.ProviderID}}{{if .MatchURL}} · запись ↗{{end}}
+ {{end}} + {{if not .Active}} +
+ предпросмотр +
+ Тип {{if .IsSeries}}сериал{{else}}фильм{{end}} · + Название {{.Title}}{{if .Year}} ({{.Year}}){{end}} · + Режиссёр +
+ {{template "layout_widget" .Files}} +
+ {{end}}
- {{if .URL}}ссылка ↗{{end}} -
- - + {{if .Active}} + + {{else}} + + {{if ne .Kind "neural"}}{{end}} +
+ {{end}}
{{end}}
- {{end}} -
+ - - -
-
- + +