Единый источник полей отображаемого имени и один рендер полного ярлыка на всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный «Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по распознаванию — усечённый «Title (Year)». - Слоистое разрешение скаляров имени: override → recognition(+match) → новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields). - naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary. - Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст. - refreshDisplayNameLocked строит полный ярлык из эффективных полей; инфо-панель ревью показывает загруженного режиссёра. - Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку не влияет, приём/вывод имени не валятся (best-effort). Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка». OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest, recognition, metadata-match, review). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.5 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-файла),0010_retried_at,0011_parsed_context(структура имени из контекста, JSON).
Назначение таблиц и почему так — 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 parsed_context "NOT NULL DEFAULT ''; структура имени из контекста (naming, JSON), базовый слой display_name (миграция 0011)"
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 retried_at "nullable; время последнего ручного retry (RFC 3339 UTC Z), сброс базиса таймаутов (миграция 0010)"
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).