Перенос дизайна в server-rendered веб-UI (web-ui)
Новая capability web-ui: презентационный перенос готового дизайна
(семантический HTML + единый jellybit.css, тёмная тема по настройке ОС)
в html/template без шага сборки.
- Встроенная (go:embed) отдача статики под /static с Cache-Control и
cache-busting (?v=<hash> по содержимому css/js).
- Шрифты IBM Plex self-hosted (@font-face, cyrillic+latin), без CDN.
- Наколеночный менеджер зависимостей: вендор (htmx + шрифты) не хранится
в репо (gitignore), идемпотентно добывается `task assets` по
web/assets.manifest с проверкой sha256; task build/run зависят от assets.
- Общие партиалы: шапка, бейдж статуса (карта всех 14 состояний),
виджет «файл источника → раскладка» (общий для review и download).
- Страницы: список с фильтром/поиском, ревью, новая страница просмотра
загрузки (/download/{id}). deleted скрыт по умолчанию.
- Превью раскладки берётся из единой логики internal/layout
(buildFileRows), без дублирования правил имён в шаблонах.
- Убраны meta-refresh и инлайн-стили; copyHash на vanilla с fallback
на execCommand и честной индикацией (целевой деплой — HTTP LAN).
Вне scope (отдельный change): живые обновления прогресса и раздел
раздачи, клиентский режим ручной раскладки файл→серия (с Alpine.js).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// --- Страница просмотра одной загрузки ---
|
||||
|
||||
type downloadDetailView struct {
|
||||
ID int64
|
||||
Title string
|
||||
Source string
|
||||
Infohash string
|
||||
InfohashShort string
|
||||
Context string
|
||||
State string
|
||||
Error string
|
||||
Note string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
|
||||
// Распознавание (если есть план).
|
||||
HasPlan bool
|
||||
MediaType string
|
||||
IsSeries bool
|
||||
RecTitle string
|
||||
OriginalTitle string
|
||||
Year int
|
||||
Provider string
|
||||
ProviderID string
|
||||
NoBase bool
|
||||
Confidence string
|
||||
Files []fileRow
|
||||
|
||||
// Действия по состоянию (как на главной).
|
||||
Terminal bool
|
||||
Reviewable bool
|
||||
Undoable bool
|
||||
Relinkable bool
|
||||
Retriable bool
|
||||
}
|
||||
|
||||
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||
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("download detail data", "id", id, "error", err)
|
||||
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
d := rd.Download
|
||||
view := downloadDetailView{
|
||||
ID: id,
|
||||
Title: d.SourceRef,
|
||||
Source: shorten(d.SourceRef, 120),
|
||||
Infohash: d.Infohash.String,
|
||||
InfohashShort: shortenHash(d.Infohash.String),
|
||||
Context: d.Context,
|
||||
State: string(d.State),
|
||||
Error: d.ErrorMsg.String,
|
||||
Note: desyncNote(d.State),
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
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,
|
||||
}
|
||||
|
||||
if rd.Recognition != nil {
|
||||
view.HasPlan = len(rd.Plan.Files) > 0
|
||||
view.MediaType = string(rd.Plan.Type)
|
||||
view.IsSeries = rd.Plan.Type == "series"
|
||||
view.RecTitle = rd.Plan.Title
|
||||
view.OriginalTitle = rd.Plan.OriginalTitle
|
||||
view.Year = rd.Plan.Year
|
||||
switch rd.Provider {
|
||||
case "", "none":
|
||||
view.NoBase = rd.Provider == "none"
|
||||
default:
|
||||
view.Provider = rd.Provider
|
||||
view.ProviderID = rd.ProviderID
|
||||
}
|
||||
if rd.Recognition.Confidence.Valid {
|
||||
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
|
||||
}
|
||||
|
||||
// Файл источника → целевой путь из превью (единая логика layout).
|
||||
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
||||
}
|
||||
|
||||
s.render(w, "download.html", view)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
)
|
||||
|
||||
// fileRow — строка виджета «файл источника → раскладка», общего для экранов
|
||||
// ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика
|
||||
// internal/layout), а не вычисляется в шаблоне.
|
||||
type fileRow struct {
|
||||
Src string
|
||||
Dst string // целевой путь; пусто — файл не раскладывается
|
||||
RoleLabel string
|
||||
Linked bool
|
||||
Ignored bool
|
||||
}
|
||||
|
||||
// buildFileRows сшивает файлы плана с целевыми путями из превью раскладки.
|
||||
func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
|
||||
dstBySrc := make(map[string]string, len(preview))
|
||||
for _, l := range preview {
|
||||
dstBySrc[l.Src] = l.Dst
|
||||
}
|
||||
rows := make([]fileRow, 0, len(plan.Files))
|
||||
for _, f := range plan.Files {
|
||||
dst := dstBySrc[f.Src]
|
||||
rows = append(rows, fileRow{
|
||||
Src: f.Src,
|
||||
Dst: dst,
|
||||
RoleLabel: roleLabel(string(f.Role)),
|
||||
Linked: dst != "",
|
||||
Ignored: f.Role == "ignore",
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// roleLabel — человекочитаемая роль файла раскладки.
|
||||
func roleLabel(role string) string {
|
||||
switch role {
|
||||
case "episode":
|
||||
return "эпизод"
|
||||
case "main", "movie":
|
||||
return "фильм"
|
||||
case "subtitle":
|
||||
return "субтитры"
|
||||
case "extra":
|
||||
return "допматериал"
|
||||
case "sample":
|
||||
return "семпл"
|
||||
case "ignore":
|
||||
return "игнор"
|
||||
default:
|
||||
return role
|
||||
}
|
||||
}
|
||||
+67
-38
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -54,24 +55,32 @@ type Deps struct {
|
||||
}
|
||||
|
||||
type server struct {
|
||||
deps Deps
|
||||
index *template.Template
|
||||
review *template.Template
|
||||
deps Deps
|
||||
tmpl *template.Template
|
||||
assetVer string
|
||||
}
|
||||
|
||||
// NewRouter собирает HTTP-обработчик сервиса.
|
||||
func NewRouter(d Deps) (http.Handler, error) {
|
||||
index, err := template.ParseFS(web.FS, "templates/index.html")
|
||||
assetVer, err := assetVersion()
|
||||
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")
|
||||
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
|
||||
}
|
||||
s := &server{deps: d, index: index, review: review}
|
||||
staticFS, err := fs.Sub(web.FS, "static")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
@@ -80,8 +89,12 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
|
||||
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)
|
||||
r.Post("/ui/downloads", s.handleUIAdd)
|
||||
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
||||
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
||||
@@ -124,18 +137,23 @@ type indexView struct {
|
||||
}
|
||||
|
||||
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)
|
||||
ID int64
|
||||
Title string // отображаемый заголовок карточки
|
||||
Source string
|
||||
Infohash string // полный (для копирования)
|
||||
InfohashShort string // усечённый (для показа)
|
||||
Context string
|
||||
State string
|
||||
Group string // группа фильтра (review/active/done/problem/other)
|
||||
SearchText string // haystack для клиентского поиска (lowercase)
|
||||
Error string
|
||||
Terminal bool
|
||||
Deleted 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) {
|
||||
@@ -149,10 +167,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
s.render(w, "index.html", view)
|
||||
}
|
||||
|
||||
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -323,16 +338,22 @@ func toDTO(d store.Download) downloadDTO {
|
||||
}
|
||||
|
||||
func toView(d store.Download) downloadView {
|
||||
state := string(d.State)
|
||||
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,
|
||||
ID: d.ID,
|
||||
Title: d.SourceRef,
|
||||
Source: shorten(d.SourceRef, 64),
|
||||
Infohash: d.Infohash.String,
|
||||
InfohashShort: shortenHash(d.Infohash.String),
|
||||
Context: d.Context,
|
||||
State: state,
|
||||
Group: stateGroup(state),
|
||||
SearchText: strings.ToLower(d.SourceRef + " " + d.Infohash.String + " " + d.Context),
|
||||
Error: d.ErrorMsg.String,
|
||||
Terminal: d.State.IsTerminal(),
|
||||
Deleted: d.State == store.StateDeleted,
|
||||
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,
|
||||
@@ -340,6 +361,14 @@ func toView(d store.Download) downloadView {
|
||||
}
|
||||
}
|
||||
|
||||
// shortenHash усекает infohash до вида "a1b2c3d4e5…f0" для компактного показа.
|
||||
func shortenHash(h string) string {
|
||||
if len(h) <= 12 {
|
||||
return h
|
||||
}
|
||||
return h[:10] + "…" + h[len(h)-2:]
|
||||
}
|
||||
|
||||
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
|
||||
func desyncNote(s store.State) string {
|
||||
switch s {
|
||||
@@ -429,9 +458,9 @@ func userErr(r *http.Request, err error, downloadID int64) string {
|
||||
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
||||
}
|
||||
|
||||
// requestLogger пишет структурированный лог по каждому запросу. Частые
|
||||
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
|
||||
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
|
||||
// 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) {
|
||||
@@ -459,8 +488,8 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
|
||||
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
|
||||
// requestLogLevel понижает уровень для служебных и навигационных запросов:
|
||||
// healthcheck и GET-страницы веб-UI (включая статику). Мутации и REST
|
||||
// API (`/api/...`) остаются на INFO.
|
||||
func requestLogLevel(r *http.Request) slog.Level {
|
||||
switch {
|
||||
|
||||
@@ -353,7 +353,7 @@ func TestReviewRenders(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
||||
"Season 02", "Применить", "Уточнить",
|
||||
"База метаданных", "269613", "выбрать", "Без базы"} {
|
||||
"Источник совпадения", "269613", "выбрать", "Без базы"} {
|
||||
if !strings.Contains(string(body), want) {
|
||||
t.Errorf("страница ревью не содержит %q", want)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestRequestLogLevel(t *testing.T) {
|
||||
want slog.Level
|
||||
}{
|
||||
{"GET", "/healthz", slog.LevelDebug}, // healthcheck — тихо
|
||||
{"GET", "/", slog.LevelDebug}, // список (авто-рефреш)
|
||||
{"GET", "/", slog.LevelDebug}, // список загрузок
|
||||
{"GET", "/review/1", slog.LevelDebug}, // страница ревью
|
||||
{"GET", "/api/downloads", slog.LevelInfo}, // REST API — на INFO
|
||||
{"POST", "/ui/downloads/1/apply", slog.LevelInfo}, // мутация — на INFO
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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 "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
|
||||
}
|
||||
}
|
||||
|
||||
// stateGroup — группа фильтра на главной (чипы): ждут меня / в работе / готово
|
||||
// / проблемы / прочее. Завязано на смысл состояния, не на цвет.
|
||||
func stateGroup(state string) string {
|
||||
switch state {
|
||||
case "review", "deferred":
|
||||
return "review"
|
||||
case "downloading", "completed", "recognizing", "linking":
|
||||
return "active"
|
||||
case "done":
|
||||
return "done"
|
||||
case "stuck", "failed", "target_missing", "orphaned":
|
||||
return "problem"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// stubReader — минимальный Reader для проверки рендера списка.
|
||||
type stubReader struct {
|
||||
list []store.Download
|
||||
one *store.Download
|
||||
}
|
||||
|
||||
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
|
||||
func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error) {
|
||||
if s.one == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return s.one, nil
|
||||
}
|
||||
|
||||
// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
|
||||
type stubReviewer struct{ data *worker.ReviewData }
|
||||
|
||||
func (s stubReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
|
||||
if s.data == nil {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return s.data, nil
|
||||
}
|
||||
func (stubReviewer) Apply(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Refine(context.Context, int64, string) error { return nil }
|
||||
func (stubReviewer) SetType(context.Context, int64, string) error { return nil }
|
||||
func (stubReviewer) IgnoreFile(context.Context, int64, string) error { return nil }
|
||||
func (stubReviewer) Defer(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Undo(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Relink(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) Rerecognize(context.Context, int64) error { return nil }
|
||||
func (stubReviewer) ChooseCandidate(context.Context, int64, int64) error { return nil }
|
||||
func (stubReviewer) SetProviderID(context.Context, int64, string, string) error { return nil }
|
||||
func (stubReviewer) ClearProvider(context.Context, int64) error { return nil }
|
||||
|
||||
func testRouter(t *testing.T, r stubReader, rv stubReviewer) http.Handler {
|
||||
t.Helper()
|
||||
h, err := NewRouter(Deps{
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Reader: r,
|
||||
Reviewer: rv,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return rr
|
||||
}
|
||||
|
||||
// TestRouterRendersPages проверяет, что шаблоны парсятся и страницы рендерятся.
|
||||
func TestRouterRendersPages(t *testing.T) {
|
||||
dl := store.Download{ID: 7, SourceRef: "Fargo.S02", Infohash: store.NullString("a1b2c3d4e5f6a7b8"), State: store.StateReview}
|
||||
h := testRouter(t,
|
||||
stubReader{list: []store.Download{dl}, one: &dl},
|
||||
stubReviewer{data: &worker.ReviewData{Download: dl}},
|
||||
)
|
||||
|
||||
if rr := get(t, h, "/"); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET / = %d, want 200", rr.Code)
|
||||
} else if !strings.Contains(rr.Body.String(), "st-review") {
|
||||
t.Errorf("index не содержит бейдж статуса")
|
||||
}
|
||||
|
||||
if rr := get(t, h, "/download/7"); rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /download/7 = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticServed проверяет отдачу встроенной статики с кэш-заголовком.
|
||||
func TestStaticServed(t *testing.T) {
|
||||
h := testRouter(t, stubReader{}, stubReviewer{})
|
||||
rr := get(t, h, "/static/css/jellybit.css")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET css = %d, want 200", rr.Code)
|
||||
}
|
||||
if cc := rr.Header().Get("Cache-Control"); cc == "" {
|
||||
t.Errorf("нет Cache-Control на статике")
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "@font-face") {
|
||||
t.Errorf("css без @font-face (шрифты не self-hosted?)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadNotFound — несуществующая загрузка → 404.
|
||||
func TestDownloadNotFound(t *testing.T) {
|
||||
h := testRouter(t, stubReader{}, stubReviewer{})
|
||||
if rr := get(t, h, "/download/999"); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /download/999 = %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -46,21 +46,13 @@ type reviewView struct {
|
||||
Confidence string
|
||||
Reasons []string
|
||||
Hints []string
|
||||
Files []reviewFileView
|
||||
Preview []string
|
||||
Files []fileRow
|
||||
HasPlan bool
|
||||
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
||||
NoBase bool // выбрано «без базы»
|
||||
Candidates []candidateView
|
||||
}
|
||||
|
||||
type reviewFileView struct {
|
||||
Src string
|
||||
Role string
|
||||
Season string
|
||||
Episode string
|
||||
Ignored bool
|
||||
}
|
||||
|
||||
type candidateView struct {
|
||||
ID int64
|
||||
Provider string
|
||||
@@ -114,16 +106,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
if rec.Confidence.Valid {
|
||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||
}
|
||||
for _, f := range rd.Plan.Files {
|
||||
view.Files = append(view.Files, reviewFileView{
|
||||
Src: f.Src,
|
||||
Role: string(f.Role),
|
||||
Season: intPtrStr(f.Season),
|
||||
Episode: intPtrStr(f.Episode),
|
||||
Ignored: f.Role == "ignore",
|
||||
})
|
||||
}
|
||||
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
||||
view.HasPlan = len(rd.Plan.Files) > 0
|
||||
view.HasLinks = len(rd.Preview) > 0
|
||||
for _, c := range rd.Candidates {
|
||||
view.Candidates = append(view.Candidates, candidateView{
|
||||
ID: c.ID,
|
||||
@@ -136,14 +121,8 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, l := range rd.Preview {
|
||||
view.Preview = append(view.Preview, l.Dst)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.review.Execute(w, view); err != nil {
|
||||
s.deps.Logger.Error("render review", "error", err)
|
||||
}
|
||||
s.render(w, "review.html", view)
|
||||
}
|
||||
|
||||
// --- Действия ревью (POST → redirect) ---
|
||||
@@ -281,10 +260,3 @@ func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string
|
||||
}
|
||||
http.Redirect(w, r, u, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func intPtrStr(p *int) string {
|
||||
if p == nil {
|
||||
return "—"
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user