Распознаём ручное удаление источника (раздача в 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>
5.8 KiB
5.8 KiB
Схема базы данных
Актуальная схема SQLite-хранилища: таблицы, поля и связи. Это живой документ — его поддерживаем в соответствии с миграциями.
Поддержка вместе с миграциями. Источник истины по схеме —
internal/store/migrations/*.sql(goose). При каждой новой миграции, меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму в том же change. Расхождение схемы с миграциями считаем багом документации.Состояние на: миграции
0001_init,0002_recognition_plan,0003_source_miss_count.
Назначение таблиц и почему так — architecture.md →
«Хранилище». Значения state и переходы — workflow.md.
ER-диаграмма
erDiagram
download ||--o{ recognition : "распознавания"
download ||--o{ hint : "подсказки"
download ||--o{ override : "ручные правки"
download ||--o{ file_link : "хардлинки"
recognition ||--o{ metadata_candidate : "кандидаты базы"
download {
INTEGER id PK "AUTOINCREMENT"
TEXT source_type "NOT NULL; magnet|torrent|url"
TEXT source_ref "NOT NULL; magnet/url/путь"
TEXT context "NOT NULL DEFAULT ''"
TEXT infohash "nullable; может появиться позже приёма"
TEXT idempotency_key "nullable; UNIQUE если NOT NULL"
TEXT state "NOT NULL; см. workflow.md"
TEXT error_code "nullable"
TEXT error_msg "nullable"
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
TEXT updated_at "NOT NULL DEFAULT datetime('now')"
}
recognition {
INTEGER id PK "AUTOINCREMENT"
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
INTEGER attempt_no "NOT NULL DEFAULT 1"
INTEGER is_current "NOT NULL DEFAULT 1; 0/1"
TEXT media_type "nullable; movie|series"
TEXT title "nullable"
TEXT original_title "nullable"
INTEGER year "nullable"
TEXT provider "nullable; tmdb|tvdb|tvmaze|none"
TEXT provider_id "nullable"
REAL confidence "nullable"
TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто"
TEXT raw_llm "nullable; сырой ответ LLM"
TEXT plan "nullable; JSON recognize.Plan (миграция 0002)"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
}
hint {
INTEGER id PK "AUTOINCREMENT"
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT text "NOT NULL"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
}
override {
INTEGER id PK "AUTOINCREMENT"
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT field "NOT NULL; UNIQUE(download_id, field)"
TEXT value "NOT NULL"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
}
metadata_candidate {
INTEGER id PK "AUTOINCREMENT"
INTEGER recognition_id FK "NOT NULL; ON DELETE CASCADE"
TEXT provider "NOT NULL"
TEXT provider_id "NOT NULL"
TEXT title "nullable"
INTEGER year "nullable"
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
TEXT created_at "NOT NULL DEFAULT datetime('now')"
}
file_link {
INTEGER id PK "AUTOINCREMENT"
INTEGER download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT apply_batch_id "NOT NULL; батч для точечного undo"
TEXT src_path "NOT NULL; исходный файл раздачи"
TEXT dst_path "NOT NULL; целевой хардлинк"
TEXT kind "NOT NULL; video|subtitle|..."
TEXT status "NOT NULL; linked|..."
TEXT created_at "NOT NULL DEFAULT datetime('now')"
}
Связи и кардинальность
download1 — Nrecognition/hint/override/file_link;recognition1 — Nmetadata_candidate. Все дочерние — сON DELETE CASCADE: удаление загрузки уносит её распознавания, подсказки, правки и ссылки.download↔file_link— один источник (раздача) ко многим разложенным файлам; внутри строкиfile_linkсвязьsrc_path → dst_path— 1:1. Не каждый файл раздачи попадает вfile_link(только распознанные медиа и субтитры); ссылки могут накапливаться несколькимиapply_batch_id.
Индексы и ограничения
download:UNIQUE(idempotency_key) WHERE idempotency_key IS NOT NULL; индекс поstate.recognition: индекс поdownload_id.override:UNIQUE(download_id, field).metadata_candidate: индекс поrecognition_id.file_link: индексы поdownload_idи поapply_batch_id.
Enum-поля (
source_type,state,provider,kind,status, флаги0/1) на уровне SQLite — обычныйTEXT/INTEGERбезCHECK; допустимые значения держит код (internal/store).