При подтверждённом матче база папки (имя+год) наследуется от живой папки-якоря того же (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>
14 KiB
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)
- матч не подтверждён (
provider/provider_idпусты/none) →("", false, nil); - иначе store-метод
LiveTitleFolders(ctx, provider, providerID, excludeDownloadID)возвращаетdst_pathссылок со статусомlinked/copied/existsзагрузок с тем же(provider, provider_id)current recognition, кроме текущей; - worker сводит к различным папкам тайтла (D2), отбрасывая несуществующие на
диске (
os.Lstat, D1) и неразбираемые (D2 fallback):- 0 →
("", false, nil)— печатаем из распознавания; - 1 →
(база, false, nil); - ≥2 →
("", true, nil)— рассинхрон.
- 0 →
Реакция:
linkPlan(авто и ручное «Применить»):desync→transition(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).