Files
jellybit/internal/httpapi/download.go
T
avandClaude Opus 4.8 ef75a0d302 Живые обновления прогресса и раздел «Раздача» (live-status)
Воркер ведёт in-memory снимок телеметрии раздач (прогресс, скорость, ETA,
рейтинг, сиды/пиры, отдано) под отдельным RWMutex, обновляя его на каждом
тике поллинга сразу после построения byHash — без лишних вызовов qBittorrent
и без хранения в БД (волатильно). qbt.Torrent дополнен полями телеметрии.

Веб-UI читает снимок через узкий контракт LiveStatus: карточки активных
загрузок показывают живой прогресс-бар (htmx-поллинг фрагмента every 3s,
точечно — без сброса фильтров), на странице загрузки появилась секция
«Раздача» для сидирующих задач. Начальный кадр рендерится сразу со
значениями; при отсутствии данных UI деградирует штатно.

Капабилити live-status (OpenSpec), web-ui дополнен. Change заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:38:25 +03:00

117 lines
3.4 KiB
Go

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)
}