Files
jellybit/internal/httpapi/httpapi.go
T
avandClaude Opus 4.8 44c5952e49 Список: скрытие cancelled, значок типа, ellipsis имени .torrent
Три мелкие правки веб-UI списка загрузок:

- Скрываем cancelled в общем списке наравне с deleted: под группой all
  без IncludeDeleted теперь `state NOT IN (deleted, cancelled)`. reverted
  остаётся видимым. Тумблер и комментарии-инварианты приведены в
  соответствие («включая отменённые и удалённые»).
- Значок типа (🎬 фильм / 📺 сериал) в строке списка: media_type текущей
  попытки распознавания протянут через LEFT JOIN в ListDownloadsPage
  (Download.RecMediaType, симметрично RecTitle) и downloadView.MediaType;
  значок рендерится в card.html, при нераспознанном типе значка нет.
- Ellipsis для длинного имени .torrent-файла: у .btn-file max-width +
  overflow/ellipsis на лейбле, чтобы длинное имя не распирало .add-row;
  показываем базовое имя без .torrent, полное — в title (JS-энхансмент).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:31:27 +03:00

872 lines
35 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 предоставляет HTTP API и веб-UI (server-rendered).
//
// Тонкий транспорт над ядром: приём идёт в ingest, команды (cancel/retry) —
// в worker, чтение — в store. В v1 без авторизации (доверенная LAN).
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"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/ident"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/torrent"
"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 string) error
Retry(ctx context.Context, id string) error
}
// Reader читает задачи (store.Store).
type Reader interface {
ListDownloads(ctx context.Context) ([]store.Download, error)
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
GetDownload(ctx context.Context, id string) (*store.Download, error)
// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
}
// Deps — зависимости транспорта.
type Deps struct {
Logger *slog.Logger
Ingestor Ingestor
Commander Commander
Reader Reader
Reviewer Reviewer
Live LiveStatus
// Loc — таймзона отображения дат в веб-UI (хранение всегда UTC). nil → UTC.
Loc *time.Location
}
type server struct {
deps Deps
tmpl *template.Template
assetVer string
}
// NewRouter собирает HTTP-обработчик сервиса.
func NewRouter(d Deps) (http.Handler, error) {
assetVer, err := assetVersion()
if err != nil {
return nil, err
}
funcs := template.FuncMap{
"add": func(a, b int) int { return a + b },
"asset": func(p string) string { return "/static/" + p + "?v=" + assetVer },
"badgeLabel": badgeLabel,
}
tmpl, err := template.New("").Funcs(funcs).
ParseFS(web.FS, "templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, err
}
staticFS, err := fs.Sub(web.FS, "static")
if err != nil {
return nil, err
}
if d.Live == nil {
d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
}
if d.Loc == nil {
d.Loc = time.UTC // таймзона отображения не задана — показываем в UTC
}
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(requestLogger(d.Logger))
r.Get("/healthz", handleHealthz)
// Статика (встроенная, с длинным кэшем; URL версионируются ?v=).
r.Handle("/static/*", http.StripPrefix("/static/", staticHandler(staticFS)))
// Веб-UI.
r.Get("/", s.handleIndex)
r.Get("/download/{id}", s.handleDownload)
// Живые фрагменты телеметрии (htmx-поллинг; читают снимок воркера).
r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
// Карточка целиком: самополлинг catched до перехода в downloading (бейдж,
// имя и появившийся прогресс обновляются без перезагрузки).
r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
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}/ignore", s.handleIgnore)
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
r.Post("/ui/downloads/{id}/source", s.handleAddSource)
r.Post("/ui/downloads/{id}/refresh-name", s.handleRefreshName)
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)
r.Post("/ui/downloads/{id}/delete", s.handleDelete)
r.Post("/ui/downloads/{id}/dismiss", s.handleDismiss)
// 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 ---
// pageSize — размер страницы списка загрузок (серверная пагинация).
const pageSize = 25
type indexView struct {
Error string
Downloads []downloadView
// Фильтр/поиск (серверные, в query).
Filter string // активная группа (all/review/active/done/problem)
Query string // текст поиска
ShowAll bool // показывать удалённые
Chips []filterChip // чипы фильтра со ссылками
ShowURL string // ссылка тумблера «показать всё»
// Пагинация.
Page int
Pages int // всего страниц (>=1)
Total int // всего строк под фильтром
Searching bool // активны фильтр/поиск — влияет на текст пустого состояния
PrevURL string // пусто — на первой странице
NextURL string // пусто — на последней
PageLinks []pageLink // пронумерованные страницы (окно)
}
type filterChip struct {
Key string
Label string
URL string
Active bool
}
type pageLink struct {
Num int
URL string
Active bool
}
type downloadView struct {
ID string
Title string // отображаемый заголовок карточки
MediaType string // тип контента для значка строки списка (movie/series; пусто — не распознан)
State string
Error string
Terminal bool
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
SelfPoll bool // catched → карточка сама опрашивает себя до перехода
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
Retriable bool // failed/stuck — можно повторить попытку
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
ActionError string // ошибка действия на htmx-пути (своп карточки), не error_msg
// Обзор жизненного цикла (мета-строка карточки).
Added string // абсолютная дата добавления (TZ сервера), «2006-01-02»
AddedAgo string // относительная давность, «5 дней назад»
Size string // размер раздачи (снимок → фолбэк по файлам → «—»)
Ratio string // рейтинг отдачи (снимок → «—»)
}
// listChips — определения чипов фильтра списка (порядок = порядок показа).
var listChips = []struct {
Key string
Label string
Group store.StateGroup
}{
{"all", "Все", store.GroupAll},
{"review", "Ждут меня", store.GroupReview},
{"active", "В работе", store.GroupActive},
{"done", "Готово", store.GroupDone},
{"problem", "Проблемы", store.GroupProblem},
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
group := parseGroup(q.Get("f"))
query := strings.TrimSpace(q.Get("q"))
showAll := q.Get("all") == "1"
page := parsePage(q.Get("page"))
downloads, total, err := s.deps.Reader.ListDownloadsPage(r.Context(), store.ListFilter{
Group: group,
Query: query,
IncludeDeleted: showAll,
Limit: pageSize,
Offset: (page - 1) * pageSize,
})
if err != nil {
s.deps.Logger.Error("list downloads", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
pages := max((total+pageSize-1)/pageSize, 1)
view := indexView{
Error: q.Get("err"),
Filter: string(group),
Query: query,
ShowAll: showAll,
Page: page,
Pages: pages,
Total: total,
Searching: query != "" || group != store.GroupAll || showAll || page > 1,
}
// Чипы: сохраняют q/all, сбрасывают страницу.
for _, c := range listChips {
view.Chips = append(view.Chips, filterChip{
Key: c.Key,
Label: c.Label,
URL: listURL(c.Group, query, showAll, 1),
Active: c.Group == group,
})
}
// Тумблер «показать всё» переключает all, сохраняя фильтр/поиск.
view.ShowURL = listURL(group, query, !showAll, 1)
// Пагинация: сохраняет f/q/all.
if page > 1 {
view.PrevURL = listURL(group, query, showAll, page-1)
}
if page < pages {
view.NextURL = listURL(group, query, showAll, page+1)
}
for _, n := range pageWindow(page, pages) {
view.PageLinks = append(view.PageLinks, pageLink{
Num: n, URL: listURL(group, query, showAll, n), Active: n == page,
})
}
// Суммарный размер разложенных файлов по странице — фолбэк размера раздачи,
// когда торрента нет в живом снимке (один батч-запрос, не N+1).
ids := make([]string, len(downloads))
for i, d := range downloads {
ids[i] = d.ID
}
layoutSizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), ids)
if err != nil {
s.deps.Logger.Error("layout sizes", "error", err)
layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает
}
now := time.Now()
for _, d := range downloads {
view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
}
s.render(w, "index.html", view)
}
// buildCardView собирает представление карточки списка из доменных данных и
// живого снимка. Общий для полной страницы (handleIndex) и htmx-свопа карточки
// после действия (renderCardFragment): чтобы htmx-ветка не дублировала обвязку
// (рейтинг/размер/прогресс). Для retry→downloading карточка обязана нести
// прогресс-поллер — поэтому Progress заполняется здесь.
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
v := s.toView(d, now)
// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
l, ok := s.liveFor(d)
v.Ratio = ratioText(l, ok)
v.Size = sizeText(l, ok, layoutSize)
// Живой прогресс активных загрузок — со значениями уже в первом кадре
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
if v.IsDownloading {
v.Progress = buildProgress(d.ID, true, l, ok)
}
return v
}
// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
func parseGroup(s string) store.StateGroup {
switch store.StateGroup(s) {
case store.GroupReview:
return store.GroupReview
case store.GroupActive:
return store.GroupActive
case store.GroupDone:
return store.GroupDone
case store.GroupProblem:
return store.GroupProblem
default:
return store.GroupAll
}
}
// parsePage разбирает номер страницы (1-based); мусор/<1 → 1. За последней
// страницей отдаём как есть — запрос вернёт пустую страницу (не ошибка).
func parsePage(s string) int {
n, err := strconv.Atoi(s)
if err != nil || n < 1 {
return 1
}
return n
}
// listURL строит ссылку списка с сохранением состояния фильтра/поиска/страницы.
// Дефолты (all, пустой поиск, page 1) в query не пишем — URL чистый.
func listURL(group store.StateGroup, query string, showAll bool, page int) string {
v := url.Values{}
if group != store.GroupAll {
v.Set("f", string(group))
}
if query != "" {
v.Set("q", query)
}
if showAll {
v.Set("all", "1")
}
if page > 1 {
v.Set("page", strconv.Itoa(page))
}
if len(v) == 0 {
return "/"
}
return "/?" + v.Encode()
}
// pageWindow возвращает номера страниц вокруг текущей (окно до 7), чтобы пагинация
// не разрасталась на больших списках.
func pageWindow(page, pages int) []int {
const win = 7
if pages <= win {
out := make([]int, pages)
for i := range out {
out[i] = i + 1
}
return out
}
start := max(page-win/2, 1)
end := start + win - 1
if end > pages {
end = pages
start = end - win + 1
}
out := make([]int, 0, win)
for n := start; n <= end; n++ {
out = append(out, n)
}
return out
}
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
// Форма — multipart (файл-пикер .torrent). Лимит тела: размер торрента +
// небольшой запас на прочие поля. ParseMultipartForm сперва разбирает
// urlencoded-тело (ParseForm), поэтому на обычной (не-multipart) отправке —
// напр. из curl или устаревшей страницы — вернёт ErrNotMultipart, но поля
// уже в PostForm: такую ошибку глотаем, текстовый путь не ломается.
r.Body = http.MaxBytesReader(w, r.Body, ingest.MaxTorrentSize+1<<20)
if err := r.ParseMultipartForm(ingest.MaxTorrentSize + 1<<20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
redirectErr(w, r, "не удалось разобрать форму (возможно, файл слишком большой)")
return
}
req := ingest.Request{
Source: r.FormValue("source"),
Context: r.FormValue("context"),
}
// Выбран .torrent-файл — приём по байтам (в приоритете над текстом).
if file, header, err := r.FormFile("torrent"); err == nil {
defer func() { _ = file.Close() }()
data, rerr := io.ReadAll(file)
if rerr != nil {
redirectErr(w, r, "не удалось прочитать .torrent-файл")
return
}
req.TorrentData = data
req.TorrentName = header.Filename // фолбек для source_ref
}
res, err := s.deps.Ingestor.Ingest(r.Context(), req)
if err != nil {
redirectErr(w, r, userErr(r, err, res.DownloadID))
return
}
if res.Deduplicated {
// Приём привязался к существующей записи (активной или «спящей» desync —
// target_missing/orphaned): ведём пользователя на её страницу, а не на
// список. Так видно, что нового не завели, и доступны действия записи
// (привязать заново / danger-зона «Закрыть»).
http.Redirect(w, r, "/download/"+res.DownloadID, http.StatusSeeOther)
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
}
s.surfaceAction(w, r, id, s.deps.Commander.Cancel(r.Context(), id))
}
func (s *server) handleUIRetry(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.Commander.Retry(r.Context(), id))
}
// surfaceAction завершает мутирующее действие, доступное и в списке, и на
// странице загрузки (undo/relink/retry/cancel). На htmx свопит фрагмент той
// поверхности, откуда пришло действие (скрытое поле surface=list|download), на
// ошибке — тот же фрагмент с сообщением и HTTP 200 (иначе htmx не подменит DOM).
// Без htmx — прежний PRG-редирект на список (форма выхода из ревью тоже сюда:
// нет htmx → навигация). actionErr — результат доменного вызова.
func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
if !isHTMX(r) {
if actionErr != nil {
redirectErr(w, r, userErr(r, actionErr, id))
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.PostFormValue("surface") == "download" {
s.renderDownloadFragment(w, r, id, actionErr)
return
}
s.renderCardFragment(w, r, id, actionErr)
}
// renderCardFragment перечитывает загрузку и рендерит партиал карточки списка
// (htmx-своп). На ошибке действия кладёт сообщение в ActionError и всё равно
// отвечает 200 — htmx не свопит DOM на 4xx/5xx.
func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
if err != nil {
s.deps.Logger.Error("layout sizes", "download_id", id, "error", err)
sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает
}
v := s.buildCardView(*d, time.Now(), sizes[id])
if actionErr != nil {
v.ActionError = userErr(r, actionErr, id)
}
s.render(w, "card", v)
}
// renderDownloadFragment перечитывает загрузку и рендерит главную область
// страницы загрузки (htmx-своп). Ошибка — в ActionError, ответ 200.
func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
s.fragErr(w, err, id)
return
}
v := s.buildDownloadView(id, rd)
if actionErr != nil {
v.ActionError = userErr(r, actionErr, id)
}
s.render(w, "download_main", v)
}
// --- REST API ---
type downloadDTO struct {
ID string `json:"id"` // ULID (lowercase)
SourceType string `json:"source_type"`
Infohashes []string `json:"infohashes,omitempty"` // все хеши загрузки (v1 раньше v2)
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 string `json:"id"` // ULID (lowercase)
Infohashes []string `json:"infohashes"` // хеши принятого источника (v1 раньше v2) — симметрично downloadDTO
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, "")
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 {
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
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, "некорректный запрос", ""))
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,
Infohashes: res.Infohashes,
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, string) error) {
id, err := pathID(r)
if err != nil {
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
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]string{"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),
Infohashes: d.HashList(),
Context: d.Context,
State: string(d.State),
ErrorCode: d.ErrorCode.String,
ErrorMsg: d.ErrorMsg.String,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
}
func (s *server) toView(d store.Download, now time.Time) downloadView {
state := string(d.State)
v := downloadView{
ID: d.ID,
Title: downloadTitle(d),
MediaType: d.RecMediaType.String, // пусто, если распознавания/типа ещё нет
State: state,
Error: d.ErrorMsg.String,
Terminal: d.State.IsTerminal(),
IsDownloading: d.State == store.StateDownloading,
SelfPoll: d.State == store.StateCatched,
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),
}
// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
// как в порядке списка); неразбираемое время просто опускаем.
if t, ok := addedTime(d); ok {
v.Added = fmtDate(t, s.deps.Loc)
v.AddedAgo = humanizeAge(t, now)
}
return v
}
// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
// то, что ушло в qBittorrent) → распознанное название (RecTitle из листинга) →
// усечённый до одной строки сырой источник. Сырой magnet не должен занимать
// несколько строк заголовка.
func downloadTitle(d store.Download) string {
if d.DisplayName != "" {
return d.DisplayName
}
if d.RecTitle.Valid && d.RecTitle.String != "" {
return d.RecTitle.String
}
return shorten(oneLine(d.SourceRef), 80)
}
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
// заголовок кладём одной строкой.
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// addedTime — базис даты добавления карточки: время добавления в источник
// (source_added_at, qBittorrent added_on) с фолбэком на время создания загрузки
// (created_at), согласованно с порядком списка. ok=false — распарсить нечего.
func addedTime(d store.Download) (time.Time, bool) {
s := d.CreatedAt
if d.SourceAddedAt.Valid && d.SourceAddedAt.String != "" {
s = d.SourceAddedAt.String
}
t, err := store.ParseTime(s)
if err != nil {
return time.Time{}, false
}
return t, true
}
// 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] + "…"
}
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
// любого обращения к БД (сравнение в SQLite побайтовое). Невалидный id
// трактуется вызывающими как несуществующая сущность (404).
func pathID(r *http.Request) (string, error) {
return ident.Parse(chi.URLParam(r, "id"))
}
// liveFor достаёт живую телеметрию по любому из хешей загрузки.
func (s *server) liveFor(d store.Download) (worker.Live, bool) {
for _, h := range d.HashList() {
if l, ok := s.deps.Live.Live(h); ok {
return l, true
}
}
return worker.Live{}, false
}
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) и некорректный ввод команды (worker.ErrInvalidInput) →
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
// (layout.ErrCollision) и конфликт состояния (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), errors.Is(err, torrent.ErrNotTorrent):
return http.StatusBadRequest, "некорректный источник"
case errors.Is(err, worker.ErrInvalidInput):
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
// не сбой сервера.
return http.StatusBadRequest, "некорректный ввод"
case errors.Is(err, worker.ErrNotReady):
// Источник ещё качается — actionable причина, показываем конкретно.
return http.StatusConflict, "торрент ещё качается, дождитесь докачки"
case errors.Is(err, layout.ErrCollision):
// Целевой путь уже занят: задача штатно ушла в review с причиной —
// это не сбой, а требующий разбора конфликт.
return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
case errors.Is(err, worker.ErrConflict):
// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
return http.StatusConflict, "действие недоступно в текущем состоянии"
case errors.Is(err, errManualSource):
// Ошибка ручного ввода источника — показываем пользователю как есть.
return http.StatusBadRequest, errManualSource.Error()
case errors.Is(err, errInvalidCandidate):
return http.StatusBadRequest, errInvalidCandidate.Error()
default:
return http.StatusInternalServerError, "внутренняя ошибка"
}
}
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
// для владельца (download_id, если операция привязана к загрузке, иначе
// request_id запроса), по которому он найдёт полную ошибку в логах.
func errBody(r *http.Request, msg string, downloadID string) map[string]any {
body := map[string]any{"error": msg}
if downloadID != "" {
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 string) {
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 string) string {
_, msg := classifyErr(err)
if downloadID != "" {
return fmt.Sprintf("%s (download_id=%s)", msg, downloadID)
}
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
}
// requestLogger пишет структурированный лог по каждому запросу. Служебные и
// навигационные GET (healthcheck, страницы веб-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 (включая статику). Мутации и 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
}
}