Долгий metaDL больше не убивается агрессивным таймаутом: дефолт magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта — added_on из qBittorrent, а не created_at (переживает retry/усыновление). Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и продвинулся за условие падения (downloading/completed по статусу торрента); qbit_error не воскрешается. Конфликт idempotency (infohash занят другой активной задачей) — оставляем в failed. Уведомления: любой переход в failed/stuck пингует автора (включая приёмный qbit_add через ingest), с дебаунсом против спама при флаппинге stalled. Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому торренту вместо слепого Add. Дельта state-reconciliation влита в живые спеки; обновлён workflow.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
475 lines
16 KiB
Go
475 lines
16 KiB
Go
// Package httpapi предоставляет HTTP API и веб-UI (server-rendered).
|
|
//
|
|
// Тонкий транспорт над ядром: приём идёт в ingest, команды (cancel/retry) —
|
|
// в worker, чтение — в store. В v1 без авторизации (доверенная LAN).
|
|
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
|
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
|
"git.vakhrushev.me/av/jellybit/web"
|
|
)
|
|
|
|
// Ingestor принимает загрузку (ingest.Service).
|
|
type Ingestor interface {
|
|
Ingest(ctx context.Context, req ingest.Request) (ingest.Result, error)
|
|
}
|
|
|
|
// Commander исполняет команды над задачей (worker.Worker).
|
|
type Commander interface {
|
|
Cancel(ctx context.Context, id int64) error
|
|
Retry(ctx context.Context, id int64) error
|
|
}
|
|
|
|
// Reader читает задачи (store.Store).
|
|
type Reader interface {
|
|
ListDownloads(ctx context.Context) ([]store.Download, error)
|
|
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
|
}
|
|
|
|
// Deps — зависимости транспорта.
|
|
type Deps struct {
|
|
Logger *slog.Logger
|
|
Ingestor Ingestor
|
|
Commander Commander
|
|
Reader Reader
|
|
Reviewer Reviewer
|
|
}
|
|
|
|
type server struct {
|
|
deps Deps
|
|
index *template.Template
|
|
review *template.Template
|
|
}
|
|
|
|
// NewRouter собирает HTTP-обработчик сервиса.
|
|
func NewRouter(d Deps) (http.Handler, error) {
|
|
index, err := template.ParseFS(web.FS, "templates/index.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
review, err := template.New("review.html").
|
|
Funcs(template.FuncMap{"add": func(a, b int) int { return a + b }}).
|
|
ParseFS(web.FS, "templates/review.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &server{deps: d, index: index, review: review}
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.RequestID)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(requestLogger(d.Logger))
|
|
|
|
r.Get("/healthz", handleHealthz)
|
|
|
|
// Веб-UI.
|
|
r.Get("/", s.handleIndex)
|
|
r.Post("/ui/downloads", s.handleUIAdd)
|
|
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
|
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
|
|
|
// Веб-UI: ревью раскладки.
|
|
r.Get("/review/{id}", s.handleReview)
|
|
r.Post("/ui/downloads/{id}/apply", s.handleApply)
|
|
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
|
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
|
|
r.Post("/ui/downloads/{id}/type", s.handleSetType)
|
|
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
|
|
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
|
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
|
|
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
|
|
r.Post("/ui/downloads/{id}/defer", s.handleDefer)
|
|
r.Post("/ui/downloads/{id}/undo", s.handleUndo)
|
|
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
|
|
|
|
// REST API.
|
|
r.Route("/api", func(r chi.Router) {
|
|
r.Get("/downloads", s.handleAPIList)
|
|
r.Post("/downloads", s.handleAPIAdd)
|
|
r.Get("/downloads/{id}", s.handleAPIGet)
|
|
r.Post("/downloads/{id}/cancel", s.handleAPICancel)
|
|
r.Post("/downloads/{id}/retry", s.handleAPIRetry)
|
|
})
|
|
|
|
return r, nil
|
|
}
|
|
|
|
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// --- Веб-UI ---
|
|
|
|
type indexView struct {
|
|
Error string
|
|
Downloads []downloadView
|
|
}
|
|
|
|
type downloadView struct {
|
|
ID int64
|
|
Source string
|
|
Infohash string
|
|
Context string
|
|
State string
|
|
Error string
|
|
Terminal bool
|
|
Reviewable bool // review/deferred — есть экран ревью
|
|
Undoable bool // done — можно откатить раскладку
|
|
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
|
Retriable bool // failed/stuck — можно повторить попытку
|
|
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
|
}
|
|
|
|
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
|
if err != nil {
|
|
s.deps.Logger.Error("list downloads", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
view := indexView{Error: r.URL.Query().Get("err")}
|
|
for _, d := range downloads {
|
|
view.Downloads = append(view.Downloads, toView(d))
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.index.Execute(w, view); err != nil {
|
|
s.deps.Logger.Error("render index", "error", err)
|
|
}
|
|
}
|
|
|
|
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
redirectErr(w, r, "не удалось разобрать форму")
|
|
return
|
|
}
|
|
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{
|
|
Source: r.PostForm.Get("source"),
|
|
Context: r.PostForm.Get("context"),
|
|
})
|
|
if err != nil {
|
|
redirectErr(w, r, userErr(r, err, res.DownloadID))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
redirectErr(w, r, "некорректный id")
|
|
return
|
|
}
|
|
if err := s.deps.Commander.Cancel(r.Context(), id); err != nil {
|
|
redirectErr(w, r, userErr(r, err, id))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
redirectErr(w, r, "некорректный id")
|
|
return
|
|
}
|
|
if err := s.deps.Commander.Retry(r.Context(), id); err != nil {
|
|
redirectErr(w, r, userErr(r, err, id))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
|
|
// --- REST API ---
|
|
|
|
type downloadDTO struct {
|
|
ID int64 `json:"id"`
|
|
SourceType string `json:"source_type"`
|
|
Infohash string `json:"infohash,omitempty"`
|
|
Context string `json:"context,omitempty"`
|
|
State string `json:"state"`
|
|
ErrorCode string `json:"error_code,omitempty"`
|
|
ErrorMsg string `json:"error_msg,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type addRequest struct {
|
|
Source string `json:"source"`
|
|
Context string `json:"context"`
|
|
}
|
|
|
|
type addResponse struct {
|
|
ID int64 `json:"id"`
|
|
Infohash string `json:"infohash"`
|
|
State string `json:"state"`
|
|
Deduplicated bool `json:"deduplicated"`
|
|
}
|
|
|
|
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
|
|
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
|
if err != nil {
|
|
s.apiErr(w, r, err, 0)
|
|
return
|
|
}
|
|
out := make([]downloadDTO, 0, len(downloads))
|
|
for _, d := range downloads {
|
|
out = append(out, toDTO(d))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
|
return
|
|
}
|
|
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
|
if err != nil {
|
|
// ErrNotFound → 404, реальный сбой БД → 500 (не маскируем под 404).
|
|
s.apiErr(w, r, err, id)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toDTO(*d))
|
|
}
|
|
|
|
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
|
|
var req addRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", 0))
|
|
return
|
|
}
|
|
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
|
|
if err != nil {
|
|
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit) —
|
|
// тогда коррелируем по download_id, иначе (ранний разбор источника) по
|
|
// request_id.
|
|
s.apiErr(w, r, err, res.DownloadID)
|
|
return
|
|
}
|
|
status := http.StatusCreated
|
|
if res.Deduplicated {
|
|
status = http.StatusOK
|
|
}
|
|
writeJSON(w, status, addResponse{
|
|
ID: res.DownloadID,
|
|
Infohash: res.Infohash,
|
|
State: string(res.State),
|
|
Deduplicated: res.Deduplicated,
|
|
})
|
|
}
|
|
|
|
func (s *server) handleAPICancel(w http.ResponseWriter, r *http.Request) {
|
|
s.apiCommand(w, r, s.deps.Commander.Cancel)
|
|
}
|
|
|
|
func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
|
|
s.apiCommand(w, r, s.deps.Commander.Retry)
|
|
}
|
|
|
|
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) {
|
|
id, err := pathID(r)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
|
|
return
|
|
}
|
|
if err := cmd(r.Context(), id); err != nil {
|
|
// Тонкий транспорт: возвращённую use-case'ом/воркером ошибку переводим в
|
|
// статус+сообщение и не логируем повторно (доменный слой уже залогировал,
|
|
// а невалидный ввод — норма, разбирать команде нечего).
|
|
s.apiErr(w, r, err, id)
|
|
return
|
|
}
|
|
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]int64{"id": id})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toDTO(*d))
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func toDTO(d store.Download) downloadDTO {
|
|
return downloadDTO{
|
|
ID: d.ID,
|
|
SourceType: string(d.SourceType),
|
|
Infohash: d.Infohash.String,
|
|
Context: d.Context,
|
|
State: string(d.State),
|
|
ErrorCode: d.ErrorCode.String,
|
|
ErrorMsg: d.ErrorMsg.String,
|
|
CreatedAt: d.CreatedAt,
|
|
UpdatedAt: d.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func toView(d store.Download) downloadView {
|
|
return downloadView{
|
|
ID: d.ID,
|
|
Source: shorten(d.SourceRef, 64),
|
|
Infohash: d.Infohash.String,
|
|
Context: d.Context,
|
|
State: string(d.State),
|
|
Error: d.ErrorMsg.String,
|
|
Terminal: d.State.IsTerminal(),
|
|
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
|
Undoable: d.State == store.StateDone,
|
|
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
|
d.State == store.StateTargetMissing,
|
|
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
|
Note: desyncNote(d.State),
|
|
}
|
|
}
|
|
|
|
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
|
|
func desyncNote(s store.State) string {
|
|
switch s {
|
|
case store.StateTargetMissing:
|
|
return "разложено, но файлов в библиотеке нет — можно привязать заново"
|
|
case store.StateOrphaned:
|
|
return "источник удалён — это последняя копия данных, откат недоступен"
|
|
case store.StateDeleted:
|
|
return "удалены и источник, и файлы в библиотеке"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func shorten(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|
|
|
|
func pathID(r *http.Request) (int64, error) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
return 0, errors.New("invalid id")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func redirectErr(w http.ResponseWriter, r *http.Request, msg string) {
|
|
http.Redirect(w, r, "/?err="+url.QueryEscape(msg), http.StatusSeeOther)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
|
|
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
|
|
// деталей реализации): ErrNotFound → 404, валидация источника
|
|
// (magnet.ErrNotMagnet) → 400, конфликт состояния (worker.ErrConflict) → 409,
|
|
// прочее → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём
|
|
// только сообщение + корреляционный ключ.
|
|
func classifyErr(err error) (int, string) {
|
|
switch {
|
|
case errors.Is(err, store.ErrNotFound):
|
|
return http.StatusNotFound, "не найдено"
|
|
case errors.Is(err, magnet.ErrNotMagnet):
|
|
return http.StatusBadRequest, "некорректный источник"
|
|
case errors.Is(err, worker.ErrConflict):
|
|
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
|
|
return http.StatusConflict, "действие недоступно в текущем состоянии"
|
|
default:
|
|
return http.StatusInternalServerError, "внутренняя ошибка"
|
|
}
|
|
}
|
|
|
|
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
|
|
// для владельца (download_id, если операция привязана к загрузке, иначе
|
|
// request_id запроса), по которому он найдёт полную ошибку в логах.
|
|
func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
|
|
body := map[string]any{"error": msg}
|
|
if downloadID > 0 {
|
|
body["download_id"] = downloadID
|
|
} else {
|
|
body["request_id"] = middleware.GetReqID(r.Context())
|
|
}
|
|
return body
|
|
}
|
|
|
|
// apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело).
|
|
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID int64) {
|
|
status, msg := classifyErr(err)
|
|
writeJSON(w, status, errBody(r, msg, downloadID))
|
|
}
|
|
|
|
// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
|
|
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
|
|
// привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт.
|
|
func userErr(r *http.Request, err error, downloadID int64) string {
|
|
_, msg := classifyErr(err)
|
|
if downloadID > 0 {
|
|
return fmt.Sprintf("%s (download_id=%d)", msg, downloadID)
|
|
}
|
|
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
|
}
|
|
|
|
// requestLogger пишет структурированный лог по каждому запросу. Частые
|
|
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
|
|
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
|
|
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
|
start := time.Now()
|
|
|
|
next.ServeHTTP(ww, r)
|
|
|
|
// http.route — низкокардинальный шаблон маршрута (chi), а не
|
|
// конкретный путь; при отсутствии шаблона падаем на путь.
|
|
route := chi.RouteContext(r.Context()).RoutePattern()
|
|
if route == "" {
|
|
route = r.URL.Path
|
|
}
|
|
logger.Log(r.Context(), requestLogLevel(r), "http request",
|
|
"transport", "http",
|
|
"http.method", r.Method,
|
|
"http.route", route,
|
|
"http.status_code", ww.Status(),
|
|
"bytes", ww.BytesWritten(),
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"request_id", middleware.GetReqID(r.Context()),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
|
|
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
|
|
// API (`/api/...`) остаются на INFO.
|
|
func requestLogLevel(r *http.Request) slog.Level {
|
|
switch {
|
|
case r.URL.Path == "/healthz":
|
|
return slog.LevelDebug
|
|
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"):
|
|
return slog.LevelDebug
|
|
default:
|
|
return slog.LevelInfo
|
|
}
|
|
}
|