Приём (Ingest) стал быстрым: синхронно только парс magnet, синтез контекста из полей ссылки, атомарный дедуп и запись загрузки в новое состояние `catched` — ответ клиенту сразу. Медленный вывод имени (LLM) и добавление в qBittorrent вынесены в асинхронный шаг машины состояний, который двигает worker. - store: состояние `catched` (нетерминальное, активная группа); атомарный переход PromoteCatched (catched → downloading + display_name) с гардом state='catched' (ре-валидация после сетевых вызовов вне блокировки) - ingest: убраны namer/qbt из пути приёма; пишем `catched`, отвечаем сразу - worker.processCatched: вне w.mu выводит имя и qbt.Add, под w.mu — короткий переход; сбой add оставляет catched (ретрай тиком); предохранитель catch_timeout → failed(qbit_add)+notify; catched исключён из проверок пропажи - config: worker.catch_timeout (дефолт 10m) - веб-UI: бейдж catched, активная группа, самозавершающийся htmx-поллинг карточки/страницы до перехода в downloading; Telegram-текст без сырого catched - OpenSpec: дельты ingest/download-tracking/web-ui влиты в спеки, change заархивирован Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
90 lines
3.0 KiB
Go
90 lines
3.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io/fs"
|
|
"net/http"
|
|
|
|
"git.vakhrushev.me/av/jellybit/web"
|
|
)
|
|
|
|
// assetVersion — короткий хеш изменяемых ассетов (css/js) для cache-busting.
|
|
// Шрифты и вендор адресуются по неизменному имени, их версионировать не нужно.
|
|
func assetVersion() (string, error) {
|
|
h := sha256.New()
|
|
for _, p := range []string{"static/css/jellybit.css", "static/js/app.js"} {
|
|
b, err := web.FS.ReadFile(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, _ = h.Write(b)
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))[:12], nil
|
|
}
|
|
|
|
// staticHandler отдаёт встроенную статику с длинным иммутабельным кэшем —
|
|
// URL версионируются через ?v=<assetVersion> в шаблонах, поэтому свежий
|
|
// деплой не отдаёт устаревший файл.
|
|
func staticHandler(fsys fs.FS) http.Handler {
|
|
fileServer := http.FileServer(http.FS(fsys))
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
fileServer.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// badgeLabel — человекочитаемая подпись состояния для бейджа (см. handoff п.4).
|
|
// CSS-класс берётся отдельно как st-<state>; неизвестное состояние показываем
|
|
// как есть, чтобы не терять его в UI.
|
|
func badgeLabel(state string) string {
|
|
switch state {
|
|
case "catched":
|
|
return "🎣 принято, добавляется"
|
|
case "downloading":
|
|
return "⬇ качается"
|
|
case "completed":
|
|
return "⬇ скачано"
|
|
case "recognizing":
|
|
return "🧠 распознаётся"
|
|
case "linking":
|
|
return "🔗 раскладка"
|
|
case "review":
|
|
return "🟡 на ревью"
|
|
case "deferred":
|
|
return "🕗 отложено"
|
|
case "done":
|
|
return "✅ готово"
|
|
case "stuck":
|
|
return "⏳ застряло"
|
|
case "target_missing":
|
|
return "⚠ нет цели"
|
|
case "failed":
|
|
return "⛔ ошибка"
|
|
case "orphaned":
|
|
return "⚠ потеряно"
|
|
case "cancelled":
|
|
return "✖ отменено"
|
|
case "reverted":
|
|
return "↩ откат"
|
|
case "deleted":
|
|
return "🗑 удалено"
|
|
default:
|
|
return state
|
|
}
|
|
}
|
|
|
|
// render отрисовывает именованный шаблон в буфер и только затем пишет ответ —
|
|
// при ошибке шаблона клиент не получит «полустраницу».
|
|
func (s *server) render(w http.ResponseWriter, name string, data any) {
|
|
var buf bytes.Buffer
|
|
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
s.deps.Logger.Error("render", "template", name, "error", err)
|
|
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = buf.WriteTo(w)
|
|
}
|