diff --git a/personal/projects/media-pipeline-file-routing.md b/personal/projects/media-pipeline-file-routing.md new file mode 100644 index 00000000..ee1a8493 --- /dev/null +++ b/personal/projects/media-pipeline-file-routing.md @@ -0,0 +1,189 @@ +--- +title: Media Pipeline — File Routing & Directory Policies +created: '2026-05-17' +updated: '2026-05-17' +status: planning +tags: + - media-pipeline + - file-routing + - transmission + - config +--- +# Media Pipeline — File Routing & Directory Policies + +## Текущее состояние (до реализации) + +Текущий конфиг уже содержит: +- `directories` — список папок для сканирования (downloads, movies, cartoons, series, documentaries) +- `transmission.sorting_rules` — genre-based routing для downloads → destination +- `transmission.default_movies_destination` / `default_series_destination` +- `output` — куда идут symlink'и library (movies, series, cartoons, documentaries) + +**Проблема:** политика действий с файлами захардкодена в `MediaFinalizer`: +- для всех директорий → всегда `symlink` в library +- нет способа сказать "для music — не делать canonical symlink" +- нет способа сказать "для downloads — переместить через Transmission; для movies — оставить на месте" +- `FileAction.move` и `TransmissionRPC.moveTorrent` существуют, но нигде не вызываются из apply-фазы + +## Цель + +Политика обработки файлов задаётся **в конфиге per-directory**, не в коде. +`MediaFinalizer` читает политику и генерирует правильный набор `FileAction`. + +## Дизайн конфига + +```json +"directory_policies": [ + { + "path": "/media/downloads", + "unsorted": true, + "on_match": { + "move": "transmission", + "destination_by_kind": { + "movie": "/media/movies", + "series": "/media/series", + "cartoon": "/media/cartoons", + "documentary": "/media/documentaries", + "music": "/media/music", + "audiobook": "/media/audiobooks", + "book": "/media/books", + "game": "/media/games" + }, + "library_symlink": true, + "nfo": true + }, + "on_unmatched": { + "move": "none" + } + }, + { + "path": "/media/movies", + "on_match": { + "move": "none", + "library_symlink": true, + "nfo": true + } + }, + { + "path": "/media/cartoons", + "on_match": { + "move": "none", + "library_symlink": true, + "nfo": true + } + }, + { + "path": "/media/series", + "on_match": { + "move": "none", + "library_symlink": true, + "nfo": true + } + }, + { + "path": "/media/documentaries", + "on_match": { + "move": "none", + "library_symlink": true, + "nfo": true + } + }, + { + "path": "/media/music", + "on_match": { + "move": "none", + "library_symlink": false, + "nfo": true + } + } +] +``` + +### `move` values +- `"transmission"` — вызвать `TransmissionRPC.moveTorrent`; если торрент не найден → fallback на `"filesystem"` +- `"filesystem"` — `FileAction.move` (прямое перемещение) +- `"none"` — файл остаётся на месте + +### `library_symlink` +- `true` — создать каноническое имя в `output.*` папке (как сейчас) +- `false` — не создавать symlink (файл сам по себе — конечная точка, напр. music) + +### `nfo` +- `true` — писать NFO рядом с файлом (если нет library_symlink) или в library (если есть) +- `false` — не писать NFO (игры, книги — нет Kodi-совместимой схемы) + +### `destination_by_kind` +Только для `unsorted: true` директорий (downloads). Для остальных не нужно — файл уже в правильном месте. +`MediaKind` определяется из resolved metadata (TMDb genre → kind mapping) или из категории торрента. + +### `on_unmatched` +Что делать если resolve не удался. По умолчанию `move: "none"` — файл остаётся в downloads. + +## Совместимость с текущим конфигом + +Старые поля (`transmission.sorting_rules`, `transmission.default_movies_destination`, `output`) сохраняются как fallback. `directory_policies` приоритетнее если задан. + +## Компоненты реализации + +### 1. `DirectoryPolicy` struct (Config.swift) +```swift +public struct DirectoryPolicy: Codable { + public let path: String + public let unsorted: Bool // defaults false + public let onMatch: MatchPolicy + public let onUnmatched: UnmatchedPolicy +} + +public struct MatchPolicy: Codable { + public let move: MoveStrategy // .transmission / .filesystem / .none + public let destinationByKind: [String: String]? // only for unsorted + public let librarySymlink: Bool // defaults true + public let nfo: Bool // defaults true +} + +public enum MoveStrategy: String, Codable { + case transmission, filesystem, none = "none" +} +``` + +### 2. `FileAction.transmissionMove` (FileAction.swift) +```swift +case transmissionMove(torrentId: Int, from: URL, to: URL) +``` + +### 3. `ApplyExecutor` — выполнение `transmissionMove` +Нужен `TransmissionRPC` в контексте apply-фазы. +При ошибке RPC — fallback на `FileAction.move`. + +### 4. `MediaFinalizer` — читает политику из конфига +```swift +func policy(for sourceURL: URL, config: Config) -> DirectoryPolicy? +``` +Вместо hardcoded `symlink` генерирует actions по политике. + +### 5. `MediaKind` detection +- Из TMDb genres через mapping → `movie`, `series`, `cartoon`, `documentary` +- Из Rutracker категории торрента → `music`, `game`, `audiobook`, `book` +- Fallback: по исходной директории (`/media/music/` → `music`) + +## Порядок реализации + +- [ ] **C1** — `DirectoryPolicy` в `Config.swift` + парсинг из JSON +- [ ] **C2** — `FileAction.transmissionMove` + выполнение в `ApplyExecutor` +- [ ] **C3** — `MediaFinalizer` — routing по политике (move + symlink + nfo) +- [ ] **C4** — `MediaKind` detection из resolved metadata +- [ ] **C5** — Обновить конфиг на Кракене +- [ ] **C6** — Тесты: downloads → movies (Transmission), movies (no move, symlink), music (no move, no symlink) + +## Связанные файлы + +- `Sources/MediaPipeline/Config/Config.swift` — добавить `DirectoryPolicy` +- `Sources/MediaPipeline/Apply/FileAction.swift` — `transmissionMove` case +- `Sources/MediaPipeline/Apply/ApplyExecutor.swift` (или аналог) — выполнение RPC +- `Sources/MediaPipeline/Apply/MediaFinalizer.swift` — routing по политике +- `Sources/MediaSources/APIs/TransmissionRPC.swift` — уже есть `moveTorrent` + +## Связанные планы + +- [[media-pipeline-tracker-parsers]] — multi-tracker (реализован) +- Шаг B (новые типы медиа) — блокирует `destination_by_kind` для music/games/books