jellyplex-sync 0.2.2__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/PKG-INFO +91 -8
  2. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/README.md +90 -7
  3. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/pyproject.toml +1 -1
  4. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/__init__.py +20 -0
  5. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/cli/sync.py +93 -1
  6. jellyplex_sync-0.3.2/src/jellyplex_sync/compare.py +82 -0
  7. jellyplex_sync-0.3.2/src/jellyplex_sync/disambig.py +190 -0
  8. jellyplex_sync-0.3.2/src/jellyplex_sync/discover.py +160 -0
  9. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/jellyfin.py +13 -1
  10. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/json_output.py +77 -1
  11. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/library.py +16 -1
  12. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/materializer.py +54 -0
  13. jellyplex_sync-0.3.2/src/jellyplex_sync/plan.py +88 -0
  14. jellyplex_sync-0.3.2/src/jellyplex_sync/planner.py +212 -0
  15. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/plex.py +8 -1
  16. jellyplex_sync-0.3.2/src/jellyplex_sync/realize.py +223 -0
  17. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/sync.py +323 -365
  18. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/LICENSE +0 -0
  19. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/cli/__init__.py +0 -0
  20. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/model.py +0 -0
  21. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/py.typed +0 -0
  22. {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.2}/src/jellyplex_sync/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jellyplex-sync
3
- Version: 0.2.2
3
+ Version: 0.3.2
4
4
  Summary: Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror
5
5
  Author: Stefan Schönberger
6
6
  Author-email: Stefan Schönberger <stefan@sniner.dev>
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  Can't decide between Jellyfin and Plex? This tool might help. It synchronizes your **movie library** between Jellyfin and Plex formats in **both directions** — without duplicating any files. Instead, it uses **hardlinks** to mirror your collection efficiently, saving storage while keeping both libraries in sync.
20
20
 
21
- > ⚠️ **0.2.0 is a major rewrite.** Subcommands, new materializer options, machine-readable `--json` output, default-sync-everything behavior, and a renamed model field (`tags` → `labels` in the Python API). If you depend on the old shape, **try the new version with `--dry-run` first**, and pin to a `0.1.x` release if anything looks off. See the [CHANGELOG](./CHANGELOG.md) for the full migration notes.
21
+ > ⚠️ **0.3.0 is another major rewrite.** The internals are reorganised into a clean `discover → plan → realize` pipeline with an immutable Plan as a first-class concept. User-visible: a new **`plan` subcommand** that shows what a sync would do (text or `--json`) without touching anything, and **video-level clashes now auto-resolve with a hash suffix** instead of aborting the movie. The `sync` and `diff` CLI is unchanged. If you depend on the old shape, **try the new version with `--dry-run` first**. Cautious users can pin to a `0.2.x` (or even `0.1.x`) release until they're comfortable. See the [CHANGELOG](./CHANGELOG.md) for the full migration notes.
22
22
 
23
23
  ## Overview
24
24
 
@@ -30,6 +30,8 @@ The script scans the source library, parses each movie folder for metadata (titl
30
30
 
31
31
  > ⚠️ **Movies only:** This script is designed exclusively for **movie libraries**. It does **not** support TV shows or miniseries. However, this is usually not a limitation in practice: for shows, Jellyfin and Plex use very similar directory structures, so you can typically point both apps to the same library without issues.
32
32
 
33
+ > ⚠️ **Stacked / multi-part movies are not supported:** Plex's multi-part filename suffixes (`pt1`/`pt2`, `cd1`/`cd2`, `disc1`/`disc2`) are not recognized — each part is treated as a separate video, which will likely confuse the target scanner. Pre-join multi-disc releases with `mkvmerge` before adding them to your library.
34
+
33
35
  > ⚠️ **Hardlinks require a shared filesystem:** Source and target paths must live on the **same filesystem**. Hardlinks cannot span filesystems or physical disks. If they don't, switch to `--copy`. On Unraid's classic array this is a real concern — see the [Unraid section](#unraid-user-scripts) for details before running this tool there.
34
36
 
35
37
  ## Installation
@@ -60,10 +62,12 @@ docker build -t jellyplex-sync .
60
62
 
61
63
  ## Usage
62
64
 
63
- The CLI is split into two subcommands:
65
+ The CLI is split into four subcommands:
64
66
 
65
67
  - **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
66
68
  - **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
69
+ - **`plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
70
+ - **`import`** — **(experimental)** import video files from a staging area into a structured library. See below.
67
71
 
68
72
  ### `sync`
69
73
 
@@ -140,22 +144,99 @@ Verify a target is a complete mirror before deleting the source:
140
144
  jellyplex-sync diff ~/Media/Jellyfin ~/Media/Plex
141
145
  ```
142
146
 
147
+ ### `plan`
148
+
149
+ ```bash
150
+ jellyplex-sync plan [OPTIONS] /path/to/source/library /path/to/target/library
151
+ ```
152
+
153
+ Shows the Plan a sync would execute — every movie folder, every translated video filename, every loose file, every asset subdirectory — without touching either filesystem. Useful as a pre-flight check ("will any movies clash? what gets translated?") or as a machine-readable input for tooling with `--json`.
154
+
155
+ Unlike `sync` and `diff`, the target directory does **not** need to exist. The whole point of `plan` is to answer "what WOULD happen" *before* any setup.
156
+
157
+ Exit codes:
158
+
159
+ - `0` — Plan produced successfully. Clashes and translation losses are reported but counted as informational, not failures.
160
+ - `2` — setup error (source missing, format undetectable).
161
+
162
+ #### Example
163
+
164
+ Pre-flight a sync, see exactly which target filenames would be created:
165
+
166
+ ```bash
167
+ jellyplex-sync plan ~/Media/Plex ~/Media/Jellyfin
168
+ ```
169
+
170
+ Machine-readable, with `jq` to find videos that needed disambiguation:
171
+
172
+ ```bash
173
+ jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
174
+ | jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
175
+ ```
176
+
177
+ ### `import` (experimental)
178
+
179
+ > ⚠️ **This subcommand is experimental.** The exact behaviour may change in future releases as real-world use cases become clearer. Always start with `--dry-run`.
180
+
181
+ ```bash
182
+ jellyplex-sync import [OPTIONS] /path/to/staging /path/to/target/library
183
+ ```
184
+
185
+ Imports video files from a staging area — a flat directory (or directory tree) where properly named video files have been dumped without the one-folder-per-movie structure that `sync` expects. Files are grouped by their parsed movie identity (title, year, provider ID) and placed into the correct movie folder in the target library.
186
+
187
+ Semantically distinct from `sync`:
188
+
189
+ - `sync` mirrors a structured library; the source stays intact.
190
+ - `import` ingests from an unstructured dump; the source files are **moved** (deleted after successful copy) by default.
191
+ - `import` never removes anything from the target — it only adds.
192
+
193
+ **Materializer flags** (mutually exclusive):
194
+
195
+ - `--move` (default) — copy each file to the target, then delete the source.
196
+ - `--copy` — copy files; the source is kept. Safe for a test run without `--dry-run`.
197
+
198
+ Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`, `--debug`.
199
+
200
+ #### Limitations
201
+
202
+ - **Loose non-video files** (posters, `.nfo`, subtitles) in the staging area are not imported — there is no folder to assign them to. The target media server can download its own metadata.
203
+ - **Asset directories** (extras, trailers) in the staging area are not collected.
204
+ - **No `--json` output** yet for import.
205
+
206
+ #### Example
207
+
208
+ Move Plex-named video files into a new Jellyfin library:
209
+
210
+ ```bash
211
+ jellyplex-sync import --create /mnt/staging /mnt/library/Jellyfin
212
+ ```
213
+
214
+ Preview first, keeping the source:
215
+
216
+ ```bash
217
+ jellyplex-sync import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
218
+ ```
219
+
143
220
  ### JSON output
144
221
 
145
- Both subcommands accept `--json` for machine-readable output. The document goes to **stdout**; logs continue to go to stderr. Under `--json`, INFO logs are suppressed unless you pass `--verbose` or `--debug`, so the output pipes cleanly into tools like `jq` without filtering.
222
+ All three subcommands accept `--json` for machine-readable output. The document goes to **stdout**; logs continue to go to stderr. Under `--json`, INFO logs are suppressed unless you pass `--verbose` or `--debug`, so the output pipes cleanly into tools like `jq` without filtering.
146
223
 
147
224
  The schema (still evolving — pin a version when consuming):
148
225
 
149
- - `operation`: `"sync"` or `"diff"`.
150
- - `exit_code`: the process exit code, mirrored in the document for convenience.
226
+ - `operation`: `"sync"`, `"diff"`, or `"plan"`.
227
+ - `exit_code` (sync, diff): the process exit code, mirrored in the document for convenience.
151
228
  - `source` / `target`: `{path, format}` for each endpoint.
152
229
  - `dry_run` (sync only): whether the run was a preview.
153
- - `summary` (sync only): counters for `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `items_ignored`, `strays_in_target`.
230
+ - `summary`: counters; the set of fields depends on the operation.
231
+ - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `items_ignored`, `strays_in_target`, `clashes`.
232
+ - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
154
233
  - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
155
234
  - `strays_in_target` (sync only): list of names found in target that aren't in source.
156
235
  - `translation_losses`: distinct `{kind, key, value, reason}` tuples for labels/attributes the target format can't express. Deduplicated — one entry per distinct loss, not per affected file.
157
236
  - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove`. For `remove`, `context` is `library_stray` / `movie_stray` / `asset_stray`. Action names are the same in `--dry-run` and real runs; the top-level `dry_run` flag tells you which mode you were in.
237
+ - `clashes` (sync only): movie-level video clashes the disambiguator couldn't resolve at all. Normally empty since the hash-fallback resolves the practical cases.
158
238
  - `diff`-specific fields: `movies_only_in_source`, `movies_only_in_target`, `differing_movies`, `in_sync`.
239
+ - `plan`-specific fields: `movies` (each with `videos`, `loose_files`, `assets`), `folder_clashes`, `movie_clashes`. Videos that needed disambiguation carry a `disambiguation` sub-object (`{strategy, detail}`) — `strategy="hash_suffix"` for the auto-resolution case.
159
240
 
160
241
  #### `jq` examples
161
242
 
@@ -239,6 +320,8 @@ This helper can also be used on other NAS systems or Linux servers — schedule
239
320
  - **Loose top-level files** — Sidecar files (`.nfo`, posters, external subtitles, plain notes) at the top of a movie folder are synced 1:1 by default since 0.2.0. Pre-0.2.0 they were silently dropped — which made the tool unsafe for migrations. Dot-files (`.DS_Store`, `.stversions`, …) stay excluded.
240
321
  - **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed. Without `--delete`, strays are still reported in the summary and the `--json` output, plus a warning at the end of the run points at `--delete`.
241
322
  - **Ignored items** — Entries the scanner couldn't classify (stray files at the library root, folders whose names don't parse) are reported in the summary and the `--json` `ignored` array. They are *not* carried over to the target — useful to verify before deleting the source.
323
+ - **Video-level clashes auto-resolve** — When two source files would translate to the same target name (a common P→J outcome: `[1080p].mkv` and `[1080p] [remux].mkv` both collapse to `- BD.mkv`), a short hash of the source filename gets appended (e.g. `Movie - BD [a3f7c819].mkv`). Both files get synced; spot the hash in the `plan` output or in `--json` via the per-file `disambiguation` field. Pre-0.3 the whole movie was skipped on clash.
324
+ - **Folder-level clashes still abort** — Two source folders mapping to the same target folder name (e.g. via dropped `[bracket]` labels) is a structural error in the source library, not something to silently merge. The run is stopped, the clashing folders are listed, and you're asked to rename one side.
242
325
 
243
326
  ## Library layouts
244
327
 
@@ -264,7 +347,7 @@ Each movie must reside in its own folder, with optional subfolders for extras. D
264
347
 
265
348
  #### Special filename handling
266
349
 
267
- Jellyfin doesn't distinguish between editions (e.g., Director's Cut) and versions (e.g., 1080p vs. 4K). To work around this, I appended labels like "DVD", "BD", or "4K" to filenames in my personal library, ensuring the highest quality appears first and is selected by default in Jellyfin. Plex, on the other hand, supports editions natively and handles different versions via naming patterns and its internal version management. These specific labels are converted into Plex versions on the way over; other suffixes are treated as editions. The detailed mapping rules (and why DVD/BD/4k beats DVD/SDR/FHD/UHD despite the naming inconsistency) live in [DEV.md](./DEV.md).
350
+ Jellyfin doesn't distinguish between editions (e.g., Director's Cut) and versions (e.g., 1080p vs. 4K). To work around this, I appended labels like "DVD", "BD", or "4K" to filenames in my personal library, ensuring the highest quality appears first and is selected by default in Jellyfin. Plex, on the other hand, supports editions natively and handles different versions via naming patterns and its internal version management. These specific labels are converted into Plex versions on the way over; other suffixes are treated as editions. The detailed mapping rules (and why DVD/BD/4k beats DVD/SDR/FHD/UHD despite the naming inconsistency) live in [SPECS.md](./SPECS.md).
268
351
 
269
352
  ### Plex
270
353
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Can't decide between Jellyfin and Plex? This tool might help. It synchronizes your **movie library** between Jellyfin and Plex formats in **both directions** — without duplicating any files. Instead, it uses **hardlinks** to mirror your collection efficiently, saving storage while keeping both libraries in sync.
4
4
 
5
- > ⚠️ **0.2.0 is a major rewrite.** Subcommands, new materializer options, machine-readable `--json` output, default-sync-everything behavior, and a renamed model field (`tags` → `labels` in the Python API). If you depend on the old shape, **try the new version with `--dry-run` first**, and pin to a `0.1.x` release if anything looks off. See the [CHANGELOG](./CHANGELOG.md) for the full migration notes.
5
+ > ⚠️ **0.3.0 is another major rewrite.** The internals are reorganised into a clean `discover → plan → realize` pipeline with an immutable Plan as a first-class concept. User-visible: a new **`plan` subcommand** that shows what a sync would do (text or `--json`) without touching anything, and **video-level clashes now auto-resolve with a hash suffix** instead of aborting the movie. The `sync` and `diff` CLI is unchanged. If you depend on the old shape, **try the new version with `--dry-run` first**. Cautious users can pin to a `0.2.x` (or even `0.1.x`) release until they're comfortable. See the [CHANGELOG](./CHANGELOG.md) for the full migration notes.
6
6
 
7
7
  ## Overview
8
8
 
@@ -14,6 +14,8 @@ The script scans the source library, parses each movie folder for metadata (titl
14
14
 
15
15
  > ⚠️ **Movies only:** This script is designed exclusively for **movie libraries**. It does **not** support TV shows or miniseries. However, this is usually not a limitation in practice: for shows, Jellyfin and Plex use very similar directory structures, so you can typically point both apps to the same library without issues.
16
16
 
17
+ > ⚠️ **Stacked / multi-part movies are not supported:** Plex's multi-part filename suffixes (`pt1`/`pt2`, `cd1`/`cd2`, `disc1`/`disc2`) are not recognized — each part is treated as a separate video, which will likely confuse the target scanner. Pre-join multi-disc releases with `mkvmerge` before adding them to your library.
18
+
17
19
  > ⚠️ **Hardlinks require a shared filesystem:** Source and target paths must live on the **same filesystem**. Hardlinks cannot span filesystems or physical disks. If they don't, switch to `--copy`. On Unraid's classic array this is a real concern — see the [Unraid section](#unraid-user-scripts) for details before running this tool there.
18
20
 
19
21
  ## Installation
@@ -44,10 +46,12 @@ docker build -t jellyplex-sync .
44
46
 
45
47
  ## Usage
46
48
 
47
- The CLI is split into two subcommands:
49
+ The CLI is split into four subcommands:
48
50
 
49
51
  - **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
50
52
  - **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
53
+ - **`plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
54
+ - **`import`** — **(experimental)** import video files from a staging area into a structured library. See below.
51
55
 
52
56
  ### `sync`
53
57
 
@@ -124,22 +128,99 @@ Verify a target is a complete mirror before deleting the source:
124
128
  jellyplex-sync diff ~/Media/Jellyfin ~/Media/Plex
125
129
  ```
126
130
 
131
+ ### `plan`
132
+
133
+ ```bash
134
+ jellyplex-sync plan [OPTIONS] /path/to/source/library /path/to/target/library
135
+ ```
136
+
137
+ Shows the Plan a sync would execute — every movie folder, every translated video filename, every loose file, every asset subdirectory — without touching either filesystem. Useful as a pre-flight check ("will any movies clash? what gets translated?") or as a machine-readable input for tooling with `--json`.
138
+
139
+ Unlike `sync` and `diff`, the target directory does **not** need to exist. The whole point of `plan` is to answer "what WOULD happen" *before* any setup.
140
+
141
+ Exit codes:
142
+
143
+ - `0` — Plan produced successfully. Clashes and translation losses are reported but counted as informational, not failures.
144
+ - `2` — setup error (source missing, format undetectable).
145
+
146
+ #### Example
147
+
148
+ Pre-flight a sync, see exactly which target filenames would be created:
149
+
150
+ ```bash
151
+ jellyplex-sync plan ~/Media/Plex ~/Media/Jellyfin
152
+ ```
153
+
154
+ Machine-readable, with `jq` to find videos that needed disambiguation:
155
+
156
+ ```bash
157
+ jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
158
+ | jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
159
+ ```
160
+
161
+ ### `import` (experimental)
162
+
163
+ > ⚠️ **This subcommand is experimental.** The exact behaviour may change in future releases as real-world use cases become clearer. Always start with `--dry-run`.
164
+
165
+ ```bash
166
+ jellyplex-sync import [OPTIONS] /path/to/staging /path/to/target/library
167
+ ```
168
+
169
+ Imports video files from a staging area — a flat directory (or directory tree) where properly named video files have been dumped without the one-folder-per-movie structure that `sync` expects. Files are grouped by their parsed movie identity (title, year, provider ID) and placed into the correct movie folder in the target library.
170
+
171
+ Semantically distinct from `sync`:
172
+
173
+ - `sync` mirrors a structured library; the source stays intact.
174
+ - `import` ingests from an unstructured dump; the source files are **moved** (deleted after successful copy) by default.
175
+ - `import` never removes anything from the target — it only adds.
176
+
177
+ **Materializer flags** (mutually exclusive):
178
+
179
+ - `--move` (default) — copy each file to the target, then delete the source.
180
+ - `--copy` — copy files; the source is kept. Safe for a test run without `--dry-run`.
181
+
182
+ Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`, `--debug`.
183
+
184
+ #### Limitations
185
+
186
+ - **Loose non-video files** (posters, `.nfo`, subtitles) in the staging area are not imported — there is no folder to assign them to. The target media server can download its own metadata.
187
+ - **Asset directories** (extras, trailers) in the staging area are not collected.
188
+ - **No `--json` output** yet for import.
189
+
190
+ #### Example
191
+
192
+ Move Plex-named video files into a new Jellyfin library:
193
+
194
+ ```bash
195
+ jellyplex-sync import --create /mnt/staging /mnt/library/Jellyfin
196
+ ```
197
+
198
+ Preview first, keeping the source:
199
+
200
+ ```bash
201
+ jellyplex-sync import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
202
+ ```
203
+
127
204
  ### JSON output
128
205
 
129
- Both subcommands accept `--json` for machine-readable output. The document goes to **stdout**; logs continue to go to stderr. Under `--json`, INFO logs are suppressed unless you pass `--verbose` or `--debug`, so the output pipes cleanly into tools like `jq` without filtering.
206
+ All three subcommands accept `--json` for machine-readable output. The document goes to **stdout**; logs continue to go to stderr. Under `--json`, INFO logs are suppressed unless you pass `--verbose` or `--debug`, so the output pipes cleanly into tools like `jq` without filtering.
130
207
 
131
208
  The schema (still evolving — pin a version when consuming):
132
209
 
133
- - `operation`: `"sync"` or `"diff"`.
134
- - `exit_code`: the process exit code, mirrored in the document for convenience.
210
+ - `operation`: `"sync"`, `"diff"`, or `"plan"`.
211
+ - `exit_code` (sync, diff): the process exit code, mirrored in the document for convenience.
135
212
  - `source` / `target`: `{path, format}` for each endpoint.
136
213
  - `dry_run` (sync only): whether the run was a preview.
137
- - `summary` (sync only): counters for `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `items_ignored`, `strays_in_target`.
214
+ - `summary`: counters; the set of fields depends on the operation.
215
+ - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `items_ignored`, `strays_in_target`, `clashes`.
216
+ - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
138
217
  - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
139
218
  - `strays_in_target` (sync only): list of names found in target that aren't in source.
140
219
  - `translation_losses`: distinct `{kind, key, value, reason}` tuples for labels/attributes the target format can't express. Deduplicated — one entry per distinct loss, not per affected file.
141
220
  - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove`. For `remove`, `context` is `library_stray` / `movie_stray` / `asset_stray`. Action names are the same in `--dry-run` and real runs; the top-level `dry_run` flag tells you which mode you were in.
221
+ - `clashes` (sync only): movie-level video clashes the disambiguator couldn't resolve at all. Normally empty since the hash-fallback resolves the practical cases.
142
222
  - `diff`-specific fields: `movies_only_in_source`, `movies_only_in_target`, `differing_movies`, `in_sync`.
223
+ - `plan`-specific fields: `movies` (each with `videos`, `loose_files`, `assets`), `folder_clashes`, `movie_clashes`. Videos that needed disambiguation carry a `disambiguation` sub-object (`{strategy, detail}`) — `strategy="hash_suffix"` for the auto-resolution case.
143
224
 
144
225
  #### `jq` examples
145
226
 
@@ -223,6 +304,8 @@ This helper can also be used on other NAS systems or Linux servers — schedule
223
304
  - **Loose top-level files** — Sidecar files (`.nfo`, posters, external subtitles, plain notes) at the top of a movie folder are synced 1:1 by default since 0.2.0. Pre-0.2.0 they were silently dropped — which made the tool unsafe for migrations. Dot-files (`.DS_Store`, `.stversions`, …) stay excluded.
224
305
  - **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed. Without `--delete`, strays are still reported in the summary and the `--json` output, plus a warning at the end of the run points at `--delete`.
225
306
  - **Ignored items** — Entries the scanner couldn't classify (stray files at the library root, folders whose names don't parse) are reported in the summary and the `--json` `ignored` array. They are *not* carried over to the target — useful to verify before deleting the source.
307
+ - **Video-level clashes auto-resolve** — When two source files would translate to the same target name (a common P→J outcome: `[1080p].mkv` and `[1080p] [remux].mkv` both collapse to `- BD.mkv`), a short hash of the source filename gets appended (e.g. `Movie - BD [a3f7c819].mkv`). Both files get synced; spot the hash in the `plan` output or in `--json` via the per-file `disambiguation` field. Pre-0.3 the whole movie was skipped on clash.
308
+ - **Folder-level clashes still abort** — Two source folders mapping to the same target folder name (e.g. via dropped `[bracket]` labels) is a structural error in the source library, not something to silently merge. The run is stopped, the clashing folders are listed, and you're asked to rename one side.
226
309
 
227
310
  ## Library layouts
228
311
 
@@ -248,7 +331,7 @@ Each movie must reside in its own folder, with optional subfolders for extras. D
248
331
 
249
332
  #### Special filename handling
250
333
 
251
- Jellyfin doesn't distinguish between editions (e.g., Director's Cut) and versions (e.g., 1080p vs. 4K). To work around this, I appended labels like "DVD", "BD", or "4K" to filenames in my personal library, ensuring the highest quality appears first and is selected by default in Jellyfin. Plex, on the other hand, supports editions natively and handles different versions via naming patterns and its internal version management. These specific labels are converted into Plex versions on the way over; other suffixes are treated as editions. The detailed mapping rules (and why DVD/BD/4k beats DVD/SDR/FHD/UHD despite the naming inconsistency) live in [DEV.md](./DEV.md).
334
+ Jellyfin doesn't distinguish between editions (e.g., Director's Cut) and versions (e.g., 1080p vs. 4K). To work around this, I appended labels like "DVD", "BD", or "4K" to filenames in my personal library, ensuring the highest quality appears first and is selected by default in Jellyfin. Plex, on the other hand, supports editions natively and handles different versions via naming patterns and its internal version management. These specific labels are converted into Plex versions on the way over; other suffixes are treated as editions. The detailed mapping rules (and why DVD/BD/4k beats DVD/SDR/FHD/UHD despite the naming inconsistency) live in [SPECS.md](./SPECS.md).
252
335
 
253
336
  ### Plex
254
337
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jellyplex-sync"
3
- version = "0.2.2"
3
+ version = "0.3.2"
4
4
  description = "Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror"
5
5
  authors = [{ name = "Stefan Schönberger", email = "stefan@sniner.dev" }]
6
6
  requires-python = ">=3.11"
@@ -8,6 +8,7 @@ from .library import (
8
8
  Drop,
9
9
  DropError,
10
10
  FileEvent,
11
+ FolderClash,
11
12
  IgnoredEntry,
12
13
  LibraryReader,
13
14
  LibraryWriter,
@@ -22,11 +23,19 @@ from .materializer import (
22
23
  FileMaterializer,
23
24
  ForceCopyMaterializer,
24
25
  HardlinkMaterializer,
26
+ MoveMaterializer,
25
27
  )
26
28
  from .model import (
27
29
  MovieInfo,
28
30
  VideoInfo,
29
31
  )
32
+ from .plan import (
33
+ DisambiguationNote,
34
+ Plan,
35
+ PlannedAsset,
36
+ PlannedFile,
37
+ PlannedMovie,
38
+ )
30
39
  from .plex import (
31
40
  PlexLibraryReader,
32
41
  PlexLibraryWriter,
@@ -35,6 +44,8 @@ from .sync import (
35
44
  DiffEntry,
36
45
  DiffResult,
37
46
  diff,
47
+ import_media,
48
+ plan,
38
49
  sync,
39
50
  )
40
51
 
@@ -44,12 +55,15 @@ __all__ = [
44
55
  "CopyMaterializer",
45
56
  "DiffEntry",
46
57
  "DiffResult",
58
+ "DisambiguationNote",
47
59
  "Drop",
48
60
  "DropError",
49
61
  "FileEvent",
50
62
  "FileMaterializer",
63
+ "FolderClash",
51
64
  "ForceCopyMaterializer",
52
65
  "HardlinkMaterializer",
66
+ "MoveMaterializer",
53
67
  "IgnoredEntry",
54
68
  "JellyfinLibraryReader",
55
69
  "JellyfinLibraryWriter",
@@ -58,6 +72,10 @@ __all__ = [
58
72
  "LoggingReporter",
59
73
  "MovieClash",
60
74
  "MovieInfo",
75
+ "Plan",
76
+ "PlannedAsset",
77
+ "PlannedFile",
78
+ "PlannedMovie",
61
79
  "PlexLibraryReader",
62
80
  "PlexLibraryWriter",
63
81
  "Reporter",
@@ -65,5 +83,7 @@ __all__ = [
65
83
  "VideoInfo",
66
84
  "dedupe_drops",
67
85
  "diff",
86
+ "import_media",
87
+ "plan",
68
88
  "sync",
69
89
  ]
@@ -7,7 +7,7 @@ import sys
7
7
 
8
8
  import jellyplex_sync as jp
9
9
 
10
- _SUBCOMMANDS = {"sync", "diff"}
10
+ _SUBCOMMANDS = {"sync", "diff", "plan", "import"}
11
11
 
12
12
 
13
13
  def _build_parser() -> argparse.ArgumentParser:
@@ -90,6 +90,46 @@ def _build_parser() -> argparse.ArgumentParser:
90
90
  )
91
91
  _add_io_args(diff_p)
92
92
 
93
+ plan_p = sub.add_parser(
94
+ "plan",
95
+ parents=[common],
96
+ help="Show the Plan a sync would execute, without touching the target.",
97
+ )
98
+ _add_io_args(plan_p)
99
+
100
+ import_p = sub.add_parser(
101
+ "import",
102
+ parents=[common],
103
+ help="Import video files from a staging area into a structured library.",
104
+ )
105
+ _add_io_args(import_p)
106
+ import_mode = import_p.add_mutually_exclusive_group()
107
+ import_mode.add_argument(
108
+ "--move",
109
+ dest="mode",
110
+ action="store_const",
111
+ const="move",
112
+ help="Move files: copy to target, then delete source (default).",
113
+ )
114
+ import_mode.add_argument(
115
+ "--copy",
116
+ dest="mode",
117
+ action="store_const",
118
+ const="copy",
119
+ help="Copy files; source is kept. On re-runs skip files whose size and mtime match.",
120
+ )
121
+ import_p.set_defaults(mode="move")
122
+ import_p.add_argument(
123
+ "--dry-run",
124
+ action="store_true",
125
+ help="Show what would be done, don't change anything on disk.",
126
+ )
127
+ import_p.add_argument(
128
+ "--create",
129
+ action="store_true",
130
+ help="Create the target library directory if it doesn't exist.",
131
+ )
132
+
93
133
  return parser
94
134
 
95
135
 
@@ -156,6 +196,10 @@ def main() -> None:
156
196
  result = _do_sync(args)
157
197
  elif args.command == "diff":
158
198
  result = _do_diff(args)
199
+ elif args.command == "plan":
200
+ result = _do_plan(args)
201
+ elif args.command == "import":
202
+ result = _do_import(args)
159
203
  else:
160
204
  parser.error(f"unknown command: {args.command!r}")
161
205
  result = 2
@@ -242,6 +286,54 @@ def _do_diff(args: argparse.Namespace) -> int:
242
286
  return 99
243
287
 
244
288
 
289
+ def _do_plan(args: argparse.Namespace) -> int:
290
+ from jellyplex_sync.sync import plan as plan_fn
291
+
292
+ try:
293
+ return plan_fn(
294
+ args.source,
295
+ args.target,
296
+ debug=args.debug,
297
+ source_format=args.source_format,
298
+ target_format=args.target_format,
299
+ as_json=args.json,
300
+ )
301
+ except KeyboardInterrupt:
302
+ logging.info("INTERRUPTED")
303
+ return 10
304
+ except Exception as exc:
305
+ logging.error("Exception: %s", exc)
306
+ return 99
307
+
308
+
309
+ def _do_import(args: argparse.Namespace) -> int:
310
+ from jellyplex_sync.sync import import_media
311
+
312
+ if args.mode == "move":
313
+ materializer = jp.MoveMaterializer()
314
+ else:
315
+ materializer = jp.CopyMaterializer()
316
+
317
+ try:
318
+ return import_media(
319
+ args.source,
320
+ args.target,
321
+ dry_run=args.dry_run,
322
+ create=args.create,
323
+ verbose=args.verbose,
324
+ debug=args.debug,
325
+ source_format=args.source_format,
326
+ target_format=args.target_format,
327
+ materializer=materializer,
328
+ )
329
+ except KeyboardInterrupt:
330
+ logging.info("INTERRUPTED")
331
+ return 10
332
+ except Exception as exc:
333
+ logging.error("Exception: %s", exc)
334
+ return 99
335
+
336
+
245
337
  def _resolve_formats_for_json(args: argparse.Namespace) -> tuple[str, str]:
246
338
  """Re-run the same format resolution `sync()` did so the JSON payload
247
339
  reports the formats that were actually used. Returns ('?', '?') if
@@ -0,0 +1,82 @@
1
+ """Plan-vs.-actual comparison: derive a DiffResult from a Plan.
2
+
3
+ The classic `diff` subcommand answers "is the target in sync with what
4
+ the source would produce?" In the 0.3 pipeline this becomes trivially
5
+ expressible: build the Plan (no I/O on target), then compare it to
6
+ whatever currently sits on the target filesystem.
7
+
8
+ The shape of `DiffResult` is preserved from sync.py so the public CLI
9
+ output and `--json` schema don't move. `drops` is left empty here —
10
+ they belong to the Reporter the caller fed to the Planner; the caller
11
+ can stitch them onto the DiffResult if they want them in the document.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pathlib
17
+
18
+ from .plan import Plan
19
+ from .sync import DiffEntry, DiffResult, MovieOnlyInSource
20
+
21
+
22
+ def compare(plan: Plan) -> DiffResult:
23
+ """Compare `plan` against the actual contents of `plan.target_root`.
24
+
25
+ Pure: reads the target filesystem, returns a DiffResult, mutates
26
+ nothing. Walks one level deep into each movie folder — same depth
27
+ as the pre-0.3 diff implementation."""
28
+ planned_folder_names = {m.target_folder.name for m in plan.movies}
29
+ planned_by_name = {m.target_folder.name: m for m in plan.movies}
30
+
31
+ actual: dict[str, set[str]] = {}
32
+ if plan.target_root.is_dir():
33
+ for entry in plan.target_root.iterdir():
34
+ if not entry.is_dir():
35
+ continue
36
+ actual[entry.name] = {sub.name for sub in entry.iterdir()}
37
+
38
+ only_in_source_names = sorted(planned_folder_names - actual.keys())
39
+ only_in_source = tuple(
40
+ MovieOnlyInSource(
41
+ source_folder=planned_by_name[name].source_path.name,
42
+ expected_target=name,
43
+ )
44
+ for name in only_in_source_names
45
+ )
46
+
47
+ only_in_target = tuple(sorted(actual.keys() - planned_folder_names))
48
+
49
+ differing: list[DiffEntry] = []
50
+ for name in sorted(planned_folder_names & actual.keys()):
51
+ pm = planned_by_name[name]
52
+ expected_files = (
53
+ {pf.target_name for pf in pm.videos}
54
+ | {pf.target_name for pf in pm.loose_files}
55
+ | {a.folder_name for a in pm.assets}
56
+ )
57
+ src_only = tuple(sorted(expected_files - actual[name]))
58
+ tgt_only = tuple(sorted(actual[name] - expected_files))
59
+ if src_only or tgt_only:
60
+ differing.append(
61
+ DiffEntry(
62
+ target_movie_name=name,
63
+ only_in_source=src_only,
64
+ only_in_target=tgt_only,
65
+ )
66
+ )
67
+
68
+ return DiffResult(
69
+ movies_only_in_source=only_in_source,
70
+ movies_only_in_target=only_in_target,
71
+ differing_movies=tuple(differing),
72
+ ignored=tuple(plan.ignored),
73
+ )
74
+
75
+
76
+ def _planned_target_folder(plan: Plan, name: str) -> pathlib.Path | None:
77
+ """Helper: find the absolute target folder for a planned-movie name,
78
+ if any. Convenience for callers that want to print full paths."""
79
+ for m in plan.movies:
80
+ if m.target_folder.name == name:
81
+ return m.target_folder
82
+ return None