diff --git a/docs/backlog.md b/docs/backlog.md index 1efa2dc..019e1aa 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -14,17 +14,75 @@ _(идея)_ — их сперва надо проработать. ### Проблема второго сезона Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…, -распознавание должно привязать новый сезон к **тому же** названию и папке, -а не завести рядом почти одинаковую вторую папку. Ключ — стабильный -`provider_id`: один и тот же `[tvdbid-…]` → одна папка сериала, новые -`Season NN` доливаются внутрь. Нужно: при матче учитывать уже существующие -в библиотеке сериалы (или прошлые распознавания с тем же провайдер-id) и -склонять LLM/выбор кандидата к согласованности с ними. +новый сезон должен лечь в **ту же** папку сериала, а не завести рядом почти +одинаковую вторую. Разбор ([drafts/logical-title-model.md](drafts/logical-title-model.md)) +показал: проблема не в группировке, а в **сходимости папки** — папка каждый +раз печатается заново из выхода LLM, и совпадение `provider_id` не +гарантирует совпадение строки («Fargo» vs «Фарго», год сезона vs год +сериала). Отдельная сущность «тайтл» **не вводится**; решение — правило +сходимости при построении плана: при подтверждённом матче наследовать базу +папки от живых `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) (папка сериала с провайдер-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) Распознавание **ручного** удаления (источник из qBittorrent / цель из @@ -35,13 +93,26 @@ preflight перед действиями. См. `openspec/specs/state-reconcili [workflow.md](specs/workflow.md) → «Сверка с реальностью». Осталось (path 2) — продолжение «единого окна»: удалять просмотренное -**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Нужно -продумать: команду удаления (снять наши хардлинки + опц. удалить раздачу из -qBittorrent с файлами), подтверждение осознанности (а не случайный клик) и -как это сочетается с инвариантом «источник неприкосновенен», когда -пользователь сам просит убрать источник. +**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Решения из +разбора ([drafts/logical-title-model.md](drafts/logical-title-model.md) +§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) → «Раскладка файлов», [workflow.md](specs/workflow.md). @@ -167,22 +238,6 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект [docs/conventions](conventions/README.md), [«Словарь единого языка»](#словарь-единого-языка-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}`) уже реализован — лог -переходов ложится в него; [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) (длительности стадий), [database.md](specs/database.md), пакеты `worker`, `store`. @@ -222,23 +280,6 @@ qBittorrent, пул LLM-вызовов и запись в SQLite спроект веб = точные правки), [architecture.md](specs/architecture.md) → «Транспорты». -### Раздачи с докачиванием (слияние при повторном добавлении) - -Свежий сериал часто раздают по мере выхода: торрент содержит 5 эпизодов из -10. Позже его перезаливают целиком (или добавляют недостающие серии), и -пользователь повторно добавляет тот же торрент. Нужно распознать, что это -**та же** раздача/сезон, и повторить раскладку с **слиянием**: доложить -недостающие хардлинки, не дублируя уже разложенное и не перезаписывая -существующее (инвариант «существующее не трогаем»). Перекликается с -«Проблемой второго сезона», но здесь доливаются эпизоды внутри одного -сезона, а не новый сезон. Нужно продумать: как опознать повторное -добавление (хеш торрента / провайдер-id + сезон), как сверять состав файлов -и доливать только новые. - -Связано: [«Проблема второго сезона»](#проблема-второго-сезона), -[jellyfin-layout.md](specs/jellyfin-layout.md) (раскладка, идемпотентность), -[workflow.md](specs/workflow.md) (повторный прогон загрузки). - ### Улучшения UI: показывать матч с записью метабазы Web-сторона реализована: страница загрузки `/download/{id}` и экран ревью @@ -286,20 +327,6 @@ qBittorrent, без исходящих запросов на пользоват Связано: [architecture.md](specs/architecture.md) → «Деплой» (data-том, «бекапить-и-не-терять»), пакет `store`. -### Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p) - -Фильм уже разложен, позже добавили раздачу лучшего качества — сейчас это -просто новая задача, упирающаяся в «коллизию цели → review», без понятия -«это та же вещь, заменить версию». Нужно осознанно обработать апгрейд -качества: распознать тот же тайтл, предложить замену существующей раскладки -либо сосуществование версий (Jellyfin поддерживает несколько версий одного -фильма). Близко к «докачиванию», но про качество, а не про эпизоды. - -Связано: [«Раздачи с докачиванием»](#раздачи-с-докачиванием-слияние-при-повторном-добавлении), -[jellyfin-layout.md](specs/jellyfin-layout.md) (never-overwrite, коллизия), -[architecture.md](specs/architecture.md) → «Идентификация торрента» -(репаки = разные infohash → разные задачи). - ### Глубокий healthcheck и статус зависимостей `/healthz` проверяет только сам сервис. Если qBittorrent, LLM или метабаза @@ -336,6 +363,19 @@ qBittorrent, без исходящих запросов на пользоват Связано: [architecture.md](specs/architecture.md) → «Транспорты», [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, коллизия). + ### Многоступенчатая верификация привязки _(идея)_ Несколько раз извлекать данные из раздачи и контекста разными промптами, diff --git a/docs/drafts/logical-title-model.md b/docs/drafts/logical-title-model.md new file mode 100644 index 0000000..31bac7d --- /dev/null +++ b/docs/drafts/logical-title-model.md @@ -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._