Мутирующие действия больше не уводят со страницы: undo/relink/retry/cancel
в списке свопят карточку (#card-{id}), на /download/{id} — #download-main;
петля ревью (refine/rerecognize) свопит #review-main и допалливает
recognizing до готового плана (fragment /fragments/downloads/{id}/review,
every 2s). Выходы ревью (apply/defer/cancel) остаются навигацией. Без htmx —
прежний PRG-редирект (деградация). Ошибка действия на htmx-пути — HTTP 200
с сообщением в фрагменте (ActionError), иначе htmx не свопит DOM.
Разметка вынесена в партиалы card/download_main/review_main (корень = элемент
с целевым id), различение поверхности — скрытым полем surface=list|download.
Извлечены buildCardView/buildDownloadView. handleSetProvider переведён на
reviewBlockAction (консистентность source-действий).
Реализация change htmx-action-swap (OpenSpec).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
5.7 KiB
Go
153 lines
5.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"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
|
|
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
|
|
Provider string
|
|
ProviderID string
|
|
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
|
|
NoBase bool
|
|
Confidence string
|
|
Files []fileRow
|
|
|
|
// Живая статистика раздачи (заполняется из снимка воркера).
|
|
Seeding seedingView
|
|
|
|
// Действия по состоянию (как на главной).
|
|
Terminal bool
|
|
Reviewable bool
|
|
Undoable bool
|
|
Relinkable bool
|
|
Retriable 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 {
|
|
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
|
|
}
|
|
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),
|
|
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,
|
|
}
|
|
// Дата добавления рядом с шапкой (source_added_at → фолбэк created_at,
|
|
// как в порядке и карточках списка); неразбираемое время просто опускаем.
|
|
if t, ok := addedTime(d); ok {
|
|
view.Added = fmtDate(t, s.deps.Loc)
|
|
view.AddedAgo = humanizeAge(t, time.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 = seasonSummary(rd.Plan)
|
|
}
|
|
view.Year = rd.Plan.Year
|
|
switch rd.Provider {
|
|
case "", "none":
|
|
view.NoBase = rd.Provider == "none"
|
|
default:
|
|
view.Provider = rd.Provider
|
|
view.ProviderID = rd.ProviderID
|
|
view.MatchURL = matchURL(rd, view.MediaType)
|
|
}
|
|
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
|
|
}
|