Раскладка: сходимость папки сериала (второй сезон в ту же папку)

При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (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>
This commit is contained in:
av
2026-07-10 13:50:49 +03:00
co-authored by Claude Opus 4.8
parent 3f1a928000
commit 5c3ef79496
20 changed files with 1031 additions and 56 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-10
@@ -0,0 +1,163 @@
## 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).
@@ -0,0 +1,63 @@
## Why
Второй/третий сезон одного сериала должен ложиться в ТУ ЖЕ папку, а не заводить
рядом почти одинаковую. Сейчас имя папки печатается заново из выхода LLM при
каждой раскладке (`layout.BuildLinks` из полей плана), и совпадение
`(provider, provider_id)` не гарантирует совпадение строки папки: LLM может дать
«Fargo» и «Фарго», год сезона вместо года сериала — и верно заматченный второй
сезон уедет в соседнюю папку. Это ядро «проблемы второго сезона»: она **не про
группировку, а про сходимость папки** (см. `docs/drafts/logical-title-model.md`
§3, §5.2).
## What Changes
- Вводим **правило сходимости папки** при построении плана раскладки: при
подтверждённом матче (`provider` и `provider_id` заданы) база папки
(`Название (Год)`) **наследуется** от живых `file_link` других загрузок того
же тайтла, а выход LLM для папки игнорируется.
- Якорь берём из **существующего `dst_path`** живых ссылок (`status IN
linked/copied/exists`) загрузок, чей current recognition имеет тот же
`(provider, provider_id)` — живые пути — истина (переименовали папку → сверка
уводит ссылки в `target_missing`, якорь исчезает, следующая загрузка печатает
заново).
- Унаследованная база применяется **и к папке, и к именам файлов внутри**
(episode/movie stem), чтобы `Fargo (2014) S02E01` совпадало с
`Fargo (2014) S01E01`. Провайдер-тег `[tvdbid-…]` по-прежнему берётся из
текущего `(provider, provider_id)`.
- **Рассинхрон** (несколько РАЗНЫХ живых папок с одним `(provider, provider_id)`,
случившийся до внедрения правила) → загрузка уходит в **review** с явной
причиной, якорь молча не выбираем.
- Отдельная сущность «тайтл» **не вводится**: это join по существующим таблицам
(`file_link → download → recognition(is_current)`), без новых
сущностей/таблиц. Схема БД не меняется.
## Capabilities
### New Capabilities
<!-- нет -->
### Modified Capabilities
- `file-layout`: добавляется правило сходимости базы папки при построении
целевого пути — при подтверждённом матче база наследуется от живых ссылок того
же `(provider, provider_id)`, а не печатается из выхода распознавания; несколько
расходящихся живых папок → уход в review с причиной «рассинхрон» вместо тихого
выбора (по прецеденту коллизии, которая тоже уводит в review из file-layout).
## Impact
- **Код:** `internal/layout` (возможность построить план с унаследованной базой
папки), `internal/worker` (`linkPlan`/построение плана раскладки: lookup якоря,
ветка рассинхрон→review), `internal/store` (новый read-метод: живые целевые
папки по `(provider, provider_id)`, исключая текущую загрузку).
- **Инварианты безопасности данных:** не трогаем. `download` остаётся мостом,
выход распознавания недоверенный, безопасность — на санитизации и проверке
пути под библиотекой. Никаких новых прав на источник.
- **Схема БД:** без изменений (только новый SELECT-join).
- **Границы:** нет якоря → папка из распознавания (как сейчас); нет матча
(`provider=none`) → авто-раскладки нет, всё через review (инвариант); смена
провайдера между сезонами якорь не склеит — редкий случай, штатно review
(осознанная цена, черновик §6.1).
- **Совместимость:** поведение первой загрузки тайтла не меняется; правило
влияет только на последующие вклады при наличии живого якоря.
@@ -0,0 +1,85 @@
## ADDED Requirements
### Requirement: Сходимость базы папки при подтверждённом матче
Система SHALL при построении плана раскладки для загрузки с подтверждённым матчем (заданы `provider` и `provider_id`) наследовать **базу имени** (`Название (Год)` — строку без provider-тега) от существующей на диске папки-якоря того же тайтла, а НЕ печатать её заново из выхода распознавания. Кандидаты
в якорь — целевые пути (`dst_path`) ссылок со статусом `linked`/`copied`/`exists`
**других** загрузок (не текущей), чей current recognition имеет тот же
`(provider, provider_id)`.
Истина — папка на диске, а не запись в БД: кандидат SHALL считаться живым
якорем, только если его папка тайтла реально существует на файловой системе.
Статус ссылки в БД недостаточен — при ручном/Jellyfin-переименовании папки
запись `file_link` какое-то время остаётся `linked` (загрузка лишь позже уходит
в `target_missing` по сверке), а `dst_path` указывает на уже несуществующий путь.
Поэтому кандидат, чья папка тайтла отсутствует на диске, в якоря НЕ берётся; если
живых якорей не осталось, следующая загрузка снова печатает базу из распознавания.
Унаследованная база SHALL применяться **и к папке сериала/фильма, и к именам
файлов внутри** (episode/movie stem), чтобы серии разных сезонов совпадали по
базе (`Fargo (2014) S02E01` рядом с `Fargo (2014) S01E01`). Provider-тег на папке
(`[tvdbid-…]`) по-прежнему SHALL строиться из текущего `(provider, provider_id)`.
База тайтла извлекается из папки-якоря снятием хвостового provider-тега
` [...]`; извлечённая база прогоняется через ту же санитизацию, что и печатаемая.
Если папку-якорь нельзя разобрать (сегмент не под корнем библиотеки, база пуста),
кандидат в якоря НЕ берётся (безопасный fallback на печать из распознавания), а
не даёт искажённую базу.
Наследование SHALL происходить только при подтверждённом матче. Нет живого якоря
(первая загрузка тайтла) → база печатается из распознавания, как прежде. Нет
матча (`provider` пуст / `none`) → авто-раскладки нет (инвариант), база не
наследуется — папку на ревью выбирает человек. Правило не вводит новых сущностей
и не меняет схему БД: это выборка по существующим `file_link → download →
recognition(is_current)` плюс проверка существования папки на диске. Безопасность
по-прежнему держится на санитизации и проверке пути под библиотекой, а не на
доверии к выходу распознавания.
Разрешение базы SHALL быть единым для авто-раскладки и ручного «Применить», а
также для **предпросмотра** раскладки на ревью — чтобы превью показывало ту же
папку/имена, что даст применение (инвариант «превью = применение»). В
предпросмотре разрешение выполняется без побочных эффектов (в review из-за
рассинхрона переводит только применение, не показ).
#### Scenario: Второй сезон ложится в папку первого
- **GIVEN** первый сезон уже разложен в `series/Фарго (2014) [tvdbid-269613]/Season 01/…` (ссылки живые)
- **AND** новая загрузка со вторым сезоном имеет матч TVDB `269613`, но распознавание дало название «Fargo» и год `2017`
- **WHEN** строится план раскладки второго сезона
- **THEN** база наследуется от живого якоря: папка = `series/Фарго (2014) [tvdbid-269613]/`
- **AND** серия ложится как `Season 02/Фарго (2014) S02E01.mkv` (база в имени файла — унаследованная, а не из выхода LLM)
#### Scenario: Нет живого якоря — печатаем из распознавания
- **GIVEN** ни у одной загрузки нет живых ссылок с тем же `(provider, provider_id)`
- **WHEN** строится план раскладки при подтверждённом матче
- **THEN** база берётся из распознавания (название+год), как прежде — первая загрузка «печатает» имя папки
#### Scenario: Нет матча — сходимость не применяется
- **GIVEN** у загрузки нет подтверждённого матча (`provider` пуст / `none`)
- **WHEN** обрабатывается раскладка
- **THEN** авто-наследования базы не происходит, загрузка идёт через review (папку выбирает человек)
#### Scenario: Папка-якорь переименована на диске — печатаем заново
- **GIVEN** у загрузки-кандидата статус ссылок ещё `linked`, но её папка тайтла на диске переименована/удалена (по `dst_path` папки нет)
- **WHEN** строится план раскладки новой загрузки с тем же матчем
- **THEN** отсутствующая на диске папка в якоря не берётся
- **AND** при отсутствии других живых якорей база печатается из распознавания
#### Scenario: Превью на ревью совпадает с применением
- **GIVEN** есть живой якорь тайтла, а распознавание текущей загрузки дало иную базу
- **WHEN** на ревью открывается предпросмотр целевой раскладки
- **THEN** превью показывает папку/имена с унаследованной базой якоря — те же, что даст «Применить»
### Requirement: Рассинхрон живых папок тайтла уходит в review
Система MUST NOT молча выбирать якорь при обнаружении **нескольких РАЗНЫХ** живых целевых папок с одним `(provider, provider_id)` (рассинхрон, случившийся до внедрения правила сходимости): такая загрузка SHALL уходить в `review` с явной причиной «рассинхрон папок тайтла», чтобы человек выбрал/свёл папку вручную (по прецеденту коллизии, которая тоже уводит в review из раскладки).
#### Scenario: Две живые папки одного матча → review
- **GIVEN** для матча TVDB `269613` существуют две разные живые папки (`Фарго (2014) …` и `Fargo (2017) …`)
- **WHEN** строится план раскладки новой загрузки с этим матчем
- **THEN** раскладка не выполняется, задача переходит в `review` с причиной рассинхрона папок тайтла
@@ -0,0 +1,23 @@
## 1. Layout: приём унаследованной базы и разбор пути
- [x] 1.1 Добавить в `layout.Plan` опциональное поле `FolderBase string` (пусто → как прежде)
- [x] 1.2 В `BuildLinks`: если `FolderBase != ""` — использовать её как `base` (через `sanitizeComponent`) вместо `titleYear(Title, Year)`, применяя и к папке, и к именам файлов; тег складывать как прежде
- [x] 1.3 Экспортировать хелпер(ы): извлечь папку тайтла из `dst_path` (первый сегмент под корнем `movies`/`series`; не под корнем → пусто) и снять базу из папки (убрать хвостовой ` [...]`; пустой результат → пусто)
- [x] 1.4 Юнит-тесты layout: `FolderBase` перекрывает базу в папке и в именах файлов (movie и series); извлечение папки/базы из пути — с тегом, без тега, с чужим/старым тегом, не под корнем
## 2. Store: lookup живых ссылок тайтла
- [x] 2.1 Добавить `LiveTitleFolders(ctx, provider, providerID, excludeDownloadID) ([]string, error)`: `dst_path` ссылок со `status IN linked/copied/exists` загрузок с тем же `(provider, provider_id)` current recognition, кроме `excludeDownloadID`; пустой provider/id → пусто
- [x] 2.2 Юнит-тест store: возврат путей по матчу; исключение текущей загрузки; superseded/collision/удалённые не попадают; пустой provider/id → пусто
## 3. Worker: правило сходимости, рассинхрон, превью
- [x] 3.1 Чистый хелпер `resolveFolderBase(ctx, dl, provider, providerID, mediaType) (base string, desync bool, err error)`: нет матча → пусто; иначе `LiveTitleFolders` → свести к различным папкам тайтла (хелпер 1.3), отбросить несуществующие на диске (`os.Lstat`) и неразбираемые; 0 → пусто, 1 → база, ≥2 → desync. Без побочных эффектов.
- [x] 3.2 В `linkPlan` (из состояния `linking`, рядом с обработкой коллизии — `deferred→review` в графе нет): вызвать `resolveFolderBase`; `desync``transition(review, "title_folder_desync")`, раскладку не выполнять; иначе прокинуть `base` в `toLayoutPlan`/`layout.Plan`
- [x] 3.3 Превью раскладки на ревью (review.go ~921/981) зовёт тот же `resolveFolderBase` и наследует базу (инвариант «превью=применение»); `desync` в превью → база из распознавания, без перевода в review
- [x] 3.4 Тесты worker/integration: сходимость второго сезона в ту же папку; отсутствие якоря → свежая папка; переименование папки на диске у кандидата → не якорь (печать заново); смена провайдера → не склеивается; рассинхрон (2 живые папки) → review с причиной; превью совпадает с применением
## 4. Спеки и проверка
- [x] 4.1 `openspec validate --strict series-folder-convergence`
- [x] 4.2 `task test` и `task lint` — зелёные