Files
jellybit/internal/httpapi/review.go
T
avandClaude Opus 4.8 3f1a928000 Единое окно: полное пользовательское удаление загрузки (delete)
Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные
хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит
раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted.
Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне;
инициатор различается через error_code=user_delete. Подтверждение обязательно:
веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый
inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои
ссылки под movies/series). Граф переходов не менялся — рёбра уже были.

OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:17:51 +03:00

505 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
IgnoreFile(ctx context.Context, id string, src string) error
Defer(ctx context.Context, id string) error
Undo(ctx context.Context, id string) error
Delete(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
SeasonSummary string // сводка сезонов для сериала (пусто для фильма)
Provider string
ProviderID string
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
Confidence string
Reasons []string
Hints []string
Files []fileRow
HasPlan bool
HasLinks bool // есть хотя бы один целевой путь → можно применять
NoBase bool // выбрано «без базы»
Sources []sourceView // единый список источников совпадения
BlockError string // ошибка выбора внутри блока (htmx); не путать с Error (?err=)
}
// sourceView — строка единого списка источников на экране ревью: нейронка или
// кандидат базы. Инфо и предпросмотр раскладки показываются для активного
// источника из верхнеуровневых полей reviewView, поэтому per-source превью
// строка не несёт.
type sourceView struct {
Kind string // "neural" | "candidate"
CandidateID string
Provider string
ProviderID string
Title string
Year int
MatchURL string
Active bool
}
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
}
s.render(w, "review.html", buildReviewView(id, rd, r.URL.Query().Get("err")))
}
// buildReviewView собирает представление страницы ревью из доменных данных.
// Общий для полной страницы (handleReview) и htmx-свопа блока источника
// (reviewBlockAction); errMsg — верхний баннер из ?err= (пусто на htmx-пути,
// там ошибка идёт в BlockError).
func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView {
view := reviewView{
ID: id,
Source: shorten(rd.Download.SourceRef, 80),
Context: rd.Download.Context,
State: string(rd.Download.State),
Error: errMsg,
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
if view.IsSeries {
view.SeasonSummary = seasonSummary(rd.Plan)
}
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,
Active: src.Active,
}
if src.Kind == worker.SourceCandidate {
sv.MatchURL = sourceMatchURL(src)
}
view.Sources = append(view.Sources, sv)
}
}
return 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) 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.reviewBlockAction(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) {
// Смена provider/id — операция над выбранным источником, как candidate/nobase/
// source: свопит блок источника (#source-block), а не всё тело ревью.
s.reviewBlockAction(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.reviewBlockAction(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.reviewBlockAction(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
}
s.surfaceAction(w, r, id, s.deps.Reviewer.Undo(r.Context(), id))
}
// handleDelete — полное удаление загрузки (снять хардлинки + снести раздачу с
// файлами из qBittorrent → deleted). Осознанное необратимое действие: транспорт
// подтверждает его перед POST (hx-confirm + отдельная danger-секция). Ошибку
// qBittorrent surfaceAction покажет как отказ (не тихий успех).
func (s *server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
s.surfaceAction(w, r, id, s.deps.Reviewer.Delete(r.Context(), id))
}
// 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
}
s.surfaceAction(w, r, id, s.deps.Reviewer.Relink(r.Context(), id))
}
// reviewAction — общий помощник петлевых действий ревью (уточнить/распознать
// заново/…): выполнить действие и обновить экран ревью. На htmx перечитывает
// состояние и рендерит партиал `review_main` на месте (при ошибке — сообщение в
// баннере и HTTP 200, иначе htmx не свопит DOM); без htmx деградирует до
// PRG-редиректа на `/review/{id}`. Действия асинхронны — своп отдаёт актуальное
// состояние (обычно `recognizing`), которое дальше само допалливается фрагментом
// (см. handleFragReview).
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
}
actionErr := fn(r.Context(), id)
if !isHTMX(r) {
msg := ""
if actionErr != nil {
msg = userErr(r, actionErr, id)
}
redirectReview(w, r, id, msg)
return
}
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.deps.Logger.Error("review data", "id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
msg := ""
if actionErr != nil {
msg = userErr(r, actionErr, id)
}
s.render(w, "review_main", buildReviewView(id, rd, msg))
}
// handleFragReview отдаёт партиал тела ревью (htmx-поллинг recognizing). Пока
// загрузка в `recognizing`, `review_main` несёт поллер и экран сам обновляется;
// как только состояние стало `review`, фрагмент возвращается без поллера — опрос
// прекращается.
func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
http.Error(w, "не найдено", http.StatusNotFound)
return
}
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
s.render(w, "review_main", buildReviewView(id, rd, ""))
}
// isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу).
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// reviewBlockAction — помощник для действий выбора источника: выполнить
// операцию и вернуть свежий блок источника. На htmx-запрос перечитывает
// состояние и рендерит партиал `review_source_block` (ошибку кладёт в
// BlockError, активный источник не меняется); без htmx деградирует до
// PRG-редиректа, как reviewAction.
func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
actionErr := fn(r.Context(), id)
if !isHTMX(r) {
msg := ""
if actionErr != nil {
msg = userErr(r, actionErr, id)
}
redirectReview(w, r, id, msg)
return
}
// htmx: перечитываем состояние (уже с новым активным источником при успехе)
// и рендерим свежий партиал блока.
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.deps.Logger.Error("review data", "id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
view := buildReviewView(id, rd, "")
if actionErr != nil {
view.BlockError = userErr(r, actionErr, id)
}
s.render(w, "review_source_block", view)
}
// 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)
}