Принимаем .torrent как загруженные байты — через файл-пикер в веб-форме и Telegram-документ, наряду с magnet. Файл несёт полные метаданные: работает там, где magnet не резолвится (закрытые трекеры, без DHT), и даёт максимум контекста для распознавания без сети. - internal/torrent: парсер поверх anacrolix/torrent/metainfo — инфохэш(и) (v1 SHA1 исходных байтов info; v2 BEP52 при наличии) + Context() из имени, дерева файлов, размера, трекеров. Извлечение файлов панико-безопасно (недоверенный вход). - Персистентность байтов: таблица-спутник download_torrent (миграция 0009); пишется в транзакции создания загрузки, только на ветке создания (не при дедупе). Байты живут весь срок строки — нужны для повторного добавления при retry. - ingest: Request.TorrentData/TorrentName, диспетч парсера; source_ref — человекочитаемый референс (имя раздачи/файла), не адрес добавления. - worker: общий sourceAddParts ветвит по source_type в ОБОИХ add-путях — processCatched и Retry (torrent добавляется файлом, не magnet-хешем). - Транспорты: multipart-форма с файл-пикером (деградация без JS) и приём Telegram-документа (скачивание с редактированием токена из ошибок — секрет не в логи; обработка до ветки pending/текста). Разработка по OpenSpec (SDD): change torrent-file-ingest, два чекпоинта ревью (дизайн до кода, код до архива) сабагентами; дельты влиты в спеки, change архивирован. Ручная проверка на живом qBittorrent (7.3) — за деплоем. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.1 KiB
Схема базы данных
Актуальная схема SQLite-хранилища: таблицы, поля и связи. Это живой документ — его поддерживаем в соответствии с миграциями.
Поддержка вместе с миграциями. Источник истины по схеме —
internal/store/migrations/*.sql(goose). При каждой новой миграции, меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму в том же change. Расхождение схемы с миграциями считаем багом документации.Состояние на: миграции
0001_init,0002_recognition_plan,0003_source_miss_count,0004_candidate_url,0005_display_name,0006_ulid_identity(Go-миграция: ULID-идентификаторы,download_infohash),0007_file_link_size,0008_rfc3339_time(метки времени → RFC 3339 UTC,DEFAULTубран),0009_download_torrent(байты.torrent-файла).
Назначение таблиц и почему так — architecture.md →
«Хранилище». Значения state и переходы — workflow.md.
Первичные ключи — ULID (TEXT, lowercase), генерятся приложением
(internal/ident) — см. конвенцию. Метки времени
(created_at/updated_at) — TEXT в RFC 3339, UTC (суффикс Z); пишет
приложение (store.Now/FormatTime), без DEFAULT на колонках.
ER-диаграмма
erDiagram
download ||--o{ download_infohash : "инфохэши (v1/v2)"
download ||--o| download_torrent : "байты .torrent (1:0..1)"
download ||--o{ recognition : "распознавания"
download ||--o{ hint : "подсказки"
download ||--o{ override : "ручные правки"
download ||--o{ file_link : "хардлинки"
recognition ||--o{ metadata_candidate : "кандидаты базы"
download {
TEXT id PK "ULID (lowercase), генерится приложением"
TEXT source_type "NOT NULL; magnet|torrent|url"
TEXT source_ref "NOT NULL; magnet/url/путь"
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
TEXT context "NOT NULL DEFAULT ''"
TEXT state "NOT NULL; см. workflow.md; активность выводится только из state"
TEXT error_code "nullable"
TEXT error_msg "nullable"
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
download_infohash {
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; PK(infohash, download_id)"
TEXT infohash PK "NOT NULL; lowercase hex (40 — v1, 64 — v2)"
TEXT kind "NOT NULL; v1|v2"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
download_torrent {
TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; байты source_type=torrent"
BLOB data "NOT NULL; исходные байты .torrent для добавления файлом (миграция 0009)"
}
recognition {
TEXT id PK "ULID"
TEXT 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; RFC 3339 UTC (Z), пишет приложение"
}
hint {
TEXT id PK "ULID"
TEXT download_id FK "NOT NULL; ON DELETE CASCADE"
TEXT text "NOT NULL"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
override {
TEXT id PK "ULID"
TEXT 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; RFC 3339 UTC (Z), пишет приложение"
}
metadata_candidate {
TEXT id PK "ULID"
TEXT recognition_id FK "NOT NULL; ON DELETE CASCADE"
TEXT provider "NOT NULL"
TEXT provider_id "NOT NULL"
TEXT title "nullable"
INTEGER year "nullable"
TEXT url "nullable; ссылка на страницу на сайте провайдера"
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
file_link {
TEXT id PK "ULID"
TEXT 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|..."
INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи"
TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение"
}
Связи и кардинальность
download1 — Ndownload_infohash/recognition/hint/override/file_link;recognition1 — Nmetadata_candidate. Все дочерние — сON DELETE CASCADE: удаление загрузки уносит её хеши, распознавания, подсказки, правки и ссылки.download_infohash— множество хешей одной загрузки (v1/v2 гибридного торрента); один и тот же infohash может принадлежать нескольким загрузкам во времени (повторный приём после терминального состояния). Инвариант «не более одной активной загрузки на infohash» держат guarded-методы store (CreateDownloadIfNoActive/ActivateIfNoOtherActive) в одной write-транзакции — на уровне схемы он не выражается (условие наstate).download1 — 0..1download_torrent— байты исходного.torrent(только уsource_type=torrent); нужны воркеру для добавления раздачи файлом и для повторного добавления при retry, поэтому живут весь срок строки загрузки.ON DELETE CASCADE— страховка на будущий delete-путь (сейчас загрузки не удаляются).download↔file_link— один источник (раздача) ко многим разложенным файлам; внутри строкиfile_linkсвязьsrc_path → dst_path— 1:1. Не каждый файл раздачи попадает вfile_link(только распознанные медиа и субтитры); ссылки могут накапливаться несколькимиapply_batch_id.
Индексы и ограничения
download: индекс поstate.download_infohash: PK(infohash, download_id)(он же индекс поиска по хешу); индекс поdownload_id.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).