Files
jellybit/docs/drafts/logical-title-model.md
T
avandClaude Opus 4.8 5c3ef79496 Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (provider, provider_id) вместо печати заново из выхода
LLM — так второй/последующий сезон ложится в ТУ ЖЕ папку, а не заводит
рядом почти одинаковую. Отдельная сущность «тайтл» не вводится.

- layout: Plan.FolderBase перекрывает базу в папке и именах файлов;
  TitleFolder разбирает dst_path в папку тайтла и базу (снятие тега).
- store: LiveTitleFolders — dst_path живых ссылок того же матча.
- worker: resolveFolderBase (живость якоря — по наличию папки на диске,
  os.Lstat, а не по статусу ссылки в БД) в linkPlan и в превью ревью
  (превью=применение); рассинхрон нескольких живых папок → review из
  linking (deferred→review в графе нет).

Схема БД не менялась. Change series-folder-convergence заархивирован,
требования влиты в openspec/specs/file-layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:50:49 +03:00

21 KiB
Raw Blame History

Черновик: идентичность загрузки и группировка тайтла (без сущности 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.