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 // 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 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 { 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, 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 }