Files
jellybit/openspec/changes/archive/2026-07-10-series-folder-convergence/design.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

14 KiB
Raw Blame History

Context

Раскладка строит целевой путь layout.BuildLinks(Plan): из Plan.Title/Year собирается база Название (Год), к ней провайдер-тег → папка, и та же база идёт в имена файлов (movieDst/seriesDst через titleYear/episodeStem). План раскладки собирается в internal/worker: effectivePlan(id) даёт recognize.Plan (с применёнными override) и провайдер-тег, linkPlan зовёт toLayoutPlan + BuildLinks + Apply и двигает FSM.

Проблема — база печатается заново из выхода распознавания на каждой раскладке; совпадение (provider, provider_id) не даёт совпадения строки папки (см. docs/drafts/logical-title-model.md §3). Решение (§5.2 черновика) — правило сходимости: при подтверждённом матче наследовать базу от живых ссылок того же тайтла. Отдельная сущность «тайтл» не вводится (§4, §7 — решено).

Goals / Non-Goals

Goals:

  • Второй/последующий вклад в тайтл с тем же (provider, provider_id) ложится в ту же папку, что и живые ссылки, включая совпадение баз в именах файлов.
  • Якорь — живой dst_path (истина на диске), не поля распознавания.
  • Рассинхрон (≥2 разных живых папок одного матча) → review, не тихий выбор.
  • Никаких изменений схемы БД и инвариантов безопасности данных.

Non-Goals:

  • Merge-докачка (доложить недостающее) — отдельный change (§6.2 черновика).
  • Группировка «тайтл» в UI и удаление целиком — отдельный change (§6.4).
  • Склейка при смене провайдера между сезонами — осознанно не решаем (§6.1).
  • Сходимость без матча (provider=none) — не автоматизируем (инвариант).
  • In-app разрешение рассинхрона (выбор/сведение среди расходящихся живых папок через UI) — вне scope: рассинхрон редкий (pre-existing до внедрения), remedy — ручное переименование папки на диске, после чего сверка убирает лишний якорь; review показывает причину. Команду-сведение вводить не будем.

Decisions

D1. Якорь — из dst_path живых ссылок, а «живость» — по диску

Базу берём из существующего целевого пути ссылки, а не из recognition.title/year загрузки-якоря. Причина: путь на диске — единственная истина о том, где реально лежит папка; поля recognition при ручном переименовании указывали бы в пустоту.

Важно (правка по ревью дизайна): статус ссылки в БД — НЕ признак живости папки. target_missing — состояние загрузки, а не статус ссылки: при переименовании/удалении папки строки file_link остаются linked/copied/exists (они нужны для самовосстановления в done), а dst_path уже указывает в пустоту; загрузка уйдёт в target_missing лишь на следующем тике сверки (reconcile.targetPresent определяет присутствие динамически через os.Lstat). Поэтому кандидаты отбираем по статусу ссылки + матчу (дешёвый SQL), но перед использованием как якорь проверяем существование папки тайтла на диске (os.Lstat) — в духе targetPresent. Отсутствующая на диске папка в якоря не идёт. Это устойчиво к любым будущим статусам и к лагу сверки, ценой одного stat на кандидата (кандидатов единицы).

Альтернатива (отвергнута): фильтровать в SQL по состоянию загрузки (done/orphaned in; target_missing/deleted/… out) — работает, но зависит от тика сверки (окно рассинхрона БД↔диск) и хрупко к добавлению новых состояний. Альтернатива (отвергнута): наследовать recognition.title/year — расходится с диском при переименовании и требует знать override'ы якоря.

D2. Извлечение базы из пути

Из dst_path берём папку тайтла — первый компонент под корнем библиотеки (series/movies): обрезка корня + первый сегмент относительного пути. Считается одинаково для сериала (root/Папка/Season NN/файл) и фильма (root/Папка/файл).

База получается снятием хвостового provider-тега [...] (а не сверкой с текущим тегом). Так надёжнее к граничному случаю (правка по ревью): если загрузку-якорь переоценили на другой provider_id и не переразложили, её папка несёт старый тег, а текущий тег уже иной — сверка «снять именно текущий тег» не нашла бы суффикс и вернула бы базу с застрявшим тегом (потом двойной тег). Снятие любого хвостового [...] даёт чистую базу; текущий тег добавляется при построении папки как обычно. Полученная база — уже санитизированная строка с диска, повторный sanitizeComponent идемпотентен.

Fallback: если сегмент не под корнем, папку не удалось выделить, или база после снятия тега пуста — кандидат не считается якорем (печатаем из распознавания), а не даём искажённую базу.

D3. Проброс унаследованной базы в layout

В layout.Plan добавляется опциональное поле FolderBase string. Пусто — поведение как прежде (base = titleYear(Title, Year)). Непусто — base берётся из FolderBase (после sanitizeComponent), и эта база идёт и в папку, и в имена файлов. Провайдер-тег складывается как прежде из ProviderTag. Так layout остаётся «глупой» — про сходимость ничего не знает, лишь принимает готовую базу; семантика (lookup, рассинхрон) — в worker.

Альтернатива (отвергнута): реверс базы в Title+Year — неоднозначно, если название само оканчивается на (NNNN).

D4. Точка внедрения — внутри linkPlan (состояние linking)

Разрешение якоря — read-операция, но уход в review при рассинхроне делаем из состояния linking (внутри linkPlan, рядом с обработкой коллизии), НЕ до claim. Причина (правка по второму ревью): apply вызывается из review и deferred, а ребра deferred → review в графе FSM (allowedTransitions) нет — прямой desync-переход до claim из deferred был бы отклонён графом. Коллизия уже решает это тем же способом: claim linking, затем linking → review (это ребро легально). Проверка сходимости встаёт туда же, ценой лишней claim-записи в редком desync-случае (как у коллизии) — осиротевший linking исключён, т.к. переход синхронный под w.mu.

Чистый хелпер worker (без побочных эффектов, только чтение БД+ФС): resolveFolderBase(ctx, downloadID, provider, providerID, mediaType) (base string, desync bool, err error)

  1. матч не подтверждён (provider/provider_id пусты/none) → ("", false, nil);
  2. иначе store-метод LiveTitleFolders(ctx, provider, providerID, excludeDownloadID) возвращает dst_path ссылок со статусом linked/copied/exists загрузок с тем же (provider, provider_id) current recognition, кроме текущей;
  3. worker сводит к различным папкам тайтла (D2), отбрасывая несуществующие на диске (os.Lstat, D1) и неразбираемые (D2 fallback):
    • 0 → ("", false, nil) — печатаем из распознавания;
    • 1 → (база, false, nil);
    • ≥2 → ("", true, nil) — рассинхрон.

Реакция:

  • linkPlan (авто и ручное «Применить»): desynctransition(review, "title_folder_desync") из linking, раскладку не выполняем; иначе строим план с base.
  • Превью на ревью (D5): тот же хелпер напрямую; desync → показываем базу из распознавания (информационно, без перевода в review), иначе — унаследованную.

Store-метод отдаёт сырые dst_path (снятие тега и проверка диска — в worker, где известны корни movies/series). Store не знает про layout-именование.

D5. Область — auto, manual apply и превью едины

linkPlan — общий путь для авто-раскладки и ручного «Применить»; оба зовут resolveFolderBase. Превью раскладки в review (toLayoutPlan+BuildLinks для показа, review.go:921/981) зовёт тот же хелпер и наследует базу — иначе нарушился бы инвариант «превью = применение» (реши́ли по ревью дизайна): пользователь увидел бы Fargo (2017)/…, а «Применить» дал бы Фарго (2014)/…. Разрешение чистое, поэтому переиспользуется без риска побочных эффектов; рассинхрон в превью не переводит задачу в review (это делает только применение).

Risks / Trade-offs

  • Гонка двух загрузок одного тайтла без якоря (оба печатают базу одновременно, LLM дал разные строки) → две папки, дальше рассинхрон → review. → Митигация: worker сериализует раскладку под единой блокировкой (w.mu); вторая уже увидит живой якорь первой. Полностью не исключено при параллельном первом заведении — приемлемо (редко), ловится рассинхроном.
  • Смена провайдера между сезонами (§6.1) — якорь по (provider, provider_id) не склеит → новая папка/через review. → Осознанная принятая цена.
  • Парсинг пути (снятие тега/корня) — хрупок к нестандартным путям. → Митигация: путь строит сам layout по фиксированной схеме; извлечение обратной операцией по тем же корню/тегу. Если сегмент не под корнем — трактуем как «нет якоря» (safe: печатаем из распознавания), не падаем.

Migration Plan

Изменение чистое (новый SELECT + опц. поле плана), схему БД не трогает, обратной миграции данных не требует. Уже разложенные до внедрения тайтлы с одной живой папкой сразу получают сходимость; с несколькими — рассинхрон-review при следующем вкладе (штатно). Откат — обычный откат коммита.

Open Questions

  • Политика склейки при смене провайдера — отложена (§10 черновика), вне scope.

Решено по ревью дизайна: превью выравниваем с наследованием (D5); in-app разрешение рассинхрона — вне scope, ручной фикс на диске (Non-Goals).