Экран ревью теперь показывает единый список источников совпадения: строка «распознано нейронкой» наравне с кандидатами баз; выбор/переключение/снятие в пользу нейронки; ручное добавление по 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>
415 lines
15 KiB
Go
415 lines
15 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/ident"
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
|
)
|
|
|
|
// Reviewer — операции ревью и раскладки (worker.Worker).
|
|
type Reviewer interface {
|
|
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
|
|
Apply(ctx context.Context, id string) error
|
|
Refine(ctx context.Context, id string, hint string) error
|
|
SetType(ctx context.Context, id string, mediaType string) error
|
|
IgnoreFile(ctx context.Context, id string, src string) error
|
|
Defer(ctx context.Context, id string) error
|
|
Undo(ctx context.Context, id string) error
|
|
Relink(ctx context.Context, id string) error
|
|
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
|
|
}
|
|
|
|
// --- Представление страницы ревью ---
|
|
|
|
type reviewView struct {
|
|
ID string
|
|
Source string
|
|
Context string
|
|
State string
|
|
Error string // из ?err=
|
|
StateError string // error_msg загрузки (напр. причина коллизии)
|
|
MediaType string
|
|
IsSeries bool
|
|
Title string
|
|
OriginalTitle string
|
|
Year int
|
|
Provider string
|
|
ProviderID string
|
|
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
|
|
Confidence string
|
|
Reasons []string
|
|
Hints []string
|
|
Files []fileRow
|
|
HasPlan bool
|
|
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
|
NoBase bool // выбрано «без базы»
|
|
Sources []sourceView // единый список источников совпадения
|
|
}
|
|
|
|
// 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) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
// Невалидный id = несуществующая сущность; в БД не ходим.
|
|
http.Error(w, "задача не найдена", http.StatusNotFound)
|
|
return
|
|
}
|
|
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "задача не найдена", http.StatusNotFound)
|
|
return
|
|
}
|
|
s.deps.Logger.Error("review data", "id", id, "error", err)
|
|
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
view := reviewView{
|
|
ID: id,
|
|
Source: shorten(rd.Download.SourceRef, 80),
|
|
Context: rd.Download.Context,
|
|
State: string(rd.Download.State),
|
|
Error: r.URL.Query().Get("err"),
|
|
StateError: rd.Download.ErrorMsg.String,
|
|
Hints: rd.Hints,
|
|
}
|
|
if rec := rd.Recognition; rec != nil {
|
|
view.MediaType = string(rd.Plan.Type)
|
|
view.IsSeries = rd.Plan.Type == "series"
|
|
view.Title = rd.Plan.Title
|
|
view.OriginalTitle = rd.Plan.OriginalTitle
|
|
view.Year = rd.Plan.Year
|
|
view.Reasons = rec.ReasonList()
|
|
switch rd.Provider {
|
|
case "", "none":
|
|
view.NoBase = rd.Provider == "none"
|
|
default:
|
|
view.Provider = rd.Provider
|
|
view.ProviderID = rd.ProviderID
|
|
view.MatchURL = matchURL(rd, string(rd.Plan.Type))
|
|
}
|
|
if rec.Confidence.Valid {
|
|
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
|
}
|
|
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
|
view.HasPlan = len(rd.Plan.Files) > 0
|
|
view.HasLinks = len(rd.Preview) > 0
|
|
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)
|
|
}
|
|
}
|
|
|
|
s.render(w, "review.html", view)
|
|
}
|
|
|
|
// --- Действия ревью (POST → redirect) ---
|
|
|
|
func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
redirectErr(w, r, "некорректный id")
|
|
return
|
|
}
|
|
if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil {
|
|
// Тонкий транспорт: ошибку воркера переводим в ответ, не логируя
|
|
// повторно (доменный слой уже залогировал реальный сбой).
|
|
redirectReview(w, r, id, userErr(r, err, id))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
|
|
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
|
_ = r.ParseForm()
|
|
return s.deps.Reviewer.Refine(ctx, id, r.PostForm.Get("hint"))
|
|
})
|
|
}
|
|
|
|
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
|
|
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
|
return s.deps.Reviewer.Rerecognize(ctx, id)
|
|
})
|
|
}
|
|
|
|
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
|
|
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
|
_ = r.ParseForm()
|
|
return s.deps.Reviewer.SetType(ctx, id, r.PostForm.Get("type"))
|
|
})
|
|
}
|
|
|
|
func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
|
|
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
|
_ = r.ParseForm()
|
|
return s.deps.Reviewer.IgnoreFile(ctx, id, r.PostForm.Get("src"))
|
|
})
|
|
}
|
|
|
|
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
|
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
|
_ = r.ParseForm()
|
|
// Входная граница: id кандидата из формы валидируется как ULID.
|
|
candidateID, err := ident.Parse(r.PostForm.Get("candidate_id"))
|
|
if err != nil {
|
|
return errInvalidCandidate
|
|
}
|
|
return s.deps.Reviewer.ChooseCandidate(ctx, id, candidateID)
|
|
})
|
|
}
|
|
|
|
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
|
|
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
|
_ = r.ParseForm()
|
|
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
|
})
|
|
}
|
|
|
|
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
|
|
s.reviewAction(w, r, func(ctx context.Context, id string) error {
|
|
return s.deps.Reviewer.ClearProvider(ctx, id)
|
|
})
|
|
}
|
|
|
|
// 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 {
|
|
redirectErr(w, r, "некорректный id")
|
|
return
|
|
}
|
|
if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil {
|
|
redirectReview(w, r, id, userErr(r, err, id))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
redirectErr(w, r, "некорректный id")
|
|
return
|
|
}
|
|
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
|
|
redirectErr(w, r, userErr(r, err, id))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// handleRelink повторно привязывает откатанную задачу: перезапускает
|
|
// распознавание, задача пройдёт recognizing → review для подтверждения.
|
|
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
redirectErr(w, r, "некорректный id")
|
|
return
|
|
}
|
|
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
|
|
redirectErr(w, r, userErr(r, err, id))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// reviewAction — общий помощник: выполнить действие и вернуться на страницу
|
|
// ревью (с ошибкой в ?err при неудаче).
|
|
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
redirectErr(w, r, "некорректный id")
|
|
return
|
|
}
|
|
if err := fn(r.Context(), id); err != nil {
|
|
// Тонкий транспорт: ошибку переводим в ?err= на странице ревью, не
|
|
// логируя повторно (доменный слой/валидация — не дело транспорта).
|
|
redirectReview(w, r, id, userErr(r, err, id))
|
|
return
|
|
}
|
|
redirectReview(w, r, id, "")
|
|
}
|
|
|
|
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
|
|
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
|
// (человек мог выбрать кандидата, затем вручную переопределить id — тогда
|
|
// кандидат указывает на другую запись). Иначе строим канонический URL; если не
|
|
// удаётся — возвращаем пусто (транспорт покажет матч текстом).
|
|
func matchURL(rd *worker.ReviewData, mediaType string) string {
|
|
for _, c := range rd.Candidates {
|
|
if c.Chosen && c.Provider == rd.Provider && c.ProviderID == rd.ProviderID &&
|
|
c.URL.Valid && c.URL.String != "" {
|
|
return c.URL.String
|
|
}
|
|
}
|
|
return providerURL(rd.Provider, rd.ProviderID, mediaType)
|
|
}
|
|
|
|
// providerURL строит канонический URL записи метабазы с учётом типа медиа.
|
|
// Пустой id или неизвестный провайдер → пусто.
|
|
func providerURL(provider, id, mediaType string) string {
|
|
if id == "" {
|
|
return ""
|
|
}
|
|
switch provider {
|
|
case "tmdb":
|
|
kind := "movie"
|
|
if mediaType == "series" {
|
|
kind = "tv"
|
|
}
|
|
return "https://www.themoviedb.org/" + kind + "/" + id
|
|
case "tvdb":
|
|
kind := "series"
|
|
if mediaType == "movie" {
|
|
kind = "movie"
|
|
}
|
|
return "https://www.thetvdb.com/dereferrer/" + kind + "/" + id
|
|
case "imdb":
|
|
return "https://www.imdb.com/title/" + id
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
|
|
u := "/review/" + id
|
|
if msg != "" {
|
|
u += "?err=" + url.QueryEscape(msg)
|
|
}
|
|
http.Redirect(w, r, u, http.StatusSeeOther)
|
|
}
|