Files
avandClaude Opus 4.8 1639ebfdd7 Пересканирование Jellyfin: расширить триггер на reverted и deleted
Скан 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>
2026-07-17 21:14:18 +03:00

24 KiB
Raw Permalink Blame History

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