Files

320 lines
12 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
)
// Recognition — строка таблицы recognition (попытка распознавания).
type Recognition struct {
ID int64 `db:"id"`
DownloadID int64 `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) (int64, error) {
reasonsJSON, err := json.Marshal(reasons)
if err != nil {
return 0, fmt.Errorf("marshal reasons: %w", err)
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return 0, 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 0, 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 0, fmt.Errorf("next attempt_no: %w", err)
}
const q = `
INSERT INTO recognition
(download_id, attempt_no, is_current, media_type, title, original_title,
year, provider, provider_id, confidence, reasons, raw_llm, plan)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
res, err := tx.ExecContext(ctx, q,
r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan)
if err != nil {
return 0, fmt.Errorf("insert recognition: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("recognition last insert id: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit recognition: %w", err)
}
return id, nil
}
// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки
// либо (nil, nil), если её ещё нет.
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*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 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 int64, text string) error {
if _, err := s.DB.ExecContext(ctx,
`INSERT INTO hint (download_id, text) VALUES (?, ?)`, downloadID, text); err != nil {
return fmt.Errorf("add hint: %w", err)
}
return nil
}
// ListHints возвращает подсказки загрузки в хронологическом порядке.
func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]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 int64, field, value string) error {
const q = `
INSERT INTO override (download_id, field, value) VALUES (?, ?, ?)
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
if _, err := s.DB.ExecContext(ctx, q, 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 int64) (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 int64 `db:"id"`
DownloadID int64 `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"`
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 (download_id, apply_batch_id, src_path, dst_path, kind, status)
VALUES (?, ?, ?, ?, ?, ?)`
for _, l := range links {
if _, err := tx.ExecContext(ctx, q,
l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); 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
}
// LatestBatchID возвращает apply_batch_id последнего применённого батча
// загрузки (для undo) либо пустую строку, если ссылок нет.
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (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 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
}
// 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 int64 `db:"id"`
RecognitionID int64 `db:"recognition_id"`
Provider string `db:"provider"`
ProviderID string `db:"provider_id"`
Title sql.NullString `db:"title"`
Year sql.NullInt64 `db:"year"`
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 (recognition_id, provider, provider_id, title, year)
VALUES (?, ?, ?, ?, ?)`
for _, c := range cands {
if _, err := tx.ExecContext(ctx, q,
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year); 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 int64) ([]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 int64) (*MetadataCandidate, error) {
var c MetadataCandidate
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get candidate %d: %w", id, err)
}
return &c, nil
}
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
// той же попытке распознавания.
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) 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
}