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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-03 10:29:27 +03:00
co-authored by Claude Opus 4.8
parent 3d3448d050
commit aac3ced262
10 changed files with 688 additions and 104 deletions
+127 -21
View File
@@ -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 {