Скан 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>
24 KiB
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 система скан не дёргает