Files
jellybit/internal/store/recognition.go
T
avandClaude Opus 4.8 bb245a90a3 Веб-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>
2026-07-04 10:34:45 +03:00

392 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"git.vakhrushev.me/av/jellybit/internal/ident"
)
// Recognition — строка таблицы recognition (попытка распознавания).
type Recognition struct {
ID string `db:"id"`
DownloadID string `db:"download_id"`
AttemptNo int `db:"attempt_no"`
IsCurrent bool `db:"is_current"`
MediaType sql.NullString `db:"media_type"`
Title sql.NullString `db:"title"`
OriginalTitle sql.NullString `db:"original_title"`
Year sql.NullInt64 `db:"year"`
Provider sql.NullString `db:"provider"`
ProviderID sql.NullString `db:"provider_id"`
Confidence sql.NullFloat64 `db:"confidence"`
Reasons string `db:"reasons"` // JSON-массив строк
RawLLM sql.NullString `db:"raw_llm"`
Plan sql.NullString `db:"plan"` // JSON recognize.Plan
CreatedAt string `db:"created_at"`
}
// ReasonList разбирает JSON-поле reasons в срез строк.
func (r Recognition) ReasonList() []string {
if r.Reasons == "" {
return nil
}
var out []string
_ = json.Unmarshal([]byte(r.Reasons), &out)
return out
}
// CreateRecognition вставляет новую попытку распознавания, помечая прежние
// как неактуальные (is_current = 0) и проставляя следующий attempt_no.
// Возвращает id новой записи. reasons сериализуется в JSON.
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (string, error) {
reasonsJSON, err := json.Marshal(reasons)
if err != nil {
return "", fmt.Errorf("marshal reasons: %w", err)
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return "", fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`UPDATE recognition SET is_current = 0 WHERE download_id = ?`, r.DownloadID); err != nil {
return "", fmt.Errorf("clear current recognitions: %w", err)
}
var nextAttempt int
if err := tx.GetContext(ctx, &nextAttempt,
`SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`,
r.DownloadID); err != nil {
return "", fmt.Errorf("next attempt_no: %w", err)
}
r.ID = ident.NewID()
const q = `
INSERT INTO recognition
(id, download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
if _, err := tx.ExecContext(ctx, q,
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan); err != nil {
return "", fmt.Errorf("insert recognition: %w", err)
}
if err := tx.Commit(); err != nil {
return "", fmt.Errorf("commit recognition: %w", err)
}
return r.ID, nil
}
// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки
// либо (nil, nil), если её ещё нет.
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (*Recognition, error) {
var r Recognition
err := s.DB.GetContext(ctx, &r,
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
ORDER BY attempt_no DESC LIMIT 1`, downloadID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get current recognition: %w", err)
}
return &r, nil
}
// --- Подсказки (hint) ---
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
if _, err := s.DB.ExecContext(ctx,
`INSERT INTO hint (id, download_id, text) VALUES (?, ?, ?)`,
ident.NewID(), downloadID, text); err != nil {
return fmt.Errorf("add hint: %w", err)
}
return nil
}
// ListHints возвращает подсказки загрузки в хронологическом порядке.
func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, error) {
var out []string
if err := s.DB.SelectContext(ctx, &out,
`SELECT text FROM hint WHERE download_id = ? ORDER BY id`, downloadID); err != nil {
return nil, fmt.Errorf("list hints: %w", err)
}
return out, nil
}
// --- Ручные правки (override) ---
// SetOverride пиннит значение поля (upsert по (download_id, field)).
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
const q = `
INSERT INTO override (id, download_id, field, value) VALUES (?, ?, ?, ?)
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value); err != nil {
return fmt.Errorf("set override %q: %w", field, err)
}
return nil
}
// ListOverrides возвращает запиненные правки загрузки как map[field]value.
func (s *Store) ListOverrides(ctx context.Context, downloadID string) (map[string]string, error) {
rows, err := s.DB.QueryxContext(ctx,
`SELECT field, value FROM override WHERE download_id = ?`, downloadID)
if err != nil {
return nil, fmt.Errorf("list overrides: %w", err)
}
defer func() { _ = rows.Close() }()
out := map[string]string{}
for rows.Next() {
var field, value string
if err := rows.Scan(&field, &value); err != nil {
return nil, fmt.Errorf("scan override: %w", err)
}
out[field] = value
}
return out, rows.Err()
}
// --- Ссылки файлов (file_link) ---
// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка).
type FileLink struct {
ID string `db:"id"`
DownloadID string `db:"download_id"`
ApplyBatchID string `db:"apply_batch_id"`
SrcPath string `db:"src_path"`
DstPath string `db:"dst_path"`
Kind string `db:"kind"`
Status string `db:"status"`
Size int64 `db:"size"` // размер файла (байт), зафиксирован при линковке
CreatedAt string `db:"created_at"`
}
// CreateFileLinks вставляет батч ссылок одной транзакцией.
func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
if len(links) == 0 {
return nil
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
const q = `
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, l.Size); err != nil {
return fmt.Errorf("insert file_link: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit file_links: %w", err)
}
return nil
}
// SupersedeForeignLinks помечает статусом superseded ссылки ДРУГИХ загрузок,
// разложенные по тем же dst_path, что и переданные. Реализует инвариант
// «один целевой путь — один владелец» (см. state-reconciliation): свежая
// раскладка забирает владение освободившимся путём, прежние записи перестают
// считаться целью при сверке. Затрагивает только активные статусы раскладки
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error {
if len(dstPaths) == 0 {
return nil
}
ph := make([]string, len(dstPaths))
args := make([]any, 0, len(dstPaths)+1)
args = append(args, downloadID)
for i, p := range dstPaths {
ph[i] = "?"
args = append(args, p)
}
q := fmt.Sprintf(`
UPDATE file_link SET status = 'superseded'
WHERE download_id != ?
AND status IN ('linked', 'copied', 'exists')
AND dst_path IN (%s)`, strings.Join(ph, ", "))
if _, err := s.DB.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("supersede foreign links: %w", err)
}
return nil
}
// LatestBatchID возвращает apply_batch_id последнего применённого батча
// загрузки (для undo) либо пустую строку, если ссылок нет.
func (s *Store) LatestBatchID(ctx context.Context, downloadID string) (string, error) {
var batch string
err := s.DB.GetContext(ctx, &batch,
`SELECT apply_batch_id FROM file_link WHERE download_id = ?
ORDER BY id DESC LIMIT 1`, downloadID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("latest batch id: %w", err)
}
return batch, nil
}
// ListFileLinksByBatch возвращает ссылки батча.
func (s *Store) ListFileLinksByBatch(ctx context.Context, batchID string) ([]FileLink, error) {
var out []FileLink
if err := s.DB.SelectContext(ctx, &out,
`SELECT * FROM file_link WHERE apply_batch_id = ? ORDER BY id`, batchID); err != nil {
return nil, fmt.Errorf("list file_links by batch: %w", err)
}
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,
`DELETE FROM file_link WHERE apply_batch_id = ?`, batchID); err != nil {
return fmt.Errorf("delete file_links by batch: %w", err)
}
return nil
}
// --- Кандидаты базы метаданных (metadata_candidate) ---
// MetadataCandidate — строка таблицы metadata_candidate. provider/provider_id
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
// см. recognize), а не обязательно нативный id провайдера поиска.
type MetadataCandidate struct {
ID string `db:"id"`
RecognitionID string `db:"recognition_id"`
Provider string `db:"provider"`
ProviderID string `db:"provider_id"`
Title sql.NullString `db:"title"`
Year sql.NullInt64 `db:"year"`
URL sql.NullString `db:"url"`
Chosen bool `db:"chosen"`
CreatedAt string `db:"created_at"`
}
// CreateCandidates вставляет кандидатов распознавания одной транзакцией.
func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate) error {
if len(cands) == 0 {
return nil
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
const q = `
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url)
VALUES (?, ?, ?, ?, ?, ?, ?)`
for _, c := range cands {
if _, err := tx.ExecContext(ctx, q,
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
return fmt.Errorf("insert candidate: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit candidates: %w", err)
}
return nil
}
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]MetadataCandidate, error) {
var out []MetadataCandidate
if err := s.DB.SelectContext(ctx, &out,
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
return nil, fmt.Errorf("list candidates: %w", err)
}
return out, nil
}
// GetCandidate возвращает кандидата по id либо (nil, nil).
func (s *Store) GetCandidate(ctx context.Context, id string) (*MetadataCandidate, error) {
var c MetadataCandidate
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get candidate %s: %w", id, err)
}
return &c, nil
}
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
// той же попытке распознавания.
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`UPDATE metadata_candidate SET chosen = 0 WHERE recognition_id = ?`, recognitionID); err != nil {
return fmt.Errorf("clear chosen: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE metadata_candidate SET chosen = 1 WHERE id = ? AND recognition_id = ?`,
candidateID, recognitionID); err != nil {
return fmt.Errorf("set chosen: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit chosen: %w", err)
}
return nil
}