## 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` (авто и ручное «Применить»): `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).