Штатные конфликты и промахи ввода возвращались голым fmt.Errorf, поэтому
classifyErr отправлял их в 500 «внутренняя ошибка» вместо 409/400 (и logCmd
писал ERROR вместо DEBUG). Продолжение f8fb4fa (Tier A), по итогам ревью Fable.
Классификация ошибок:
- новый sentinel worker.ErrInvalidInput → 400 для валидации ввода команд
(refine/set type/ignore/add source/set provider/choose candidate);
- обёртки %w ErrConflict в Cancel/Retry/Defer/Undo (штатный конфликт состояния);
- classifyErr: ErrInvalidInput→400, layout.ErrCollision→409 (коллизия цели
штатно уводит в review); ветка ErrCollision в tgbot (сообщение + refreshCard);
- logCmd относит ErrInvalidInput и ErrCollision в DEBUG «command rejected».
Конвенции (docs/conventions):
- logging.md: публичные команды воркера = доменная граница (лог один раз,
logCmd); таблица уровней доменных отказов (граница команды vs асинхронная
стадия); правило про *url.Error/секреты в URL; канон категории
state transition; уровень повторяющихся сбоев фоновых циклов;
- errors.md: таблица маппинга ошибка→статус; развилка «транзиентный ответ vs
персистентная диагностика» решена как (а) — error_msg/reasons на review-экране
и tg-карточке = операторская поверхность владельца (сырой текст ок, секреты
запрещены; аудит подтвердил, что секреты туда не текут).
Унификация категории лога state transition: cancel/retry/relink/recovery
переведены с семантических msg на общий state transition (from/to) — весь
жизненный цикл собирается одним jq-фильтром.
Мелочи: reason-коды linkPlan в const-блок; httpapi лог-поля id→download_id и
msg «… failed»; комментарий «почему» у parseIgnored; preview build failure в
ReviewData DEBUG→WARN.
Беклог: задача сведена к остатку (ext.* ERROR-шторм при недоступном qBittorrent
+ эскалация устойчивого сбоя тика), понижена в приоритете.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
505 lines
19 KiB
Go
505 lines
19 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
|
||
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 failed", "download_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 failed", "download_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 failed", "download_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)
|
||
}
|