Веб-UI: обзор жизненного цикла в карточке загрузки

Карточка списка на главной теперь даёт краткий обзор «от загрузки до
решения об удалении»: метка «ID:» перед идентификатором, дата добавления
(абсолютная + относительная, всегда), размер раздачи и рейтинг отдачи.
Спойлер контекста убран — контекст смотрят на /download/{id}.

Данные:
- рейтинг и общий размер — из живого снимка воркера (qbt total_size →
  worker.Live.TotalSize); размер доступен для любой раздачи в снимке;
- размер-фолбэк, когда торрента нет в qBittorrent (orphaned) — сумма
  размеров разложенных файлов: новая колонка file_link.size, layouter
  пишет размер при линковке, ридер LayoutSizeByDownload суммирует по
  странице одним запросом (дедуп по dst_path);
- дата — source_added_at → фолбэк created_at, показ в TZ сервера.

handleIndex читает снимок для всех карточек (map-lookup), рейтинг/размер
статичны на рендере (без поллинга). Миграция 0007, ER-схема обновлена.
Change download-card-lifecycle-overview влит в спеки и заархивирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-04 10:34:45 +03:00
co-authored by Claude Opus 4.8
parent 9ed732e49e
commit bb245a90a3
27 changed files with 845 additions and 49 deletions
+36
View File
@@ -154,6 +154,42 @@ func TestFileLinks_BatchLifecycle(t *testing.T) {
}
}
func TestLayoutSizeByDownload(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
other := mustCreate(t, st, "bbccddeeff00112233445566778899aabbccddee")
// Активные статусы суммируются; superseded исключается; повторный apply даёт
// вторую строку с тем же dst_path — DISTINCT не даёт задвоить размер.
if err := st.CreateFileLinks(ctx, []FileLink{
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "linked", Size: 100},
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/a.srt", DstPath: "/m/A.ru.srt", Kind: "subtitle", Status: "copied", Size: 50},
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/b.mkv", DstPath: "/m/B.mkv", Kind: "video", Status: "exists", Size: 25},
{DownloadID: dl, ApplyBatchID: "b2", SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "exists", Size: 100},
{DownloadID: dl, ApplyBatchID: "b1", SrcPath: "/d/s.mkv", DstPath: "/m/S.mkv", Kind: "video", Status: "superseded", Size: 999},
}); err != nil {
t.Fatalf("create links: %v", err)
}
got, err := st.LayoutSizeByDownload(ctx, []string{dl, other})
if err != nil {
t.Fatalf("layout size: %v", err)
}
if got[dl] != 175 { // 100 + 50 + 25, повтор A.mkv и superseded не в счёт
t.Errorf("размер %s = %d, want 175", dl, got[dl])
}
if _, ok := got[other]; ok {
t.Errorf("загрузка без разложенных файлов не должна быть в карте: %v", got[other])
}
// Пустой список id — пустая карта, без ошибки.
empty, err := st.LayoutSizeByDownload(ctx, nil)
if err != nil || len(empty) != 0 {
t.Errorf("пустой ids: got %v, err %v", empty, err)
}
}
func TestSupersedeForeignLinks(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()