Итог параллельной волны фиксов (worktree-изоляция, cherry-pick в master): - ingest-dedup-integrity (F1, F6) → спека ingest - retry-stall-basis (MAJOR-1, MAJOR-2) → спека state-reconciliation - linking-transition-robustness (MAJOR-4, MINOR-7) → спеки file-layout и state-reconciliation Дельты влиты в openspec/specs, changes перенесены в openspec/changes/archive/2026-07-08-*. Беклог не трогаю (по решению). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
147 lines
10 KiB
Markdown
147 lines
10 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`
|
||
|