Files
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

164 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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).