Files
jellybit/docs/drafts/logical-title-model.md
T
avandClaude Fable 5 b808ceff25 Переработал черновик логической модели и беклог по итогам разбора (docs)
Итог explore-сессии: сущность title не вводим — download остаётся мостом
qBittorrent ↔ файлы, «второй сезон» решается правилом сходимости папки,
группировка тайтла вычисляется. Черновик перекроен под это решение
(отвергнутые варианты и триггер пересмотра зафиксированы), задачи беклога
обновлены и приоритезированы по калибровке болей.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:24:39 +03:00

287 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Черновик: идентичность загрузки и группировка тайтла (без сущности title)
> **Статус:** черновик-размышление (explore), не источник истины и не принятое
> решение. Начат 2026-07-01; **переработан 2026-07-02** после второго захода
> обсуждения. Когда/если решим делать — переезжает в OpenSpec change(и) и
> `docs/specs`/`docs/adr`.
>
> **Итог разбора:** отдельную сущность `title` **не вводим**. Все целевые
> сценарии решаются идентичностью загрузки (ULID + множество инфохэшей),
> **правилом сходимости папки** при раскладке и вычисляемой группировкой.
> Отвергнутые варианты и триггер пересмотра — в §7.
## 1. Зачем это
Сейчас домен идентифицирует загрузку **инфохэшем**, а целевые файлы принадлежат
**отдельной загрузке** по целевому пути. Этого хватает для базового потока, но
плохо ложится на то, что один логический тайтл (фильм/сериал) складывается из
**нескольких загрузок** во времени: сезоны, докачивание серий, перезаливы.
Калибровка по реальным болям (зафиксирована в обсуждении 2026-07-02):
- **боль:** второй сезон должен лечь в ту же папку сериала;
- **боль:** докачивание/перезалив серий (E01–10 вместо E01–05) — доложить
недостающее;
- **боль:** удалить тайтл целиком одним действием (включая опц. раздачи);
- **не боль:** апгрейд качества — из приоритета выпадает (коллизия по-прежнему
уходит в review, coexist через Jellyfin-версии доступен).
Связано с беклогом: «Идентичность загрузки: ULID + множество инфохэшей»,
«Проблема второго сезона», «Раздачи с докачиванием», «История переходов
загрузки», «Удаление средствами jellybit (path 2)».
## 2. Что уже есть (текущая модель)
```
download (INTEGER id PK, AUTOINCREMENT)
├─ source_type, source_ref, display_name, context
├─ infohash (nullable), idempotency_key (UNIQUE если NOT NULL)
├─ state, error_code/msg, source_miss_count, source_added_at
└─ created_at / updated_at
├─(1—N)→ recognition (is_current, media_type, title, year,
│ provider, provider_id, confidence, plan JSON, …)
│ └─(1—N)→ metadata_candidate (provider, provider_id, url, chosen)
├─(1—N)→ hint / override
└─(1—N)→ file_link (apply_batch_id, src_path, dst_path, kind, status)
```
Ключевые инварианты сегодня:
- **Идентичность загрузки = infohash** (`idempotency_key`), дедуп через
`FindActiveByInfohash`. Воркер сопоставляет по трём хешам (hash/v1/v2).
- **Владение целевым путём:** один `dst_path` — один владелец-`file_link`.
`SupersedeForeignLinks(downloadID, dstPaths)` при раскладке помечает
`status='superseded'` у ссылок **других** загрузок на те же пути
(last-writer-owns). Статусы: `linked|copied|exists|collision|superseded`.
- **Источник неприкосновенен**, **существующее не перезаписываем**
(`collision` → review), **откат снимает лишний хардлинк, а не последнюю
копию** (`nlink<=1` → отказ).
- **Сверка «источник × цель»** двигает рассинхрон в
`target_missing`/`orphaned`/`deleted`.
Владеют **путями**, а не «папкой сериала» — поэтому разные сезоны (разные пути)
уже сосуществуют без конфликтов, супересида между ними нет.
## 3. Что не решено сегодня
- **Сходимость папки.** Папка строится каждый раз заново из выхода
распознавания (`internal/layout/name.go`): `"Название (Год) [tmdbid-123]"`.
Совпадение `provider_id` **не гарантирует** совпадение строки папки: LLM
может дать «Fargo» и «Фарго», год сезона вместо года сериала — и второй
сезон уедет в соседнюю папку при верном матче. Это ядро «проблемы второго
сезона»: она **не про группировку, а про сходимость папки**.
- **Докачивание** — «просто новая загрузка», упирающаяся в коллизию цели →
review, без логики «доложить недостающее».
- **«Удалить сериал целиком»** — ручной сбор всех причастных загрузок.
- **Идентичность на infohash хрупкая** (v1/v2/гибрид, перезаливы) — см. §6.
## 4. Итог разбора: почему БЕЗ сущности title
Главный аргумент: **download — мост между раздачей в qBittorrent и набором
файлов на диске**, и каждая сущность цепочки отвечает на свои операции:
```
qBittorrent ──1:1── download ──владение──▶ файлы на диске
(раздача) (мост) (пути)
pause/cancel/retry FSM, ULID undo/relay, per-path
```
У `title` при разборе **не нашлось ни одной собственной операции**: сходимость
папки — правило при построении плана; merge докачивания — per-path логика;
удаление целиком — цикл по вычисляемой группе. Сущность без собственных
операций — это линза, а линзу достаточно вычислять, не хранить.
Второе: «папка — это title-уровневое состояние, ей нужен дом» (аргумент за
хранимый title) разбивается о то, что **дом у папки уже есть** — файловая
система и `dst_path` живых `file_link`'ов. Реестр дублировал бы то, что и так
записано в БД в N экземплярах. Причём вычисляемый якорь **корректнее**
хранимого: если все файлы сериала снесли, живых ссылок нет — и новая загрузка
честно создаёт свежую папку; хранимый `title.folder` указывал бы в пустоту.
Третье: отказ от сущности **устраняет** (а не решает) целый хвост развилок:
жизненный цикл тайтла (рождение/смерть/пустой тайтл), слияние тайтлов, ad-hoc
тайтл без провайдера, обратная миграция существующих строк, title-лог.
## 5. Целевая модель
Три элемента: стабильная идентичность загрузки, правило сходимости папки,
вычисляемая группировка. Плюс опциональная история переходов.
### 5.1 Идентичность: ULID + download_infohash
```
download download_infohash
id TEXT PK (ULID, генерим download_id FK→download
при приёме) infohash TEXT
…остальное как сейчас, kind v1|v2
минус idempotency_key UNIQUE(infohash) ← дедуп переезжает сюда
```
- `download.id` = ULID — публичный стабильный ключ домена; переживает
перезаливы, не завязан на хеш.
- `download_infohash` — множество хешей одной загрузки (v1/v2, в будущем —
«этот перезалив — та же загрузка»). Поиск при приёме и в поллинге — по
любому из хешей.
### 5.2 Правило сходимости папки
При построении плана раскладки для загрузки с **подтверждённым матчем**
`(provider, provider_id)`:
```
1. найти ЖИВЫЕ file_link'и (status IN linked|copied|exists) загрузок,
чей current recognition имеет тот же (provider, provider_id)
2. есть → база папки (имя+год) наследуется из существующего dst_path;
LLM-выход для папки игнорируется ← якорь
3. нет → папка из распознавания, как сейчас ← первая
загрузка «печатает» имя, остальные наследуют
```
- Это join по существующим таблицам (`file_link → download →
recognition(is_current)`), **ни одной новой сущности**.
- Правило локальное: download остаётся мостом, распознавание — недоверенным,
безопасность — на валидации пути (инварианты не трогаем).
- Человек/Jellyfin переименовал папку на диске → сверка переведёт ссылки в
`target_missing` → якорь исчезает → следующая загрузка печатает заново.
Истина — живые пути, отдельного «источника истины по папке» нет.
- Без подтверждённого матча авто-раскладки нет (инвариант) → раскладка идёт
через review, папку выбирает человек. Сходимость «без базы» не автоматизируем.
### 5.3 Вычисляемая группировка (тайтл как линза)
- «Из чего состоит сериал» = `GROUP BY (provider, provider_id)` текущих
распознаваний с живыми ссылками; эквивалентно — по общей папке в `dst_path`.
- «Удалить целиком» = перечислить загрузки группы → штатный undo каждой
(`superseded` пропускаем — путь у другого владельца; `nlink<=1` — отказ) →
опц. удалить раздачи из qBittorrent (осознанный выход за инвариант «источник
неприкосновенен», только по явному подтверждению) → опц. снести опустевшую
папку.
- На домашнем масштабе `GROUP BY` бесплатен; денормализации не нужны.
### 5.4 История переходов (опционально, дёшево)
```
state_transition (download_id, from_state, to_state, reason, actor, at)
actor ∈ {worker, human, reconcile}
```
Питает таймлайн на `/download/{id}` и метрики длительности стадий. Композиция
тайтла во времени («B долил Season 02») выводима из `download` + `file_link` +
`state_transition` — отдельный лог не нужен.
## 6. Разбор операций
### 6.1 Второй сезон
```
S1 ──lay──▶ …/Fargo (2014) [tvdbid-269613]/Season 01/… (владеет A)
S2: матч tvdb=269613 → живые ссылки A найдены → папка унаследована
S2 ──lay──▶ …/Fargo (2014) [tvdbid-269613]/Season 02/… (владеет B)
```
Пути не пересекаются → супересида нет, A не трогаем. Сходимость дало правило
§5.2, группировку — линза §5.3.
Принятая цена: если S1 заматчился через один провайдер, а S2 — через другой
(смена конфига метабаз), якорь по `(provider, provider_id)` не склеит — случай
редкий, штатно уходит в review.
### 6.2 Докачивание серий (merge)
```
существует: Season 01/E01..E05 (владеет A)
C приносит: Season 01/E01..E10 (та же папка — за счёт сходимости)
merge: E01..E05 — уже есть → не перезаписываем (владение у A)
E06..E10 — кладём (владеет C)
```
Целевая merge-логика: **доложить только недостающее**. Владение сезоном
делится между A и C по путям — нормально в per-path модели (split-ownership
принят как дефолт). Обе раздачи сидируют независимо.
### 6.3 Апгрейд качества — вне приоритета
Не боль. Коллизия на тот же `dst_path` по-прежнему → review; сосуществование
версий (Jellyfin multi-version, другой `dst`) доступно без спец-логики. Явный
replace (undo старого → lay нового → супересид) — отдельный change, если/когда
понадобится.
### 6.4 Удаление (частичное и целиком)
Частичное (одна загрузка/сезон) — уже штатный undo. Целиком — по группе §5.3.
Никакой «памяти о тайтле» после полного удаления не остаётся — и не должно
(линза без содержимого не нужна; «список того, что смотрел» — дрейф в
медиатеку, см. §7).
## 7. Отвергнутые варианты и триггер пересмотра
Разбирались и были отвергнуты (2026-07-02):
- **L2: `title` с ключом `(provider, provider_id)`** — привязывает
долгоживущую сущность к провайдеру, который может смениться.
- **L2-min: `title` со своим ULID + `title_external_id`** (провайдерные ID —
множество-атрибут, симметрично `download_infohash`). Красивая схема: решает
смену провайдера, ad-hoc тайтлы, слияние. Отвергнута потому, что у тайтла
**нет собственных операций** (§4) — все сценарии закрылись правилом
сходимости и вычисляемой группировкой, а сущность тянула жизненный цикл,
миграцию и четыре развилки.
- **L3 (title-центрично, медиатека)** — сонарр, осознанно не идём: не ходим в
индексеры, не мониторим тайтлы, не ведём профили качества, контент приносит
пользователь. См. таблицу ответственности в истории документа (git) либо
BRIEF.
**Триггер пересмотра** (чтобы не гонять этот круг заново): сущность `title`
возвращается в обсуждение, только когда появится **операция или состояние,
которому реально негде жить** в download+file_link — например, «переименовать
сериал целиком с переносом ссылок» как регулярное действие или заметки уровня
группы. До того — вычисляем.
## 8. Идентичность: ULID vs infohash (памятка)
infohash надёжен как ключ конкретной метадаты-раздачи в одном инстансе
qBittorrent, но: v1/v2/гибрид дают разные значения; перезалив/репак/докачка →
другой хеш; один логический объект → много хешей. Поэтому доменный PK — ULID,
а инфохэши — many-to-one атрибут (§5.1).
## 9. Этапность (не обязательство)
```
1. ULID загрузки + download_infohash (дедуп переезжает). ← фундамент
2. правило сходимости папки при плане раскладки. ← «второй сезон»
3. merge-раскладка (докачивание: доложить недостающее). ← §6.2
4. группа «тайтл» в UI (вычисляемая) + удаление целиком (path 2). ← §6.4
(state_transition — вставить, когда захочется таймлайн/метрики)
```
Каждый шаг — отдельный OpenSpec change; 1–2 самодостаточны и закрывают главную
боль.
## 10. Открытые вопросы (оставшиеся)
- **Несколько живых папок с одним `(provider, provider_id)`** (уже случившийся
рассинхрон до внедрения сходимости): какой якорь брать — самую свежую, самую
населённую, или отдавать в review? Скорее review: молча выбирать нехорошо.
- **Слияние загрузок при перезаливе «той же вещи»**: когда несколько инфохэшей
считать одной загрузкой (одна строка `download` + много `infohash`) vs
разными загрузками? Влияет на семантику `download_infohash` и merge §6.2.
- **Явный replace при апгрейде** — отложен целиком; вернуться, если станет
болью.
## 11. Мини-словарь (для согласованности имён)
- **Тайтл** — логический фильм/сериал; **вычисляемая группа** загрузок по
`(provider, provider_id)` / общей папке, не хранимая сущность.
- **Загрузка (download)** — один приём/раздача-вклад; свой ULID; несколько
инфохэшей; мост qBittorrent ↔ файлы.
- **Владение путём** — `file_link` отвечает за конкретный `dst_path`.
- **Супересид** — переход владения путём к более новой загрузке.
- **Сходимость папки** — наследование базы папки от живых ссылок с тем же
`(provider, provider_id)` вместо выхода LLM.
---
_Дальше по этому черновику: при желании — `opsx:propose` на шаг 1 (ULID +
download_infohash) как фундамент; шаг 2 (сходимость папки) — следующим
отдельным change._