Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef75a0d302
|
||
|
|
b646381cf9
|
||
|
|
4e2593ac31
|
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"frontend-design@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
/jellybit
|
/jellybit
|
||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
|
# Вендорные фронтенд-ассеты — не храним в репо, добываются `task assets`
|
||||||
|
# по web/assets.manifest (идемпотентно, с проверкой sha256).
|
||||||
|
/web/static/vendor/
|
||||||
|
|
||||||
# Реальный конфиг (секреты) и локальная БД
|
# Реальный конфиг (секреты) и локальная БД
|
||||||
/config.toml
|
/config.toml
|
||||||
/.env
|
/.env
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ tasks:
|
|||||||
|
|
||||||
run:
|
run:
|
||||||
desc: 'Локальный запуск (нужен ./config.toml с db_path -> ./jellybit.db)'
|
desc: 'Локальный запуск (нужен ./config.toml с db_path -> ./jellybit.db)'
|
||||||
|
deps: [assets]
|
||||||
cmds:
|
cmds:
|
||||||
- go run {{.PKG}} --config ./config.toml
|
- go run {{.PKG}} --config ./config.toml
|
||||||
|
|
||||||
build:
|
build:
|
||||||
desc: Статический бинарь linux/amd64 для сервера (Intel N150)
|
desc: Статический бинарь linux/amd64 для сервера (Intel N150)
|
||||||
|
deps: [assets]
|
||||||
cmds:
|
cmds:
|
||||||
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o {{.BINARY}} {{.PKG}}
|
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o {{.BINARY}} {{.PKG}}
|
||||||
|
|
||||||
@@ -43,6 +45,25 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
|
|
||||||
|
assets:
|
||||||
|
desc: 'Идемпотентный фетч вендорных ассетов в web/static/vendor (по web/assets.manifest, проверка sha256; Node не нужен)'
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in ''|\#*) continue;; esac
|
||||||
|
set -- $line
|
||||||
|
dest=$1; url=$2; sum=$3
|
||||||
|
file="web/static/$dest"
|
||||||
|
if [ -f "$file" ] && echo "$sum $file" | sha256sum -c --status - 2>/dev/null; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$file")"
|
||||||
|
echo "↓ $dest"
|
||||||
|
curl -fsSL --retry 3 -o "$file" "$url"
|
||||||
|
echo "$sum $file" | sha256sum -c -
|
||||||
|
done < web/assets.manifest
|
||||||
|
|
||||||
image:
|
image:
|
||||||
desc: Docker-образ из готового бинаря (см. docs/adr docker-deploy)
|
desc: Docker-образ из готового бинаря (см. docs/adr docker-deploy)
|
||||||
deps: [build]
|
deps: [build]
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ func runServe(args []string) error {
|
|||||||
Commander: wrk,
|
Commander: wrk,
|
||||||
Reader: st,
|
Reader: st,
|
||||||
Reviewer: wrk,
|
Reviewer: wrk,
|
||||||
|
Live: wrk,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// Живая статистика раздачи (заполняется из снимка воркера).
|
||||||
|
Seeding seedingView
|
||||||
|
|
||||||
|
// Действия по состоянию (как на главной).
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Живая статистика раздачи — со значениями уже в первом кадре; секция
|
||||||
|
// деградирует (пустой контейнер), если торрент не сидирует/данных нет.
|
||||||
|
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||||
|
view.Seeding = buildSeeding(id, l, ok)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-18
@@ -10,6 +10,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -51,27 +52,39 @@ type Deps struct {
|
|||||||
Commander Commander
|
Commander Commander
|
||||||
Reader Reader
|
Reader Reader
|
||||||
Reviewer Reviewer
|
Reviewer Reviewer
|
||||||
|
Live LiveStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
deps Deps
|
deps Deps
|
||||||
index *template.Template
|
tmpl *template.Template
|
||||||
review *template.Template
|
assetVer string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter собирает HTTP-обработчик сервиса.
|
// NewRouter собирает HTTP-обработчик сервиса.
|
||||||
func NewRouter(d Deps) (http.Handler, error) {
|
func NewRouter(d Deps) (http.Handler, error) {
|
||||||
index, err := template.ParseFS(web.FS, "templates/index.html")
|
assetVer, err := assetVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
review, err := template.New("review.html").
|
funcs := template.FuncMap{
|
||||||
Funcs(template.FuncMap{"add": func(a, b int) int { return a + b }}).
|
"add": func(a, b int) int { return a + b },
|
||||||
ParseFS(web.FS, "templates/review.html")
|
"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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s := &server{deps: d, index: index, review: review}
|
staticFS, err := fs.Sub(web.FS, "static")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if d.Live == nil {
|
||||||
|
d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
|
||||||
|
}
|
||||||
|
s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
@@ -80,8 +93,16 @@ func NewRouter(d Deps) (http.Handler, error) {
|
|||||||
|
|
||||||
r.Get("/healthz", handleHealthz)
|
r.Get("/healthz", handleHealthz)
|
||||||
|
|
||||||
|
// Статика (встроенная, с длинным кэшем; URL версионируются ?v=).
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", staticHandler(staticFS)))
|
||||||
|
|
||||||
// Веб-UI.
|
// Веб-UI.
|
||||||
r.Get("/", s.handleIndex)
|
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)
|
||||||
r.Post("/ui/downloads", s.handleUIAdd)
|
r.Post("/ui/downloads", s.handleUIAdd)
|
||||||
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
|
||||||
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)
|
||||||
@@ -125,12 +146,19 @@ type indexView struct {
|
|||||||
|
|
||||||
type downloadView struct {
|
type downloadView struct {
|
||||||
ID int64
|
ID int64
|
||||||
|
Title string // отображаемый заголовок карточки
|
||||||
Source string
|
Source string
|
||||||
Infohash string
|
Infohash string // полный (для копирования)
|
||||||
|
InfohashShort string // усечённый (для показа)
|
||||||
Context string
|
Context string
|
||||||
State string
|
State string
|
||||||
|
Group string // группа фильтра (review/active/done/problem/other)
|
||||||
|
SearchText string // haystack для клиентского поиска (lowercase)
|
||||||
Error string
|
Error string
|
||||||
Terminal bool
|
Terminal bool
|
||||||
|
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||||
|
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||||
|
Deleted bool // скрыт по умолчанию на главной
|
||||||
Reviewable bool // review/deferred — есть экран ревью
|
Reviewable bool // review/deferred — есть экран ревью
|
||||||
Undoable bool // done — можно откатить раскладку
|
Undoable bool // done — можно откатить раскладку
|
||||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||||
@@ -147,12 +175,16 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
view := indexView{Error: r.URL.Query().Get("err")}
|
view := indexView{Error: r.URL.Query().Get("err")}
|
||||||
for _, d := range downloads {
|
for _, d := range downloads {
|
||||||
view.Downloads = append(view.Downloads, toView(d))
|
v := toView(d)
|
||||||
|
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||||
|
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
|
||||||
|
if v.IsDownloading {
|
||||||
|
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||||
|
v.Progress = buildProgress(d.ID, true, l, ok)
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
view.Downloads = append(view.Downloads, v)
|
||||||
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) {
|
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -323,14 +355,21 @@ func toDTO(d store.Download) downloadDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toView(d store.Download) downloadView {
|
func toView(d store.Download) downloadView {
|
||||||
|
state := string(d.State)
|
||||||
return downloadView{
|
return downloadView{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
|
Title: d.SourceRef,
|
||||||
Source: shorten(d.SourceRef, 64),
|
Source: shorten(d.SourceRef, 64),
|
||||||
Infohash: d.Infohash.String,
|
Infohash: d.Infohash.String,
|
||||||
|
InfohashShort: shortenHash(d.Infohash.String),
|
||||||
Context: d.Context,
|
Context: d.Context,
|
||||||
State: string(d.State),
|
State: state,
|
||||||
|
Group: stateGroup(state),
|
||||||
|
SearchText: strings.ToLower(d.SourceRef + " " + d.Infohash.String + " " + d.Context),
|
||||||
Error: d.ErrorMsg.String,
|
Error: d.ErrorMsg.String,
|
||||||
Terminal: d.State.IsTerminal(),
|
Terminal: d.State.IsTerminal(),
|
||||||
|
IsDownloading: d.State == store.StateDownloading,
|
||||||
|
Deleted: d.State == store.StateDeleted,
|
||||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||||
Undoable: d.State == store.StateDone,
|
Undoable: d.State == store.StateDone,
|
||||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||||
@@ -340,6 +379,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).
|
// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
|
||||||
func desyncNote(s store.State) string {
|
func desyncNote(s store.State) string {
|
||||||
switch s {
|
switch s {
|
||||||
@@ -429,9 +476,9 @@ func userErr(r *http.Request, err error, downloadID int64) string {
|
|||||||
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestLogger пишет структурированный лог по каждому запросу. Частые
|
// requestLogger пишет структурированный лог по каждому запросу. Служебные и
|
||||||
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
|
// навигационные GET (healthcheck, страницы веб-UI, статика) пишем на DEBUG,
|
||||||
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
|
// чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
|
||||||
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -459,8 +506,8 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
|
// requestLogLevel понижает уровень для служебных и навигационных запросов:
|
||||||
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
|
// healthcheck и GET-страницы веб-UI (включая статику). Мутации и REST
|
||||||
// API (`/api/...`) остаются на INFO.
|
// API (`/api/...`) остаются на INFO.
|
||||||
func requestLogLevel(r *http.Request) slog.Level {
|
func requestLogLevel(r *http.Request) slog.Level {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ func TestReviewRenders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
||||||
"Season 02", "Применить", "Уточнить",
|
"Season 02", "Применить", "Уточнить",
|
||||||
"База метаданных", "269613", "выбрать", "Без базы"} {
|
"Источник совпадения", "269613", "выбрать", "Без базы"} {
|
||||||
if !strings.Contains(string(body), want) {
|
if !strings.Contains(string(body), want) {
|
||||||
t.Errorf("страница ревью не содержит %q", want)
|
t.Errorf("страница ревью не содержит %q", want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LiveStatus — источник живой телеметрии загрузок (снимок воркера). Контракт
|
||||||
|
// узкий и не зависит от способа доставки в браузер (поллинг сейчас, SSE позже).
|
||||||
|
type LiveStatus interface {
|
||||||
|
// Live возвращает телеметрию по infohash; ok=false — данных нет (нет
|
||||||
|
// торрента в последнем тике), читатель деградирует без живых значений.
|
||||||
|
Live(infohash string) (worker.Live, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// noLive — заглушка на случай, когда источник телеметрии не подключён
|
||||||
|
// (Deps.Live == nil): живых данных нет, UI деградирует штатно.
|
||||||
|
type noLive struct{}
|
||||||
|
|
||||||
|
func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }
|
||||||
|
|
||||||
|
// progressView — живой прогресс активной загрузки (для карточки и фрагмента
|
||||||
|
// /progress). Active управляется store-состоянием (downloading), а не qbt:
|
||||||
|
// когда задача покидает downloading, фрагмент возвращается без поллинга.
|
||||||
|
type progressView struct {
|
||||||
|
ID int64
|
||||||
|
Active bool // store-состояние downloading → показываем бар и поллим
|
||||||
|
Has bool // есть данные снимка
|
||||||
|
Percent int
|
||||||
|
DlSpeed string
|
||||||
|
ETA string
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedingView — живая статистика раздачи (для страницы и фрагмента /seeding).
|
||||||
|
// Has истинно только если торрент сидирует и данные есть — иначе секция
|
||||||
|
// деградирует (пустой контейнер, поллинг прекращается).
|
||||||
|
type seedingView struct {
|
||||||
|
ID int64
|
||||||
|
Has bool
|
||||||
|
Percent int
|
||||||
|
Ratio string
|
||||||
|
Uploaded string
|
||||||
|
Seeds int
|
||||||
|
Peers int
|
||||||
|
UpSpeed string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildProgress(id int64, active bool, l worker.Live, ok bool) progressView {
|
||||||
|
v := progressView{ID: id, Active: active}
|
||||||
|
if ok {
|
||||||
|
v.Has = true
|
||||||
|
v.Percent = pct(l.Progress)
|
||||||
|
v.DlSpeed = fmtSpeed(l.DlSpeed)
|
||||||
|
// ETA опускаем при неизвестном/sentinel (stalled) — «осталось —» уродливо;
|
||||||
|
// шаблонный {{if .ETA}} тогда скрывает хвост строки.
|
||||||
|
if e := fmtETA(l.ETA); e != "—" {
|
||||||
|
v.ETA = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSeeding(id int64, l worker.Live, ok bool) seedingView {
|
||||||
|
v := seedingView{ID: id}
|
||||||
|
if ok && l.Seeding {
|
||||||
|
v.Has = true
|
||||||
|
v.Percent = pct(l.Progress)
|
||||||
|
v.Ratio = fmtRatio(l.Ratio)
|
||||||
|
v.Uploaded = fmtBytes(l.Uploaded)
|
||||||
|
v.Seeds = l.Seeds
|
||||||
|
v.Peers = l.Peers
|
||||||
|
v.UpSpeed = fmtSpeed(l.UpSpeed)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFragProgress отдаёт партиал живого прогресса карточки (htmx-поллинг).
|
||||||
|
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
s.fragErr(w, err, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active := d.State == store.StateDownloading
|
||||||
|
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||||
|
s.render(w, "progress", buildProgress(id, active, l, ok))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFragSeeding отдаёт партиал секции «Раздача» (htmx-поллинг).
|
||||||
|
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := pathID(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "некорректный id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := s.deps.Reader.GetDownload(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
s.fragErr(w, err, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l, ok := s.deps.Live.Live(d.Infohash.String)
|
||||||
|
s.render(w, "seeding", buildSeeding(id, l, ok))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fragErr транслирует ошибку чтения задачи для фрагмент-роутов: ErrNotFound →
|
||||||
|
// 404, прочее → 500 (полная ошибка уже залогирована на доменной границе).
|
||||||
|
func (s *server) fragErr(w http.ResponseWriter, err error, id int64) {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
http.Error(w, "не найдено", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
|
||||||
|
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- форматирование телеметрии ---
|
||||||
|
|
||||||
|
// etaInfinity — sentinel qBittorrent для неизвестного/бесконечного ETA.
|
||||||
|
const etaInfinity = 8640000
|
||||||
|
|
||||||
|
func pct(progress float64) int {
|
||||||
|
if progress < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if progress > 1 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return int(progress*100 + 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtBytes переводит байты в человекочитаемые единицы (двоичные, IEC).
|
||||||
|
func fmtBytes(n int64) string {
|
||||||
|
if n < 1024 {
|
||||||
|
return fmt.Sprintf("%d Б", n)
|
||||||
|
}
|
||||||
|
const unit = 1024
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
units := []string{"КиБ", "МиБ", "ГиБ", "ТиБ", "ПиБ", "ЭиБ"}
|
||||||
|
for v := n / unit; v >= unit && exp < len(units)-1; v /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), units[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtSpeed форматирует скорость (байт/с).
|
||||||
|
func fmtSpeed(n int64) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return "0 Б/с"
|
||||||
|
}
|
||||||
|
return fmtBytes(n) + "/с"
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtETA форматирует оценку времени; sentinel/отрицательное → «—».
|
||||||
|
func fmtETA(sec int64) string {
|
||||||
|
if sec < 0 || sec >= etaInfinity {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case sec < 60:
|
||||||
|
return fmt.Sprintf("%d с", sec)
|
||||||
|
case sec < 3600:
|
||||||
|
return fmt.Sprintf("%d мин", sec/60)
|
||||||
|
case sec < 86400:
|
||||||
|
return fmt.Sprintf("%d ч %d мин", sec/3600, (sec%3600)/60)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d дн", sec/86400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtRatio форматирует рейтинг отдачи; отрицательный (sentinel) → «—».
|
||||||
|
func fmtRatio(r float64) string {
|
||||||
|
if r < 0 {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f", r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFragProgressDownloading: активная задача → фрагмент с прогрессом,
|
||||||
|
// значениями снимка и атрибутами htmx-поллинга.
|
||||||
|
func TestFragProgressDownloading(t *testing.T) {
|
||||||
|
dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDownloading}
|
||||||
|
lv := stubLive{m: map[string]worker.Live{"ih5": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
|
||||||
|
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
|
||||||
|
|
||||||
|
rr := get(t, h, "/fragments/downloads/5/progress")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, want := range []string{`hx-trigger="every 3s"`, "/fragments/downloads/5/progress", "width:42%", "42%"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("фрагмент прогресса не содержит %q\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragProgressStopsWhenNotDownloading: когда задача покинула downloading,
|
||||||
|
// фрагмент отдаётся без атрибутов поллинга (поллинг прекращается).
|
||||||
|
func TestFragProgressStopsWhenNotDownloading(t *testing.T) {
|
||||||
|
dl := store.Download{ID: 5, Infohash: store.NullString("ih5"), State: store.StateDone}
|
||||||
|
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||||
|
|
||||||
|
rr := get(t, h, "/fragments/downloads/5/progress")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if body := rr.Body.String(); strings.Contains(body, "hx-trigger") {
|
||||||
|
t.Errorf("завершённая задача всё ещё поллит:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragSeeding: сидирующая задача → секция «Раздача» со статистикой и
|
||||||
|
// поллингом.
|
||||||
|
func TestFragSeeding(t *testing.T) {
|
||||||
|
dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone}
|
||||||
|
lv := stubLive{m: map[string]worker.Live{"ih9": {
|
||||||
|
Seeding: true, Progress: 1, Ratio: 2.41, Seeds: 38, Peers: 14, Uploaded: 1 << 30, UpSpeed: 1153433,
|
||||||
|
}}}
|
||||||
|
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, lv)
|
||||||
|
|
||||||
|
rr := get(t, h, "/fragments/downloads/9/seeding")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, want := range []string{"Раздача", "2.41", "38 / 14", `hx-trigger="every 3s"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("фрагмент раздачи не содержит %q\n%s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragSeedingDegrades: нет живых данных → секция отсутствует, поллинга нет.
|
||||||
|
func TestFragSeedingDegrades(t *testing.T) {
|
||||||
|
dl := store.Download{ID: 9, Infohash: store.NullString("ih9"), State: store.StateDone}
|
||||||
|
h := testRouterLive(t, stubReader{one: &dl}, stubReviewer{}, stubLive{})
|
||||||
|
|
||||||
|
rr := get(t, h, "/fragments/downloads/9/seeding")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if strings.Contains(body, "Раздача") || strings.Contains(body, "hx-trigger") {
|
||||||
|
t.Errorf("секция раздачи не деградировала:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIndexCardShowsLiveProgress: активная карточка в списке несёт прогресс уже
|
||||||
|
// в первом кадре (значения снимка) и атрибуты поллинга.
|
||||||
|
func TestIndexCardShowsLiveProgress(t *testing.T) {
|
||||||
|
dl := store.Download{ID: 3, SourceRef: "The.Bear.S03", Infohash: store.NullString("ih3"), State: store.StateDownloading}
|
||||||
|
lv := stubLive{m: map[string]worker.Live{"ih3": {Progress: 0.46, DlSpeed: 6400000, ETA: 720}}}
|
||||||
|
h := testRouterLive(t, stubReader{list: []store.Download{dl}}, stubReviewer{}, lv)
|
||||||
|
|
||||||
|
rr := get(t, h, "/")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
for _, want := range []string{`class="progress"`, "width:46%", "/fragments/downloads/3/progress"} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("карточка без живого прогресса: нет %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFragNotFound: фрагмент несуществующей задачи → 404.
|
||||||
|
func TestFragNotFound(t *testing.T) {
|
||||||
|
h := testRouterLive(t, stubReader{}, stubReviewer{}, stubLive{})
|
||||||
|
if rr := get(t, h, "/fragments/downloads/404/progress"); rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ func TestRequestLogLevel(t *testing.T) {
|
|||||||
want slog.Level
|
want slog.Level
|
||||||
}{
|
}{
|
||||||
{"GET", "/healthz", slog.LevelDebug}, // healthcheck — тихо
|
{"GET", "/healthz", slog.LevelDebug}, // healthcheck — тихо
|
||||||
{"GET", "/", slog.LevelDebug}, // список (авто-рефреш)
|
{"GET", "/", slog.LevelDebug}, // список загрузок
|
||||||
{"GET", "/review/1", slog.LevelDebug}, // страница ревью
|
{"GET", "/review/1", slog.LevelDebug}, // страница ревью
|
||||||
{"GET", "/api/downloads", slog.LevelInfo}, // REST API — на INFO
|
{"GET", "/api/downloads", slog.LevelInfo}, // REST API — на INFO
|
||||||
{"POST", "/ui/downloads/1/apply", slog.LevelInfo}, // мутация — на 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,125 @@
|
|||||||
|
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 }
|
||||||
|
|
||||||
|
// stubLive — заглушка источника живой телеметрии.
|
||||||
|
type stubLive struct{ m map[string]worker.Live }
|
||||||
|
|
||||||
|
func (s stubLive) Live(infohash string) (worker.Live, bool) {
|
||||||
|
l, ok := s.m[infohash]
|
||||||
|
return l, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRouter(t *testing.T, r stubReader, rv stubReviewer) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
return testRouterLive(t, r, rv, stubLive{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRouterLive(t *testing.T, r stubReader, rv stubReviewer, lv stubLive) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
h, err := NewRouter(Deps{
|
||||||
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
Reader: r,
|
||||||
|
Reviewer: rv,
|
||||||
|
Live: lv,
|
||||||
|
})
|
||||||
|
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
|
Confidence string
|
||||||
Reasons []string
|
Reasons []string
|
||||||
Hints []string
|
Hints []string
|
||||||
Files []reviewFileView
|
Files []fileRow
|
||||||
Preview []string
|
|
||||||
HasPlan bool
|
HasPlan bool
|
||||||
|
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
||||||
NoBase bool // выбрано «без базы»
|
NoBase bool // выбрано «без базы»
|
||||||
Candidates []candidateView
|
Candidates []candidateView
|
||||||
}
|
}
|
||||||
|
|
||||||
type reviewFileView struct {
|
|
||||||
Src string
|
|
||||||
Role string
|
|
||||||
Season string
|
|
||||||
Episode string
|
|
||||||
Ignored bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type candidateView struct {
|
type candidateView struct {
|
||||||
ID int64
|
ID int64
|
||||||
Provider string
|
Provider string
|
||||||
@@ -114,16 +106,9 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
if rec.Confidence.Valid {
|
if rec.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||||
}
|
}
|
||||||
for _, f := range rd.Plan.Files {
|
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
||||||
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.HasPlan = len(rd.Plan.Files) > 0
|
view.HasPlan = len(rd.Plan.Files) > 0
|
||||||
|
view.HasLinks = len(rd.Preview) > 0
|
||||||
for _, c := range rd.Candidates {
|
for _, c := range rd.Candidates {
|
||||||
view.Candidates = append(view.Candidates, candidateView{
|
view.Candidates = append(view.Candidates, candidateView{
|
||||||
ID: c.ID,
|
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")
|
s.render(w, "review.html", view)
|
||||||
if err := s.review.Execute(w, view); err != nil {
|
|
||||||
s.deps.Logger.Error("render review", "error", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Действия ревью (POST → redirect) ---
|
// --- Действия ревью (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)
|
http.Redirect(w, r, u, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func intPtrStr(p *int) string {
|
|
||||||
if p == nil {
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
return strconv.Itoa(*p)
|
|
||||||
}
|
|
||||||
|
|||||||
+13
-1
@@ -44,7 +44,10 @@ type Client struct {
|
|||||||
mu sync.Mutex // сериализует логин
|
mu sync.Mutex // сериализует логин
|
||||||
}
|
}
|
||||||
|
|
||||||
// Torrent — подмножество полей /torrents/info, нужное jellybit.
|
// Torrent — подмножество полей /torrents/info, нужное jellybit. Помимо полей
|
||||||
|
// для машины состояний и сопоставления несёт живую телеметрию (скорости, ETA,
|
||||||
|
// статистика раздачи) — её собирает снимок воркера для веб-UI; все эти поля
|
||||||
|
// приходят в том же ответе, отдельного вызова не нужно.
|
||||||
type Torrent struct {
|
type Torrent struct {
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -58,6 +61,15 @@ type Torrent struct {
|
|||||||
AddedOn int64 `json:"added_on"`
|
AddedOn int64 `json:"added_on"`
|
||||||
InfohashV1 string `json:"infohash_v1"`
|
InfohashV1 string `json:"infohash_v1"`
|
||||||
InfohashV2 string `json:"infohash_v2"`
|
InfohashV2 string `json:"infohash_v2"`
|
||||||
|
|
||||||
|
// Живая телеметрия (для снимка воркера и веб-UI).
|
||||||
|
Dlspeed int64 `json:"dlspeed"` // скорость загрузки, байт/с
|
||||||
|
Upspeed int64 `json:"upspeed"` // скорость отдачи, байт/с
|
||||||
|
Eta int64 `json:"eta"` // оценка до завершения, с (8640000 ≈ ∞)
|
||||||
|
Ratio float64 `json:"ratio"` // рейтинг отдачи (может быть -1 = ∞/н/д)
|
||||||
|
NumSeeds int `json:"num_seeds"` // подключённые сиды
|
||||||
|
NumLeechs int `json:"num_leechs"` // подключённые личи (пиры)
|
||||||
|
Uploaded int64 `json:"uploaded"` // отдано всего, байт
|
||||||
}
|
}
|
||||||
|
|
||||||
// File — элемент /torrents/files: путь файла относительно save_path
|
// File — элемент /torrents/files: путь файла относительно save_path
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPollBuildsLiveSnapshot: после Poll снимок несёт телеметрию качающейся и
|
||||||
|
// сидирующей задач (с верным Seeding), доступную по любому из трёх хэшей;
|
||||||
|
// неизвестный/пустой infohash → ok=false.
|
||||||
|
func TestPollBuildsLiveSnapshot(t *testing.T) {
|
||||||
|
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||||
|
// Качается (Category пуст → discover не усыновляет, store не мешает).
|
||||||
|
{Hash: "aaa", State: "downloading", Progress: 0.5, Dlspeed: 1000, Eta: 120},
|
||||||
|
// Сидирует, торрент v2 (три ключа).
|
||||||
|
{
|
||||||
|
Hash: "bbb", InfohashV1: "bbb1", InfohashV2: "BBB2",
|
||||||
|
State: "uploading", Progress: 1.0,
|
||||||
|
Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
w := newTestWorker(&fakeStore{downloads: map[int64]*store.Download{}}, qb)
|
||||||
|
|
||||||
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Poll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dl, ok := w.Live("aaa")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("нет телеметрии качающейся задачи")
|
||||||
|
}
|
||||||
|
if dl.Seeding {
|
||||||
|
t.Error("качающаяся задача помечена Seeding")
|
||||||
|
}
|
||||||
|
if dl.Progress != 0.5 || dl.DlSpeed != 1000 || dl.ETA != 120 {
|
||||||
|
t.Errorf("телеметрия качания неверна: %+v", dl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сидирующая задача находится по любому из трёх хэшей (lowercase).
|
||||||
|
for _, h := range []string{"bbb", "bbb1", "BBB2", "bbb2"} {
|
||||||
|
sd, ok := w.Live(h)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("нет телеметрии раздачи по ключу %q", h)
|
||||||
|
}
|
||||||
|
if !sd.Seeding {
|
||||||
|
t.Errorf("ключ %q: Seeding=false для uploading", h)
|
||||||
|
}
|
||||||
|
if sd.Ratio != 2.5 || sd.Seeds != 3 || sd.Peers != 1 || sd.Uploaded != 999 || sd.UpSpeed != 50 {
|
||||||
|
t.Errorf("ключ %q: статистика раздачи неверна: %+v", h, sd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := w.Live("unknownhash"); ok {
|
||||||
|
t.Error("неизвестный infohash вернул ok=true")
|
||||||
|
}
|
||||||
|
if _, ok := w.Live(""); ok {
|
||||||
|
t.Error("пустой infohash вернул ok=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,40 @@ type Config struct {
|
|||||||
SourceMissingThreshold int
|
SourceMissingThreshold int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live — живая телеметрия одной раздачи из снимка воркера. Курированный срез
|
||||||
|
// qbt.Torrent: читатели (httpapi) не зависят от пакета qbt, контракт чтения
|
||||||
|
// узкий. Seeding вычисляется воркером (classify), чтобы трактовка завершённости
|
||||||
|
// не дублировалась в транспорте.
|
||||||
|
type Live struct {
|
||||||
|
Progress float64 // доля 0..1
|
||||||
|
DlSpeed int64 // скорость загрузки, байт/с
|
||||||
|
ETA int64 // оценка до завершения, с (8640000 ≈ ∞)
|
||||||
|
State string // сырое состояние qBittorrent
|
||||||
|
Seeding bool // торрент завершён и раздаётся
|
||||||
|
|
||||||
|
Ratio float64 // рейтинг отдачи (может быть <0 = ∞/н/д)
|
||||||
|
Seeds int // подключённые сиды
|
||||||
|
Peers int // подключённые личи
|
||||||
|
Uploaded int64 // отдано всего, байт
|
||||||
|
UpSpeed int64 // скорость отдачи, байт/с
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveFrom собирает Live из торрента qBittorrent (Seeding — через classify).
|
||||||
|
func liveFrom(t qbt.Torrent) Live {
|
||||||
|
return Live{
|
||||||
|
Progress: t.Progress,
|
||||||
|
DlSpeed: t.Dlspeed,
|
||||||
|
ETA: t.Eta,
|
||||||
|
State: t.State,
|
||||||
|
Seeding: classify(t.State) == classReady,
|
||||||
|
Ratio: t.Ratio,
|
||||||
|
Seeds: t.NumSeeds,
|
||||||
|
Peers: t.NumLeechs,
|
||||||
|
Uploaded: t.Uploaded,
|
||||||
|
UpSpeed: t.Upspeed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Worker — поллер и владелец переходов.
|
// Worker — поллер и владелец переходов.
|
||||||
type Worker struct {
|
type Worker struct {
|
||||||
store Store
|
store Store
|
||||||
@@ -149,6 +183,14 @@ type Worker struct {
|
|||||||
notifier Notifier // опц. исходящие пинги
|
notifier Notifier // опц. исходящие пинги
|
||||||
scanner Scanner // опц. пересканирование Jellyfin
|
scanner Scanner // опц. пересканирование Jellyfin
|
||||||
|
|
||||||
|
// live — снимок живой телеметрии раздач (ключ — lowercase infohash, по три
|
||||||
|
// ключа на торрент, как byHash). Обновляется атомарным свопом карты на
|
||||||
|
// каждом тике Poll. Отдельный RWMutex (не w.mu): UI читает телеметрию часто,
|
||||||
|
// смешивать частые чтения с замком переходов — лишняя конкуренция. Снимок
|
||||||
|
// волатилен, в БД не хранится.
|
||||||
|
liveMu sync.RWMutex
|
||||||
|
live map[string]Live
|
||||||
|
|
||||||
// failNotified — дебаунс повторных EventFailed по задаче (download_id →
|
// failNotified — дебаунс повторных EventFailed по задаче (download_id →
|
||||||
// время последнего пинга). Мерцающий stalled-торрент колеблется
|
// время последнего пинга). Мерцающий stalled-торрент колеблется
|
||||||
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
|
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
|
||||||
@@ -179,9 +221,30 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
|
|||||||
now: time.Now,
|
now: time.Now,
|
||||||
newID: defaultBatchID,
|
newID: defaultBatchID,
|
||||||
failNotified: map[int64]time.Time{},
|
failNotified: map[int64]time.Time{},
|
||||||
|
live: map[string]Live{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live возвращает живую телеметрию раздачи по infohash (любому из v1/v2/hash).
|
||||||
|
// ok=false, если infohash пуст или раздачи не было в последнем тике поллинга —
|
||||||
|
// тогда читатель деградирует без живых значений. Чтение под RLock.
|
||||||
|
func (w *Worker) Live(infohash string) (Live, bool) {
|
||||||
|
if infohash == "" {
|
||||||
|
return Live{}, false
|
||||||
|
}
|
||||||
|
w.liveMu.RLock()
|
||||||
|
defer w.liveMu.RUnlock()
|
||||||
|
l, ok := w.live[strings.ToLower(infohash)]
|
||||||
|
return l, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// setLive атомарно подменяет снимок телеметрии готовой картой.
|
||||||
|
func (w *Worker) setLive(snap map[string]Live) {
|
||||||
|
w.liveMu.Lock()
|
||||||
|
w.live = snap
|
||||||
|
w.liveMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// defaultBatchID — уникальный идентификатор батча раскладки.
|
// defaultBatchID — уникальный идентификатор батча раскладки.
|
||||||
func defaultBatchID() string {
|
func defaultBatchID() string {
|
||||||
return fmt.Sprintf("b-%d", time.Now().UnixNano())
|
return fmt.Sprintf("b-%d", time.Now().UnixNano())
|
||||||
@@ -235,13 +298,20 @@ func (w *Worker) Poll(ctx context.Context) error {
|
|||||||
return fmt.Errorf("poll: list torrents: %w", err)
|
return fmt.Errorf("poll: list torrents: %w", err)
|
||||||
}
|
}
|
||||||
byHash := make(map[string]qbt.Torrent, len(torrents)*2)
|
byHash := make(map[string]qbt.Torrent, len(torrents)*2)
|
||||||
|
live := make(map[string]Live, len(torrents)*2)
|
||||||
for _, t := range torrents {
|
for _, t := range torrents {
|
||||||
|
l := liveFrom(t)
|
||||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||||
if h != "" {
|
if h != "" {
|
||||||
byHash[strings.ToLower(h)] = t
|
key := strings.ToLower(h)
|
||||||
|
byHash[key] = t
|
||||||
|
live[key] = l
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Снимок зависит только от torrents — свопаем сразу, до store-операций
|
||||||
|
// ниже (их ранний return по ошибке не должен лишать UI свежей телеметрии).
|
||||||
|
w.setLive(live)
|
||||||
|
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-30
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Воркер (`internal/worker`) уже поллит qBittorrent каждые `PollInterval` (дефолт
|
||||||
|
5с): `Poll` тянет `Torrents(ctx, "")` (все торренты) и строит `byHash`
|
||||||
|
(infohash → `qbt.Torrent`) для reconcile/discovery. Это единственная точка в
|
||||||
|
системе, где есть свежее состояние раздач. Прогресс в БД не хранится (в
|
||||||
|
`store.Download` полей нет — и не нужно, значение волатильно).
|
||||||
|
|
||||||
|
Веб-UI (`internal/httpapi`) server-rendered: `index.html` рендерит карточки из
|
||||||
|
`[]downloadView` (есть `Infohash`), `download.html` — детальную страницу из
|
||||||
|
`downloadDetailView`. htmx уже вендорится и грузится во всех шаблонах, но ни
|
||||||
|
одного `hx-*` пока нет. Зависимости транспорта собираются в `cmd/jellybit/serve.go`
|
||||||
|
(`httpapi.Deps`), воркер `wrk` уже передаётся как `Commander`/`Reviewer`.
|
||||||
|
|
||||||
|
Сейчас прогресс виден только при ручной перезагрузке, статистики раздачи нет.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Живой прогресс активных загрузок на главной без перезагрузки страницы и без
|
||||||
|
сброса клиентских фильтров/поиска.
|
||||||
|
- Секция «Раздача» на странице загрузки (рейтинг, сиды/пиры, отдано, скорость
|
||||||
|
отдачи).
|
||||||
|
- Снимок телеметрии в памяти воркера, обновляемый на том же тике поллинга, без
|
||||||
|
лишних сетевых вызовов и без записи в БД.
|
||||||
|
- Контракт чтения снимка изолирован так, чтобы позже заменить поллинг на SSE
|
||||||
|
без переделки доменного слоя.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- SSE/WebSocket в этой фазе (только htmx-поллинг фрагментов).
|
||||||
|
- Хранение истории прогресса/скорости в БД, графики.
|
||||||
|
- Управление раздачей (пауза/лимиты/удаление торрента) — источник
|
||||||
|
неприкосновенен.
|
||||||
|
- Живое обновление страницы ревью.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Снимок в воркере, ключ — infohash
|
||||||
|
|
||||||
|
Воркер ведёт `map[string]Live` (ключ — lowercase infohash), обновляемый
|
||||||
|
целиком (swap готовой карты) под **отдельным** `sync.RWMutex` — не `w.mu`.
|
||||||
|
Причина: `w.mu` сериализует переходы состояний и команды (cancel/retry), а UI
|
||||||
|
читает телеметрию часто (каждые несколько секунд × число вкладок); смешивать
|
||||||
|
частые чтения с замком переходов — лишняя конкуренция. Снимок строится из уже
|
||||||
|
полученного `torrents` — дополнительного вызова qBittorrent нет.
|
||||||
|
|
||||||
|
**Три ключа на торрент.** Как и существующий `byHash` (`worker.go:237-244`),
|
||||||
|
снимок кладёт каждую раздачу под все три значения: `t.Hash`, `t.InfohashV1`,
|
||||||
|
`t.InfohashV2` (lowercase). Иначе v2-торрент, у которого `d.Infohash` =
|
||||||
|
infohash_v1, а в карту положили только по `Hash`, не найдётся при чтении.
|
||||||
|
|
||||||
|
**Момент свопа — сразу после построения карты, до store-операций.** `Poll`
|
||||||
|
после получения `torrents` делает ещё несколько шагов с ранними `return` по
|
||||||
|
ошибкам store (`ListDownloadsByState` и т.п.). Снимок зависит только от
|
||||||
|
`torrents`, поэтому собираем и свопаем его сразу после построения `byHash` —
|
||||||
|
тогда телеметрия обновится даже если последующий reconcile упадёт.
|
||||||
|
|
||||||
|
Ключ по infohash, а не по `download_id`: `Poll` уже держит торренты по
|
||||||
|
infohash, а каждая `downloadView`/`downloadDetailView` несёт `Infohash`. Это
|
||||||
|
избавляет воркер от загрузки всех записей `store.Download` ради маппинга id и
|
||||||
|
естественно покрывает и активные, и сидирующие задачи (снимок = все торренты
|
||||||
|
последнего тика). Дедуп активных по infohash уже гарантирован воркером.
|
||||||
|
|
||||||
|
_Альтернатива (ключ download_id, как в исходной заметке):_ потребовал бы в
|
||||||
|
снимке резолвить torrent→download_id, т.е. держать обратный маппинг и грузить
|
||||||
|
записи. Отверг — infohash проще и уже под рукой на обеих сторонах.
|
||||||
|
|
||||||
|
### 2. Тип `worker.Live` — курированный, не `qbt.Torrent`
|
||||||
|
|
||||||
|
В снимок кладём отдельный `worker.Live{Progress, DlSpeed, ETA, State, Seeding,
|
||||||
|
Ratio, Seeds, Peers, Uploaded, UpSpeed}`, а не сырой `qbt.Torrent`. Так
|
||||||
|
`httpapi` не зависит от пакета qbt и контракт чтения остаётся узким (легче
|
||||||
|
заменить источник). `qbt.Torrent` дополняется полями `Dlspeed`, `Eta`,
|
||||||
|
`Ratio`, `NumSeeds`, `NumLeechs`, `Uploaded`, `Upspeed` (теги `json:"dlspeed"`
|
||||||
|
и т.д.) — они приходят в том же ответе `/torrents/info`.
|
||||||
|
|
||||||
|
**Флаг «сидирует» вычисляет воркер.** «Сидирует» — это свойство qbt-состояния
|
||||||
|
(`uploading`/`stalledUP`/…), а не доменного состояния задачи в БД. Воркер уже
|
||||||
|
владеет `classify` (`worker.go:466-479`), поэтому при сборке снимка он считает
|
||||||
|
`Seeding bool` (= `classify(state) == classReady`) и кладёт в `Live`. Так
|
||||||
|
httpapi не дублирует перечень qbt-состояний и трактовка завершённости живёт в
|
||||||
|
одном месте.
|
||||||
|
|
||||||
|
### 3. Контракт чтения: dep-интерфейс `LiveStatus` в httpapi
|
||||||
|
|
||||||
|
```go
|
||||||
|
// в internal/httpapi
|
||||||
|
type LiveStatus interface {
|
||||||
|
Live(infohash string) (worker.Live, bool) // ok=false → нет данных
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Воркер реализует `Live(infohash string) (Live, bool)` чтением снимка под
|
||||||
|
RLock. В `httpapi.Deps` добавляется поле `Live LiveStatus`; в `serve.go`
|
||||||
|
передаётся тот же `wrk`. `bool` — явный признак отсутствия (graceful
|
||||||
|
degradation). Геттер по одному infohash достаточен: и список (по карточке), и
|
||||||
|
страница загрузки читают по одной задаче.
|
||||||
|
|
||||||
|
### 4. Доставка в браузер: per-card htmx-поллинг, swap фрагмента карточки
|
||||||
|
|
||||||
|
На главной каждая карточка **активной** загрузки содержит элемент с
|
||||||
|
`hx-get="/fragments/downloads/{id}/progress"`, `hx-trigger="every Ns"`,
|
||||||
|
`hx-swap="outerHTML"`. Поллит не весь список, а свой прогресс-блок — поэтому
|
||||||
|
клиентские фильтры/поиск/прокрутка (на уровне `#list`) не затрагиваются.
|
||||||
|
|
||||||
|
Когда задача покидает `downloading`, фрагмент возвращается **без** htmx-атрибутов
|
||||||
|
поллинга (или с `hx-trigger` снятым) — поллинг сам собой прекращается. Смена
|
||||||
|
набора действий/бейджа при завершении остаётся за обычной навигацией/ручным
|
||||||
|
обновлением (живой пересбор всей карточки с действиями — вне scope, чтобы не
|
||||||
|
дублировать в htmx-ветке логику доступных действий). На странице загрузки
|
||||||
|
секция «Раздача» поллится аналогично (`/fragments/downloads/{id}/seeding`),
|
||||||
|
пока задача сидирует.
|
||||||
|
|
||||||
|
**Интервал поллинга UI — фиксированный 3s** (статичный `hx-trigger="every 3s"`
|
||||||
|
в шаблоне). Решение: не прокидывать `PollInterval` в шаблоны — для однопользова-
|
||||||
|
тельского домашнего сервиса лишние идентичные ответы при `PollInterval` > 3s
|
||||||
|
ничего не стоят, а плавность и простота важнее. Браузер читает только снимок —
|
||||||
|
qBittorrent при этом не дёргается (Decision 1), так что «лишние» запросы не
|
||||||
|
доходят до qBittorrent. Требование спеки сформулировано соответственно (ключевой
|
||||||
|
инвариант — браузер не опрашивает qBittorrent напрямую и не видит данные свежее
|
||||||
|
тика, а не «строго не чаще тика»).
|
||||||
|
|
||||||
|
_Альтернатива (интервал из `PollInterval`):_ буквально «не чаще, чем меняются
|
||||||
|
данные», но требует проводки cfg → Deps → шаблон ради экономии, которой здесь
|
||||||
|
нет. Отверг. _Альтернатива (swap всего `#list`):_ сбрасывал бы клиентские
|
||||||
|
фильтры (они на JS через display) — отверг. _SSE:_ отложено, контракт
|
||||||
|
`LiveStatus` оставляет путь.
|
||||||
|
|
||||||
|
### 5. Фрагмент-роуты и шаблоны-партиалы
|
||||||
|
|
||||||
|
Новые роуты в `NewRouter`: `GET /fragments/downloads/{id}/progress` и
|
||||||
|
`GET /fragments/downloads/{id}/seeding`. Оба отдают HTML-партиал (не JSON):
|
||||||
|
читают `GetDownload` (для infohash/состояния) + `Live(infohash)` и рендерят
|
||||||
|
партиал `partials/progress.html` / `partials/seeding.html`.
|
||||||
|
|
||||||
|
**Начальный кадр — сразу со значениями (без мигания).** Те же партиалы
|
||||||
|
включаются при первом полном рендере карточки/страницы, и значения для них
|
||||||
|
готовятся там же: `handleIndex`/`handleDownload` для нужных задач тоже зовут
|
||||||
|
`s.deps.Live.Live(infohash)` и кладут телеметрию в view-модель. Для этого
|
||||||
|
`downloadView` (карточка) и `downloadDetailView` (страница) расширяются полями
|
||||||
|
телеметрии (та же модель, что отдаёт фрагмент-роут) — один источник разметки и
|
||||||
|
данных для начального рендера и для поллинга. При `ok=false` партиал рендерит
|
||||||
|
нейтральный плейсхолдер/скрывает секцию (graceful degradation), а не падает.
|
||||||
|
|
||||||
|
Уровень лога для фрагментов — DEBUG (навигационный GET, как прочие страницы;
|
||||||
|
`requestLogLevel` уже понижает не-`/api` GET — отдельной правки логов не нужно).
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Карточка и фрагмент рассинхронятся по доступным действиям]** при
|
||||||
|
завершении загрузки фоновым поллингом обновится только прогресс-блок, а
|
||||||
|
кнопки — нет → Mitigation: фрагмент при выходе из `downloading` гасит свой
|
||||||
|
поллинг и показывает финальное состояние прогресс-блока; полный актуальный
|
||||||
|
набор действий пользователь видит при следующем заходе/обновлении. Это
|
||||||
|
осознанный компромисс ради простоты (не тянем логику действий в htmx-ветку).
|
||||||
|
- **[Снимок устаревает на рестарте]** до первого тика снимок пуст →
|
||||||
|
Mitigation: `Live` возвращает `ok=false`, UI рендерит без живых значений
|
||||||
|
(спека требует graceful degradation).
|
||||||
|
- **[Рост карты снимка]** свопаем карту целиком на каждом тике из актуального
|
||||||
|
списка торрентов → исчезнувшие раздачи естественно выпадают, утечки нет.
|
||||||
|
- **[Гонка чтения/записи снимка]** → отдельный `RWMutex`, запись — atomic swap
|
||||||
|
готовой карты в конце `Poll`, чтения под RLock.
|
||||||
|
- **[Пустой infohash]** у задачи (теоретически) → `Live("")` возвращает
|
||||||
|
`ok=false`, без паник.
|
||||||
|
- **[Sentinel-значения qBittorrent]** `eta=8640000` означает «∞/неизвестно»,
|
||||||
|
`ratio` может быть `-1`, скорости/размеры — в байтах → Mitigation: хелперы
|
||||||
|
форматирования (httpapi) трактуют sentinel'ы явно (ETA → «—»/«∞», `ratio<0` →
|
||||||
|
«—») и переводят байты в человекочитаемые единицы. Берём `num_seeds`/
|
||||||
|
`num_leechs` (подключённые пиры), не `num_complete`/`num_incomplete` (рой) —
|
||||||
|
выбор фиксируем в хелпере/партиале.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Изменение аддитивное: новые поля `qbt.Torrent` (обратносовместимо), новый
|
||||||
|
снимок и геттер в воркере, новый dep + роуты в httpapi, htmx-атрибуты в
|
||||||
|
шаблонах. БД не меняется — миграций нет. Откат — обратный revert коммита;
|
||||||
|
рантайм-состояние волатильно, чистить нечего. Деплой — обычный (бинарь на
|
||||||
|
umbar).
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Разрешены на чекпоинте ревью дизайна:
|
||||||
|
|
||||||
|
- **Начальный кадр** — рендерим сразу со значениями (Decision 5): `handleIndex`/
|
||||||
|
`handleDownload` читают `Live` и кладут телеметрию во view.
|
||||||
|
- **«Сидирует»** — вычисляемый флаг `Seeding` в `worker.Live` на базе
|
||||||
|
`classify` (Decision 2), без дублирования состояний в httpapi.
|
||||||
|
- **Интервал поллинга** — фиксированный `every 3s` в шаблоне (Decision 4),
|
||||||
|
`PollInterval` в шаблоны не прокидывается; формулировка спеки смягчена.
|
||||||
|
|
||||||
|
Остаётся уточнить при apply (мелочь, не блокер):
|
||||||
|
|
||||||
|
- Конкретные единицы/стиль отображения скоростей и размеров — согласовать с
|
||||||
|
дизайн-системой (`jellybit.css`).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Сейчас прогресс скачивания в веб-UI виден только при ручной перезагрузке
|
||||||
|
страницы, а статистики раздачи (рейтинг, сиды/пиры, отдано) нет вовсе — в
|
||||||
|
фазе 1 секцию намеренно отложили до появления живых данных. Воркер уже
|
||||||
|
опрашивает qBittorrent каждые несколько секунд и держит свежее состояние
|
||||||
|
каждой задачи — нужно лишь сохранить эту телеметрию в памяти и показать её в
|
||||||
|
UI без перезагрузки. Это «однооконный» сервис: смотреть, как идёт загрузка,
|
||||||
|
должно быть видно вживую.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Воркер на каждом тике поллинга сохраняет **in-memory снимок** телеметрии
|
||||||
|
всех известных раздач (прогресс, скорость загрузки, ETA + для раздачи:
|
||||||
|
рейтинг, число сидов/пиров, отдано, скорость отдачи). Снимок волатильный,
|
||||||
|
в БД не пишется.
|
||||||
|
- `qbt.Torrent` дополняется полями телеметрии (`dlspeed`, `eta`, `ratio`,
|
||||||
|
`num_seeds`, `num_leechs`, `uploaded`, `upspeed`) из `/torrents/info` —
|
||||||
|
лишний сетевой вызов не добавляется, они приходят в том же ответе.
|
||||||
|
- `httpapi` получает новую зависимость-источник телеметрии и **фрагмент-роут**;
|
||||||
|
активные карточки на главной поллят свой прогресс через htmx и обновляют
|
||||||
|
прогресс-бар/скорость/ETA на месте, без перезагрузки страницы и без сброса
|
||||||
|
клиентских фильтров.
|
||||||
|
- На странице загрузки `/download/{id}` появляется секция **«Раздача»** с
|
||||||
|
живой статистикой (рейтинг, сиды/пиры, отдано, скорость отдачи) для задач,
|
||||||
|
чей торрент сидирует.
|
||||||
|
- htmx, который уже вендорится и грузится, впервые задействуется (`hx-*`).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `live-status`: живая телеметрия загрузок и раздач — воркер как единственный
|
||||||
|
сэмплер qBittorrent ведёт in-memory снимок прогресса/скорости/ETA и
|
||||||
|
статистики раздачи; веб-UI отображает её в реальном времени (поллинг
|
||||||
|
фрагментов) без перезагрузки страницы и без хранения в БД.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `web-ui`: страница загрузки `/download/{id}` получает секцию живой
|
||||||
|
статистики раздачи (в фазе 1 явно вынесена из scope «вводится вместе с
|
||||||
|
живыми обновлениями»); карточки активных загрузок на главной показывают
|
||||||
|
живой прогресс-бар через htmx-поллинг.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Код:** `internal/qbt` (поля `Torrent`), `internal/worker` (снимок +
|
||||||
|
геттер телеметрии под отдельным RWMutex), `internal/httpapi` (dep-интерфейс
|
||||||
|
`LiveStatus`, фрагмент-роут, секция раздачи, прогресс во view), `cmd/jellybit`
|
||||||
|
(инъекция снимка в `httpapi.Deps`), `web/templates` (прогресс-бар на
|
||||||
|
карточках + htmx-атрибуты, секция «Раздача»), `web/static/css` при
|
||||||
|
необходимости (стили прогресс-бара уже есть в дизайн-системе).
|
||||||
|
- **БД:** изменений нет — телеметрия волатильна, миграций/ER не требуется.
|
||||||
|
- **Внешние вызовы:** дополнительных нет (расширяется только разбор
|
||||||
|
существующего ответа `/torrents/info`).
|
||||||
|
- **Безопасность:** телеметрия не содержит секретов; контракт чтения снимка
|
||||||
|
изолируется так, чтобы будущий апгрейд доставки до SSE не ломал API.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Снимок живой телеметрии
|
||||||
|
|
||||||
|
Воркер — единственный сэмплер qBittorrent — SHALL на каждом тике поллинга
|
||||||
|
обновлять in-memory снимок телеметрии всех известных раздач. Снимок MUST NOT
|
||||||
|
персиститься в БД: он волатилен и переживает только до рестарта процесса.
|
||||||
|
|
||||||
|
#### Scenario: Обновление снимка на тике
|
||||||
|
|
||||||
|
- **WHEN** воркер завершает успешный тик поллинга qBittorrent
|
||||||
|
- **THEN** снимок телеметрии содержит актуальные данные по каждой раздаче,
|
||||||
|
присутствующей в ответе qBittorrent
|
||||||
|
|
||||||
|
#### Scenario: Снимок волатилен
|
||||||
|
|
||||||
|
- **WHEN** процесс только что перезапущен и первый тик поллинга ещё не прошёл
|
||||||
|
- **THEN** снимок пуст, а UI отображает задачи без живых значений, не падая
|
||||||
|
|
||||||
|
### Requirement: Состав телеметрии
|
||||||
|
|
||||||
|
Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость
|
||||||
|
загрузки и ETA, а для сидирующих раздач дополнительно — рейтинг, число сидов
|
||||||
|
и пиров, объём отданного и скорость отдачи. Значения SHALL извлекаться из
|
||||||
|
ответа qBittorrent `/torrents/info` без дополнительного сетевого вызова.
|
||||||
|
|
||||||
|
#### Scenario: Телеметрия качающейся задачи
|
||||||
|
|
||||||
|
- **WHEN** торрент задачи находится в состоянии загрузки
|
||||||
|
- **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA
|
||||||
|
|
||||||
|
#### Scenario: Телеметрия сидирующей задачи
|
||||||
|
|
||||||
|
- **WHEN** торрент задачи завершён и раздаётся
|
||||||
|
- **THEN** в снимке для неё доступны рейтинг, число сидов/пиров, объём
|
||||||
|
отданного и скорость отдачи
|
||||||
|
|
||||||
|
### Requirement: Чтение телеметрии транспортом
|
||||||
|
|
||||||
|
Сервис SHALL предоставлять чтение снимка телеметрии по задаче через
|
||||||
|
изолированный контракт, не зависящий от способа доставки в браузер (поллинг
|
||||||
|
сейчас, SSE в будущем). Если для задачи нет записи в снимке (соответствующий
|
||||||
|
торрент отсутствовал в qBittorrent на последнем тике), чтение SHALL сообщать
|
||||||
|
об отсутствии данных, а UI MUST деградировать без живых значений, не падая.
|
||||||
|
|
||||||
|
#### Scenario: Данные есть
|
||||||
|
|
||||||
|
- **WHEN** транспорт читает телеметрию задачи, чей торрент был в последнем тике
|
||||||
|
- **THEN** он получает живые значения этой задачи
|
||||||
|
|
||||||
|
#### Scenario: Данных нет
|
||||||
|
|
||||||
|
- **WHEN** транспорт читает телеметрию задачи, торрента которой нет в qBittorrent
|
||||||
|
- **THEN** он получает признак отсутствия данных и рендерит страницу без живых
|
||||||
|
значений
|
||||||
|
|
||||||
|
### Requirement: Свежесть не выше тика поллинга
|
||||||
|
|
||||||
|
Живые значения, видимые в браузере, SHALL быть не свежее последнего тика
|
||||||
|
поллинга воркера; браузер MUST NOT опрашивать qBittorrent напрямую. Любой
|
||||||
|
запрос UI за телеметрией SHALL обслуживаться из in-memory снимка, не порождая
|
||||||
|
обращения к qBittorrent — поэтому частота обновления UI может быть выбрана
|
||||||
|
свободно (в т.ч. чаще тика для плавности), не нагружая qBittorrent.
|
||||||
|
|
||||||
|
#### Scenario: Браузер не обгоняет воркер
|
||||||
|
|
||||||
|
- **WHEN** браузер запрашивает фрагмент телеметрии чаще, чем длится тик
|
||||||
|
поллинга
|
||||||
|
- **THEN** он получает значения последнего тика, и обращения к qBittorrent при
|
||||||
|
этом не происходит
|
||||||
|
|
||||||
|
### Requirement: Живой прогресс активных загрузок
|
||||||
|
|
||||||
|
Веб-UI SHALL обновлять прогресс активных (downloading) загрузок на главной без
|
||||||
|
перезагрузки страницы — поллингом фрагмента через htmx. Обновление MUST NOT
|
||||||
|
сбрасывать клиентские фильтр, поиск и прокрутку. Когда задача покидает
|
||||||
|
состояние downloading, поллинг её прогресса SHALL прекращаться.
|
||||||
|
|
||||||
|
#### Scenario: Прогресс растёт без перезагрузки
|
||||||
|
|
||||||
|
- **WHEN** загрузка качается и пользователь смотрит на главную
|
||||||
|
- **THEN** её прогресс-бар, скорость и ETA обновляются на месте без
|
||||||
|
перезагрузки страницы
|
||||||
|
|
||||||
|
#### Scenario: Клиентское состояние сохраняется
|
||||||
|
|
||||||
|
- **WHEN** применён фильтр или поиск и происходит фоновое обновление прогресса
|
||||||
|
- **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются
|
||||||
|
|
||||||
|
#### Scenario: Завершение останавливает поллинг
|
||||||
|
|
||||||
|
- **WHEN** загрузка переходит из downloading в другое состояние
|
||||||
|
- **THEN** фоновый поллинг прогресса для этой карточки прекращается
|
||||||
|
|
||||||
|
### Requirement: Секция раздачи на странице загрузки
|
||||||
|
|
||||||
|
Страница `/download/{id}` SHALL показывать секцию «Раздача» с живой статистикой
|
||||||
|
(рейтинг, число сидов и пиров, объём отданного, скорость отдачи) для задач,
|
||||||
|
чей торрент сидирует. Если живых данных по задаче нет, секция SHALL
|
||||||
|
отсутствовать либо явно показывать «нет данных», не ломая остальную страницу.
|
||||||
|
|
||||||
|
#### Scenario: Сидирующая задача показывает раздачу
|
||||||
|
|
||||||
|
- **WHEN** открыта страница задачи, торрент которой раздаётся
|
||||||
|
- **THEN** в секции «Раздача» видны рейтинг, сиды/пиры, отдано и скорость отдачи
|
||||||
|
|
||||||
|
#### Scenario: Нет живых данных — секция деградирует
|
||||||
|
|
||||||
|
- **WHEN** открыта страница задачи, торрента которой нет в qBittorrent
|
||||||
|
- **THEN** секция «Раздача» отсутствует или показывает «нет данных», а
|
||||||
|
распознавание, файлы и история отображаются нормально
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Страницы веб-UI
|
||||||
|
|
||||||
|
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
||||||
|
добавления и фильтром/поиском (`/`), экран ревью одной загрузки (`/review/{id}`)
|
||||||
|
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
||||||
|
файлами→раскладкой, историей и — для сидирующих задач — секцией живой
|
||||||
|
статистики раздачи. Карточки активных (downloading) загрузок в списке SHALL
|
||||||
|
содержать индикатор прогресса. Состояние `deleted` SHALL быть скрыто в списке
|
||||||
|
по умолчанию (с переключателем «показать всё»). Механика живого обновления
|
||||||
|
прогресса и наполнение секции раздачи определяются capability `live-status`.
|
||||||
|
|
||||||
|
#### Scenario: Просмотр одной загрузки
|
||||||
|
|
||||||
|
- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки
|
||||||
|
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
|
||||||
|
историей
|
||||||
|
|
||||||
|
#### Scenario: Прогресс активной загрузки в списке
|
||||||
|
|
||||||
|
- **WHEN** в списке есть загрузка в состоянии `downloading`
|
||||||
|
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
|
||||||
|
и ETA)
|
||||||
|
|
||||||
|
#### Scenario: Удалённые скрыты по умолчанию
|
||||||
|
|
||||||
|
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать
|
||||||
|
всё» не включён
|
||||||
|
- **THEN** они не отображаются, но доступны при включённом переключателе
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
## 1. Телеметрия в клиенте qBittorrent
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить в `qbt.Torrent` поля `Dlspeed`, `Eta`, `Ratio`, `NumSeeds`,
|
||||||
|
`NumLeechs`, `Uploaded`, `Upspeed` с json-тегами из `/torrents/info`
|
||||||
|
(`dlspeed`, `eta`, `ratio`, `num_seeds`, `num_leechs`, `uploaded`, `upspeed`)
|
||||||
|
- [x] 1.2 Обновить комментарий-доку `Torrent` (подмножество полей расширено)
|
||||||
|
|
||||||
|
## 2. Снимок телеметрии в воркере
|
||||||
|
|
||||||
|
- [x] 2.1 Добавить тип `worker.Live{Progress, DlSpeed, ETA, State, Seeding,
|
||||||
|
Ratio, Seeds, Peers, Uploaded, UpSpeed}` (курированный, без зависимости на
|
||||||
|
qbt у читателей); `Seeding` вычислять через `classify(state) == classReady`
|
||||||
|
- [x] 2.2 Добавить в `Worker` поле снимка `live map[string]Live` + отдельный
|
||||||
|
`sync.RWMutex` (не `w.mu`); инициализация в `New`
|
||||||
|
- [x] 2.3 В `Poll` собрать новую карту из полученного `torrents` — **три ключа
|
||||||
|
на торрент** (lowercase `Hash`/`InfohashV1`/`InfohashV2`, как `byHash`) — и
|
||||||
|
атомарно подменить снимок **сразу после построения `byHash`, до store-
|
||||||
|
операций** (телеметрия обновляется даже при последующем сбое reconcile)
|
||||||
|
- [x] 2.4 Реализовать `Live(infohash string) (Live, bool)` — чтение под RLock,
|
||||||
|
`ok=false` при пустом/неизвестном infohash
|
||||||
|
- [x] 2.5 Тесты: снимок обновляется после `Poll`; `Live` отдаёт данные
|
||||||
|
качающейся и сидирующей задачи (с верным `Seeding`); поиск по любому из трёх
|
||||||
|
хэшей; неизвестный/пустой infohash → `ok=false`
|
||||||
|
|
||||||
|
## 3. Транспорт: контракт и фрагмент-роуты
|
||||||
|
|
||||||
|
- [x] 3.1 Объявить интерфейс `LiveStatus` в `internal/httpapi` и добавить поле
|
||||||
|
`Live LiveStatus` в `Deps`
|
||||||
|
- [x] 3.2 Прокинуть `Live: wrk` в `httpapi.Deps` в `cmd/jellybit/serve.go`
|
||||||
|
- [x] 3.3 Добавить роуты `GET /fragments/downloads/{id}/progress` и
|
||||||
|
`GET /fragments/downloads/{id}/seeding` в `NewRouter`
|
||||||
|
- [x] 3.4 Реализовать обработчики фрагментов: `GetDownload` → infohash/состояние
|
||||||
|
+ `Live(infohash)`; рендер партиала; `ok=false` → деградация без живых значений
|
||||||
|
- [x] 3.5 **Начальный рендер со значениями:** расширить `downloadView` и
|
||||||
|
`downloadDetailView` полями телеметрии; в `handleIndex`/`handleDownload`
|
||||||
|
вызвать `Live(infohash)` для нужных задач и положить во view — одна модель для
|
||||||
|
страницы и для фрагмент-роута (без мигания при первой загрузке)
|
||||||
|
- [x] 3.6 Добавить хелперы форматирования скорости/размера/ETA: человекочитаемые
|
||||||
|
единицы (байты→КиБ/МиБ, скорость/с), sentinel'ы qBittorrent (`eta=8640000`→
|
||||||
|
«—»/«∞», `ratio<0`→«—»); зафиксировать выбор `num_seeds`/`num_leechs`
|
||||||
|
|
||||||
|
## 4. Шаблоны и живое обновление
|
||||||
|
|
||||||
|
- [x] 4.1 Партиал `partials/progress.html` (прогресс-бар + скорость + ETA);
|
||||||
|
включить его в карточку `index.html` для активных загрузок
|
||||||
|
- [x] 4.2 Навесить на прогресс-блок активной карточки `hx-get` /
|
||||||
|
`hx-trigger="every 3s"` / `hx-swap="outerHTML"`; при выходе из downloading
|
||||||
|
фрагмент не содержит атрибутов поллинга (поллинг прекращается)
|
||||||
|
- [x] 4.3 Партиал `partials/seeding.html` (рейтинг, сиды/пиры, отдано, скорость
|
||||||
|
отдачи); секция «Раздача» в `download.html` для сидирующих задач, с
|
||||||
|
htmx-поллингом и деградацией при отсутствии данных
|
||||||
|
- [x] 4.4 Проверить, что фоновое обновление не сбрасывает фильтр/поиск/прокрутку
|
||||||
|
на главной (поллинг точечный, на уровне карточки)
|
||||||
|
- [x] 4.5 При необходимости — стили прогресс-бара/раздачи в `jellybit.css`
|
||||||
|
(использовать существующие токены, без инлайн-хардкода цветов)
|
||||||
|
|
||||||
|
## 5. Проверки и ревью
|
||||||
|
|
||||||
|
- [x] 5.1 `task test` (включая новые тесты воркера и httpapi-рендера фрагментов;
|
||||||
|
в т.ч. тест «фрагмент при выходе из downloading отдаётся без htmx-атрибутов
|
||||||
|
поллинга»)
|
||||||
|
- [x] 5.2 `task lint`, `gofmt`, `openspec validate --strict`
|
||||||
|
- [x] 5.3 Ревью кода (второй чекпоинт) перед archive: соответствие спекам и
|
||||||
|
конвенциям (логирование без секретов, ошибки `%w`, отдельный RWMutex)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-30
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Веб-UI — тонкий транспорт над ядром (приём в `ingest`, команды в `worker`,
|
||||||
|
чтение в `store`). Рендер server-rendered через `html/template`, ассеты
|
||||||
|
встраиваются `go:embed` (`web/web.go`). Сейчас встроены только шаблоны;
|
||||||
|
статика (CSS/JS/шрифты) не отдаётся, «живость» сделана грубым
|
||||||
|
`<meta http-equiv="refresh" content="5">`, оформление — инлайн-`<style>` с
|
||||||
|
хардкодом цветов.
|
||||||
|
|
||||||
|
Готов дизайн (`tmp/design/`): семантический HTML + единый `jellybit.css`
|
||||||
|
(токены, тёмная тема по ОС), спроектированный под этот стек — без сборки,
|
||||||
|
npm, React. Превью целевых путей раскладки уже считается на сервере через
|
||||||
|
`internal/layout.BuildLinks` (поле `reviewView.Preview`) — это источник истины
|
||||||
|
имён, дублировать его нельзя (инвариант: превью = реальная раскладка).
|
||||||
|
|
||||||
|
Стек зафиксирован с заказчиком: htmx (через манифест с checksum, без Node),
|
||||||
|
шрифты self-hosted, статика через `go:embed`. Реактивных фреймворков нет:
|
||||||
|
клиентский режим ручной раскладки файл→серия отложен (операция редкая), вместе
|
||||||
|
с ним отложен и Alpine.js — его добавим, когда виджет понадобится. Живые
|
||||||
|
обновления прогресса — отдельный change (`web-live-updates`), здесь meta-refresh
|
||||||
|
просто убирается.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Перенести дизайн `tmp/design/` в прод-шаблоны без шага сборки.
|
||||||
|
- Встроить и отдавать статику (CSS, htmx, Alpine, шрифты) из одного бинаря.
|
||||||
|
- Единая дизайн-система: один CSS, токены, тёмная тема по ОС, бейджи статусов.
|
||||||
|
- Общие партиалы (шапка, бейдж, виджет «файл→раскладка») вместо дублей.
|
||||||
|
- Добавить страницу `download` (просмотр одной загрузки).
|
||||||
|
- Превью путей на ревью — показ из единого серверного расчёта `internal/layout`.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Живые обновления прогресса/SSE (отдельный change).
|
||||||
|
- Клиентский режим ручной раскладки файл→серия (S·E, нумерация, live-превью) и
|
||||||
|
Alpine.js под него — отложены до реальной необходимости (операция редкая).
|
||||||
|
- Изменение доменного поведения (приём, распознавание, петля ревью, команды).
|
||||||
|
- Авторизация веб-UI (остаётся вне scope, доверенная LAN).
|
||||||
|
- npm/бандлер/CSS-постпроцессинг.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1. Статика через `go:embed` + `http.FileServer` под `/static/`
|
||||||
|
|
||||||
|
`web/web.go` встраивает `static/*` рядом с `templates/*`. Роут
|
||||||
|
`r.Handle("/static/*", http.StripPrefix("/static/", fileServer))` с
|
||||||
|
`Cache-Control` (длинный max-age для вендорных файлов и шрифтов; для CSS —
|
||||||
|
умеренный, либо cache-busting через `?v=<hash>`, посчитанный при старте).
|
||||||
|
|
||||||
|
*Альтернативы:* отдавать с диска — отвергнуто (ломает однобинарность);
|
||||||
|
CDN для htmx/Alpine — отвергнуто (внешняя сеть, инвариант self-host).
|
||||||
|
|
||||||
|
### D2. Шрифты self-hosted через `@font-face`
|
||||||
|
|
||||||
|
IBM Plex Sans/Mono кладём в `web/static/fonts/`, подключаем `@font-face` в
|
||||||
|
`jellybit.css`. Из шаблонов убираем `<link>` на Google Fonts.
|
||||||
|
|
||||||
|
*Почему:* umbar — домашний сервер, не зависим от внешней сети, не утекает
|
||||||
|
referrer. Handoff сам это рекомендует.
|
||||||
|
|
||||||
|
### D3. Зависимости фронта — наколеночный менеджер: gitignore + идемпотентный фетч
|
||||||
|
|
||||||
|
Вендорные ассеты (htmx + шрифты IBM Plex) **не храним в репозитории** —
|
||||||
|
`web/static/vendor/` в gitignore. Источник истины — манифест `web/assets.manifest`
|
||||||
|
(строки `<путь под web/static/> <url> <sha256>`). `task assets` идемпотентен:
|
||||||
|
для каждой записи — если файл есть и sha256 совпал, берём с диска; иначе качаем и
|
||||||
|
сверяем (несовпадение валит таргет). Node/бандлер не вводим. Alpine.js не вводим
|
||||||
|
— реактивных виджетов в этом change нет (см. Non-Goals).
|
||||||
|
|
||||||
|
**Граница вендора.** В vendor только фетчабельные третьесторонние пакеты (htmx,
|
||||||
|
woff2-шрифты). Авторские `jellybit.css` (с вшитым `@font-face` на `../vendor/
|
||||||
|
fonts/`) и `app.js` — наш исходник, коммитятся как обычно (качать неоткуда).
|
||||||
|
|
||||||
|
**Сборка.** `go:embed templates static` встраивает и vendor, поэтому файлы должны
|
||||||
|
быть на диске к моменту `go build`. `task build`/`task run` зависят от `task
|
||||||
|
assets`. Бинарь остаётся самодостаточным (всё встроено), репозиторий — без
|
||||||
|
вендора. Размен: канонична сборка через Task; голый `go build` без `task assets`
|
||||||
|
соберёт бинарь с неполной статикой (css есть, шрифтов/htmx нет) — это принято.
|
||||||
|
|
||||||
|
*Альтернативы:* (1) коммитить результат фетча — отвергнуто заказчиком (не хотим
|
||||||
|
вендор в репо). (2) npm + бандлер (Parcel/esbuild) через Docker — отвергнуто
|
||||||
|
сейчас: бандлить нечего (htmx — готовый `<script>`, своего JS ~ноль), целый
|
||||||
|
рантайм ради пары файлов. Дорастёт фронт до своих компонентов — отдельный change
|
||||||
|
и esbuild (проще Parcel; есть Go-биндинги, можно без Node).
|
||||||
|
|
||||||
|
### D4. Партиалы через `template.ParseFS` нескольких файлов
|
||||||
|
|
||||||
|
Выносим общие куски в `web/templates/partials/`: `header.html`,
|
||||||
|
`status_badge.html` (карта `state→class/подпись`), `layout_widget.html`
|
||||||
|
(виджет «файл источника → раскладка», общий для review и download). Бейдж
|
||||||
|
рендерится по `state` — карту держим в одном партиале (`{{define}}`), не
|
||||||
|
дублируем по страницам. Текущий `add`-FuncMap сохраняем; при нужде добавляем
|
||||||
|
маленькие чистые функции-хелперы.
|
||||||
|
|
||||||
|
*Почему:* handoff (п. 7) прямо просит вынести шапку, бейдж и виджет в общие
|
||||||
|
партиалы — сейчас они продублированы.
|
||||||
|
|
||||||
|
### D5. `download.html` — новая страница `/download/{id}`
|
||||||
|
|
||||||
|
Новый GET-хендлер + view-структура: распознавание, файлы→раскладка (тот же
|
||||||
|
партиал), раздача, история/таймлайн. Действия — по состоянию (как в index),
|
||||||
|
формы переиспользуют уже существующие `/ui/downloads/{id}/*` эндпоинты.
|
||||||
|
|
||||||
|
### D6. Ручная раскладка файл→серия — отложена
|
||||||
|
|
||||||
|
Клиентский режим ручного редактирования (S·E по файлам, нумерация,
|
||||||
|
live-превью) в этот change не входит — операция редкая, не оправдывает
|
||||||
|
реактивный виджет и Alpine.js сейчас. На ревью остаётся показ файлов и
|
||||||
|
серверного превью (read-only) + существующие действия через формы/htmx
|
||||||
|
(уточнить/перераспознать/выбрать кандидата/применить/позже/отклонить).
|
||||||
|
|
||||||
|
*Когда понадобится* — заведём отдельный change: тогда же выберем подход
|
||||||
|
(клиентская валидация + серверный dry-run превью переиспользует
|
||||||
|
`layout.BuildLinks`, чтобы не дублировать правила имён в JS) и добавим Alpine.
|
||||||
|
|
||||||
|
### D7. Убрать meta-refresh и инлайн-стили
|
||||||
|
|
||||||
|
`<meta http-equiv="refresh">` и `<style>` удаляются. До change живых
|
||||||
|
обновлений список статичен (обновление — перезагрузкой). Это осознанный
|
||||||
|
временный шаг, чтобы не смешивать презентацию с поведенческим изменением.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Устаревание/дрейф htmx** → версия и sha256 в манифесте; обновление
|
||||||
|
осознанное (правка манифеста + `task assets` с проверкой хеша).
|
||||||
|
- **URL-rot / офлайн-сборка** (vendor не в репо) → первая сборка требует сети,
|
||||||
|
дальше файлы кэшируются на диске (идемпотентно). Митигация: пиннинг версий +
|
||||||
|
два рабочих зеркала (unpkg/jsdelivr), URL правится в манифесте. Если понадобится
|
||||||
|
жёсткая офлайн-воспроизводимость — внутреннее зеркало или возврат к коммиту.
|
||||||
|
- **Регресс действий при переносе шаблонов** (поле без `name`, не та форма) →
|
||||||
|
опираемся на разметку `<!-- action: … -->` в прототипах и существующие
|
||||||
|
хендлеры; проверяем каждое действие по состоянию; ревью кода до archive.
|
||||||
|
- **Временная потеря авто-обновления** (D7) → приемлемо, закрывается
|
||||||
|
следующим change; список обновляется перезагрузкой.
|
||||||
|
- **Рассинхрон карты статусов** между бейджем и доменом → карта в одном
|
||||||
|
партиале + спека требует полноты покрытия всех состояний.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Манифест + идемпотентный `task assets` (фетч htmx и шрифтов в gitignored
|
||||||
|
`web/static/vendor/` с проверкой sha256); `task build`/`run` зависят от
|
||||||
|
`assets`; добавить авторские `web/static/{css,js}`, расширить `go:embed`.
|
||||||
|
2. Роут `/static/*` + `Cache-Control`.
|
||||||
|
3. Партиалы (header, status_badge, layout_widget); переключить index/review.
|
||||||
|
4. Добавить `download.html` + хендлер `/download/{id}`.
|
||||||
|
5. Убрать meta-refresh и инлайн-стили.
|
||||||
|
6. `task lint` + `task test`; ручная проверка действий по состояниям.
|
||||||
|
|
||||||
|
Откат — презентационный, без миграций БД и схемы: revert коммита
|
||||||
|
восстанавливает прежние шаблоны.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Cache-busting CSS: `?v=<hash>` при старте или фиксированный `Cache-Control`
|
||||||
|
с коротким max-age. Решим при реализации D1 (склонность — хеш при старте).
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Текущий веб-UI — черновой: инлайн `<style>`, хардкод цветов, грубый
|
||||||
|
`<meta http-equiv="refresh" content="5">` для «живости». Готов полноценный
|
||||||
|
дизайн (`tmp/design/`) — семантический HTML + один CSS-файл, заточенный ровно
|
||||||
|
под нашу архитектуру (server-rendered Go, без сборки/npm/React). Пора перенести
|
||||||
|
его в прод, чтобы «единое окно» стало пригодным для ежедневного использования.
|
||||||
|
|
||||||
|
Это презентационный change: поведение ядра (приём, распознавание, раскладка,
|
||||||
|
петля ревью) не меняется — оформляем уже существующие действия и состояния.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Отдача статики:** роут `/static/*` через `http.FileServer` над `web.FS`,
|
||||||
|
`go:embed` для `web/static/{css,js,fonts,vendor}`, разумные `Cache-Control`.
|
||||||
|
- **Шрифты self-hosted:** IBM Plex Sans + IBM Plex Mono кладём локально и
|
||||||
|
подключаем через `@font-face` (без Google Fonts CDN — не зависим от внешней
|
||||||
|
сети, не светим referrer).
|
||||||
|
- **Дизайн-система:** `jellybit.css` (токены, тёмная тема по настройке ОС) как
|
||||||
|
единственный CSS; новые цвета не вводим — только из токенов.
|
||||||
|
- **Зависимости фронта:** вендорные ассеты (htmx + шрифты) **не храним в репо** —
|
||||||
|
`web/static/vendor/` в gitignore. `task assets` идемпотентно добывает их по
|
||||||
|
манифесту (имя/версия/URL/sha256): нет файла или хеш не сошёлся — качает, иначе
|
||||||
|
берёт с диска. Без Node и бандлера. Авторские css/js — обычный коммит.
|
||||||
|
- **Общие партиалы:** шапка `.app-header`, бейдж статуса (карта `state→class`),
|
||||||
|
виджет «файл источника → раскладка» (общий для review и download).
|
||||||
|
- **Шаблоны:** переносим `index` и `review`, добавляем `download` (просмотр одной
|
||||||
|
загрузки). Референсные `states.html`/`directions.html` в прод не идут.
|
||||||
|
- **Убираем meta-refresh** и инлайн-стили; до отдельного change живых
|
||||||
|
обновлений список остаётся статичным (без авто-обновления прогресса).
|
||||||
|
- **Клиентский JS по минимуму:** `copyHash` (vanilla), спойлер контекста
|
||||||
|
(нативный `<details>`). Реактивных виджетов нет — все действия идут через
|
||||||
|
формы/htmx.
|
||||||
|
- **Превью путей** на экране ревью отображается из серверного расчёта
|
||||||
|
(`internal/layout`), без дублирования правил в шаблоне.
|
||||||
|
- **Формы:** проставить `name` полей, совпадающие с Go-структурами обработчиков.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `web-ui`: презентационный слой веб-интерфейса — встроенная отдача статики и
|
||||||
|
шрифтов, единая дизайн-система, рендеринг страниц (список/ревью/просмотр),
|
||||||
|
бейджи статусов и клиентские взаимодействия без сборки (копирование infohash,
|
||||||
|
спойлер, режим ручной раскладки с live-превью).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
<!-- Нет. Доменное поведение приёма/распознавания/ревью не меняется; правила
|
||||||
|
ревью-поверхности живут в docs/specs/review-ux.md (ещё не перенесены в
|
||||||
|
OpenSpec). Этот change оформляет уже существующие действия и состояния. -->
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Код:** `internal/httpapi` (роут статики, новые/обновлённые хендлеры рендера,
|
||||||
|
партиалы), `web/web.go` (`go:embed` статики), новые `web/static/**` и
|
||||||
|
`web/templates/{index,review,download}.html`. Превью зовёт `internal/naming`.
|
||||||
|
- **Зависимости:** вендор (htmx + шрифты) добывается `task assets` по манифесту с
|
||||||
|
checksum, не npm/не Go-модуль; Node не вводим. `web/static/vendor/` в gitignore.
|
||||||
|
`go:embed` требует ассеты на сборке, поэтому `task build`/`task run` зависят от
|
||||||
|
`task assets` (идемпотентного) — бинарь остаётся самодостаточным, но голый
|
||||||
|
`go build` без фетча неполноценен (осознанный размен).
|
||||||
|
- **Поведение:** без изменений в API/ядре. Вне scope: живые обновления прогресса
|
||||||
|
(отдельный change `web-live-updates`) и клиентский режим ручной раскладки
|
||||||
|
файл→серия (отложен до реальной необходимости — операция редкая).
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Встроенная отдача статики
|
||||||
|
|
||||||
|
Сервис SHALL отдавать статические ассеты (CSS, клиентский JS, вендорные
|
||||||
|
библиотеки, шрифты) из встроенной (`go:embed`) файловой системы под префиксом
|
||||||
|
`/static/`, без внешних зависимостей времени выполнения. Бинарь остаётся
|
||||||
|
единым и самодостаточным.
|
||||||
|
|
||||||
|
#### Scenario: Запрос встроенного ассета
|
||||||
|
|
||||||
|
- **WHEN** клиент запрашивает `GET /static/css/jellybit.css`
|
||||||
|
- **THEN** сервис отвечает `200` с телом из встроенной FS и корректным
|
||||||
|
`Content-Type`
|
||||||
|
|
||||||
|
#### Scenario: Кэширование статики
|
||||||
|
|
||||||
|
- **WHEN** сервис отдаёт ответ на запрос под `/static/`
|
||||||
|
- **THEN** ответ содержит заголовок `Cache-Control`, разрешающий кэширование
|
||||||
|
ассета браузером
|
||||||
|
|
||||||
|
#### Scenario: Несуществующий ассет
|
||||||
|
|
||||||
|
- **WHEN** клиент запрашивает несуществующий путь под `/static/`
|
||||||
|
- **THEN** сервис отвечает `404` и не раскрывает структуру файловой системы
|
||||||
|
|
||||||
|
### Requirement: Шрифты без внешних сетей
|
||||||
|
|
||||||
|
Веб-UI SHALL подключать шрифты (IBM Plex Sans, IBM Plex Mono) только из
|
||||||
|
локально встроенных файлов через `@font-face`. Страницы MUST NOT обращаться к
|
||||||
|
сторонним хостам (Google Fonts CDN и т. п.) для загрузки шрифтов или стилей.
|
||||||
|
|
||||||
|
#### Scenario: Нет внешних ссылок на шрифты
|
||||||
|
|
||||||
|
- **WHEN** браузер открывает любую страницу веб-UI
|
||||||
|
- **THEN** все используемые шрифты загружаются из-под `/static/` того же
|
||||||
|
origin, без обращений к внешним доменам
|
||||||
|
|
||||||
|
### Requirement: Единая дизайн-система
|
||||||
|
|
||||||
|
Все страницы веб-UI SHALL использовать единственный CSS-файл `jellybit.css` с
|
||||||
|
дизайн-токенами; страницы MUST NOT содержать инлайн-`<style>` или хардкод
|
||||||
|
цветов вне токенов. Тема (светлая/тёмная) SHALL определяться настройкой ОС
|
||||||
|
(`color-scheme` + `prefers-color-scheme`), без отдельного переключателя.
|
||||||
|
|
||||||
|
#### Scenario: Тёмная тема по настройке ОС
|
||||||
|
|
||||||
|
- **WHEN** в ОS пользователя выбрана тёмная тема и открывается страница
|
||||||
|
- **THEN** интерфейс отображается в тёмной палитре из токенов, без действий
|
||||||
|
пользователя
|
||||||
|
|
||||||
|
#### Scenario: Нет инлайн-стилей
|
||||||
|
|
||||||
|
- **WHEN** рендерится любая страница веб-UI
|
||||||
|
- **THEN** оформление берётся из подключённого `jellybit.css`, в разметке нет
|
||||||
|
блоков `<style>` с хардкодом цветов
|
||||||
|
|
||||||
|
### Requirement: Бейдж статуса загрузки
|
||||||
|
|
||||||
|
Веб-UI SHALL отображать состояние каждой загрузки бейджем
|
||||||
|
`<span class="badge st-{STATE}">` с человекочитаемой русской подписью; класс
|
||||||
|
определяет цвет группы. Маппинг SHALL покрывать все состояния домена
|
||||||
|
(`downloading`, `completed`, `recognizing`, `linking`, `review`, `deferred`,
|
||||||
|
`done`, `stuck`, `target_missing`, `failed`, `orphaned`, `cancelled`,
|
||||||
|
`reverted`, `deleted`).
|
||||||
|
|
||||||
|
#### Scenario: Состояние отрисовано бейджем
|
||||||
|
|
||||||
|
- **WHEN** загрузка находится в состоянии `review`
|
||||||
|
- **THEN** в списке у неё бейдж с классом `st-review` и подписью «на ревью»
|
||||||
|
|
||||||
|
#### Scenario: Полнота покрытия
|
||||||
|
|
||||||
|
- **WHEN** загрузка находится в любом из состояний домена
|
||||||
|
- **THEN** для него существует класс бейджа и русская подпись (неизвестных
|
||||||
|
состояний без оформления нет)
|
||||||
|
|
||||||
|
### Requirement: Страницы веб-UI
|
||||||
|
|
||||||
|
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
||||||
|
добавления и фильтром/поиском (`/`), экран ревью одной загрузки (`/review/{id}`)
|
||||||
|
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
||||||
|
файлами→раскладкой и историей. Состояние `deleted` SHALL
|
||||||
|
быть скрыто в списке по умолчанию (с переключателем «показать всё»).
|
||||||
|
(Раздел живой статистики раздачи на странице загрузки — вне scope этого change,
|
||||||
|
вводится вместе с живыми обновлениями.)
|
||||||
|
|
||||||
|
#### Scenario: Просмотр одной загрузки
|
||||||
|
|
||||||
|
- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки
|
||||||
|
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
|
||||||
|
историей
|
||||||
|
|
||||||
|
#### Scenario: Удалённые скрыты по умолчанию
|
||||||
|
|
||||||
|
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать
|
||||||
|
всё» не включён
|
||||||
|
- **THEN** они не отображаются, но доступны при включённом переключателе
|
||||||
|
|
||||||
|
### Requirement: Действия соответствуют состоянию
|
||||||
|
|
||||||
|
Каждая страница SHALL показывать только действия, допустимые в текущем
|
||||||
|
состоянии загрузки, и каждое действие-кнопка SHALL отправлять форму с полями,
|
||||||
|
имена которых совпадают с ожидаемыми обработчиком (`internal/httpapi`).
|
||||||
|
|
||||||
|
#### Scenario: Набор действий по состоянию
|
||||||
|
|
||||||
|
- **WHEN** загрузка в состоянии `done`
|
||||||
|
- **THEN** доступно действие отката (undo), но недоступны «применить»/«позже»
|
||||||
|
|
||||||
|
#### Scenario: Имена полей формы
|
||||||
|
|
||||||
|
- **WHEN** пользователь отправляет форму действия (например, «уточнить»)
|
||||||
|
- **THEN** поля формы имеют `name`, которые обработчик читает без
|
||||||
|
переименования, и действие исполняется
|
||||||
|
|
||||||
|
### Requirement: Превью раскладки через единую логику именования
|
||||||
|
|
||||||
|
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
|
||||||
|
именования, что и реальная раскладка (`internal/naming`), а не дублировать
|
||||||
|
правила в шаблоне. Показанные пути MUST совпадать с теми, что создались бы при
|
||||||
|
применении.
|
||||||
|
|
||||||
|
#### Scenario: Превью совпадает с реальной раскладкой
|
||||||
|
|
||||||
|
- **WHEN** на экране ревью отображается превью целевых путей для текущей
|
||||||
|
догадки
|
||||||
|
- **THEN** эти пути идентичны тем, что создаст применение (те же правила имён,
|
||||||
|
спецвыпусков, мультифайла, запрещённых символов и коллизий)
|
||||||
|
|
||||||
|
### Requirement: Клиентские взаимодействия без сборки
|
||||||
|
|
||||||
|
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
|
||||||
|
фреймворков: копирование infohash (vanilla JS) и раскрытие контекста нативным
|
||||||
|
`<details>`. Все действия над загрузкой SHALL выполняться через формы/htmx
|
||||||
|
(раундтрип на сервер), без клиентского пересчёта доменного состояния.
|
||||||
|
|
||||||
|
#### Scenario: Копирование infohash
|
||||||
|
|
||||||
|
- **WHEN** пользователь нажимает кнопку копирования рядом с infohash
|
||||||
|
- **THEN** значение infohash копируется в буфер обмена без перезагрузки
|
||||||
|
страницы
|
||||||
|
|
||||||
|
#### Scenario: Раскрытие контекста без JS
|
||||||
|
|
||||||
|
- **WHEN** пользователь раскрывает спойлер переданного контекста
|
||||||
|
- **THEN** контекст показывается нативным `<details>`, без скриптов
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
## 1. Статика и встраивание
|
||||||
|
|
||||||
|
- [x] 1.1 Создать структуру `web/static/{css,js,vendor}/`; `web/static/vendor/` в gitignore
|
||||||
|
- [x] 1.2 Шрифты IBM Plex Sans + IBM Plex Mono (cyrillic+latin, нужные начертания) — в манифест, фетч в `web/static/vendor/fonts/`
|
||||||
|
- [x] 1.3 Перенести `jellybit.css` из `tmp/design/` в `web/static/css/`, добавить `@font-face` на `../vendor/fonts/` (убрать ссылки на Google Fonts)
|
||||||
|
- [x] 1.4 Манифест `web/assets.manifest` (путь/URL/sha256) + идемпотентный `task assets` (нет файла или хеш не сошёлся → качаем, иначе с диска); `task build`/`run` зависят от `assets`
|
||||||
|
- [x] 1.5 Вынести `copyHash` в `web/static/js/app.js` (vanilla)
|
||||||
|
- [x] 1.6 Расширить `go:embed` в `web/web.go` на `static/*`
|
||||||
|
- [x] 1.7 Добавить роут `/static/*` (`http.FileServer` + `StripPrefix`) с `Cache-Control`; решить cache-busting CSS (хеш при старте)
|
||||||
|
|
||||||
|
## 2. Общие партиалы
|
||||||
|
|
||||||
|
- [x] 2.1 Партиал `web/templates/partials/header.html` (`.app-header`)
|
||||||
|
- [x] 2.2 Партиал `partials/status_badge.html` — карта `state→class/подпись` для всех 14 состояний (один источник)
|
||||||
|
- [x] 2.3 Партиал `partials/layout_widget.html` — виджет «файл источника → раскладка» (общий для review и download)
|
||||||
|
- [x] 2.4 Перенастроить `template.ParseFS` в `httpapi` на загрузку партиалов; сохранить/дополнить FuncMap
|
||||||
|
|
||||||
|
## 3. Страница списка (index)
|
||||||
|
|
||||||
|
- [x] 3.1 Перенести вёрстку `index.html` из `tmp/design/` в `web/templates/index.html` (карточки `range`, единое окно добавления, фильтр/поиск)
|
||||||
|
- [x] 3.2 Подключить `jellybit.css` и htmx; убрать `<meta http-equiv="refresh">` и инлайн-`<style>`
|
||||||
|
- [x] 3.3 Бейдж статуса через партиал; блок `.card-acts` — условия по `state` (соответствие `downloadView`)
|
||||||
|
- [x] 3.4 `deleted` скрыт по умолчанию + переключатель «показать всё»
|
||||||
|
- [x] 3.5 Проставить `name` полей форм действий под существующие хендлеры; сверить каждое действие по состоянию
|
||||||
|
|
||||||
|
## 4. Страница ревью (review)
|
||||||
|
|
||||||
|
- [x] 4.1 Перенести вёрстку `review.html` (догадка, кандидаты `range`, причины, уточнение, действия)
|
||||||
|
- [x] 4.2 Строка «нейронка» (`.nn-cand`) — обычный кандидат, не особый режим
|
||||||
|
- [x] 4.3 Превью путей через серверное поле `reviewView.Preview` (единая `internal/layout`)
|
||||||
|
- [x] 4.4 Сверить поля форм (`apply`/`refine`/`rerecognize`/`type`/`candidate`/`provider`/`nobase`/`defer`) с хендлерами
|
||||||
|
|
||||||
|
## 5. Страница просмотра (download)
|
||||||
|
|
||||||
|
- [x] 5.1 View-структура `downloadDetailView` (распознавание, файлы, раскладка, раздача, история)
|
||||||
|
- [x] 5.2 Хендлер `GET /download/{id}` + роут; 404 на отсутствующую загрузку
|
||||||
|
- [x] 5.3 Шаблон `web/templates/download.html` (переиспользует партиал виджета и бейдж)
|
||||||
|
- [x] 5.4 Таймлайн истории (`.timeline`) и действия по состоянию
|
||||||
|
|
||||||
|
## 6. Проверка и завершение
|
||||||
|
|
||||||
|
- [x] 6.1 `task lint` и `task test` зелёные
|
||||||
|
- [x] 6.2 Ручная проверка: все действия по каждому состоянию срабатывают, превью на ревью = реальная раскладка
|
||||||
|
- [x] 6.3 Проверить отсутствие внешних сетевых запросов (шрифты/стили только с `/static/`)
|
||||||
|
- [x] 6.4 `openspec validate --strict web-ui-design-port`
|
||||||
|
- [x] 6.5 Ревью кода (до archive) — пройдено; находки MEDIUM-1/2, LOW-2/3 исправлены
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# live-status Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TBD - created by archiving change live-status. Update Purpose after archive.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Снимок живой телеметрии
|
||||||
|
|
||||||
|
Воркер — единственный сэмплер qBittorrent — SHALL на каждом тике поллинга
|
||||||
|
обновлять in-memory снимок телеметрии всех известных раздач. Снимок MUST NOT
|
||||||
|
персиститься в БД: он волатилен и переживает только до рестарта процесса.
|
||||||
|
|
||||||
|
#### Scenario: Обновление снимка на тике
|
||||||
|
|
||||||
|
- **WHEN** воркер завершает успешный тик поллинга qBittorrent
|
||||||
|
- **THEN** снимок телеметрии содержит актуальные данные по каждой раздаче,
|
||||||
|
присутствующей в ответе qBittorrent
|
||||||
|
|
||||||
|
#### Scenario: Снимок волатилен
|
||||||
|
|
||||||
|
- **WHEN** процесс только что перезапущен и первый тик поллинга ещё не прошёл
|
||||||
|
- **THEN** снимок пуст, а UI отображает задачи без живых значений, не падая
|
||||||
|
|
||||||
|
### Requirement: Состав телеметрии
|
||||||
|
|
||||||
|
Телеметрия одной раздачи SHALL включать прогресс (доля 0..1), скорость
|
||||||
|
загрузки и ETA, а для сидирующих раздач дополнительно — рейтинг, число сидов
|
||||||
|
и пиров, объём отданного и скорость отдачи. Значения SHALL извлекаться из
|
||||||
|
ответа qBittorrent `/torrents/info` без дополнительного сетевого вызова.
|
||||||
|
|
||||||
|
#### Scenario: Телеметрия качающейся задачи
|
||||||
|
|
||||||
|
- **WHEN** торрент задачи находится в состоянии загрузки
|
||||||
|
- **THEN** в снимке для неё доступны прогресс, скорость загрузки и ETA
|
||||||
|
|
||||||
|
#### Scenario: Телеметрия сидирующей задачи
|
||||||
|
|
||||||
|
- **WHEN** торрент задачи завершён и раздаётся
|
||||||
|
- **THEN** в снимке для неё доступны рейтинг, число сидов/пиров, объём
|
||||||
|
отданного и скорость отдачи
|
||||||
|
|
||||||
|
### Requirement: Чтение телеметрии транспортом
|
||||||
|
|
||||||
|
Сервис SHALL предоставлять чтение снимка телеметрии по задаче через
|
||||||
|
изолированный контракт, не зависящий от способа доставки в браузер (поллинг
|
||||||
|
сейчас, SSE в будущем). Если для задачи нет записи в снимке (соответствующий
|
||||||
|
торрент отсутствовал в qBittorrent на последнем тике), чтение SHALL сообщать
|
||||||
|
об отсутствии данных, а UI MUST деградировать без живых значений, не падая.
|
||||||
|
|
||||||
|
#### Scenario: Данные есть
|
||||||
|
|
||||||
|
- **WHEN** транспорт читает телеметрию задачи, чей торрент был в последнем тике
|
||||||
|
- **THEN** он получает живые значения этой задачи
|
||||||
|
|
||||||
|
#### Scenario: Данных нет
|
||||||
|
|
||||||
|
- **WHEN** транспорт читает телеметрию задачи, торрента которой нет в qBittorrent
|
||||||
|
- **THEN** он получает признак отсутствия данных и рендерит страницу без живых
|
||||||
|
значений
|
||||||
|
|
||||||
|
### Requirement: Свежесть не выше тика поллинга
|
||||||
|
|
||||||
|
Живые значения, видимые в браузере, SHALL быть не свежее последнего тика
|
||||||
|
поллинга воркера; браузер MUST NOT опрашивать qBittorrent напрямую. Любой
|
||||||
|
запрос UI за телеметрией SHALL обслуживаться из in-memory снимка, не порождая
|
||||||
|
обращения к qBittorrent — поэтому частота обновления UI может быть выбрана
|
||||||
|
свободно (в т.ч. чаще тика для плавности), не нагружая qBittorrent.
|
||||||
|
|
||||||
|
#### Scenario: Браузер не обгоняет воркер
|
||||||
|
|
||||||
|
- **WHEN** браузер запрашивает фрагмент телеметрии чаще, чем длится тик
|
||||||
|
поллинга
|
||||||
|
- **THEN** он получает значения последнего тика, и обращения к qBittorrent при
|
||||||
|
этом не происходит
|
||||||
|
|
||||||
|
### Requirement: Живой прогресс активных загрузок
|
||||||
|
|
||||||
|
Веб-UI SHALL обновлять прогресс активных (downloading) загрузок на главной без
|
||||||
|
перезагрузки страницы — поллингом фрагмента через htmx. Обновление MUST NOT
|
||||||
|
сбрасывать клиентские фильтр, поиск и прокрутку. Когда задача покидает
|
||||||
|
состояние downloading, поллинг её прогресса SHALL прекращаться.
|
||||||
|
|
||||||
|
#### Scenario: Прогресс растёт без перезагрузки
|
||||||
|
|
||||||
|
- **WHEN** загрузка качается и пользователь смотрит на главную
|
||||||
|
- **THEN** её прогресс-бар, скорость и ETA обновляются на месте без
|
||||||
|
перезагрузки страницы
|
||||||
|
|
||||||
|
#### Scenario: Клиентское состояние сохраняется
|
||||||
|
|
||||||
|
- **WHEN** применён фильтр или поиск и происходит фоновое обновление прогресса
|
||||||
|
- **THEN** выбранный фильтр, текст поиска и позиция прокрутки не сбрасываются
|
||||||
|
|
||||||
|
#### Scenario: Завершение останавливает поллинг
|
||||||
|
|
||||||
|
- **WHEN** загрузка переходит из downloading в другое состояние
|
||||||
|
- **THEN** фоновый поллинг прогресса для этой карточки прекращается
|
||||||
|
|
||||||
|
### Requirement: Секция раздачи на странице загрузки
|
||||||
|
|
||||||
|
Страница `/download/{id}` SHALL показывать секцию «Раздача» с живой статистикой
|
||||||
|
(рейтинг, число сидов и пиров, объём отданного, скорость отдачи) для задач,
|
||||||
|
чей торрент сидирует. Если живых данных по задаче нет, секция SHALL
|
||||||
|
отсутствовать либо явно показывать «нет данных», не ломая остальную страницу.
|
||||||
|
|
||||||
|
#### Scenario: Сидирующая задача показывает раздачу
|
||||||
|
|
||||||
|
- **WHEN** открыта страница задачи, торрент которой раздаётся
|
||||||
|
- **THEN** в секции «Раздача» видны рейтинг, сиды/пиры, отдано и скорость отдачи
|
||||||
|
|
||||||
|
#### Scenario: Нет живых данных — секция деградирует
|
||||||
|
|
||||||
|
- **WHEN** открыта страница задачи, торрента которой нет в qBittorrent
|
||||||
|
- **THEN** секция «Раздача» отсутствует или показывает «нет данных», а
|
||||||
|
распознавание, файлы и история отображаются нормально
|
||||||
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# web-ui Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Презентационный слой веб-интерфейса: встроенная (`go:embed`) отдача статики и
|
||||||
|
self-hosted шрифтов, единая дизайн-система (`jellybit.css`, тёмная тема по
|
||||||
|
настройке ОС), рендеринг страниц (список загрузок, ревью, просмотр) с бейджами
|
||||||
|
состояний и клиентскими взаимодействиями без сборки (копирование infohash,
|
||||||
|
спойлер контекста). Превью раскладки берётся из единой логики `internal/layout`,
|
||||||
|
а не дублируется в шаблонах. Тонкий транспорт над ядром (приём — `ingest`,
|
||||||
|
команды — `worker`, чтение — `store`).
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Встроенная отдача статики
|
||||||
|
|
||||||
|
Сервис SHALL отдавать статические ассеты (CSS, клиентский JS, вендорные
|
||||||
|
библиотеки, шрифты) из встроенной (`go:embed`) файловой системы под префиксом
|
||||||
|
`/static/`, без внешних зависимостей времени выполнения. Бинарь остаётся
|
||||||
|
единым и самодостаточным.
|
||||||
|
|
||||||
|
#### Scenario: Запрос встроенного ассета
|
||||||
|
|
||||||
|
- **WHEN** клиент запрашивает `GET /static/css/jellybit.css`
|
||||||
|
- **THEN** сервис отвечает `200` с телом из встроенной FS и корректным
|
||||||
|
`Content-Type`
|
||||||
|
|
||||||
|
#### Scenario: Кэширование статики
|
||||||
|
|
||||||
|
- **WHEN** сервис отдаёт ответ на запрос под `/static/`
|
||||||
|
- **THEN** ответ содержит заголовок `Cache-Control`, разрешающий кэширование
|
||||||
|
ассета браузером
|
||||||
|
|
||||||
|
#### Scenario: Несуществующий ассет
|
||||||
|
|
||||||
|
- **WHEN** клиент запрашивает несуществующий путь под `/static/`
|
||||||
|
- **THEN** сервис отвечает `404` и не раскрывает структуру файловой системы
|
||||||
|
|
||||||
|
### Requirement: Шрифты без внешних сетей
|
||||||
|
|
||||||
|
Веб-UI SHALL подключать шрифты (IBM Plex Sans, IBM Plex Mono) только из
|
||||||
|
локально встроенных файлов через `@font-face`. Страницы MUST NOT обращаться к
|
||||||
|
сторонним хостам (Google Fonts CDN и т. п.) для загрузки шрифтов или стилей.
|
||||||
|
|
||||||
|
#### Scenario: Нет внешних ссылок на шрифты
|
||||||
|
|
||||||
|
- **WHEN** браузер открывает любую страницу веб-UI
|
||||||
|
- **THEN** все используемые шрифты загружаются из-под `/static/` того же
|
||||||
|
origin, без обращений к внешним доменам
|
||||||
|
|
||||||
|
### Requirement: Единая дизайн-система
|
||||||
|
|
||||||
|
Все страницы веб-UI SHALL использовать единственный CSS-файл `jellybit.css` с
|
||||||
|
дизайн-токенами; страницы MUST NOT содержать инлайн-`<style>` или хардкод
|
||||||
|
цветов вне токенов. Тема (светлая/тёмная) SHALL определяться настройкой ОС
|
||||||
|
(`color-scheme` + `prefers-color-scheme`), без отдельного переключателя.
|
||||||
|
|
||||||
|
#### Scenario: Тёмная тема по настройке ОС
|
||||||
|
|
||||||
|
- **WHEN** в ОS пользователя выбрана тёмная тема и открывается страница
|
||||||
|
- **THEN** интерфейс отображается в тёмной палитре из токенов, без действий
|
||||||
|
пользователя
|
||||||
|
|
||||||
|
#### Scenario: Нет инлайн-стилей
|
||||||
|
|
||||||
|
- **WHEN** рендерится любая страница веб-UI
|
||||||
|
- **THEN** оформление берётся из подключённого `jellybit.css`, в разметке нет
|
||||||
|
блоков `<style>` с хардкодом цветов
|
||||||
|
|
||||||
|
### Requirement: Бейдж статуса загрузки
|
||||||
|
|
||||||
|
Веб-UI SHALL отображать состояние каждой загрузки бейджем
|
||||||
|
`<span class="badge st-{STATE}">` с человекочитаемой русской подписью; класс
|
||||||
|
определяет цвет группы. Маппинг SHALL покрывать все состояния домена
|
||||||
|
(`downloading`, `completed`, `recognizing`, `linking`, `review`, `deferred`,
|
||||||
|
`done`, `stuck`, `target_missing`, `failed`, `orphaned`, `cancelled`,
|
||||||
|
`reverted`, `deleted`).
|
||||||
|
|
||||||
|
#### Scenario: Состояние отрисовано бейджем
|
||||||
|
|
||||||
|
- **WHEN** загрузка находится в состоянии `review`
|
||||||
|
- **THEN** в списке у неё бейдж с классом `st-review` и подписью «на ревью»
|
||||||
|
|
||||||
|
#### Scenario: Полнота покрытия
|
||||||
|
|
||||||
|
- **WHEN** загрузка находится в любом из состояний домена
|
||||||
|
- **THEN** для него существует класс бейджа и русская подпись (неизвестных
|
||||||
|
состояний без оформления нет)
|
||||||
|
|
||||||
|
### Requirement: Страницы веб-UI
|
||||||
|
|
||||||
|
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
||||||
|
добавления и фильтром/поиском (`/`), экран ревью одной загрузки (`/review/{id}`)
|
||||||
|
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
||||||
|
файлами→раскладкой, историей и — для сидирующих задач — секцией живой
|
||||||
|
статистики раздачи. Карточки активных (downloading) загрузок в списке SHALL
|
||||||
|
содержать индикатор прогресса. Состояние `deleted` SHALL быть скрыто в списке
|
||||||
|
по умолчанию (с переключателем «показать всё»). Механика живого обновления
|
||||||
|
прогресса и наполнение секции раздачи определяются capability `live-status`.
|
||||||
|
|
||||||
|
#### Scenario: Просмотр одной загрузки
|
||||||
|
|
||||||
|
- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки
|
||||||
|
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
|
||||||
|
историей
|
||||||
|
|
||||||
|
#### Scenario: Прогресс активной загрузки в списке
|
||||||
|
|
||||||
|
- **WHEN** в списке есть загрузка в состоянии `downloading`
|
||||||
|
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
|
||||||
|
и ETA)
|
||||||
|
|
||||||
|
#### Scenario: Удалённые скрыты по умолчанию
|
||||||
|
|
||||||
|
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать
|
||||||
|
всё» не включён
|
||||||
|
- **THEN** они не отображаются, но доступны при включённом переключателе
|
||||||
|
|
||||||
|
### Requirement: Действия соответствуют состоянию
|
||||||
|
|
||||||
|
Каждая страница SHALL показывать только действия, допустимые в текущем
|
||||||
|
состоянии загрузки, и каждое действие-кнопка SHALL отправлять форму с полями,
|
||||||
|
имена которых совпадают с ожидаемыми обработчиком (`internal/httpapi`).
|
||||||
|
|
||||||
|
#### Scenario: Набор действий по состоянию
|
||||||
|
|
||||||
|
- **WHEN** загрузка в состоянии `done`
|
||||||
|
- **THEN** доступно действие отката (undo), но недоступны «применить»/«позже»
|
||||||
|
|
||||||
|
#### Scenario: Имена полей формы
|
||||||
|
|
||||||
|
- **WHEN** пользователь отправляет форму действия (например, «уточнить»)
|
||||||
|
- **THEN** поля формы имеют `name`, которые обработчик читает без
|
||||||
|
переименования, и действие исполняется
|
||||||
|
|
||||||
|
### Requirement: Превью раскладки через единую логику именования
|
||||||
|
|
||||||
|
Превью целевых путей раскладки в веб-UI SHALL вычисляться той же логикой
|
||||||
|
именования, что и реальная раскладка (`internal/naming`), а не дублировать
|
||||||
|
правила в шаблоне. Показанные пути MUST совпадать с теми, что создались бы при
|
||||||
|
применении.
|
||||||
|
|
||||||
|
#### Scenario: Превью совпадает с реальной раскладкой
|
||||||
|
|
||||||
|
- **WHEN** на экране ревью отображается превью целевых путей для текущей
|
||||||
|
догадки
|
||||||
|
- **THEN** эти пути идентичны тем, что создаст применение (те же правила имён,
|
||||||
|
спецвыпусков, мультифайла, запрещённых символов и коллизий)
|
||||||
|
|
||||||
|
### Requirement: Клиентские взаимодействия без сборки
|
||||||
|
|
||||||
|
Веб-UI SHALL реализовывать клиентскую логику без шага сборки и без реактивных
|
||||||
|
фреймворков: копирование infohash (vanilla JS) и раскрытие контекста нативным
|
||||||
|
`<details>`. Все действия над загрузкой SHALL выполняться через формы/htmx
|
||||||
|
(раундтрип на сервер), без клиентского пересчёта доменного состояния.
|
||||||
|
|
||||||
|
#### Scenario: Копирование infohash
|
||||||
|
|
||||||
|
- **WHEN** пользователь нажимает кнопку копирования рядом с infohash
|
||||||
|
- **THEN** значение infohash копируется в буфер обмена без перезагрузки
|
||||||
|
страницы
|
||||||
|
|
||||||
|
#### Scenario: Раскрытие контекста без JS
|
||||||
|
|
||||||
|
- **WHEN** пользователь раскрывает спойлер переданного контекста
|
||||||
|
- **THEN** контекст показывается нативным `<details>`, без скриптов
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Манифест фронтенд-ассетов jellybit — источник истины зависимостей фронта.
|
||||||
|
# Формат строки: <путь под web/static/> <url> <sha256>
|
||||||
|
# Обновление: правим версию/URL → 'task assets' (качает и сверяет sha256).
|
||||||
|
# Вендор в репо не коммитим (gitignore); 'task assets' идемпотентно добывает его
|
||||||
|
# в web/static/vendor/ перед сборкой (нет файла или хеш не сошёлся → качаем).
|
||||||
|
# Версии: htmx 2.0.4, IBM Plex Sans 5.2.8, IBM Plex Mono 5.2.7.
|
||||||
|
vendor/htmx.min.js https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447
|
||||||
|
vendor/fonts/ibm-plex-sans-cyrillic-400.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-cyrillic-400-normal.woff2 69afad88a0740b6e5647f1104c9176a261f1ad9d60202f605456a983799752e9
|
||||||
|
vendor/fonts/ibm-plex-sans-latin-400.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-latin-400-normal.woff2 3b646991d30055a93a4ecc499713d4347953a74a947ecab435ab72070cbdab0e
|
||||||
|
vendor/fonts/ibm-plex-sans-cyrillic-500.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-cyrillic-500-normal.woff2 200be3a2a6b1789fb8824a4a74c7cccb7137e83fde6200ea78d8829f99d43546
|
||||||
|
vendor/fonts/ibm-plex-sans-latin-500.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-latin-500-normal.woff2 0717336fb31fcdcde4b8deb3675bb4a0f7f6d484864afcd6751ac29975962203
|
||||||
|
vendor/fonts/ibm-plex-sans-cyrillic-600.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-cyrillic-600-normal.woff2 730becc6f005c18c31440f14d34677f2704a85e6afffd4bee6a64d5c95f45a55
|
||||||
|
vendor/fonts/ibm-plex-sans-latin-600.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-latin-600-normal.woff2 8960851d691c054ed38e259bdcf1a6190d157b4203ed5bb32c632a863fb8ec2f
|
||||||
|
vendor/fonts/ibm-plex-sans-cyrillic-700.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-cyrillic-700-normal.woff2 40540598b4c9eb2cd2562f1017e71f2697adb43e76f49079030dfd7f00b4b786
|
||||||
|
vendor/fonts/ibm-plex-sans-latin-700.woff2 https://unpkg.com/@fontsource/ibm-plex-sans@5.2.8/files/ibm-plex-sans-latin-700-normal.woff2 42e7b0c143c19df9d99fd896e76b48f846edf0902d200bc29796b34d12c33aa7
|
||||||
|
vendor/fonts/ibm-plex-mono-cyrillic-400.woff2 https://unpkg.com/@fontsource/ibm-plex-mono@5.2.7/files/ibm-plex-mono-cyrillic-400-normal.woff2 7635422bd10ddbbcb0e0caa1154b928298f5c8319b8c00b30978d4395f4783ce
|
||||||
|
vendor/fonts/ibm-plex-mono-latin-400.woff2 https://unpkg.com/@fontsource/ibm-plex-mono@5.2.7/files/ibm-plex-mono-latin-400-normal.woff2 08949f728dc52d528e69b1667d15c89a5686a4ee9a296ff90983985f99c380f7
|
||||||
|
vendor/fonts/ibm-plex-mono-cyrillic-500.woff2 https://unpkg.com/@fontsource/ibm-plex-mono@5.2.7/files/ibm-plex-mono-cyrillic-500-normal.woff2 1d89462e86cb8e87e7e9d6f6635605bd7c3081baca6caaecee84916bf7b60edc
|
||||||
|
vendor/fonts/ibm-plex-mono-latin-500.woff2 https://unpkg.com/@fontsource/ibm-plex-mono@5.2.7/files/ibm-plex-mono-latin-500-normal.woff2 01d285447409c8a588692162439a038b8cbd7871309ee20267b0d2d91c6e8e22
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
/* Шрифты IBM Plex — self-hosted (без внешних CDN). Файлы: web/static/fonts/,
|
||||||
|
добываются 'task assets' по web/assets.manifest. Субсеты cyrillic+latin. */
|
||||||
|
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-cyrillic-400.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||||
|
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-latin-400.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||||
|
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-cyrillic-500.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||||
|
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-latin-500.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||||
|
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-cyrillic-600.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||||
|
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-latin-600.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||||
|
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:700;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-cyrillic-700.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||||
|
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:700;font-display:swap;src:url(../vendor/fonts/ibm-plex-sans-latin-700.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||||
|
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(../vendor/fonts/ibm-plex-mono-cyrillic-400.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||||
|
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(../vendor/fonts/ibm-plex-mono-latin-400.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||||
|
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(../vendor/fonts/ibm-plex-mono-cyrillic-500.woff2) format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
|
||||||
|
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(../vendor/fonts/ibm-plex-mono-latin-500.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
jellybit — единый CSS. Переносится в Go html/template как один файл.
|
||||||
|
Тема: спокойный сине-голубой акцент, средняя плотность,
|
||||||
|
IBM Plex Sans + IBM Plex Mono, light/dark по настройке ОС.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- 1. Токены ---------- */
|
||||||
|
:root{
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
--font: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
--mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
|
||||||
|
|
||||||
|
/* поверхности и текст (light) */
|
||||||
|
--bg:#f4f6f8;
|
||||||
|
--surface:#ffffff;
|
||||||
|
--surface-2:#eef1f5;
|
||||||
|
--surface-3:#e6eaf0;
|
||||||
|
--border:#e2e6ec;
|
||||||
|
--border-strong:#d2d8e0;
|
||||||
|
--text:#1a1f26;
|
||||||
|
--text-dim:#5d6772;
|
||||||
|
--text-faint:#8a939e;
|
||||||
|
|
||||||
|
/* акцент */
|
||||||
|
--accent:#2f6fed;
|
||||||
|
--accent-hover:#235fd8;
|
||||||
|
--accent-weak:#e7f0fe;
|
||||||
|
--accent-border:#bcd4fb;
|
||||||
|
--on-accent:#ffffff;
|
||||||
|
|
||||||
|
/* семантика статусов (5 групп) */
|
||||||
|
--st-prog:#2f6fed; --st-prog-bg:#e7f0fe; /* в работе: качается / скачано */
|
||||||
|
--st-recog:#7c5cdb; --st-recog-bg:#efeafc; /* распознаётся */
|
||||||
|
--st-link:#0d9488; --st-link-bg:#dcf5f1; /* раскладка */
|
||||||
|
--st-wait:#c98a04; --st-wait-bg:#fdf3d7; /* ждёт человека: ревью / отложено */
|
||||||
|
--st-ok:#1f9d57; --st-ok-bg:#dcf4e6; /* успех: готово */
|
||||||
|
--st-warn:#d97315; --st-warn-bg:#fdeede; /* проблема: застряло / нет цели */
|
||||||
|
--st-err:#d8443c; --st-err-bg:#fce4e2; /* ошибка: failed / потеряно */
|
||||||
|
--st-mute:#6b7280; --st-mute-bg:#eceef1; /* терминальное: отменено/откат/удалено */
|
||||||
|
|
||||||
|
/* радиусы и тени */
|
||||||
|
--r-sm:6px; --r-md:9px; --r-lg:14px; --r-pill:999px;
|
||||||
|
--shadow-sm:0 1px 2px rgba(20,30,50,.06);
|
||||||
|
--shadow-md:0 3px 12px rgba(20,30,50,.10);
|
||||||
|
--shadow-lg:0 8px 28px rgba(20,30,50,.16);
|
||||||
|
|
||||||
|
/* шкала отступов */
|
||||||
|
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px; --sp-7:48px;
|
||||||
|
|
||||||
|
/* типографика */
|
||||||
|
--fs-xs:12px; --fs-sm:13px; --fs-base:15px; --fs-md:16px; --fs-lg:20px; --fs-xl:24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark){
|
||||||
|
:root{
|
||||||
|
--bg:#121519;
|
||||||
|
--surface:#1a1e24;
|
||||||
|
--surface-2:#22272e;
|
||||||
|
--surface-3:#2a3038;
|
||||||
|
--border:#2b323a;
|
||||||
|
--border-strong:#3a424c;
|
||||||
|
--text:#e6e9ed;
|
||||||
|
--text-dim:#9aa4b0;
|
||||||
|
--text-faint:#6b7682;
|
||||||
|
|
||||||
|
--accent:#5b8def;
|
||||||
|
--accent-hover:#6f9cf3;
|
||||||
|
--accent-weak:#1a2a45;
|
||||||
|
--accent-border:#33507f;
|
||||||
|
--on-accent:#0e1420;
|
||||||
|
|
||||||
|
--st-prog:#6fa0f4; --st-prog-bg:#172843;
|
||||||
|
--st-recog:#a791ea; --st-recog-bg:#251f3c;
|
||||||
|
--st-link:#36c2b0; --st-link-bg:#0f2e2b;
|
||||||
|
--st-wait:#e0b13c; --st-wait-bg:#352a10;
|
||||||
|
--st-ok:#46c47e; --st-ok-bg:#13311f;
|
||||||
|
--st-warn:#ec9a4e; --st-warn-bg:#352213;
|
||||||
|
--st-err:#ef6a62; --st-err-bg:#37201e;
|
||||||
|
--st-mute:#8b95a1; --st-mute-bg:#262b31;
|
||||||
|
|
||||||
|
--shadow-sm:0 1px 2px rgba(0,0,0,.3);
|
||||||
|
--shadow-md:0 3px 12px rgba(0,0,0,.4);
|
||||||
|
--shadow-lg:0 8px 28px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 2. База ---------- */
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html{-webkit-text-size-adjust:100%}
|
||||||
|
body{
|
||||||
|
margin:0;background:var(--bg);color:var(--text);
|
||||||
|
font-family:var(--font);font-size:var(--fs-base);line-height:1.5;
|
||||||
|
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||||
|
}
|
||||||
|
a{color:var(--accent);text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}
|
||||||
|
.mono{font-family:var(--mono)}
|
||||||
|
.muted{color:var(--text-dim)}
|
||||||
|
.faint{color:var(--text-faint)}
|
||||||
|
.nowrap{white-space:nowrap}
|
||||||
|
|
||||||
|
/* ---------- 3. Layout + шапка ---------- */
|
||||||
|
.app-header{
|
||||||
|
position:sticky;top:0;z-index:20;
|
||||||
|
background:color-mix(in srgb,var(--surface) 88%,transparent);
|
||||||
|
backdrop-filter:saturate(1.4) blur(10px);
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.app-header .bar{
|
||||||
|
max-width:980px;margin:0 auto;padding:0 var(--sp-4);
|
||||||
|
display:flex;align-items:center;gap:var(--sp-4);height:56px;
|
||||||
|
}
|
||||||
|
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:var(--fs-md);
|
||||||
|
letter-spacing:-.01em;color:var(--text)}
|
||||||
|
.brand:hover{text-decoration:none}
|
||||||
|
.brand .logo{width:22px;height:22px;border-radius:6px;background:var(--accent);
|
||||||
|
display:grid;place-items:center;color:var(--on-accent);font-size:13px;flex:none}
|
||||||
|
.nav{display:flex;gap:2px;margin-left:var(--sp-2)}
|
||||||
|
.nav a{padding:6px 11px;border-radius:var(--r-sm);font-size:var(--fs-sm);font-weight:500;
|
||||||
|
color:var(--text-dim)}
|
||||||
|
.nav a:hover{background:var(--surface-2);color:var(--text);text-decoration:none}
|
||||||
|
.nav a.active{background:var(--accent-weak);color:var(--accent)}
|
||||||
|
.header-spacer{flex:1}
|
||||||
|
|
||||||
|
.dep-badge{display:inline-flex;align-items:center;gap:6px;font-size:var(--fs-xs);font-weight:500;
|
||||||
|
padding:4px 10px;border-radius:var(--r-pill);background:var(--st-err-bg);color:var(--st-err);
|
||||||
|
border:1px solid color-mix(in srgb,var(--st-err) 30%,transparent)}
|
||||||
|
.dep-badge::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
|
||||||
|
.dep-badge.ok{background:var(--st-ok-bg);color:var(--st-ok);
|
||||||
|
border-color:color-mix(in srgb,var(--st-ok) 30%,transparent)}
|
||||||
|
|
||||||
|
.main{max-width:980px;margin:0 auto;padding:var(--sp-5) var(--sp-4) var(--sp-7)}
|
||||||
|
.page-title{font-size:var(--fs-xl);font-weight:700;letter-spacing:-.02em;margin:0 0 var(--sp-1)}
|
||||||
|
.page-sub{color:var(--text-dim);margin:0 0 var(--sp-5)}
|
||||||
|
|
||||||
|
/* ---------- 4. Кнопки ---------- */
|
||||||
|
.btn{
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
||||||
|
font:inherit;font-weight:600;font-size:var(--fs-sm);line-height:1;
|
||||||
|
padding:9px 15px;border-radius:var(--r-md);
|
||||||
|
border:1px solid var(--border-strong);background:var(--surface);color:var(--text);
|
||||||
|
cursor:pointer;white-space:nowrap;transition:background .12s,border-color .12s,opacity .12s;
|
||||||
|
}
|
||||||
|
.btn:hover{background:var(--surface-2)}
|
||||||
|
.btn:focus-visible{outline:none;box-shadow:0 0 0 3px var(--accent-weak);border-color:var(--accent)}
|
||||||
|
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.btn-primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
|
||||||
|
.btn-primary:hover{background:var(--accent-hover)}
|
||||||
|
.btn-ghost{background:transparent;border-color:transparent;color:var(--text-dim)}
|
||||||
|
.btn-ghost:hover{background:var(--surface-2);color:var(--text)}
|
||||||
|
.btn-danger{color:var(--st-err);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)}
|
||||||
|
.btn-danger:hover{background:var(--st-err-bg)}
|
||||||
|
.btn-sm{padding:6px 11px;font-size:var(--fs-xs);font-weight:500}
|
||||||
|
.btn-lg{padding:11px 20px;font-size:var(--fs-base)}
|
||||||
|
.btn-block{width:100%}
|
||||||
|
|
||||||
|
/* ---------- 5. Поля ввода ---------- */
|
||||||
|
.input,.textarea,.select{
|
||||||
|
font:inherit;width:100%;color:var(--text);background:var(--surface);
|
||||||
|
border:1px solid var(--border-strong);border-radius:var(--r-md);
|
||||||
|
padding:9px 12px;transition:border-color .12s,box-shadow .12s;
|
||||||
|
}
|
||||||
|
.textarea{resize:vertical;min-height:64px;line-height:1.5}
|
||||||
|
.input::placeholder,.textarea::placeholder{color:var(--text-faint)}
|
||||||
|
.input:focus,.textarea:focus,.select:focus{
|
||||||
|
outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-weak);
|
||||||
|
}
|
||||||
|
.input.mono,.field-mono{font-family:var(--mono);font-size:var(--fs-sm)}
|
||||||
|
.label{display:block;font-size:var(--fs-xs);font-weight:600;color:var(--text-dim);
|
||||||
|
margin-bottom:5px;text-transform:uppercase;letter-spacing:.04em}
|
||||||
|
.field{margin-bottom:var(--sp-3)}
|
||||||
|
|
||||||
|
/* единое окно добавления */
|
||||||
|
.add-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
|
||||||
|
padding:var(--sp-4);box-shadow:var(--shadow-sm);margin-bottom:var(--sp-5)}
|
||||||
|
.add-row{display:flex;gap:var(--sp-2)}
|
||||||
|
.add-row .input{flex:1}
|
||||||
|
.add-hint{margin:9px 2px 0;font-size:var(--fs-xs);color:var(--text-faint)}
|
||||||
|
|
||||||
|
/* ---------- 6. Карточка загрузки ---------- */
|
||||||
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
|
||||||
|
padding:var(--sp-4) var(--sp-5);box-shadow:var(--shadow-sm)}
|
||||||
|
.card + .card{margin-top:var(--sp-3)}
|
||||||
|
.card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:var(--sp-3)}
|
||||||
|
.card-title{font-weight:600;font-size:var(--fs-md);line-height:1.3;letter-spacing:-.005em;
|
||||||
|
word-break:break-word}
|
||||||
|
.hash-row{display:flex;align-items:center;gap:8px;margin-top:7px;color:var(--text-dim);
|
||||||
|
font-size:var(--fs-sm);flex-wrap:wrap}
|
||||||
|
.hash{font-family:var(--mono);font-size:var(--fs-sm)}
|
||||||
|
.copy-btn{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--border);
|
||||||
|
background:var(--surface-2);border-radius:var(--r-sm);cursor:pointer;font:inherit;
|
||||||
|
font-size:var(--fs-xs);padding:2px 8px;color:var(--text-dim);transition:background .12s}
|
||||||
|
.copy-btn:hover{background:var(--surface-3);color:var(--text)}
|
||||||
|
.copy-btn.copied{color:var(--st-ok);border-color:color-mix(in srgb,var(--st-ok) 35%,transparent)}
|
||||||
|
.card-foot{display:flex;justify-content:space-between;align-items:center;gap:var(--sp-3);
|
||||||
|
margin-top:var(--sp-4);flex-wrap:wrap}
|
||||||
|
.card-meta{font-size:var(--fs-sm);color:var(--text-faint)}
|
||||||
|
.card-acts{display:flex;gap:var(--sp-2);flex-wrap:wrap}
|
||||||
|
|
||||||
|
/* прогресс */
|
||||||
|
.progress{height:5px;background:var(--surface-2);border-radius:3px;overflow:hidden;margin-top:10px}
|
||||||
|
.progress i{display:block;height:100%;background:var(--st-prog);border-radius:3px;transition:width .3s}
|
||||||
|
.dl-live:empty{display:none}
|
||||||
|
.dl-stats{margin-top:6px;font-size:var(--fs-xs);color:var(--text-dim);font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
/* ---------- 7. Спойлер ---------- */
|
||||||
|
details.spoiler{margin-top:var(--sp-3)}
|
||||||
|
.spoiler summary{cursor:pointer;color:var(--accent);font-size:var(--fs-sm);
|
||||||
|
list-style:none;display:inline-flex;align-items:center;gap:5px;user-select:none}
|
||||||
|
.spoiler summary::-webkit-details-marker{display:none}
|
||||||
|
.spoiler summary::before{content:"▸";font-size:10px;transition:transform .12s}
|
||||||
|
.spoiler[open] summary::before{transform:rotate(90deg)}
|
||||||
|
.spoiler .spoiler-body{margin-top:9px;padding:11px 13px;background:var(--surface-2);
|
||||||
|
border:1px solid var(--border);border-radius:var(--r-sm);font-size:var(--fs-sm);
|
||||||
|
color:var(--text-dim);line-height:1.55}
|
||||||
|
|
||||||
|
/* ---------- 8. Бейдж состояния ---------- */
|
||||||
|
.badge{display:inline-flex;align-items:center;gap:6px;font-size:var(--fs-xs);font-weight:500;
|
||||||
|
padding:3px 10px 3px 9px;border-radius:var(--r-pill);border:1px solid transparent;
|
||||||
|
white-space:nowrap;line-height:1.5}
|
||||||
|
.badge::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--_c);flex:none}
|
||||||
|
.badge{color:var(--text);background:var(--_bg);border-color:color-mix(in srgb,var(--_c) 30%,transparent)}
|
||||||
|
.st-downloading,.st-completed{--_c:var(--st-prog);--_bg:var(--st-prog-bg)}
|
||||||
|
.st-recognizing{--_c:var(--st-recog);--_bg:var(--st-recog-bg)}
|
||||||
|
.st-linking{--_c:var(--st-link);--_bg:var(--st-link-bg)}
|
||||||
|
.st-review,.st-deferred{--_c:var(--st-wait);--_bg:var(--st-wait-bg)}
|
||||||
|
.st-done{--_c:var(--st-ok);--_bg:var(--st-ok-bg)}
|
||||||
|
.st-stuck,.st-target_missing{--_c:var(--st-warn);--_bg:var(--st-warn-bg)}
|
||||||
|
.st-failed,.st-orphaned{--_c:var(--st-err);--_bg:var(--st-err-bg)}
|
||||||
|
.st-cancelled,.st-reverted,.st-deleted{--_c:var(--st-mute);--_bg:var(--st-mute-bg)}
|
||||||
|
|
||||||
|
/* ---------- 9. Панель фильтров ---------- */
|
||||||
|
.toolbar{display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-4);flex-wrap:wrap}
|
||||||
|
.search{position:relative;flex:1;min-width:180px}
|
||||||
|
.search .input{padding-left:34px}
|
||||||
|
.search .ico{position:absolute;left:11px;top:50%;transform:translateY(-50%);
|
||||||
|
color:var(--text-faint);font-size:14px;pointer-events:none}
|
||||||
|
.filter-chips{display:flex;gap:6px;flex-wrap:wrap}
|
||||||
|
.chip{font:inherit;font-size:var(--fs-xs);font-weight:500;padding:6px 11px;border-radius:var(--r-pill);
|
||||||
|
border:1px solid var(--border-strong);background:var(--surface);color:var(--text-dim);
|
||||||
|
cursor:pointer;transition:all .12s}
|
||||||
|
.chip:hover{background:var(--surface-2);color:var(--text)}
|
||||||
|
.chip.active{background:var(--accent-weak);color:var(--accent);border-color:var(--accent-border)}
|
||||||
|
.chip .n{opacity:.7;margin-left:3px}
|
||||||
|
.toggle-line{display:flex;align-items:center;gap:8px;font-size:var(--fs-sm);color:var(--text-dim);
|
||||||
|
margin-bottom:var(--sp-4)}
|
||||||
|
.toggle-line label{display:inline-flex;align-items:center;gap:7px;cursor:pointer}
|
||||||
|
|
||||||
|
/* пагинация */
|
||||||
|
.pager{display:flex;align-items:center;justify-content:center;gap:var(--sp-2);
|
||||||
|
margin-top:var(--sp-5);font-size:var(--fs-sm);color:var(--text-dim)}
|
||||||
|
.pager .btn{min-width:36px}
|
||||||
|
|
||||||
|
/* ---------- 10. Секция ревью ---------- */
|
||||||
|
.section{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
|
||||||
|
padding:var(--sp-5);margin-bottom:var(--sp-4);box-shadow:var(--shadow-sm)}
|
||||||
|
.section-head{display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-4)}
|
||||||
|
.section-head h2{font-size:var(--fs-md);font-weight:600;margin:0;letter-spacing:-.005em}
|
||||||
|
.section-head .hint{font-size:var(--fs-xs);color:var(--text-faint);font-weight:400}
|
||||||
|
|
||||||
|
/* шапка ревью */
|
||||||
|
.review-head{display:flex;justify-content:space-between;align-items:flex-start;gap:var(--sp-4);
|
||||||
|
flex-wrap:wrap;margin-bottom:var(--sp-2)}
|
||||||
|
.review-title{font-size:var(--fs-lg);font-weight:700;letter-spacing:-.01em;
|
||||||
|
font-family:var(--mono);word-break:break-word}
|
||||||
|
.reason-line{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:var(--fs-sm);
|
||||||
|
color:var(--text-dim);margin-top:var(--sp-1)}
|
||||||
|
.reason-tag{background:var(--st-wait-bg);color:var(--st-wait);padding:2px 9px;border-radius:var(--r-pill);
|
||||||
|
font-size:var(--fs-xs);font-weight:500;border:1px solid color-mix(in srgb,var(--st-wait) 30%,transparent)}
|
||||||
|
.conf{display:inline-flex;align-items:center;gap:7px}
|
||||||
|
.conf-bar{width:84px;height:6px;border-radius:3px;background:var(--surface-3);overflow:hidden}
|
||||||
|
.conf-bar i{display:block;height:100%;border-radius:3px;background:var(--st-wait)}
|
||||||
|
|
||||||
|
/* тип: фильм/сериал — сегменты */
|
||||||
|
.segment{display:inline-flex;border:1px solid var(--border-strong);border-radius:var(--r-md);
|
||||||
|
overflow:hidden;background:var(--surface)}
|
||||||
|
.segment label{padding:7px 16px;font-size:var(--fs-sm);font-weight:500;cursor:pointer;
|
||||||
|
color:var(--text-dim);border-right:1px solid var(--border)}
|
||||||
|
.segment label:last-child{border-right:none}
|
||||||
|
.segment input{position:absolute;opacity:0;pointer-events:none}
|
||||||
|
.segment input:checked + label,.segment label.on{background:var(--accent);color:var(--on-accent)}
|
||||||
|
|
||||||
|
/* сетка полей догадки */
|
||||||
|
.guess-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--sp-3)}
|
||||||
|
|
||||||
|
/* ---------- 11. Кандидаты метабаз ---------- */
|
||||||
|
.cand-list{display:flex;flex-direction:column;gap:var(--sp-2)}
|
||||||
|
.cand{display:flex;align-items:center;gap:var(--sp-3);padding:11px var(--sp-4);
|
||||||
|
border:1px solid var(--border-strong);border-radius:var(--r-md);background:var(--surface);
|
||||||
|
cursor:pointer;transition:border-color .12s,background .12s}
|
||||||
|
.cand:hover{background:var(--surface-2)}
|
||||||
|
.cand.selected{border-color:var(--accent);background:var(--accent-weak);
|
||||||
|
box-shadow:0 0 0 1px var(--accent) inset}
|
||||||
|
.cand .radio{width:18px;height:18px;border-radius:50%;border:2px solid var(--border-strong);
|
||||||
|
flex:none;display:grid;place-items:center;transition:border-color .12s}
|
||||||
|
.cand.selected .radio{border-color:var(--accent)}
|
||||||
|
.cand.selected .radio::after{content:"";width:9px;height:9px;border-radius:50%;background:var(--accent)}
|
||||||
|
.cand .body{flex:1;min-width:0}
|
||||||
|
.cand .name{font-weight:600;font-size:var(--fs-sm);display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||||
|
.cand .sub{font-size:var(--fs-xs);color:var(--text-faint);margin-top:2px;font-family:var(--mono)}
|
||||||
|
.prov{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;
|
||||||
|
padding:2px 7px;border-radius:var(--r-sm);background:var(--surface-3);color:var(--text-dim)}
|
||||||
|
.prov.tmdb{background:#0d253f;color:#5cc8a8}
|
||||||
|
.prov.tvdb{background:#1a4029;color:#7fd99f}
|
||||||
|
.prov.nn{background:var(--st-recog-bg);color:var(--st-recog)}
|
||||||
|
.cand.nn-cand{border-style:dashed}
|
||||||
|
.cand.nn-cand.selected{border-style:solid}
|
||||||
|
.ext-link{font-size:var(--fs-xs);color:var(--accent);white-space:nowrap}
|
||||||
|
|
||||||
|
/* ручной ввод / без базы */
|
||||||
|
.cand-extra{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap;align-items:center}
|
||||||
|
.cand-extra .input{flex:1;min-width:120px}
|
||||||
|
|
||||||
|
/* ---------- 12. Таблицы ---------- */
|
||||||
|
.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:var(--r-md)}
|
||||||
|
table.tbl{width:100%;border-collapse:collapse;font-size:var(--fs-sm);min-width:560px}
|
||||||
|
table.tbl th{text-align:left;font-weight:600;font-size:var(--fs-xs);text-transform:uppercase;
|
||||||
|
letter-spacing:.04em;color:var(--text-dim);padding:9px var(--sp-3);
|
||||||
|
border-bottom:1px solid var(--border);background:var(--surface-2);white-space:nowrap}
|
||||||
|
table.tbl td{padding:8px var(--sp-3);border-bottom:1px solid var(--border);vertical-align:middle}
|
||||||
|
table.tbl tr:last-child td{border-bottom:none}
|
||||||
|
table.tbl tr.ignored td{opacity:.45}
|
||||||
|
table.tbl tr.invalid{background:var(--st-err-bg)}
|
||||||
|
table.tbl .fname{font-family:var(--mono);font-size:var(--fs-xs);word-break:break-all}
|
||||||
|
table.tbl .link-to{display:flex;align-items:baseline;gap:6px;margin-top:5px;
|
||||||
|
font-family:var(--mono);font-size:var(--fs-xs);flex-wrap:wrap}
|
||||||
|
table.tbl .link-to .arr{color:var(--text-faint);flex:none}
|
||||||
|
table.tbl .link-to .dir{color:var(--text-faint)}
|
||||||
|
table.tbl .link-to .src{color:var(--st-ok)}
|
||||||
|
table.tbl .link-to .no-link{color:var(--text-faint);font-style:italic;font-family:var(--font)}
|
||||||
|
table.tbl .se-edit{display:inline-flex;align-items:center;gap:4px;margin-right:6px;
|
||||||
|
font-family:var(--font);color:var(--text-faint);font-size:var(--fs-xs)}
|
||||||
|
table.tbl .se-edit .se-input{width:42px}
|
||||||
|
table.tbl .size{font-family:var(--mono);color:var(--text-dim);white-space:nowrap;text-align:right}
|
||||||
|
.cell-num{width:30px;color:var(--text-faint);font-family:var(--mono);text-align:right}
|
||||||
|
.se-input{width:46px;font-family:var(--mono);text-align:center;padding:5px 6px;
|
||||||
|
border:1px solid var(--border-strong);border-radius:var(--r-sm);background:var(--surface);
|
||||||
|
color:var(--text);font-size:var(--fs-sm)}
|
||||||
|
.se-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-weak)}
|
||||||
|
.role-select{font:inherit;font-size:var(--fs-xs);padding:4px 6px;border-radius:var(--r-sm);
|
||||||
|
border:1px solid var(--border-strong);background:var(--surface);color:var(--text)}
|
||||||
|
.tbl-actions{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap;align-items:center}
|
||||||
|
.validation{font-size:var(--fs-xs);margin-top:var(--sp-2);display:flex;gap:var(--sp-3);flex-wrap:wrap}
|
||||||
|
.validation .ok{color:var(--st-ok)}
|
||||||
|
.validation .bad{color:var(--st-err)}
|
||||||
|
|
||||||
|
/* ---------- 13. Превью раскладки ---------- */
|
||||||
|
.preview-list{font-family:var(--mono);font-size:var(--fs-sm);line-height:1.9;
|
||||||
|
background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r-md);
|
||||||
|
padding:var(--sp-3) var(--sp-4);overflow-x:auto}
|
||||||
|
.preview-list .pth{color:var(--text)}
|
||||||
|
.preview-list .pth .dir{color:var(--text-faint)}
|
||||||
|
.preview-list .pth .src{color:var(--st-ok)}
|
||||||
|
.preview-list .ref{color:var(--text-faint)}
|
||||||
|
.preview-empty{color:var(--text-faint);font-style:italic;font-family:var(--font)}
|
||||||
|
|
||||||
|
/* ---------- 14. Уточнение ---------- */
|
||||||
|
.hint-list{display:flex;flex-direction:column;gap:var(--sp-2);margin-bottom:var(--sp-3)}
|
||||||
|
.hint-item{display:flex;gap:9px;align-items:flex-start;font-size:var(--fs-sm);
|
||||||
|
padding:9px 12px;background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r-sm)}
|
||||||
|
.hint-item .idx{font-family:var(--mono);font-size:var(--fs-xs);color:var(--text-faint);flex:none}
|
||||||
|
.hint-item .txt{flex:1}
|
||||||
|
.hint-item time{font-size:var(--fs-xs);color:var(--text-faint);white-space:nowrap}
|
||||||
|
.refine-row{display:flex;gap:var(--sp-2);margin-top:var(--sp-3);flex-wrap:wrap}
|
||||||
|
|
||||||
|
/* ---------- 15. Нижняя панель действий ---------- */
|
||||||
|
.action-bar{position:sticky;bottom:0;z-index:10;margin-top:var(--sp-5);
|
||||||
|
background:color-mix(in srgb,var(--surface) 92%,transparent);
|
||||||
|
backdrop-filter:saturate(1.4) blur(10px);
|
||||||
|
border:1px solid var(--border);border-radius:var(--r-lg);
|
||||||
|
padding:var(--sp-3) var(--sp-4);box-shadow:var(--shadow-md);
|
||||||
|
display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap}
|
||||||
|
.action-bar .grow{flex:1;font-size:var(--fs-sm);color:var(--text-dim);min-width:140px}
|
||||||
|
.action-bar .btn-lg{padding:11px 22px}
|
||||||
|
|
||||||
|
/* плашка результата после применения */
|
||||||
|
.result-banner{background:var(--st-ok-bg);border:1px solid color-mix(in srgb,var(--st-ok) 35%,transparent);
|
||||||
|
border-radius:var(--r-lg);padding:var(--sp-4) var(--sp-5);margin-bottom:var(--sp-4)}
|
||||||
|
.result-banner h2{margin:0 0 var(--sp-2);font-size:var(--fs-md);color:var(--st-ok);
|
||||||
|
display:flex;align-items:center;gap:8px}
|
||||||
|
.result-banner ul{margin:0;padding-left:18px;font-family:var(--mono);font-size:var(--fs-sm);
|
||||||
|
color:var(--text);line-height:1.9}
|
||||||
|
|
||||||
|
/* ---------- 16. Прочее ---------- */
|
||||||
|
.empty-state{text-align:center;padding:var(--sp-7) var(--sp-4);color:var(--text-faint)}
|
||||||
|
.empty-state .big{font-size:32px;margin-bottom:var(--sp-3)}
|
||||||
|
.divider{height:1px;background:var(--border);margin:var(--sp-4) 0;border:none}
|
||||||
|
.back-link{display:inline-flex;align-items:center;gap:6px;font-size:var(--fs-sm);
|
||||||
|
color:var(--text-dim);margin-bottom:var(--sp-3)}
|
||||||
|
.back-link:hover{color:var(--accent)}
|
||||||
|
|
||||||
|
/* ---------- 17. Страница загрузки ---------- */
|
||||||
|
.detail-grid{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:var(--sp-4);align-items:start}
|
||||||
|
|
||||||
|
/* распознано как — карточка с постером */
|
||||||
|
.meta-card{display:flex;gap:var(--sp-4);align-items:flex-start}
|
||||||
|
.poster{flex:none;width:104px;height:156px;border-radius:var(--r-md);overflow:hidden;
|
||||||
|
border:1px solid var(--border);
|
||||||
|
background-image:repeating-linear-gradient(135deg,var(--surface-2) 0 9px,var(--surface-3) 9px 18px);
|
||||||
|
display:grid;place-items:center}
|
||||||
|
.poster span{font-family:var(--mono);font-size:10px;color:var(--text-faint);
|
||||||
|
background:var(--surface);padding:2px 6px;border-radius:4px}
|
||||||
|
.meta-body{flex:1;min-width:0}
|
||||||
|
.meta-body .h{font-size:var(--fs-lg);font-weight:700;letter-spacing:-.01em;margin:0 0 2px;line-height:1.2}
|
||||||
|
.meta-body .o{font-family:var(--mono);font-size:var(--fs-sm);color:var(--text-faint);margin:0 0 var(--sp-3)}
|
||||||
|
|
||||||
|
/* key-value список */
|
||||||
|
.kv{display:grid;grid-template-columns:auto 1fr;gap:7px var(--sp-4);font-size:var(--fs-sm);margin:0}
|
||||||
|
.kv dt{color:var(--text-faint);white-space:nowrap}
|
||||||
|
.kv dd{margin:0;color:var(--text);text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
.kv dd.mono{font-family:var(--mono)}
|
||||||
|
|
||||||
|
/* плитки статов */
|
||||||
|
.stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:var(--sp-3)}
|
||||||
|
.stat{background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r-md);
|
||||||
|
padding:var(--sp-3) var(--sp-4)}
|
||||||
|
.stat .v{font-size:var(--fs-lg);font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
|
||||||
|
.stat .v.mono{font-family:var(--mono);font-size:var(--fs-md)}
|
||||||
|
.stat .k{font-size:var(--fs-xs);color:var(--text-faint);margin-top:2px}
|
||||||
|
.stat .v.good{color:var(--st-ok)}
|
||||||
|
|
||||||
|
/* таймлайн истории состояний */
|
||||||
|
.timeline{position:relative;margin:0;padding:0;list-style:none}
|
||||||
|
.timeline::before{content:"";position:absolute;left:6px;top:6px;bottom:8px;width:2px;background:var(--border)}
|
||||||
|
.tl-item{position:relative;padding:0 0 var(--sp-4) var(--sp-5);font-size:var(--fs-sm)}
|
||||||
|
.tl-item:last-child{padding-bottom:0}
|
||||||
|
.tl-item::before{content:"";position:absolute;left:0;top:4px;width:14px;height:14px;border-radius:50%;
|
||||||
|
background:var(--surface);border:2px solid var(--border-strong)}
|
||||||
|
.tl-item.is-done::before{background:var(--st-ok);border-color:var(--st-ok)}
|
||||||
|
.tl-item.is-now::before{background:var(--accent);border-color:var(--accent);
|
||||||
|
box-shadow:0 0 0 4px var(--accent-weak)}
|
||||||
|
.tl-item .tl-t{font-weight:600;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||||
|
.tl-item time{color:var(--text-faint);font-size:var(--fs-xs);font-variant-numeric:tabular-nums}
|
||||||
|
.tl-item .tl-note{color:var(--text-dim);margin-top:2px}
|
||||||
|
|
||||||
|
/* ---------- 18. Адаптив ---------- */
|
||||||
|
@media (max-width:640px){
|
||||||
|
.main{padding:var(--sp-4) var(--sp-3) var(--sp-6)}
|
||||||
|
.page-title{font-size:var(--fs-lg)}
|
||||||
|
.add-row{flex-direction:column}
|
||||||
|
.add-row .btn{width:100%}
|
||||||
|
.card{padding:var(--sp-4)}
|
||||||
|
.card-top{flex-direction:column}
|
||||||
|
.review-head{flex-direction:column}
|
||||||
|
.section{padding:var(--sp-4)}
|
||||||
|
.action-bar{flex-direction:column;align-items:stretch}
|
||||||
|
.action-bar .btn{width:100%}
|
||||||
|
.action-bar .grow{text-align:center;order:-1}
|
||||||
|
.nav a{padding:6px 9px}
|
||||||
|
.toolbar{flex-direction:column;align-items:stretch}
|
||||||
|
.detail-grid{grid-template-columns:1fr}
|
||||||
|
.meta-card{flex-direction:column}
|
||||||
|
.poster{width:88px;height:132px}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* доступность фокуса */
|
||||||
|
:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||||
|
.copy-btn:focus-visible,.chip:focus-visible{outline-offset:2px}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Клиентская мелочь без сборки и фреймворков.
|
||||||
|
// htmx тянет серверные фрагменты, здесь — только то, что серверу знать не нужно.
|
||||||
|
|
||||||
|
// Копирование infohash в буфер обмена. На целевом деплое (домашний LAN по HTTP)
|
||||||
|
// navigator.clipboard недоступен — secure context только на HTTPS/localhost,
|
||||||
|
// поэтому есть fallback на execCommand, а индикация честная: «✓» лишь при успехе.
|
||||||
|
function copyHash(btn, full) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(full).then(
|
||||||
|
() => flashCopy(btn, true),
|
||||||
|
() => flashCopy(btn, legacyCopy(full)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
flashCopy(btn, legacyCopy(full));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyCopy(text) {
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashCopy(btn, ok) {
|
||||||
|
const old = btn.dataset.label || btn.textContent;
|
||||||
|
btn.dataset.label = old;
|
||||||
|
btn.textContent = ok ? '✓ скопировано' : '✗ не вышло';
|
||||||
|
btn.classList.toggle('copied', ok);
|
||||||
|
setTimeout(() => { btn.textContent = old; btn.classList.remove('copied'); }, 1400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Фильтр/поиск по списку загрузок (по уже отрисованным карточкам) ---
|
||||||
|
|
||||||
|
let activeFilter = 'all';
|
||||||
|
|
||||||
|
function setFilter(el) {
|
||||||
|
document.querySelectorAll('#chips .chip').forEach((c) => c.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
activeFilter = el.dataset.f;
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
const searchEl = document.querySelector('#list-toolbar input[type=search]');
|
||||||
|
const q = (searchEl ? searchEl.value : '').toLowerCase().trim();
|
||||||
|
const showAllEl = document.getElementById('showAll');
|
||||||
|
const showAll = showAllEl ? showAllEl.checked : false;
|
||||||
|
let visible = 0;
|
||||||
|
document.querySelectorAll('#list .card').forEach((card) => {
|
||||||
|
const grp = card.dataset.group;
|
||||||
|
const isDeleted = card.dataset.deleted === '1';
|
||||||
|
let ok = true;
|
||||||
|
if (isDeleted && !showAll) ok = false;
|
||||||
|
if (ok && activeFilter !== 'all' && grp !== activeFilter) ok = false;
|
||||||
|
if (ok && q && !(card.dataset.text || '').includes(q)) ok = false;
|
||||||
|
card.style.display = ok ? '' : 'none';
|
||||||
|
if (ok) visible += 1;
|
||||||
|
});
|
||||||
|
const empty = document.getElementById('empty');
|
||||||
|
if (empty) empty.style.display = visible ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применить начальный фильтр (скрыть удалённые) после загрузки.
|
||||||
|
document.addEventListener('DOMContentLoaded', applyFilters);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>jellybit — загрузка #{{.ID}}</title>
|
||||||
|
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
|
||||||
|
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||||
|
<script src="{{asset "js/app.js"}}" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{template "header" ""}}
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<a class="back-link" href="/">← ко всем загрузкам</a>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||||
|
|
||||||
|
<!-- Шапка -->
|
||||||
|
<div class="review-head" style="margin-bottom:var(--sp-4)">
|
||||||
|
<div>
|
||||||
|
<div class="review-title" style="font-size:var(--fs-xl)">{{.Title}}</div>
|
||||||
|
<div class="hash-row" style="margin-top:9px">
|
||||||
|
{{template "status_badge" .State}}
|
||||||
|
{{if .Infohash}}<span class="hash">{{.InfohashShort}}</span>
|
||||||
|
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.Infohash}}')">⧉ копировать</button>{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .Note}}<p class="muted" style="margin:var(--sp-2) 0 0">{{.Note}}</p>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="card-acts" style="align-items:center">
|
||||||
|
{{if .Reviewable}}<a class="btn btn-primary" href="/review/{{.ID}}">Ревью →</a>{{end}}
|
||||||
|
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo"><button class="btn btn-danger" type="submit">↩ Откатить</button></form>{{end}}
|
||||||
|
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink"><button class="btn" type="submit">Привязать заново</button></form>{{end}}
|
||||||
|
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry"><button class="btn" type="submit">Повторить</button></form>{{end}}
|
||||||
|
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-ghost" type="submit">Отменить</button></form>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Распознано как -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>Распознано как</h2></div>
|
||||||
|
{{if .HasPlan}}
|
||||||
|
<div class="meta-card">
|
||||||
|
<div class="meta-body">
|
||||||
|
<p class="h">{{.RecTitle}}</p>
|
||||||
|
{{if .OriginalTitle}}<p class="o">{{.OriginalTitle}}</p>{{end}}
|
||||||
|
<dl class="kv" style="max-width:360px">
|
||||||
|
<dt>Тип</dt><dd>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</dd>
|
||||||
|
{{if .Year}}<dt>Год</dt><dd>{{.Year}}</dd>{{end}}
|
||||||
|
<dt>База</dt><dd>{{if .Provider}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{else if .NoBase}}без базы{{else}}—{{end}}</dd>
|
||||||
|
{{if .Confidence}}<dt>Уверенность</dt><dd class="mono">{{.Confidence}}</dd>{{end}}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted" style="margin:0">Ещё не распознано.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Файлы и раскладка -->
|
||||||
|
{{if .HasPlan}}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>Файлы и раскладка</h2><span class="hint">файл источника → целевой хардлинк</span></div>
|
||||||
|
{{template "layout_widget" .Files}}
|
||||||
|
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
|
||||||
|
Целевые файлы связаны хардлинками — место не дублируется, исходник остаётся на раздаче.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Раздача (живая статистика; секция деградирует, если торрент не сидирует) -->
|
||||||
|
{{template "seeding" .Seeding}}
|
||||||
|
|
||||||
|
<!-- История -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>История</h2></div>
|
||||||
|
<ul class="timeline">
|
||||||
|
{{if .CreatedAt}}<li class="tl-item is-done"><div class="tl-t">Добавлено</div><time>{{.CreatedAt}}</time></li>{{end}}
|
||||||
|
<li class="tl-item is-now"><div class="tl-t">{{badgeLabel .State}} <span class="badge st-{{.State}}">текущее</span></div>{{if .UpdatedAt}}<time>{{.UpdatedAt}}</time>{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Контекст -->
|
||||||
|
{{if .Context}}
|
||||||
|
<div class="section" style="margin-bottom:0">
|
||||||
|
<div class="section-head"><h2>Контекст</h2><span class="hint">сообщение при добавлении</span></div>
|
||||||
|
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+87
-83
@@ -3,98 +3,102 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="refresh" content="5">
|
<title>jellybit — загрузки</title>
|
||||||
<title>jellybit</title>
|
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
|
||||||
<style>
|
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||||
:root { color-scheme: light dark; }
|
<script src="{{asset "js/app.js"}}" defer></script>
|
||||||
body { font-family: system-ui, sans-serif; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; }
|
|
||||||
h1 { margin-bottom: .25rem; }
|
|
||||||
form.add { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1rem 0; }
|
|
||||||
form.add input[name=source] { flex: 1 1 24rem; }
|
|
||||||
input, button { padding: .4rem .6rem; font-size: 1rem; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
|
||||||
th, td { text-align: left; padding: .4rem .5rem; border-bottom: 1px solid #8884; vertical-align: top; }
|
|
||||||
td.src { font-family: monospace; font-size: .85rem; word-break: break-all; }
|
|
||||||
.err { color: #c0392b; }
|
|
||||||
.state { font-size: .8rem; padding: .1rem .5rem; border-radius: 1rem; background: #8883; white-space: nowrap; }
|
|
||||||
.state-completed { background: #2ecc7155; }
|
|
||||||
.state-downloading { background: #3498db55; }
|
|
||||||
.state-recognizing { background: #9b59b655; }
|
|
||||||
.state-review { background: #f1c40f88; }
|
|
||||||
.state-deferred { background: #f39c1255; }
|
|
||||||
.state-linking { background: #1abc9c55; }
|
|
||||||
.state-done { background: #2ecc7188; }
|
|
||||||
.state-stuck { background: #f39c1255; }
|
|
||||||
.state-failed { background: #e74c3c55; }
|
|
||||||
.state-cancelled { background: #95a5a655; }
|
|
||||||
.state-reverted { background: #95a5a655; }
|
|
||||||
.state-target_missing { background: #e67e2255; }
|
|
||||||
.state-orphaned { background: #e74c3c88; }
|
|
||||||
.state-deleted { background: #95a5a655; }
|
|
||||||
.actions { display: flex; gap: .4rem; flex-wrap: wrap; }
|
|
||||||
a.button { display: inline-block; padding: .35rem .6rem; border: 1px solid #8886; border-radius: .3rem; text-decoration: none; }
|
|
||||||
small { color: #8888; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>jellybit</h1>
|
|
||||||
<p><small>обновляется каждые 5 с</small></p>
|
|
||||||
|
|
||||||
<form class="add" method="post" action="/ui/downloads">
|
{{template "header" "downloads"}}
|
||||||
<input type="text" name="source" placeholder="magnet:?xt=urn:btih:…" required>
|
|
||||||
<input type="text" name="context" placeholder="контекст (опц.)">
|
<main class="main">
|
||||||
<button type="submit">Добавить</button>
|
<h1 class="page-title">Загрузки</h1>
|
||||||
|
<p class="page-sub">Кинь magnet, ссылку, .torrent или сообщение бота — остальное jellybit сделает сам.</p>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||||
|
|
||||||
|
<!-- Единое окно добавления -->
|
||||||
|
<form class="add-box" method="post" action="/ui/downloads">
|
||||||
|
<div class="add-row">
|
||||||
|
<input class="input" type="text" name="source" autocomplete="off" required
|
||||||
|
placeholder="magnet:?xt=urn:btih:… · ссылка · .torrent · или вставь текст бота">
|
||||||
|
<button class="btn btn-primary btn-lg" type="submit">Добавить</button>
|
||||||
|
</div>
|
||||||
|
<p class="add-hint">Можно вставить целиком сообщение трекер-бота — magnet и контекст разберутся автоматически.</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
{{if .Downloads}}
|
||||||
|
<!-- Панель фильтров -->
|
||||||
|
<div class="toolbar" id="list-toolbar">
|
||||||
|
<div class="search">
|
||||||
|
<span class="ico">⌕</span>
|
||||||
|
<input class="input" type="search" placeholder="Поиск по названию или infohash…" oninput="applyFilters()">
|
||||||
|
</div>
|
||||||
|
<div class="filter-chips" id="chips">
|
||||||
|
<button class="chip active" type="button" data-f="all" onclick="setFilter(this)">Все</button>
|
||||||
|
<button class="chip" type="button" data-f="review" onclick="setFilter(this)">Ждут меня</button>
|
||||||
|
<button class="chip" type="button" data-f="active" onclick="setFilter(this)">В работе</button>
|
||||||
|
<button class="chip" type="button" data-f="done" onclick="setFilter(this)">Готово</button>
|
||||||
|
<button class="chip" type="button" data-f="problem" onclick="setFilter(this)">Проблемы</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table>
|
<div class="toggle-line">
|
||||||
<thead>
|
<label><input type="checkbox" id="showAll" onchange="applyFilters()"> Показать всё, включая удалённые</label>
|
||||||
<tr><th>#</th><th>Источник</th><th>Контекст</th><th>Состояние</th><th></th></tr>
|
</div>
|
||||||
</thead>
|
|
||||||
<tbody>
|
<!-- Список карточек -->
|
||||||
|
<div id="list">
|
||||||
{{range .Downloads}}
|
{{range .Downloads}}
|
||||||
<tr>
|
<article class="card" data-group="{{.Group}}" data-text="{{.SearchText}}"{{if .Deleted}} data-deleted="1"{{end}}>
|
||||||
<td>{{.ID}}</td>
|
<div class="card-top">
|
||||||
<td class="src" title="{{.Infohash}}">{{.Source}}</td>
|
<div>
|
||||||
<td>{{.Context}}</td>
|
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
||||||
<td>
|
{{if .Infohash}}
|
||||||
<span class="state state-{{.State}}">{{.State}}</span>
|
<div class="hash-row">
|
||||||
{{if .Note}}<br><small>{{.Note}}</small>{{end}}
|
<span class="hash">{{.InfohashShort}}</span>
|
||||||
{{if .Error}}<br><small>{{.Error}}</small>{{end}}
|
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.Infohash}}')">⧉ копировать</button>
|
||||||
</td>
|
</div>
|
||||||
<td>
|
|
||||||
<div class="actions">
|
|
||||||
{{if .Reviewable}}
|
|
||||||
<a class="button" href="/review/{{.ID}}">Ревью →</a>
|
|
||||||
{{end}}
|
|
||||||
{{if .Undoable}}
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/undo">
|
|
||||||
<button type="submit">Откатить</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
{{if .Relinkable}}
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/relink">
|
|
||||||
<button type="submit">Привязать заново</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
{{if .Retriable}}
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/retry">
|
|
||||||
<button type="submit">Повторить</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
{{if not .Terminal}}
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel">
|
|
||||||
<button type="submit">Отклонить</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
{{template "status_badge" .State}}
|
||||||
</tr>
|
</div>
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="5"><small>пока пусто</small></td></tr>
|
{{if .IsDownloading}}{{template "progress" .Progress}}{{end}}
|
||||||
|
|
||||||
|
{{if .Context}}
|
||||||
|
<details class="spoiler">
|
||||||
|
<summary>контекст</summary>
|
||||||
|
<div class="spoiler-body">«{{.Context}}»</div>
|
||||||
|
</details>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
|
||||||
</table>
|
<div class="card-foot">
|
||||||
|
<span class="card-meta">{{if .Note}}{{.Note}}{{else if .Error}}{{.Error}}{{end}}</span>
|
||||||
|
<div class="card-acts">
|
||||||
|
{{if .Reviewable}}<a class="btn btn-primary btn-sm" href="/review/{{.ID}}">Ревью →</a>{{end}}
|
||||||
|
{{if .Undoable}}<form method="post" action="/ui/downloads/{{.ID}}/undo"><button class="btn btn-sm btn-ghost" type="submit">Откатить</button></form>{{end}}
|
||||||
|
{{if .Relinkable}}<form method="post" action="/ui/downloads/{{.ID}}/relink"><button class="btn btn-sm" type="submit">Привязать заново</button></form>{{end}}
|
||||||
|
{{if .Retriable}}<form method="post" action="/ui/downloads/{{.ID}}/retry"><button class="btn btn-sm" type="submit">Повторить</button></form>{{end}}
|
||||||
|
{{if not .Terminal}}<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-sm btn-danger" type="submit">{{if .Reviewable}}Отклонить{{else}}Отменить{{end}}</button></form>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" id="empty" style="display:none">
|
||||||
|
<div class="big">🍿</div>
|
||||||
|
Ничего не найдено. Поменяй фильтр или запрос.
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="big">🍿</div>
|
||||||
|
Пока пусто. Добавь первую загрузку выше.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{define "header"}}
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="bar">
|
||||||
|
<a class="brand" href="/"><span class="logo">jb</span>jellybit</a>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/"{{if eq . "downloads"}} class="active"{{end}}>Загрузки</a>
|
||||||
|
</nav>
|
||||||
|
<span class="header-spacer"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{define "layout_widget"}}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead><tr><th class="cell-num">#</th><th>файл источника → раскладка</th><th>роль</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $i, $f := .}}
|
||||||
|
<tr class="{{if $f.Ignored}}ignored{{end}}">
|
||||||
|
<td class="cell-num">{{add $i 1}}</td>
|
||||||
|
<td>
|
||||||
|
<div class="fname">{{$f.Src}}</div>
|
||||||
|
<div class="link-to">
|
||||||
|
<span class="arr">→</span>
|
||||||
|
{{if $f.Linked}}<span class="src">{{$f.Dst}}</span>{{else}}<span class="no-link">не разложен</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="badge {{if $f.Ignored}}st-cancelled{{else}}st-done{{end}}">{{$f.RoleLabel}}</span></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{define "progress"}}<div class="dl-live" id="dl-live-{{.ID}}"{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress" hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>{{if .Active}}
|
||||||
|
<div class="progress"><i style="width:{{.Percent}}%"></i></div>
|
||||||
|
{{if .Has}}<div class="dl-stats">{{.Percent}}% · ↓ {{.DlSpeed}}{{if .ETA}} · осталось {{.ETA}}{{end}}</div>{{end}}
|
||||||
|
{{end}}</div>{{end}}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{{define "seeding"}}{{if .Has}}<div class="section" id="seeding-{{.ID}}" hx-get="/fragments/downloads/{{.ID}}/seeding" hx-trigger="every 3s" hx-swap="outerHTML">
|
||||||
|
<div class="section-head"><h2>Раздача</h2><span class="hint">qBittorrent · источник продолжает раздаваться</span></div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat"><div class="v good">{{.Percent}}%</div><div class="k">скачано</div></div>
|
||||||
|
<div class="stat"><div class="v mono">{{.Ratio}}</div><div class="k">рейтинг</div></div>
|
||||||
|
<div class="stat"><div class="v mono">{{.Uploaded}}</div><div class="k">отдано</div></div>
|
||||||
|
<div class="stat"><div class="v mono">{{.Seeds}} / {{.Peers}}</div><div class="k">сиды / пиры</div></div>
|
||||||
|
<div class="stat"><div class="v mono">↑ {{.UpSpeed}}</div><div class="k">сейчас</div></div>
|
||||||
|
</div>
|
||||||
|
</div>{{else}}<div id="seeding-{{.ID}}"></div>{{end}}{{end}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{define "status_badge"}}<span class="badge st-{{.}}">{{badgeLabel .}}</span>{{end}}
|
||||||
+127
-145
@@ -3,169 +3,151 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>jellybit · ревью #{{.ID}}</title>
|
<title>jellybit — ревью #{{.ID}}</title>
|
||||||
<style>
|
<link rel="stylesheet" href="{{asset "css/jellybit.css"}}">
|
||||||
:root { color-scheme: light dark; }
|
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
|
||||||
body { font-family: system-ui, sans-serif; max-width: 70rem; margin: 2rem auto; padding: 0 1rem; }
|
<script src="{{asset "js/app.js"}}" defer></script>
|
||||||
h1 { margin-bottom: .25rem; }
|
|
||||||
a { color: inherit; }
|
|
||||||
input, button, select, textarea { padding: .4rem .6rem; font-size: 1rem; font-family: inherit; }
|
|
||||||
table { width: 100%; border-collapse: collapse; margin: .5rem 0 1rem; }
|
|
||||||
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid #8884; vertical-align: top; }
|
|
||||||
td.src, li.path { font-family: monospace; font-size: .85rem; word-break: break-all; }
|
|
||||||
.err { color: #c0392b; }
|
|
||||||
.state { font-size: .8rem; padding: .1rem .5rem; border-radius: 1rem; background: #8883; }
|
|
||||||
.state-review { background: #f1c40f88; }
|
|
||||||
.state-recognizing { background: #9b59b655; }
|
|
||||||
.state-deferred { background: #f39c1255; }
|
|
||||||
section { border: 1px solid #8883; border-radius: .5rem; padding: .75rem 1rem; margin: 1rem 0; }
|
|
||||||
.reasons li { color: #b9770e; }
|
|
||||||
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
|
||||||
.actions { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1rem; }
|
|
||||||
.actions form { display: inline; }
|
|
||||||
.ignored td { opacity: .5; text-decoration: line-through; }
|
|
||||||
ul.preview { margin: 0; padding-left: 1.2rem; }
|
|
||||||
small { color: #8888; }
|
|
||||||
textarea { width: 100%; box-sizing: border-box; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<p><a href="/">← к списку</a></p>
|
|
||||||
<h1>Ревью #{{.ID}} <span class="state state-{{.State}}">{{.State}}</span></h1>
|
|
||||||
<p class="src"><small>{{.Source}}</small></p>
|
|
||||||
{{if .Context}}<p>Контекст: «{{.Context}}»</p>{{end}}
|
|
||||||
|
|
||||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
{{template "header" ""}}
|
||||||
{{if .StateError}}<p class="err">{{.StateError}}</p>{{end}}
|
|
||||||
|
<main class="main">
|
||||||
|
<a class="back-link" href="/">← ко всем загрузкам</a>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.Error}}</h2></div>{{end}}
|
||||||
|
{{if .StateError}}<div class="result-banner" style="background:var(--st-err-bg);border-color:color-mix(in srgb,var(--st-err) 35%,transparent)"><h2 style="color:var(--st-err)">{{.StateError}}</h2></div>{{end}}
|
||||||
|
|
||||||
|
<!-- Шапка ревью -->
|
||||||
|
<div class="review-head">
|
||||||
|
<div>
|
||||||
|
<div class="review-title">{{.Source}}</div>
|
||||||
|
<div class="reason-line">
|
||||||
|
{{template "status_badge" .State}}
|
||||||
|
{{if .Reasons}}<span>Причины: {{range $i, $r := .Reasons}}{{if $i}} · {{end}}<b>{{$r}}</b>{{end}}</span>{{end}}
|
||||||
|
{{if .Confidence}}<span class="conf">уверенность {{.Confidence}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if eq .State "recognizing"}}
|
{{if eq .State "recognizing"}}
|
||||||
<p>⏳ Идёт распознавание, обновите страницу через несколько секунд…</p>
|
<div class="section">
|
||||||
<p><a href="/review/{{.ID}}">Обновить</a></p>
|
<p style="margin:0">⏳ Идёт распознавание — обновите страницу через несколько секунд.</p>
|
||||||
|
<p style="margin:var(--sp-2) 0 0"><a href="/review/{{.ID}}">Обновить</a></p>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Reasons}}
|
<!-- Контекст -->
|
||||||
<section>
|
{{if .Context}}
|
||||||
<strong>Причины ревью</strong>{{if .Confidence}} · уверенность {{.Confidence}}{{end}}
|
<div class="section">
|
||||||
<ul class="reasons">{{range .Reasons}}<li>{{.}}</li>{{end}}</ul>
|
<div class="section-head"><h2>Контекст</h2><span class="hint">исходное сообщение при добавлении</span></div>
|
||||||
</section>
|
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .HasPlan}}
|
{{if .HasPlan}}
|
||||||
<section>
|
<!-- Догадка -->
|
||||||
<strong>Догадка</strong>
|
<div class="section">
|
||||||
<p class="row">
|
<div class="section-head"><h2>Догадка</h2></div>
|
||||||
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
|
<div style="margin-bottom:var(--sp-4)">
|
||||||
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
|
<span class="label">Тип</span>
|
||||||
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
|
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type" style="display:flex;gap:var(--sp-2);align-items:center">
|
||||||
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{else if .NoBase}}· База: <b>без базы</b>{{end}}
|
<button class="btn btn-sm{{if not .IsSeries}} btn-primary{{end}}" name="type" value="movie"{{if not .IsSeries}} disabled{{end}}>фильм</button>
|
||||||
</p>
|
<button class="btn btn-sm{{if .IsSeries}} btn-primary{{end}}" name="type" value="series"{{if .IsSeries}} disabled{{end}}>сериал</button>
|
||||||
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
|
<span class="hint">переключение пересоберёт план</span>
|
||||||
Переключить тип:
|
|
||||||
<button name="type" value="movie" {{if not .IsSeries}}disabled{{end}}>фильм</button>
|
|
||||||
<button name="type" value="series" {{if .IsSeries}}disabled{{end}}>сериал</button>
|
|
||||||
<small>(пересоберёт план)</small>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
|
<div class="guess-grid">
|
||||||
|
<div class="field"><span class="label">Название</span><div>{{.Title}}</div></div>
|
||||||
|
{{if .OriginalTitle}}<div class="field"><span class="label">Ориг. название</span><div class="mono">{{.OriginalTitle}}</div></div>{{end}}
|
||||||
|
{{if .Year}}<div class="field"><span class="label">Год</span><div>{{.Year}}</div></div>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section>
|
<!-- Источник совпадения -->
|
||||||
<strong>База метаданных</strong>
|
<div class="section">
|
||||||
{{if .Provider}}<p>Выбрано: <b>{{.Provider}}</b> {{.ProviderID}}</p>
|
<div class="section-head"><h2>Источник совпадения</h2><span class="hint">выбери базу или оставь распознавание нейронкой</span></div>
|
||||||
{{else if .NoBase}}<p>Выбрано: <b>без базы</b> (тег папки не ставится)</p>
|
|
||||||
{{else}}<p><small>Матч не подтверждён — выберите кандидата, введите id или «без базы».</small></p>{{end}}
|
{{if .Provider}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>{{.Provider}}</b> <span class="mono">{{.ProviderID}}</span></p>
|
||||||
|
{{else if .NoBase}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>без базы</b> <span class="faint">(тег папки не ставится)</span></p>
|
||||||
|
{{else}}<p style="margin:0 0 var(--sp-3)"><span class="faint">Матч не подтверждён — выбери кандидата, введи id или «без базы».</span></p>{{end}}
|
||||||
|
|
||||||
{{if .Candidates}}
|
{{if .Candidates}}
|
||||||
<table>
|
<div class="cand-list">
|
||||||
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th>ссылка</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range .Candidates}}
|
{{range .Candidates}}
|
||||||
<tr>
|
<div class="cand{{if .Chosen}} selected{{end}}">
|
||||||
<td>{{.Provider}}</td>
|
<span class="radio"></span>
|
||||||
<td>{{.Title}}</td>
|
<div class="body">
|
||||||
<td>{{if .Year}}{{.Year}}{{end}}</td>
|
<div class="name"><span class="prov {{.Provider}}">{{.Provider}}</span> {{.Title}}{{if .Year}} <span class="muted">· {{.Year}}</span>{{end}}</div>
|
||||||
<td class="src">{{.ProviderID}}</td>
|
<div class="sub">id {{.ProviderID}}</div>
|
||||||
<td>{{if .URL}}<a href="{{.URL}}" target="_blank" rel="noopener">{{.Provider}}</a>{{end}}</td>
|
</div>
|
||||||
<td>
|
{{if .URL}}<a class="ext-link" href="{{.URL}}" target="_blank" rel="noopener">ссылка ↗</a>{{end}}
|
||||||
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
|
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
|
||||||
<input type="hidden" name="candidate_id" value="{{.ID}}">
|
<input type="hidden" name="candidate_id" value="{{.ID}}">
|
||||||
<button type="submit" {{if .Chosen}}disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
|
<button class="btn btn-sm{{if not .Chosen}} btn-primary{{end}}" type="submit"{{if .Chosen}} disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<form class="row" method="post" action="/ui/downloads/{{.ID}}/provider">
|
|
||||||
Вручную:
|
|
||||||
<select name="provider">
|
|
||||||
<option value="tvdb">tvdb</option>
|
|
||||||
<option value="tmdb">tmdb</option>
|
|
||||||
<option value="imdb">imdb</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" name="provider_id" placeholder="id (напр. 269613)" required>
|
|
||||||
<button type="submit">задать id</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:.4rem">
|
|
||||||
<button type="submit">Без базы</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<strong>Файлы → роль</strong>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>#</th><th>файл</th><th>роль</th><th>S</th><th>E</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{range $i, $f := .Files}}
|
|
||||||
<tr {{if $f.Ignored}}class="ignored"{{end}}>
|
|
||||||
<td>{{add $i 1}}</td>
|
|
||||||
<td class="src">{{$f.Src}}</td>
|
|
||||||
<td>{{$f.Role}}</td>
|
|
||||||
<td>{{$f.Season}}</td>
|
|
||||||
<td>{{$f.Episode}}</td>
|
|
||||||
<td>
|
|
||||||
{{if not $f.Ignored}}
|
|
||||||
<form method="post" action="/ui/downloads/{{$.ID}}/ignore">
|
|
||||||
<input type="hidden" name="src" value="{{$f.Src}}">
|
|
||||||
<button type="submit">игнор</button>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{if .Preview}}
|
|
||||||
<section>
|
|
||||||
<strong>Превью раскладки</strong> <small>(будут созданы хардлинки)</small>
|
|
||||||
<ul class="preview">{{range .Preview}}<li class="path">{{.}}</li>{{end}}</ul>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<strong>Уточнить и перераспознать</strong>
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/refine">
|
|
||||||
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
|
|
||||||
<button type="submit">🔁 Уточнить</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
|
|
||||||
<button type="submit">🔄 Распознать заново</button>
|
|
||||||
<small>(без новой подсказки — по уже накопленному контексту)</small>
|
|
||||||
</form>
|
|
||||||
{{if .Hints}}
|
|
||||||
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
|
|
||||||
{{end}}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
{{if .Preview}}
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/apply"><button type="submit">✅ Применить</button></form>
|
|
||||||
{{end}}
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button type="submit">🕗 Позже</button></form>
|
|
||||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button type="submit">❌ Отклонить</button></form>
|
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form class="cand-extra" method="post" action="/ui/downloads/{{.ID}}/provider">
|
||||||
|
<select class="select" name="provider" style="max-width:120px">
|
||||||
|
<option value="tmdb">TMDB</option>
|
||||||
|
<option value="tvdb">TVDB</option>
|
||||||
|
<option value="imdb">IMDb</option>
|
||||||
|
</select>
|
||||||
|
<input class="input mono" name="provider_id" placeholder="ввести id вручную, напр. 60622" required>
|
||||||
|
<button class="btn btn-sm" type="submit">Задать id</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:var(--sp-2)">
|
||||||
|
<button class="btn btn-sm btn-ghost" type="submit">Без базы</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Раскладка -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Раскладка</h2>
|
||||||
|
<span class="hint">файл источника → целевой хардлинк</span>
|
||||||
|
</div>
|
||||||
|
{{template "layout_widget" .Files}}
|
||||||
|
<p class="faint" style="font-size:var(--fs-xs);margin:var(--sp-3) 0 0">
|
||||||
|
Буквальные целевые пути. Применяются как хардлинки — исходник остаётся на раздаче.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Уточнить и перераспознать -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-head"><h2>Уточнить и перераспознать</h2></div>
|
||||||
|
{{if .Hints}}
|
||||||
|
<div class="hint-list">
|
||||||
|
{{range $i, $h := .Hints}}
|
||||||
|
<div class="hint-item"><span class="idx">#{{add $i 1}}</span><span class="txt">{{$h}}</span></div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/refine">
|
||||||
|
<textarea class="textarea" name="hint" placeholder="Например: «это Fargo, год сериала 2014, не 2015» или «sample.mkv не считать эпизодом»"></textarea>
|
||||||
|
<div class="refine-row">
|
||||||
|
<button class="btn btn-primary btn-sm" type="submit">🔁 Уточнить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:var(--sp-2)">
|
||||||
|
<button class="btn btn-sm" type="submit">🔄 Распознать заново</button>
|
||||||
|
<span class="hint">без новой подсказки — по уже накопленному контексту</span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Панель действий -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<span class="grow">{{if .HasLinks}}Превью готово — можно применять.{{else}}Подтверди источник, чтобы получить превью раскладки.{{end}}</span>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/defer"><button class="btn btn-lg" type="submit">🕗 Позже</button></form>
|
||||||
|
<form method="post" action="/ui/downloads/{{.ID}}/cancel"><button class="btn btn-lg btn-danger" type="submit">❌ Отклонить</button></form>
|
||||||
|
{{if .HasLinks}}<form method="post" action="/ui/downloads/{{.ID}}/apply"><button class="btn btn-lg btn-primary" type="submit">✅ Применить</button></form>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+8
-3
@@ -1,9 +1,14 @@
|
|||||||
// Package web несёт встроенные (embed) ресурсы веб-UI: HTML-шаблоны.
|
// Package web несёт встроенные (embed) ресурсы веб-UI: HTML-шаблоны и статику
|
||||||
|
// (CSS, JS, шрифты, вендорные библиотеки). Бинарь самодостаточен — внешних
|
||||||
|
// ресурсов времени выполнения нет.
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
// FS — встроенные шаблоны (templates/*.html).
|
// FS — встроенные ресурсы: templates/** (шаблоны и партиалы) и static/**
|
||||||
|
// (авторские css/js + вендор htmx/шрифты под static/vendor). Вендор в репо не
|
||||||
|
// хранится (gitignore); его добывает `task assets` по web/assets.manifest перед
|
||||||
|
// сборкой, поэтому `task build`/`run` зависят от `assets`.
|
||||||
//
|
//
|
||||||
//go:embed templates/*.html
|
//go:embed templates static
|
||||||
var FS embed.FS
|
var FS embed.FS
|
||||||
|
|||||||
Reference in New Issue
Block a user