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