Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой папки-якоря того же (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:
@@ -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).
|
||||
Reference in New Issue
Block a user