- условие самообновления — доменный предикат store.State.IsObservable() вместо фазы catched; один поллер на поверхность, интервалы 5 с и 15 с - отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели вместо 404/500, который htmx не свопит - заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел «Живой поллинг» в конвенции веб-UI
192 lines
8.9 KiB
Go
192 lines
8.9 KiB
Go
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
|
||
}
|