Распознаём ручное удаление источника (раздача в qBittorrent) и/или цели (разложенные хардлинки) и отражаем его в состоянии задачи, без автодействий. - Новая capability state-reconciliation (OpenSpec): фоновая сверка по матрице «источник × цель» → состояния target_missing/orphaned/deleted, переходы и самовосстановление (healing). - worker: reconcileDesync в Poll (только разложенные/desync-задачи), дебаунс пропажи источника (порог [worker].source_missing_threshold) и синхронный preflight перед действиями (relink/recognize/apply/undo) — не доверяем state в БД. - layout.Undo: отказ снять последнюю копию (nlink<=1 или нет источника), отказ всего батча без частичного отката (ErrLastCopy). - store: единый список terminalStates для IsTerminal и FindActiveByInfohash (иначе семантика «активности» разъезжается), столбец source_miss_count, миграция 0003. - httpapi/web и Telegram: показ новых состояний и уведомления о рассинхроне. - Доки: workflow.md, jellyfin-layout.md, database.md (+0003), config. Change заархивирован в openspec/changes/archive, дельта влита в openspec/specs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
249 lines
10 KiB
Go
249 lines
10 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// State — состояние загрузки в машине состояний (см. architecture.md).
|
||
// В Ф1 используется подмножество: downloading → completed, плюс stuck,
|
||
// failed, cancelled. Остальные состояния заведены под будущие фазы.
|
||
type State string
|
||
|
||
const (
|
||
StateDownloading State = "downloading"
|
||
StateCompleted State = "completed"
|
||
StateRecognizing State = "recognizing" // Ф2
|
||
StateReview State = "review" // Ф3
|
||
StateLinking State = "linking" // Ф3
|
||
StateDone State = "done" // Ф3
|
||
StateDeferred State = "deferred" // Ф3
|
||
StateStuck State = "stuck"
|
||
StateFailed State = "failed"
|
||
StateCancelled State = "cancelled"
|
||
StateReverted State = "reverted" // Ф3
|
||
|
||
// Состояния рассинхрона с реальностью (см. state-reconciliation).
|
||
StateTargetMissing State = "target_missing" // источник есть, цель удалена → relink
|
||
StateOrphaned State = "orphaned" // источник пропал, цель (последняя копия) есть
|
||
StateDeleted State = "deleted" // нет ни источника, ни цели
|
||
)
|
||
|
||
// terminalStates — единый список окончательно остановленных состояний:
|
||
// источник истины и для IsTerminal, и для выборки «активных» задач
|
||
// (FindActiveByInfohash). Любое новое терминальное состояние добавляется
|
||
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
|
||
// снимается по IsTerminal, а активность считалась бы по другому списку).
|
||
//
|
||
// Состояния рассинхрона (target_missing/orphaned/deleted) — терминальны по
|
||
// тем же причинам, что reverted/cancelled: дальше двигает либо человек
|
||
// (relink из target_missing), либо фоновая сверка (healing/прогрессия),
|
||
// напрямую через SetDownloadState; ключ идемпотентности при этом не нужен.
|
||
var terminalStates = []State{
|
||
StateDone, StateCancelled, StateFailed, StateReverted,
|
||
StateTargetMissing, StateOrphaned, StateDeleted,
|
||
}
|
||
|
||
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
|
||
// состояний снимается ключ идемпотентности — тот же infohash можно завести
|
||
// заново новой задачей (см. architecture.md, «повторное добавление»).
|
||
// stuck терминальным не считается: задача восстановима (retry).
|
||
func (s State) IsTerminal() bool {
|
||
return slices.Contains(terminalStates, s)
|
||
}
|
||
|
||
// SourceType — вид источника загрузки.
|
||
type SourceType string
|
||
|
||
const (
|
||
SourceMagnet SourceType = "magnet"
|
||
SourceTorrent SourceType = "torrent"
|
||
SourceURL SourceType = "url"
|
||
)
|
||
|
||
// Download — строка таблицы download.
|
||
type Download struct {
|
||
ID int64 `db:"id"`
|
||
SourceType SourceType `db:"source_type"`
|
||
SourceRef string `db:"source_ref"`
|
||
Context string `db:"context"`
|
||
Infohash sql.NullString `db:"infohash"`
|
||
IdempotencyKey sql.NullString `db:"idempotency_key"`
|
||
State State `db:"state"`
|
||
ErrorCode sql.NullString `db:"error_code"`
|
||
ErrorMsg sql.NullString `db:"error_msg"`
|
||
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
||
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
||
SourceMissCount int `db:"source_miss_count"`
|
||
CreatedAt string `db:"created_at"`
|
||
UpdatedAt string `db:"updated_at"`
|
||
}
|
||
|
||
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
||
const sqliteTimeLayout = "2006-01-02 15:04:05"
|
||
|
||
// ParseTime разбирает временную метку SQLite (datetime('now'), всегда UTC).
|
||
func ParseTime(s string) (time.Time, error) {
|
||
return time.ParseInLocation(sqliteTimeLayout, s, time.UTC)
|
||
}
|
||
|
||
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
||
|
||
// NullString строит sql.NullString: пустая строка → NULL.
|
||
func NullString(s string) sql.NullString {
|
||
return sql.NullString{String: s, Valid: s != ""}
|
||
}
|
||
|
||
// CreateDownload вставляет загрузку и возвращает её id.
|
||
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
|
||
const q = `
|
||
INSERT INTO download (source_type, source_ref, context, infohash, idempotency_key, state)
|
||
VALUES (?, ?, ?, ?, ?, ?)`
|
||
res, err := s.DB.ExecContext(ctx, q,
|
||
d.SourceType, d.SourceRef, d.Context, d.Infohash, d.IdempotencyKey, d.State)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("insert download: %w", err)
|
||
}
|
||
id, err := res.LastInsertId()
|
||
if err != nil {
|
||
return 0, fmt.Errorf("download last insert id: %w", err)
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
// GetDownload возвращает загрузку по id.
|
||
func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
||
var d Download
|
||
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, fmt.Errorf("get download %d: %w", id, ErrNotFound)
|
||
}
|
||
return nil, fmt.Errorf("get download %d: %w", id, err)
|
||
}
|
||
return &d, nil
|
||
}
|
||
|
||
// ListDownloads возвращает все загрузки, новые сверху.
|
||
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
||
var out []Download
|
||
if err := s.DB.SelectContext(ctx, &out, `SELECT * FROM download ORDER BY id DESC`); err != nil {
|
||
return nil, fmt.Errorf("list downloads: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ListDownloadsByState возвращает загрузки в одном из указанных состояний.
|
||
func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Download, error) {
|
||
if len(states) == 0 {
|
||
return nil, nil
|
||
}
|
||
ph := make([]string, len(states))
|
||
args := make([]any, len(states))
|
||
for i, st := range states {
|
||
ph[i] = "?"
|
||
args[i] = string(st)
|
||
}
|
||
q := `SELECT * FROM download WHERE state IN (` + strings.Join(ph, ",") + `) ORDER BY id DESC`
|
||
var out []Download
|
||
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||
return nil, fmt.Errorf("list downloads by state: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
|
||
// (nil, nil), если её нет. Основа идемпотентного приёма.
|
||
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
|
||
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
|
||
// (terminalStates), иначе семантика активности разъедется с idempotency_key.
|
||
ph := make([]string, len(terminalStates))
|
||
args := make([]any, 0, len(terminalStates)+1)
|
||
args = append(args, infohash)
|
||
for i, st := range terminalStates {
|
||
ph[i] = "?"
|
||
args = append(args, string(st))
|
||
}
|
||
q := `SELECT * FROM download WHERE infohash = ? AND state NOT IN (` +
|
||
strings.Join(ph, ",") + `) ORDER BY id DESC LIMIT 1`
|
||
var d Download
|
||
err := s.DB.GetContext(ctx, &d, q, args...)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, nil
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("find active by infohash: %w", err)
|
||
}
|
||
return &d, nil
|
||
}
|
||
|
||
// ExistsByInfohash сообщает, есть ли хоть одна загрузка (в любом состоянии)
|
||
// с данным infohash. Discovery усыновляет раздачу только если её ещё не
|
||
// видели — так готовые задачи не переобрабатываются на каждом тике.
|
||
func (s *Store) ExistsByInfohash(ctx context.Context, infohash string) (bool, error) {
|
||
var n int
|
||
if err := s.DB.GetContext(ctx, &n,
|
||
`SELECT COUNT(1) FROM download WHERE infohash = ?`, infohash); err != nil {
|
||
return false, fmt.Errorf("exists by infohash: %w", err)
|
||
}
|
||
return n > 0, nil
|
||
}
|
||
|
||
// SetDownloadState переводит загрузку в новое состояние. Ключ
|
||
// идемпотентности пересчитывается из текущего infohash: для терминального
|
||
// состояния снимается (NULL), иначе равен infohash — так partial unique
|
||
// index гарантирует не более одной активной задачи на infohash.
|
||
func (s *Store) SetDownloadState(ctx context.Context, id int64, state State, errCode, errMsg string) error {
|
||
const q = `
|
||
UPDATE download
|
||
SET state = ?,
|
||
error_code = ?,
|
||
error_msg = ?,
|
||
idempotency_key = CASE WHEN ? = 1 THEN NULL ELSE infohash END,
|
||
updated_at = datetime('now')
|
||
WHERE id = ?`
|
||
terminal := 0
|
||
if state.IsTerminal() {
|
||
terminal = 1
|
||
}
|
||
res, err := s.DB.ExecContext(ctx, q, string(state), nullArg(errCode), nullArg(errMsg), terminal, id)
|
||
if err != nil {
|
||
return fmt.Errorf("set download %d state %q: %w", id, state, err)
|
||
}
|
||
n, err := res.RowsAffected()
|
||
if err != nil {
|
||
return fmt.Errorf("set download %d state %q: %w", id, state, err)
|
||
}
|
||
if n == 0 {
|
||
return fmt.Errorf("set download %d state %q: not found", id, state)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
|
||
// Состояние не трогает — это отдельная от перехода фоновая отметка.
|
||
func (s *Store) SetSourceMissCount(ctx context.Context, id int64, n int) error {
|
||
res, err := s.DB.ExecContext(ctx,
|
||
`UPDATE download SET source_miss_count = ? WHERE id = ?`, n, id)
|
||
if err != nil {
|
||
return fmt.Errorf("set download %d source_miss_count: %w", id, err)
|
||
}
|
||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||
return fmt.Errorf("set download %d source_miss_count: not found", id)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
|
||
func nullArg(s string) any {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return s
|
||
}
|