Files
jellybit/openspec/specs/file-layout/spec.md
T
avandClaude Opus 4.8 4cc4de4269 OpenSpec: архивация трёх параллельных changes + синк спек
Итог параллельной волны фиксов (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>
2026-07-08 17:21:22 +03:00

10 KiB
Raw 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