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) }