При подтверждённом матче база папки (имя+год) наследуется от живой папки-якоря того же (provider, provider_id) вместо печати заново из выхода LLM — так второй/последующий сезон ложится в ТУ ЖЕ папку, а не заводит рядом почти одинаковую. Отдельная сущность «тайтл» не вводится. - layout: Plan.FolderBase перекрывает базу в папке и именах файлов; TitleFolder разбирает dst_path в папку тайтла и базу (снятие тега). - store: LiveTitleFolders — dst_path живых ссылок того же матча. - worker: resolveFolderBase (живость якоря — по наличию папки на диске, os.Lstat, а не по статусу ссылки в БД) в linkPlan и в превью ревью (превью=применение); рассинхрон нескольких живых папок → review из linking (deferred→review в графе нет). Схема БД не менялась. Change series-folder-convergence заархивирован, требования влиты в openspec/specs/file-layout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
421 lines
17 KiB
Go
421 lines
17 KiB
Go
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, created_at)
|
||
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,
|
||
FormatTime(Now())); 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, created_at) VALUES (?, ?, ?, ?)`,
|
||
ident.NewID(), downloadID, text, FormatTime(Now())); 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, created_at) VALUES (?, ?, ?, ?, ?)
|
||
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
|
||
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value, FormatTime(Now())); 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() }()
|
||
|
||
now := FormatTime(Now())
|
||
const q = `
|
||
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status, size, created_at)
|
||
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, now); 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
|
||
}
|
||
|
||
// LiveTitleFolders возвращает целевые пути (dst_path) живых ссылок
|
||
// (`status IN linked/copied/exists`) загрузок, чьё current recognition имеет тот
|
||
// же (provider, provider_id), КРОМЕ excludeDownloadID. Питает правило сходимости
|
||
// папки (см. file-layout): вызывающий сводит пути к папкам тайтла, проверяет их
|
||
// существование на диске и наследует базу имени. Пустой provider/id → пусто (без
|
||
// подтверждённого матча якоря нет). Проверка живости папки на диске —
|
||
// ответственность вызывающего (статус ссылки не гарантирует наличие папки).
|
||
func (s *Store) LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
|
||
if provider == "" || providerID == "" {
|
||
return nil, nil
|
||
}
|
||
const q = `
|
||
SELECT fl.dst_path
|
||
FROM file_link fl
|
||
JOIN recognition r ON r.download_id = fl.download_id AND r.is_current = 1
|
||
WHERE fl.status IN ('linked', 'copied', 'exists')
|
||
AND fl.download_id != ?
|
||
AND r.provider = ?
|
||
AND r.provider_id = ?`
|
||
var out []string
|
||
if err := s.DB.SelectContext(ctx, &out, q, excludeDownloadID, provider, providerID); err != nil {
|
||
return nil, fmt.Errorf("live title folders: %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() }()
|
||
|
||
now := FormatTime(Now())
|
||
const q = `
|
||
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url, created_at)
|
||
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, now); 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
|
||
}
|