Переработал черновик логической модели и беклог по итогам разбора (docs)
Итог explore-сессии: сущность title не вводим — download остаётся мостом qBittorrent ↔ файлы, «второй сезон» решается правилом сходимости папки, группировка тайтла вычисляется. Черновик перекроен под это решение (отвергнутые варианты и триггер пересмотра зафиксированы), задачи беклога обновлены и приоритезированы по калибровке болей. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+101
-61
@@ -14,17 +14,75 @@ _(идея)_ — их сперва надо проработать.
|
|||||||
### Проблема второго сезона
|
### Проблема второго сезона
|
||||||
|
|
||||||
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
|
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
|
||||||
распознавание должно привязать новый сезон к **тому же** названию и папке,
|
новый сезон должен лечь в **ту же** папку сериала, а не завести рядом почти
|
||||||
а не завести рядом почти одинаковую вторую папку. Ключ — стабильный
|
одинаковую вторую. Разбор ([drafts/logical-title-model.md](drafts/logical-title-model.md))
|
||||||
`provider_id`: один и тот же `[tvdbid-…]` → одна папка сериала, новые
|
показал: проблема не в группировке, а в **сходимости папки** — папка каждый
|
||||||
`Season NN` доливаются внутрь. Нужно: при матче учитывать уже существующие
|
раз печатается заново из выхода LLM, и совпадение `provider_id` не
|
||||||
в библиотеке сериалы (или прошлые распознавания с тем же провайдер-id) и
|
гарантирует совпадение строки («Fargo» vs «Фарго», год сезона vs год
|
||||||
склонять LLM/выбор кандидата к согласованности с ними.
|
сериала). Отдельная сущность «тайтл» **не вводится**; решение — правило
|
||||||
|
сходимости при построении плана: при подтверждённом матче наследовать базу
|
||||||
|
папки от живых `file_link`'ов загрузок с тем же `(provider, provider_id)`,
|
||||||
|
игнорируя LLM-выход; якоря нет — папка из распознавания, как сейчас (первая
|
||||||
|
загрузка «печатает» имя).
|
||||||
|
|
||||||
Связано: [recognition.md](specs/recognition.md) (модель уверенности,
|
- [ ] lookup живых ссылок по `(provider, provider_id)` через current recognition
|
||||||
|
- [ ] наследование базы папки (имя + год) при построении плана раскладки
|
||||||
|
- [ ] рассинхрон (несколько живых папок с одним матчем) → review, не молча
|
||||||
|
- [ ] тесты: сходимость, отсутствие якоря (свежая папка), смена провайдера
|
||||||
|
|
||||||
|
Связано: [drafts/logical-title-model.md](drafts/logical-title-model.md) §5.2,
|
||||||
|
[recognition.md](specs/recognition.md) (модель уверенности,
|
||||||
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
|
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
|
||||||
сериала с провайдер-id).
|
сериала с провайдер-id).
|
||||||
|
|
||||||
|
### Идентичность загрузки: ULID + множество инфохэшей
|
||||||
|
|
||||||
|
Фундамент для «второго сезона», «докачивания» и истории переходов (шаг 1 в
|
||||||
|
[drafts/logical-title-model.md](drafts/logical-title-model.md)). Сейчас
|
||||||
|
загрузка идентифицируется хешем торрента — это хрупко: v1/v2/гибрид дают
|
||||||
|
разные значения, перезалив/репак/докачка — другой хеш, у одной логической
|
||||||
|
загрузки хешей несколько. Вводим ULID, генерируемый при приёме, как
|
||||||
|
первичный ключ домена; инфохэши — таблица `download_infohash` «многие к
|
||||||
|
одному», на которую переезжают дедуп и поиск.
|
||||||
|
|
||||||
|
- [ ] `download.id` → TEXT ULID (миграция с заполнением существующих строк)
|
||||||
|
- [ ] таблица `download_infohash` (`download_id`, `infohash`, `kind` v1|v2, `UNIQUE(infohash)`)
|
||||||
|
- [ ] дедуп/идемпотентность через `download_infohash` (вместо `idempotency_key`)
|
||||||
|
- [ ] поиск при приёме и в поллинге — по любому из хешей
|
||||||
|
- [ ] обновить ER-схему [database.md](specs/database.md)
|
||||||
|
|
||||||
|
Связано: [drafts/logical-title-model.md](drafts/logical-title-model.md) §5.1,
|
||||||
|
[database.md](specs/database.md) (PK `download`, `infohash`),
|
||||||
|
[architecture.md](specs/architecture.md) → «Идентификация торрента», пакет
|
||||||
|
`store`.
|
||||||
|
|
||||||
|
### Раздачи с докачиванием (слияние при повторном добавлении)
|
||||||
|
|
||||||
|
Свежий сериал раздают по мере выхода: торрент содержит 5 эпизодов из 10,
|
||||||
|
позже его перезаливают целиком, и пользователь добавляет раздачу повторно.
|
||||||
|
Решение проработано ([drafts/logical-title-model.md](drafts/logical-title-model.md)
|
||||||
|
§6.2): новая загрузка приходит в ту же папку за счёт правила сходимости, а
|
||||||
|
раскладка становится **merge** — доложить только недостающее. Существующие
|
||||||
|
пути не трогаем (never-overwrite, владение остаётся у старой загрузки),
|
||||||
|
новые кладём (владеет новая). Split-ownership сезона (серии поделены между
|
||||||
|
загрузками) принят как норма per-path модели; обе раздачи сидируют
|
||||||
|
независимо.
|
||||||
|
|
||||||
|
- [ ] в плане раскладки отличать «путь занят живой ссылкой того же матча»
|
||||||
|
(→ пропустить) от настоящей коллизии (→ review, как сейчас)
|
||||||
|
- [ ] merge-раскладка: существующее пропустить, недостающее доложить
|
||||||
|
- [ ] показать итог в карточке: сколько доложено, сколько уже было
|
||||||
|
- [ ] решить «слияние загрузок» при перезаливе той же вещи (одна строка
|
||||||
|
`download` + новый infohash vs новая загрузка) — открытый вопрос
|
||||||
|
черновика §10
|
||||||
|
|
||||||
|
Зависит от правила сходимости ([«Проблема второго
|
||||||
|
сезона»](#проблема-второго-сезона)) и выигрывает от ULID-идентичности.
|
||||||
|
|
||||||
|
Связано: [jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка,
|
||||||
|
идемпотентность), [workflow.md](specs/workflow.md) (повторный прогон
|
||||||
|
загрузки).
|
||||||
|
|
||||||
### Удаление средствами jellybit («единое окно», path 2)
|
### Удаление средствами jellybit («единое окно», path 2)
|
||||||
|
|
||||||
Распознавание **ручного** удаления (источник из qBittorrent / цель из
|
Распознавание **ручного** удаления (источник из qBittorrent / цель из
|
||||||
@@ -35,13 +93,26 @@ preflight перед действиями. См. `openspec/specs/state-reconcili
|
|||||||
[workflow.md](specs/workflow.md) → «Сверка с реальностью».
|
[workflow.md](specs/workflow.md) → «Сверка с реальностью».
|
||||||
|
|
||||||
Осталось (path 2) — продолжение «единого окна»: удалять просмотренное
|
Осталось (path 2) — продолжение «единого окна»: удалять просмотренное
|
||||||
**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Нужно
|
**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Решения из
|
||||||
продумать: команду удаления (снять наши хардлинки + опц. удалить раздачу из
|
разбора ([drafts/logical-title-model.md](drafts/logical-title-model.md)
|
||||||
qBittorrent с файлами), подтверждение осознанности (а не случайный клик) и
|
§5.3, §6.4): «тайтл» — вычисляемая группа загрузок по
|
||||||
как это сочетается с инвариантом «источник неприкосновенен», когда
|
`(provider, provider_id)` / общей папке, без новой сущности; удаление
|
||||||
пользователь сам просит убрать источник.
|
целиком — обход загрузок группы штатным undo; удаление раздачи из
|
||||||
|
qBittorrent — осознанный выход за инвариант «источник неприкосновенен»,
|
||||||
|
только по явному подтверждению (не случайному клику).
|
||||||
|
|
||||||
Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
|
- [ ] удаление одной загрузки: снять её живые хардлинки (штатный undo,
|
||||||
|
`superseded` пропускаем, `nlink`-гард) + опц. удалить раздачу из
|
||||||
|
qBittorrent с файлами — с осознанным подтверждением
|
||||||
|
- [ ] вычисляемая группа «тайтл» в UI: состав сериала/фильма (загрузки,
|
||||||
|
сезоны, файлы) одним экраном
|
||||||
|
- [ ] удаление тайтла целиком: обход загрузок группы + опц. снос
|
||||||
|
опустевшей папки
|
||||||
|
- [ ] после полного удаления память о тайтле не остаётся (линза без
|
||||||
|
содержимого не нужна)
|
||||||
|
|
||||||
|
Связано: [drafts/logical-title-model.md](drafts/logical-title-model.md),
|
||||||
|
[ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
|
||||||
[architecture.md](specs/architecture.md) → «Раскладка файлов»,
|
[architecture.md](specs/architecture.md) → «Раскладка файлов»,
|
||||||
[workflow.md](specs/workflow.md).
|
[workflow.md](specs/workflow.md).
|
||||||
|
|
||||||
@@ -167,22 +238,6 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
|
|||||||
[docs/conventions](conventions/README.md),
|
[docs/conventions](conventions/README.md),
|
||||||
[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language).
|
[«Словарь единого языка»](#словарь-единого-языка-ubiquitous-language).
|
||||||
|
|
||||||
### Автогенерируемый идентификатор загрузки (ULID/UUID)
|
|
||||||
|
|
||||||
Сейчас загрузка фактически идентифицируется хешем торрента (`infohash`). Это
|
|
||||||
хрупко: у одной логической загрузки может быть **несколько** хешей
|
|
||||||
(перезаливы, докачивание, репаки, v1/v2 infohash), и привязка домена к хешу
|
|
||||||
мешает слиянию и истории. Ввести собственный стабильный идентификатор
|
|
||||||
(ULID/UUID), генерируемый при приёме, как первичный ключ домена;
|
|
||||||
`infohash`(ы) — отдельный атрибут/таблица «многие к одному», по которому
|
|
||||||
**остаётся** поиск и дедуп для обратной совместимости. Enabler для
|
|
||||||
«докачивания», «второго сезона», «версий/качества» и истории переходов.
|
|
||||||
|
|
||||||
Связано: [database.md](specs/database.md) (PK `download`, `infohash`),
|
|
||||||
[«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении),
|
|
||||||
[architecture.md](specs/architecture.md) → «Идентификация торрента», пакет
|
|
||||||
`store`.
|
|
||||||
|
|
||||||
### История переходов загрузки
|
### История переходов загрузки
|
||||||
|
|
||||||
Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто
|
Сохранять полную историю переходов состояний загрузки (что/когда/почему/кто
|
||||||
@@ -193,7 +248,10 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
|
|||||||
Естественно ложится на собственный идентификатор загрузки.
|
Естественно ложится на собственный идентификатор загрузки.
|
||||||
|
|
||||||
Связано: детальный экран загрузки (`/download/{id}`) уже реализован — лог
|
Связано: детальный экран загрузки (`/download/{id}`) уже реализован — лог
|
||||||
переходов ложится в него; [workflow.md](specs/workflow.md) (граф состояний),
|
переходов ложится в него;
|
||||||
|
[drafts/logical-title-model.md](drafts/logical-title-model.md) §5.4 (схема
|
||||||
|
`state_transition`, actor `worker|human|reconcile`),
|
||||||
|
[workflow.md](specs/workflow.md) (граф состояний),
|
||||||
[«Наблюдаемость: метрики»](#наблюдаемость-метрики-и-учёт-стоимости-llm)
|
[«Наблюдаемость: метрики»](#наблюдаемость-метрики-и-учёт-стоимости-llm)
|
||||||
(длительности стадий), [database.md](specs/database.md), пакеты `worker`,
|
(длительности стадий), [database.md](specs/database.md), пакеты `worker`,
|
||||||
`store`.
|
`store`.
|
||||||
@@ -222,23 +280,6 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект
|
|||||||
веб = точные правки), [architecture.md](specs/architecture.md) →
|
веб = точные правки), [architecture.md](specs/architecture.md) →
|
||||||
«Транспорты».
|
«Транспорты».
|
||||||
|
|
||||||
### Раздачи с докачиванием (слияние при повторном добавлении)
|
|
||||||
|
|
||||||
Свежий сериал часто раздают по мере выхода: торрент содержит 5 эпизодов из
|
|
||||||
10. Позже его перезаливают целиком (или добавляют недостающие серии), и
|
|
||||||
пользователь повторно добавляет тот же торрент. Нужно распознать, что это
|
|
||||||
**та же** раздача/сезон, и повторить раскладку с **слиянием**: доложить
|
|
||||||
недостающие хардлинки, не дублируя уже разложенное и не перезаписывая
|
|
||||||
существующее (инвариант «существующее не трогаем»). Перекликается с
|
|
||||||
«Проблемой второго сезона», но здесь доливаются эпизоды внутри одного
|
|
||||||
сезона, а не новый сезон. Нужно продумать: как опознать повторное
|
|
||||||
добавление (хеш торрента / провайдер-id + сезон), как сверять состав файлов
|
|
||||||
и доливать только новые.
|
|
||||||
|
|
||||||
Связано: [«Проблема второго сезона»](#проблема-второго-сезона),
|
|
||||||
[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка, идемпотентность),
|
|
||||||
[workflow.md](specs/workflow.md) (повторный прогон загрузки).
|
|
||||||
|
|
||||||
### Улучшения UI: показывать матч с записью метабазы
|
### Улучшения UI: показывать матч с записью метабазы
|
||||||
|
|
||||||
Web-сторона реализована: страница загрузки `/download/{id}` и экран ревью
|
Web-сторона реализована: страница загрузки `/download/{id}` и экран ревью
|
||||||
@@ -286,20 +327,6 @@ qBittorrent, без исходящих запросов на пользоват
|
|||||||
Связано: [architecture.md](specs/architecture.md) → «Деплой» (data-том,
|
Связано: [architecture.md](specs/architecture.md) → «Деплой» (data-том,
|
||||||
«бекапить-и-не-терять»), пакет `store`.
|
«бекапить-и-не-терять»), пакет `store`.
|
||||||
|
|
||||||
### Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
|
|
||||||
|
|
||||||
Фильм уже разложен, позже добавили раздачу лучшего качества — сейчас это
|
|
||||||
просто новая задача, упирающаяся в «коллизию цели → review», без понятия
|
|
||||||
«это та же вещь, заменить версию». Нужно осознанно обработать апгрейд
|
|
||||||
качества: распознать тот же тайтл, предложить замену существующей раскладки
|
|
||||||
либо сосуществование версий (Jellyfin поддерживает несколько версий одного
|
|
||||||
фильма). Близко к «докачиванию», но про качество, а не про эпизоды.
|
|
||||||
|
|
||||||
Связано: [«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении),
|
|
||||||
[jellyfin-layout.md](specs/jellyfin-layout.md) (never-overwrite, коллизия),
|
|
||||||
[architecture.md](specs/architecture.md) → «Идентификация торрента»
|
|
||||||
(репаки = разные infohash → разные задачи).
|
|
||||||
|
|
||||||
### Глубокий healthcheck и статус зависимостей
|
### Глубокий healthcheck и статус зависимостей
|
||||||
|
|
||||||
`/healthz` проверяет только сам сервис. Если qBittorrent, LLM или метабаза
|
`/healthz` проверяет только сам сервис. Если qBittorrent, LLM или метабаза
|
||||||
@@ -336,6 +363,19 @@ qBittorrent, без исходящих запросов на пользоват
|
|||||||
Связано: [architecture.md](specs/architecture.md) → «Транспорты»,
|
Связано: [architecture.md](specs/architecture.md) → «Транспорты»,
|
||||||
[review-ux.md](specs/review-ux.md), пакет `httpapi`.
|
[review-ux.md](specs/review-ux.md), пакет `httpapi`.
|
||||||
|
|
||||||
|
### Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)
|
||||||
|
|
||||||
|
По калибровке болей (2026-07-02,
|
||||||
|
[drafts/logical-title-model.md](drafts/logical-title-model.md) §6.3) —
|
||||||
|
**не боль**, из приоритета выпало. Сосуществование версий доступно уже
|
||||||
|
сейчас (Jellyfin multi-version, другой целевой путь), коллизия на тот же
|
||||||
|
путь штатно уходит в review. Явный replace (undo старого хардлинка → lay
|
||||||
|
нового → супересид владения путём) — отдельный change, если/когда станет
|
||||||
|
болью.
|
||||||
|
|
||||||
|
Связано: [«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении),
|
||||||
|
[jellyfin-layout.md](specs/jellyfin-layout.md) (never-overwrite, коллизия).
|
||||||
|
|
||||||
### Многоступенчатая верификация привязки _(идея)_
|
### Многоступенчатая верификация привязки _(идея)_
|
||||||
|
|
||||||
Несколько раз извлекать данные из раздачи и контекста разными промптами,
|
Несколько раз извлекать данные из раздачи и контекста разными промптами,
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# Черновик: идентичность загрузки и группировка тайтла (без сущности 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._
|
||||||
Reference in New Issue
Block a user