Скан Jellyfin (POST /Library/Refresh) слался только при входе в done.
После Undo (reverted) и Delete (deleted) наши хардлинки сняты, а Jellyfin
держал битые записи до скана по расписанию.
Гейт скана в едином чекпоинте transitionErr переведён с state == done на
предикат triggersScan(state) по множеству {done, reverted, deleted}: гейт по
состоянию-цели естественно ловит пользовательские Undo/Delete и
reconcile-производный deleted, идемпотентно. target_missing/orphaned —
промежуточный рассинхрон (ждём relink/лечения) — исключены.
OpenSpec: заведена и влита дельта file-layout (требование
«Пересканирование Jellyfin после изменения библиотечных ссылок»); change
архивирован. Синк рукописных доков architecture.md/workflow.md. Тесты:
скан стреляет на reverted и deleted, молчит на входе вне множества.
Закрыта задача беклога jellyfin-skan-posle-udaleniya.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
286 lines
24 KiB
Markdown
286 lines
24 KiB
Markdown
# file-layout Specification
|
||
|
||
## Purpose
|
||
Раскладка распознанных файлов хардлинками под библиотеку Jellyfin: целевые
|
||
имена фильмов и сериалов (папка/файл, provider-id, сезоны), сопоставление
|
||
источник→цель, санитизация пути и запрет выхода за библиотеку, never-overwrite
|
||
(коллизия → review) и copy-fallback при невозможности хардлинка. Владение
|
||
целевым путём (`superseded`) и безопасный `Undo` (`nlink<=1`) —
|
||
в `state-reconciliation`.
|
||
## Requirements
|
||
### Requirement: Целевые имена фильмов
|
||
|
||
Фильм система SHALL раскладывать в папку и файл вида `Название (Год)`, помещённые
|
||
под `paths.movies`. При подтверждённом матче в базе имя папки SHALL нести
|
||
provider-id (`[tmdbid-…]`/`[tvdbid-…]`) — он снимает неоднозначность русских
|
||
названий для Jellyfin. Внешние субтитры SHALL именоваться `Имя.<lang>[.flag].srt`
|
||
(флаги `forced`/`sdh`/`default`/`hi`), с базой имени, совпадающей с именем
|
||
видеофайла; пары VobSub — `.idx` + `.sub`.
|
||
|
||
#### Scenario: Фильм с provider-id
|
||
|
||
- **GIVEN** распознанный фильм «Дюна Часть вторая» (2024) с матчем TMDB `693134`
|
||
- **WHEN** строится целевой путь
|
||
- **THEN** папка = `movies/Дюна Часть вторая (2024) [tmdbid-693134]/`
|
||
- **AND** видеофайл = `Дюна Часть вторая (2024).mkv`
|
||
|
||
### Requirement: Целевые имена сериалов
|
||
|
||
Сериал система SHALL раскладывать под `paths.series` в папку `Название (Год)` с
|
||
provider-id на папке сериала, сезонными подпапками `Season xx` и файлами вида
|
||
`Название (Год) SxxEyy`.
|
||
|
||
#### Scenario: Серия сезона
|
||
|
||
- **GIVEN** распознанный сериал «Фарго» (2024) с матчем TVDB `123456`, серия S01E02
|
||
- **WHEN** строится целевой путь
|
||
- **THEN** путь = `series/Фарго (2024) [tvdbid-123456]/Season 01/Фарго (2024) S01E02.mkv`
|
||
|
||
### Requirement: Сопоставление источник → цель хардлинками
|
||
|
||
Для каждого распознанного **файла** (не каталога) система SHALL создавать
|
||
**хардлинк** в `paths.movies`/`paths.series`; исходный путь берётся из
|
||
qBittorrent (`save_path` + относительное имя файла из `/torrents/files`, уже
|
||
включающее корневую папку многофайловой раздачи). Целевые каталоги SHALL
|
||
создаваться `mkdir` (0755, `1000:1000`). Исходный файл система НЕ SHALL трогать —
|
||
раздача продолжается, inode общий, диск не дублируется.
|
||
|
||
#### Scenario: Хардлинк не дублирует данные
|
||
|
||
- **GIVEN** видеофайл раздачи под `paths.downloads`
|
||
- **WHEN** файл раскладывается
|
||
- **THEN** в библиотеке создаётся хардлинк на тот же inode
|
||
- **AND** исходный файл остаётся на месте
|
||
|
||
### Requirement: Санитизация целевого пути и запрет выхода за библиотеку
|
||
|
||
Целевое имя система SHALL санитизировать (без разделителей пути, `..`,
|
||
управляющих символов), а финальный путь SHALL проверять на строгое нахождение под
|
||
`paths.movies`/`paths.series`. Путь, выходящий за пределы библиотеки, система НЕ
|
||
SHALL создавать. Безопасность SHALL держаться на валидации пути, а не на доверии к
|
||
выходу LLM.
|
||
|
||
#### Scenario: Traversal отклоняется
|
||
|
||
- **GIVEN** распознанное имя, содержащее `../`
|
||
- **WHEN** строится и проверяется целевой путь
|
||
- **THEN** путь отклоняется как выходящий за пределы библиотеки, хардлинк не создаётся
|
||
|
||
### Requirement: Существующую цель не перезаписываем
|
||
|
||
Существующий целевой файл система НЕ SHALL перезаписывать. Если по целевому пути
|
||
уже лежит тот же inode — операция идемпотентна (готово); если другой файл —
|
||
это коллизия, и задача SHALL уходить в `review`.
|
||
|
||
#### Scenario: Коллизия уходит в review
|
||
|
||
- **GIVEN** по целевому пути уже лежит другой файл
|
||
- **WHEN** выполняется раскладка
|
||
- **THEN** файл не перезаписывается, задача переходит в `review` с причиной коллизии
|
||
|
||
### Requirement: Copy-fallback при невозможности хардлинка
|
||
|
||
Система SHALL при невозможности хардлинка (разные ФС или ФС без поддержки жёстких
|
||
ссылок) НЕ падать, а копировать файл с предупреждением в лог, помечая ссылку
|
||
статусом `copied`.
|
||
|
||
#### Scenario: Разные ФС — копирование
|
||
|
||
- **GIVEN** целевой и исходный каталоги на разных ФС
|
||
- **WHEN** выполняется раскладка файла
|
||
- **THEN** файл копируется, ссылка получает статус `copied`, в лог пишется предупреждение
|
||
|
||
### Requirement: Запись размера разложенного файла
|
||
|
||
При линковке файла раскладка SHALL сохранять его размер в байтах вместе с
|
||
записью о созданной ссылке (`file_link`). Сохранённый размер SHALL позволять
|
||
вычислить суммарный размер разложенных файлов загрузки — он служит фолбэком
|
||
размера раздачи, когда торрента нет в qBittorrent (например, состояние
|
||
`orphaned`, где файлы библиотеки — последняя копия данных). Запись размера MUST
|
||
NOT влиять на инвариант неприкосновенности источника (по-прежнему только
|
||
`mkdir`/`link`/копия в цель).
|
||
|
||
#### Scenario: Размер сохраняется при линковке
|
||
|
||
- **WHEN** раскладка создаёт хардлинк (или копию при copy-fallback) файла
|
||
- **THEN** размер этого файла в байтах сохраняется в записи ссылки `file_link`
|
||
|
||
#### Scenario: Суммарный размер доступен без торрента
|
||
|
||
- **WHEN** у загрузки есть разложенные файлы, а её торрента нет в qBittorrent
|
||
- **THEN** суммарный размер разложенных файлов доступен как размер раздачи для
|
||
показа в карточке
|
||
|
||
### Requirement: Claim раскладки коммитится до хардлинков и устойчив к сбою учёта
|
||
|
||
Раскладка — «claim-then-side-effect»: система SHALL сперва зафиксировать переход
|
||
задачи в `linking` (claim шага раскладки), и только затем создавать хардлинки.
|
||
Если запись claim перехода в `linking` провалилась, система НЕ SHALL создавать
|
||
хардлинки и SHALL прервать раскладку, оставив задачу в исходном состоянии
|
||
(`review`/`deferred` при ручном применении; `recognizing` при авто-раскладке) —
|
||
чтобы у шага сохранился владелец, а хардлинки не легли при незакоммиченном claim
|
||
(иначе финальный переход `linking → done` из фактического состояния был бы
|
||
отклонён графом, и задача застряла бы со stale-планом).
|
||
|
||
Если хардлинки уже созданы, но запись их учёта (`file_link`) провалилась
|
||
(транзиентная ошибка хранилища), задача НЕ SHALL оставаться в `linking`: система
|
||
SHALL перевести её в `review` с причиной. Повторное применение SHALL быть
|
||
идемпотентным — уже созданные хардлинки распознаются как существующие
|
||
(`StatusExists`), а их учёт дописывается.
|
||
|
||
#### Scenario: Провал claim не создаёт хардлинков
|
||
|
||
- **GIVEN** задача в `review` с готовым источником и валидным планом
|
||
- **WHEN** запись перехода в `linking` проваливается
|
||
- **THEN** хардлинки не создаются, учёт `file_link` не пишется
|
||
- **AND** задача остаётся в `review`, а команда отказывает с ошибкой
|
||
|
||
#### Scenario: Провал учёта уводит в review, не оставляя в linking
|
||
|
||
- **GIVEN** хардлинки по плану уже созданы на файловой системе
|
||
- **WHEN** запись строк `file_link` проваливается транзиентной ошибкой
|
||
- **THEN** задача переходит в `review` с причиной (код `persist`), а не остаётся
|
||
в `linking`
|
||
- **AND** созданные хардлинки остаются на диске
|
||
- **AND** повторное «Применить» идемпотентно дописывает учёт и доводит до `done`
|
||
|
||
### 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` с причиной рассинхрона папок тайтла
|
||
|
||
### Requirement: Пересканирование Jellyfin после изменения библиотечных ссылок
|
||
|
||
При сконфигурированном пересканировании Jellyfin (секция `[jellyfin]` включена) система SHALL при входе задачи в одно из состояний множества `{done, reverted, deleted}` **неблокирующе** просить Jellyfin пересканировать медиатеку (`POST /Library/Refresh`, скан всех библиотек). Эти три состояния — точки, где раскладка задачи **улеглась** так, что видимый Jellyfin каталог мог рассинхронизироваться с диском: `done` — наши хардлинки разложены (или восстановлены сверкой); `reverted` — Undo снял наши ссылки; `deleted` — ссылки сняты (Delete) либо констатировано их отсутствие (сверка), задача терминальна.
|
||
|
||
Условие срабатывания система SHALL проверять **по состоянию-цели перехода** в
|
||
едином чекпоинте записи состояния. Такой гейт SHALL естественно покрывать как
|
||
пользовательские команды (Undo → `reverted`, Delete → `deleted`), так и
|
||
reconcile-производный `deleted` — инициатор перехода роли не играет; повторный/
|
||
лишний скан безвреден (инкрементальный скан дёшев, операция идемпотентна).
|
||
|
||
Состояния **вне** этого множества система сканировать SHALL NOT. Сюда входят как
|
||
входы, не меняющие наши ссылки (`review`, `linking`, `cancelled` через Dismiss),
|
||
так и **промежуточные состояния рассинхрона** `target_missing` и `orphaned`: там
|
||
раскладка ещё не улеглась — задача ждёт relink/восстановления и может
|
||
«залечиться» обратно в `done`, поэтому скан на них система откладывает, а не шлёт
|
||
на каждое колебание сверки. `target_missing` система не сканирует сознательно,
|
||
хотя цель там пропала: это внешняя пропажа при живом источнике, не наше снятие.
|
||
|
||
Скан система SHALL выполнять **вне** блокировки воркера, в фоновом контексте и в
|
||
отдельной горутине, со scoped-логгером задачи для корреляции. Недоступность
|
||
Jellyfin на состояние задачи влиять SHALL NOT — ошибка вызова лишь логируется
|
||
(её пишет клиент Jellyfin как запись внешнего вызова). Если пересканирование не
|
||
сконфигурировано (`[jellyfin]` выключено), скан не дёргается ни в одном из этих
|
||
переходов.
|
||
|
||
#### Scenario: Скан после раскладки
|
||
|
||
- **GIVEN** пересканирование Jellyfin включено
|
||
- **WHEN** задача входит в `done` после успешной раскладки хардлинков
|
||
- **THEN** система неблокирующе дёргает `POST /Library/Refresh`
|
||
|
||
#### Scenario: Скан после отката (Undo)
|
||
|
||
- **GIVEN** пересканирование Jellyfin включено, задача в `done` с разложенными ссылками
|
||
- **WHEN** пользователь выполняет Undo и задача входит в `reverted` (наши ссылки сняты)
|
||
- **THEN** система неблокирующе дёргает `POST /Library/Refresh`
|
||
|
||
#### Scenario: Скан после удаления (Delete)
|
||
|
||
- **GIVEN** пересканирование Jellyfin включено, задача в `done`
|
||
- **WHEN** пользователь выполняет Delete и задача входит в `deleted` (наши ссылки сняты)
|
||
- **THEN** система неблокирующе дёргает `POST /Library/Refresh`
|
||
|
||
#### Scenario: Без конфигурации Jellyfin скан не дёргается
|
||
|
||
- **GIVEN** пересканирование Jellyfin выключено (`[jellyfin]` не сконфигурировано)
|
||
- **WHEN** задача входит в `done`, `reverted` или `deleted`
|
||
- **THEN** система скан не дёргает
|
||
|
||
#### Scenario: Вход вне множества не сканирует
|
||
|
||
- **GIVEN** пересканирование Jellyfin включено
|
||
- **WHEN** задача входит в состояние вне `{done, reverted, deleted}` (например, `review` или промежуточный `target_missing`)
|
||
- **THEN** система скан не дёргает
|
||
|