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