Раскладка файлов после распознавния
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
-- +goose Up
|
||||
-- Структурированный план раскладки (файл → роль/сезон/серия) для превью и
|
||||
-- применения до создания хардлинков. Плоские поля recognition (media_type,
|
||||
-- title, year, …) остаются для списков; план — каноничный JSON recognize.Plan.
|
||||
ALTER TABLE recognition ADD COLUMN plan TEXT;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE recognition DROP COLUMN plan;
|
||||
@@ -0,0 +1,230 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedDownload(t *testing.T, st *Store) int64 {
|
||||
t.Helper()
|
||||
id, err := st.CreateDownload(context.Background(),
|
||||
newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
|
||||
if err != nil {
|
||||
t.Fatalf("seed download: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
dl := seedDownload(t, st)
|
||||
|
||||
id1, err := st.CreateRecognition(ctx, &Recognition{
|
||||
DownloadID: dl,
|
||||
MediaType: NullString("series"),
|
||||
Title: NullString("Show"),
|
||||
Year: sql.NullInt64{Int64: 2006, Valid: true},
|
||||
Plan: NullString(`{"type":"series"}`),
|
||||
}, []string{"нет матча в базе"})
|
||||
if err != nil {
|
||||
t.Fatalf("create #1: %v", err)
|
||||
}
|
||||
|
||||
id2, err := st.CreateRecognition(ctx, &Recognition{
|
||||
DownloadID: dl,
|
||||
MediaType: NullString("movie"),
|
||||
Title: NullString("Show v2"),
|
||||
}, []string{"уточнено"})
|
||||
if err != nil {
|
||||
t.Fatalf("create #2: %v", err)
|
||||
}
|
||||
if id2 == id1 {
|
||||
t.Fatal("ids must differ")
|
||||
}
|
||||
|
||||
cur, err := st.GetCurrentRecognition(ctx, dl)
|
||||
if err != nil {
|
||||
t.Fatalf("get current: %v", err)
|
||||
}
|
||||
if cur.ID != id2 {
|
||||
t.Errorf("current id = %d, want %d", cur.ID, id2)
|
||||
}
|
||||
if cur.AttemptNo != 2 {
|
||||
t.Errorf("attempt_no = %d, want 2", cur.AttemptNo)
|
||||
}
|
||||
if !cur.IsCurrent {
|
||||
t.Error("current recognition must have is_current = true")
|
||||
}
|
||||
if cur.Title.String != "Show v2" {
|
||||
t.Errorf("title = %q", cur.Title.String)
|
||||
}
|
||||
if got := cur.ReasonList(); len(got) != 1 || got[0] != "уточнено" {
|
||||
t.Errorf("reasons = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrentRecognition_None(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
dl := seedDownload(t, st)
|
||||
cur, err := st.GetCurrentRecognition(context.Background(), dl)
|
||||
if err != nil {
|
||||
t.Fatalf("get current: %v", err)
|
||||
}
|
||||
if cur != nil {
|
||||
t.Errorf("want nil, got %+v", cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHints(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
dl := seedDownload(t, st)
|
||||
|
||||
for _, h := range []string{"второй сезон", "рус+англ дорожки"} {
|
||||
if err := st.AddHint(ctx, dl, h); err != nil {
|
||||
t.Fatalf("add hint: %v", err)
|
||||
}
|
||||
}
|
||||
got, err := st.ListHints(ctx, dl)
|
||||
if err != nil {
|
||||
t.Fatalf("list hints: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != "второй сезон" || got[1] != "рус+англ дорожки" {
|
||||
t.Errorf("hints = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverrides_Upsert(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
dl := seedDownload(t, st)
|
||||
|
||||
if err := st.SetOverride(ctx, dl, "media_type", "series"); err != nil {
|
||||
t.Fatalf("set override: %v", err)
|
||||
}
|
||||
if err := st.SetOverride(ctx, dl, "media_type", "movie"); err != nil { // перезапись
|
||||
t.Fatalf("override upsert: %v", err)
|
||||
}
|
||||
if err := st.SetOverride(ctx, dl, "ignored_files", `["sample.mkv"]`); err != nil {
|
||||
t.Fatalf("set override 2: %v", err)
|
||||
}
|
||||
|
||||
got, err := st.ListOverrides(ctx, dl)
|
||||
if err != nil {
|
||||
t.Fatalf("list overrides: %v", err)
|
||||
}
|
||||
if got["media_type"] != "movie" {
|
||||
t.Errorf("media_type = %q, want movie (upsert)", got["media_type"])
|
||||
}
|
||||
if got["ignored_files"] != `["sample.mkv"]` {
|
||||
t.Errorf("ignored_files = %q", got["ignored_files"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileLinks_BatchLifecycle(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
dl := seedDownload(t, st)
|
||||
|
||||
batch := "batch-1"
|
||||
links := []FileLink{
|
||||
{DownloadID: dl, ApplyBatchID: batch, SrcPath: "/d/a.mkv", DstPath: "/m/A.mkv", Kind: "video", Status: "linked"},
|
||||
{DownloadID: dl, ApplyBatchID: batch, SrcPath: "/d/a.srt", DstPath: "/m/A.ru.srt", Kind: "subtitle", Status: "linked"},
|
||||
}
|
||||
if err := st.CreateFileLinks(ctx, links); err != nil {
|
||||
t.Fatalf("create links: %v", err)
|
||||
}
|
||||
|
||||
latest, err := st.LatestBatchID(ctx, dl)
|
||||
if err != nil || latest != batch {
|
||||
t.Fatalf("latest batch = %q, %v", latest, err)
|
||||
}
|
||||
|
||||
got, err := st.ListFileLinksByBatch(ctx, batch)
|
||||
if err != nil {
|
||||
t.Fatalf("list by batch: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0].DstPath != "/m/A.mkv" {
|
||||
t.Errorf("links = %+v", got)
|
||||
}
|
||||
|
||||
if err := st.DeleteFileLinksByBatch(ctx, batch); err != nil {
|
||||
t.Fatalf("delete batch: %v", err)
|
||||
}
|
||||
after, _ := st.ListFileLinksByBatch(ctx, batch)
|
||||
if len(after) != 0 {
|
||||
t.Errorf("links remain after delete: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestBatchID_None(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
dl := seedDownload(t, st)
|
||||
latest, err := st.LatestBatchID(context.Background(), dl)
|
||||
if err != nil {
|
||||
t.Fatalf("latest batch: %v", err)
|
||||
}
|
||||
if latest != "" {
|
||||
t.Errorf("want empty, got %q", latest)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user