# 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 именоваться `Имя.[.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** суммарный размер разложенных файлов доступен как размер раздачи для показа в карточке