# Черновик: идентичность загрузки и группировка тайтла (без сущности 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 — вставить, когда захочется таймлайн/метрики) ``` > Шаг 2 (правило сходимости папки) реализован — change > `openspec/changes/archive/2026-07-10-series-folder-convergence/`, требования > влиты в `openspec/specs/file-layout/`. Отличие от §5.2 черновика: живость якоря > определяется существованием папки на диске (`os.Lstat`), а не только статусом > ссылки; рассинхрон нескольких живых папок → review; in-app разрешение > рассинхрона осознанно вне scope (ручной фикс на диске). Каждый шаг — отдельный 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._