Веб-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
+45 -3
View File
@@ -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,