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 }