- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
(added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
6.2 KiB
Markdown
125 lines
6.2 KiB
Markdown
# Схема базы данных
|
||
|
||
Актуальная схема SQLite-хранилища: таблицы, поля и связи. Это **живой**
|
||
документ — его поддерживаем в соответствии с миграциями.
|
||
|
||
> **Поддержка вместе с миграциями.** Источник истины по схеме —
|
||
> `internal/store/migrations/*.sql` (goose). При **каждой** новой миграции,
|
||
> меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму
|
||
> в том же change. Расхождение схемы с миграциями считаем багом
|
||
> документации.
|
||
>
|
||
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
|
||
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`.
|
||
|
||
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
||
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
||
|
||
## ER-диаграмма
|
||
|
||
```mermaid
|
||
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 display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
|
||
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 source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
||
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"
|
||
TEXT url "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')"
|
||
}
|
||
```
|
||
|
||
## Связи и кардинальность
|
||
|
||
- `download` 1 — N `recognition` / `hint` / `override` / `file_link`;
|
||
`recognition` 1 — N `metadata_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`).
|