package httpapi import ( "errors" "net/http" "strconv" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) // --- Страница просмотра одной загрузки --- type downloadDetailView struct { ID int64 Title string SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте» SourceFull string // полный источник (magnet) — блок «Информация о торренте» Infohash string InfohashShort string Context string State string Error string Note string CreatedAt string UpdatedAt string // Распознавание (если есть план). HasPlan bool MediaType string IsSeries bool RecTitle string OriginalTitle 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 { http.Error(w, "некорректный id", http.StatusBadRequest) 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 } d := rd.Download view := downloadDetailView{ ID: id, Title: detailTitle(d, rd), SourceType: string(d.SourceType), SourceFull: d.SourceRef, Infohash: d.Infohash.String, InfohashShort: shortenHash(d.Infohash.String), 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, } 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 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.deps.Live.Live(d.Infohash.String) view.Seeding = buildSeeding(id, l, ok) s.render(w, "download.html", view) }