Files
jellybit/internal/httpapi/download.go
T
av a5d873b62d web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо
  фазы catched; один поллер на поверхность, интервалы 5 с и 15 с
- отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели
  вместо 404/500, который htmx не свопит
- заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел
  «Живой поллинг» в конвенции веб-UI
2026-08-10 14:02:38 +03:00

192 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"errors"
"net/http"
"strconv"
"git.vakhrushev.me/av/jellybit/internal/naming"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// --- Страница просмотра одной загрузки ---
type downloadDetailView struct {
ID string
Title string
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
Infohashes []string // все хеши загрузки (блок «Информация о торренте»)
Context string
State string
SelfPoll bool // задача наблюдаема → страница сама опрашивает себя
PollEvery string
Error string
ActionError string // ошибка действия на htmx-пути (своп download_main), не error_msg
Note string
Added string // дата добавления (source_added_at → created_at), как в списке
AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить
CreatedAt string
UpdatedAt string
// Распознавание (если есть план).
HasPlan bool
MediaType string
IsSeries bool
RecTitle string
OriginalTitle string
Season string // сводка сезонов для сериала (пусто для фильма)
Year int
Director string // режиссёр эффективного источника (пусто — неизвестен)
Provider string
ProviderID string
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
NoBase bool
Confidence string
Files []fileRow
// Живая статистика раздачи (заполняется из снимка воркера).
Seeding seedingView
// Nameable — доступно ручное обновление имени: есть распознанное название,
// которое можно перелить в display_name/ярлык раздачи. Гейтится наличием
// распознавания (в т.ч. на done/orphaned), НЕ состоянием ревью.
Nameable bool
// Действия по состоянию (как на главной).
Terminal bool
Reviewable bool
Undoable bool
Relinkable bool
Retriable bool
Deletable bool // полное удаление доступно (done/orphaned/target_missing)
// Dismissable — доступен стоп-кран «Закрыть» (перевод в cancelled без
// действий над файлами/раздачей). Показываем в danger-зоне для терминальных,
// кроме deleted (строго терминален) и cancelled (уже закрыта, no-op); у
// не-терминальных ту же роль играет обычная «Отменить» — не дублируем.
Dismissable bool
}
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
// распознанное название (план) → усечённый до одной строки сырой источник.
func detailTitle(d store.Download, rd *worker.ReviewData) string {
if d.DisplayName != "" {
return d.DisplayName
}
if rd.Plan.Title != "" {
return rd.Plan.Title
}
return shorten(oneLine(d.SourceRef), 120)
}
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
// Невалидный id = несуществующая сущность; в БД не ходим.
http.Error(w, "задача не найдена", http.StatusNotFound)
return
}
rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
if err != nil {
// Тик самообновления страницы идёт этим же маршрутом (hx-get="/download/{id}"
// с hx-select="#download-main"). Отвечать ему статусом ошибки нельзя: htmx не
// свопит 4xx/5xx и не снимает hx-trigger — страница осталась бы навсегда
// устаревшей, а опрос продолжался бы до закрытия вкладки. Навигационный GET
// (адресная строка, закладка) по-прежнему получает честный статус.
if isHTMX(r) {
s.fragTickErr(w, err, id, "download-main")
return
}
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "задача не найдена", http.StatusNotFound)
return
}
s.deps.Logger.Error("download detail data failed", "download_id", id, "error", err)
http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
return
}
s.render(w, "download.html", s.buildDownloadView(id, rd))
}
// buildDownloadView собирает представление страницы загрузки из доменных данных
// и живого снимка. Общий для полной страницы (handleDownload) и htmx-свопа
// главной области после действия (renderDownloadFragment).
func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDetailView {
d := rd.Download
view := downloadDetailView{
ID: id,
Title: detailTitle(d, rd),
SourceType: string(d.SourceType),
SourceFull: d.SourceRef,
Infohashes: d.HashList(),
Context: d.Context,
State: string(d.State),
SelfPoll: d.State.IsObservable(),
// Блока живых цифр качания на странице нет вовсе, а тик считает
// предпросмотр раскладки и ходит в ФС — интервал всегда медленный.
PollEvery: pollSlow,
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,
Deletable: d.State == store.StateDone || d.State == store.StateOrphaned ||
d.State == store.StateTargetMissing,
Dismissable: d.State.IsTerminal() &&
d.State != store.StateDeleted && d.State != store.StateCancelled,
}
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
// как в порядке и карточках списка); неразбираемое время просто опускаем.
if t, ok := addedTime(d); ok {
view.Added = fmtDate(t, s.deps.Loc)
view.AddedAgo = humanizeAge(t, store.Now())
}
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
if view.IsSeries {
view.Season = recognize.SeasonSummary(rd.Plan)
}
view.Year = rd.Plan.Year
// Режиссёр — из того же слоистого разрешения, что и display_name в шапке
// (override → распознавание+матч → контекст), чтобы поле не расходилось с
// заголовком; пустой во всех слоях → прочерк в шаблоне.
view.Director = naming.EffectiveFields(d.ParsedContext, rd.Plan).Director
// Ручное обновление имени доступно, когда есть распознанное название,
// которое можно перелить (иначе FormatTitleYear даст пусто → no-op).
view.Nameable = rd.Plan.Title != ""
switch rd.Provider {
case "", "none":
view.NoBase = rd.Provider == "none"
default:
view.Provider = rd.Provider
view.ProviderID = rd.ProviderID
view.MatchURL = rd.MatchURL()
}
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.liveFor(d)
view.Seeding = buildSeeding(id, l, ok)
return view
}