Files
jellybit/internal/httpapi/download.go
T
avandClaude Opus 4.8 1369a9cabe Приём: дедуп по target_missing/orphaned + стоп-кран «Закрыть»
Два дубля-близнеца на один инфохэш рождались, когда повторный приём
попадал на запись в target_missing: дедуп искал только активную задачу,
а target_missing терминален → заводилась новая загрузка, воркер усыновлял
уже присутствующий торрент и раскладывал его.

- Приём: критерий дедупа расширен до «блокирующей повторный приём» =
  активные ∪ {target_missing, orphaned}. Повторный приём такого инфохэша
  привязывается к существующей записи (спящей, без обращения к qBittorrent),
  а не плодит близнеца. Прочие терминальные (done/cancelled/failed/reverted/
  deleted) повторный приём не блокируют — осознанная свежая попытка. Новый
  read-метод FindReingestBlockingByInfohash (приоритет активной над desync);
  общий active-гард не тронут.
- Команда «Закрыть» (Dismiss) — универсальный стоп-кран из любого состояния,
  кроме deleted → cancelled (error_code=user_dismiss). Только меняет статус:
  файлы (в т.ч. хардлинки done/orphaned) и раздачу qBittorrent не трогает,
  в отличие от «Удалить». Веб — danger-зона внизу страницы; Telegram —
  кнопка с подтверждением; из cancelled — идемпотентный no-op.
- Транспорты при дедупе на desync-запись сообщают адресно (target_missing —
  привязать заново/закрыть; orphaned — закрыть и добавить заново); веб при
  дедупе ведёт на страницу существующей записи.

Спеки: ingest (дедуп), state-reconciliation (стоп-кран); граф переходов
допополнен рёбрами <терминал>→cancelled. OpenSpec change
dedup-target-missing-and-dismiss заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:15:37 +03:00

173 lines
7.4 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
SelfPoll bool // catched → страница сама опрашивает себя до перехода
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
// 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 {
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 == store.StateCatched,
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, 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
// Ручное обновление имени доступно, когда есть распознанное название,
// которое можно перелить (иначе 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 = 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
}