Веб-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:
@@ -0,0 +1,10 @@
|
||||
-- +goose Up
|
||||
-- Размер разложенного файла (байт), фиксируется при линковке. Служит фолбэком
|
||||
-- размера раздачи в веб-UI, когда торрента нет в qBittorrent (например,
|
||||
-- состояние orphaned — файлы библиотеки последняя копия данных). Старые записи
|
||||
-- получают 0 (бэкофилл не делаем: для done/сидирующих размер берётся из живого
|
||||
-- снимка qBittorrent).
|
||||
ALTER TABLE file_link ADD COLUMN size INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE file_link DROP COLUMN size;
|
||||
@@ -166,6 +166,7 @@ type FileLink struct {
|
||||
DstPath string `db:"dst_path"`
|
||||
Kind string `db:"kind"`
|
||||
Status string `db:"status"`
|
||||
Size int64 `db:"size"` // размер файла (байт), зафиксирован при линковке
|
||||
CreatedAt string `db:"created_at"`
|
||||
}
|
||||
|
||||
@@ -181,11 +182,11 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
const q = `
|
||||
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
for _, l := range links {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
|
||||
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status, l.Size); err != nil {
|
||||
return fmt.Errorf("insert file_link: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -249,6 +250,47 @@ func (s *Store) ListFileLinksByBatch(ctx context.Context, batchID string) ([]Fil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LayoutSizeByDownload возвращает суммарный размер разложенных файлов по каждой
|
||||
// из загрузок ids: карта download_id → сумма байт. Учитываются только активные
|
||||
// статусы раскладки (linked/copied/exists); superseded-пути принадлежат уже
|
||||
// другой загрузке. Каждый dst_path считается один раз (повторный apply даёт
|
||||
// новую строку 'exists' с тем же путём) — сначала схлопываем строки по пути,
|
||||
// потом суммируем, иначе размер задвоился бы. Служит фолбэком размера раздачи в
|
||||
// карточке, когда торрента нет в живом снимке. Загрузки без разложенных файлов
|
||||
// в карте отсутствуют (читатель берёт 0).
|
||||
func (s *Store) LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
var args []any
|
||||
ph := placeholders(&args, ids)
|
||||
q := `SELECT download_id, SUM(size) AS total FROM (
|
||||
SELECT download_id, dst_path, MAX(size) AS size FROM file_link
|
||||
WHERE download_id IN (` + ph + `)
|
||||
AND status IN ('linked', 'copied', 'exists')
|
||||
GROUP BY download_id, dst_path
|
||||
) GROUP BY download_id`
|
||||
rows, err := s.DB.QueryxContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("layout size by download: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[string]int64, len(ids))
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var total int64
|
||||
if err := rows.Scan(&id, &total); err != nil {
|
||||
return nil, fmt.Errorf("layout size by download: scan: %w", err)
|
||||
}
|
||||
out[id] = total
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("layout size by download: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС).
|
||||
func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error {
|
||||
if _, err := s.DB.ExecContext(ctx,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user