231 lines
8.1 KiB
Go
231 lines
8.1 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
|
|
}
|