jellyplex-sync 0.3.2__tar.gz → 0.4.0__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 (28) hide show
  1. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/PKG-INFO +55 -46
  2. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/README.md +44 -45
  3. jellyplex_sync-0.4.0/pyproject.toml +97 -0
  4. jellyplex_sync-0.4.0/pyproject.toml.orig +87 -0
  5. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/__init__.py +11 -3
  6. jellyplex_sync-0.3.2/src/jellyplex_sync/cli/sync.py → jellyplex_sync-0.4.0/src/jellyplex_sync/cli/main.py +47 -87
  7. jellyplex_sync-0.4.0/src/jellyplex_sync/cli/sync.py +22 -0
  8. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/compare.py +43 -16
  9. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/disambig.py +44 -27
  10. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/discover.py +23 -7
  11. jellyplex_sync-0.4.0/src/jellyplex_sync/formats.py +164 -0
  12. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/jellyfin.py +20 -12
  13. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/json_output.py +31 -20
  14. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/library.py +17 -54
  15. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/materializer.py +23 -16
  16. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/plan.py +30 -7
  17. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/planner.py +52 -25
  18. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/plex.py +13 -4
  19. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/realize.py +14 -11
  20. jellyplex_sync-0.4.0/src/jellyplex_sync/report.py +135 -0
  21. jellyplex_sync-0.4.0/src/jellyplex_sync/sync.py +481 -0
  22. jellyplex_sync-0.3.2/pyproject.toml +0 -56
  23. jellyplex_sync-0.3.2/src/jellyplex_sync/sync.py +0 -698
  24. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/LICENSE +0 -0
  25. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/cli/__init__.py +0 -0
  26. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/model.py +0 -0
  27. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/py.typed +0 -0
  28. {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/utils.py +0 -0
@@ -1,25 +1,33 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jellyplex-sync
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror
5
+ Keywords: plex,jellyfin,media-library,movies,sync,hardlink,mirror
5
6
  Author: Stefan Schönberger
6
7
  Author-email: Stefan Schönberger <stefan@sniner.dev>
7
8
  License-Expression: BSD-3-Clause
8
9
  License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
9
14
  Classifier: Programming Language :: Python :: 3
10
15
  Classifier: Programming Language :: Python :: 3.11
11
16
  Classifier: Programming Language :: Python :: 3.12
12
17
  Classifier: Programming Language :: Python :: 3.13
13
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Multimedia :: Video
14
20
  Requires-Python: >=3.11
21
+ Project-URL: Homepage, https://github.com/sniner/jellyplex-sync
22
+ Project-URL: Repository, https://github.com/sniner/jellyplex-sync
23
+ Project-URL: Issues, https://github.com/sniner/jellyplex-sync/issues
24
+ Project-URL: Changelog, https://github.com/sniner/jellyplex-sync/blob/main/CHANGELOG.md
15
25
  Description-Content-Type: text/markdown
16
26
 
17
27
  # Bidirectional Movie Library Sync for Plex and Jellyfin
18
28
 
19
29
  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
30
 
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
-
23
31
  ## Overview
24
32
 
25
33
  The script scans the source library, parses each movie folder for metadata (title, year, optional provider ID), and reproduces the same directory structure in the target location. Rather than copying video files, it creates hard links to avoid extra storage usage. Asset folders (e.g., `extras`, `subtitles`) are also mirrored. Loose top-level files (posters, NFOs, subtitles) are synced 1:1 by default since 0.2.0. With `--delete`, any files or folders in the target that are no longer present in the source will be removed.
@@ -44,7 +52,7 @@ The easiest way to install the CLI is via [uv](https://docs.astral.sh/uv/):
44
52
  uv tool install jellyplex-sync
45
53
  ```
46
54
 
47
- This places `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
55
+ This places `jellyplex` and `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
48
56
 
49
57
  ### Docker
50
58
 
@@ -62,17 +70,19 @@ docker build -t jellyplex-sync .
62
70
 
63
71
  ## Usage
64
72
 
65
- The CLI is split into four subcommands:
73
+ The main CLI is `jellyplex` with four subcommands:
74
+
75
+ - **`jellyplex sync`** — mirror a source library into the target layout.
76
+ - **`jellyplex diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
77
+ - **`jellyplex 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.
78
+ - **`jellyplex import`** — **(experimental)** import video files from a staging area into a structured library. See below.
66
79
 
67
- - **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
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.
80
+ For backward compatibility, `jellyplex-sync` is also installed as a standalone command that does exactly what `jellyplex sync` does — same options, no subcommand needed.
71
81
 
72
82
  ### `sync`
73
83
 
74
84
  ```bash
75
- jellyplex-sync sync [OPTIONS] /path/to/source/library /path/to/target/library
85
+ jellyplex sync [OPTIONS] /path/to/source/library /path/to/target/library
76
86
  ```
77
87
 
78
88
  The first positional is the source library, the second is the target. By default the tool auto-detects whether the source is a Jellyfin or Plex layout and converts to the other format.
@@ -96,36 +106,45 @@ The first positional is the source library, the second is the target. By default
96
106
 
97
107
  Use `--copy` when source and target are on different filesystems (NAS to local disk, cross-pool moves, etc.). The size+mtime check makes re-runs cheap.
98
108
 
109
+ #### Exit codes
110
+
111
+ - `0` — sync completed (including "nothing to do").
112
+ - `1` — setup error (missing directories, undecipherable format, hardlinks across
113
+ filesystem boundaries).
114
+ - `2` — folder-level clash: the run was aborted, nothing was synced.
115
+ - `3` — sync completed, but some items marked for removal could not be removed
116
+ (permissions, busy files); they remain in the target.
117
+
99
118
  #### Examples
100
119
 
101
120
  Mirror a Jellyfin library into a new Plex structure:
102
121
 
103
122
  ```bash
104
- jellyplex-sync sync --create ~/Media/Jellyfin ~/Media/Plex
123
+ jellyplex sync --create ~/Media/Jellyfin ~/Media/Plex
105
124
  ```
106
125
 
107
126
  Migration with cleanup (target becomes a clean mirror):
108
127
 
109
128
  ```bash
110
- jellyplex-sync sync --delete --create ~/Media/Jellyfin ~/Media/Plex
129
+ jellyplex sync --delete --create ~/Media/Jellyfin ~/Media/Plex
111
130
  ```
112
131
 
113
132
  Cross-filesystem copy with safe re-runs:
114
133
 
115
134
  ```bash
116
- jellyplex-sync sync --copy --create /mnt/nas/Movies /mnt/local/Plex
135
+ jellyplex sync --copy --create /mnt/nas/Movies /mnt/local/Plex
117
136
  ```
118
137
 
119
138
  Dry-run a verbose, full sync with deletion:
120
139
 
121
140
  ```bash
122
- jellyplex-sync sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
141
+ jellyplex sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
123
142
  ```
124
143
 
125
144
  ### `diff`
126
145
 
127
146
  ```bash
128
- jellyplex-sync diff [OPTIONS] /path/to/source/library /path/to/target/library
147
+ jellyplex diff [OPTIONS] /path/to/source/library /path/to/target/library
129
148
  ```
130
149
 
131
150
  Compares the two libraries without touching anything. Reports movies that exist only on one side, file-level differences inside shared movies, translation losses (Plex labels with no Jellyfin equivalent, for example), and entries the scanner ignored.
@@ -141,13 +160,13 @@ Exit codes follow the Unix `diff` convention:
141
160
  Verify a target is a complete mirror before deleting the source:
142
161
 
143
162
  ```bash
144
- jellyplex-sync diff ~/Media/Jellyfin ~/Media/Plex
163
+ jellyplex diff ~/Media/Jellyfin ~/Media/Plex
145
164
  ```
146
165
 
147
166
  ### `plan`
148
167
 
149
168
  ```bash
150
- jellyplex-sync plan [OPTIONS] /path/to/source/library /path/to/target/library
169
+ jellyplex plan [OPTIONS] /path/to/source/library /path/to/target/library
151
170
  ```
152
171
 
153
172
  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`.
@@ -164,13 +183,13 @@ Exit codes:
164
183
  Pre-flight a sync, see exactly which target filenames would be created:
165
184
 
166
185
  ```bash
167
- jellyplex-sync plan ~/Media/Plex ~/Media/Jellyfin
186
+ jellyplex plan ~/Media/Plex ~/Media/Jellyfin
168
187
  ```
169
188
 
170
189
  Machine-readable, with `jq` to find videos that needed disambiguation:
171
190
 
172
191
  ```bash
173
- jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
192
+ jellyplex plan --json ~/Media/Plex ~/Media/Jellyfin \
174
193
  | jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
175
194
  ```
176
195
 
@@ -179,7 +198,7 @@ jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
179
198
  > ⚠️ **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
199
 
181
200
  ```bash
182
- jellyplex-sync import [OPTIONS] /path/to/staging /path/to/target/library
201
+ jellyplex import [OPTIONS] /path/to/staging /path/to/target/library
183
202
  ```
184
203
 
185
204
  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.
@@ -208,13 +227,13 @@ Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`
208
227
  Move Plex-named video files into a new Jellyfin library:
209
228
 
210
229
  ```bash
211
- jellyplex-sync import --create /mnt/staging /mnt/library/Jellyfin
230
+ jellyplex import --create /mnt/staging /mnt/library/Jellyfin
212
231
  ```
213
232
 
214
233
  Preview first, keeping the source:
215
234
 
216
235
  ```bash
217
- jellyplex-sync import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
236
+ jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
218
237
  ```
219
238
 
220
239
  ### JSON output
@@ -228,55 +247,39 @@ The schema (still evolving — pin a version when consuming):
228
247
  - `source` / `target`: `{path, format}` for each endpoint.
229
248
  - `dry_run` (sync only): whether the run was a preview.
230
249
  - `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`.
250
+ - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`.
232
251
  - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
233
252
  - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
234
253
  - `strays_in_target` (sync only): list of names found in target that aren't in source.
235
254
  - `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.
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.
255
+ - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove` — `link` covers every creation, including copies in `--copy` mode. 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
256
  - `clashes` (sync only): movie-level video clashes the disambiguator couldn't resolve at all. Normally empty since the hash-fallback resolves the practical cases.
238
257
  - `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.
258
+ - `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. Videos and movies that lose labels or attributes in translation carry their own `drops` / `folder_drops` lists (same `{kind, key, value, reason}` shape as `translation_losses`, but per file — so you can see which file loses what).
240
259
 
241
260
  #### `jq` examples
242
261
 
243
262
  Show every file that would be replaced (with full paths):
244
263
 
245
264
  ```bash
246
- jellyplex-sync sync --json --dry-run /src /dst \
265
+ jellyplex sync --json --dry-run /src /dst \
247
266
  | jq '.events[] | select(.action == "replace") | {source, target}'
248
267
  ```
249
268
 
250
269
  Verify nothing important gets deleted before running a migration with `--delete`:
251
270
 
252
271
  ```bash
253
- jellyplex-sync sync --json --dry-run --delete /src /dst \
272
+ jellyplex sync --json --dry-run --delete /src /dst \
254
273
  | jq '.events[] | select(.action == "remove")'
255
274
  ```
256
275
 
257
276
  Action distribution:
258
277
 
259
278
  ```bash
260
- jellyplex-sync sync --json --dry-run /src /dst \
279
+ jellyplex sync --json --dry-run /src /dst \
261
280
  | jq '[.events[].action] | group_by(.) | map({action: .[0], count: length})'
262
281
  ```
263
282
 
264
- ### Default subcommand
265
-
266
- For backward compatibility, omitting the subcommand is treated as an implicit `sync` (as long as the first argument is a positional, not a flag):
267
-
268
- ```bash
269
- jellyplex-sync ~/Media/Jellyfin ~/Media/Plex
270
- ```
271
-
272
- is equivalent to
273
-
274
- ```bash
275
- jellyplex-sync sync ~/Media/Jellyfin ~/Media/Plex
276
- ```
277
-
278
- If you start with a flag, you must spell out `sync` explicitly.
279
-
280
283
  ### Docker
281
284
 
282
285
  ```bash
@@ -321,7 +324,7 @@ This helper can also be used on other NAS systems or Linux servers — schedule
321
324
  - **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`.
322
325
  - **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
326
  - **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.
327
+ - **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 with exit code 2, the clashing folders are listed, and you're asked to rename one side.
325
328
 
326
329
  ## Library layouts
327
330
 
@@ -347,7 +350,7 @@ Each movie must reside in its own folder, with optional subfolders for extras. D
347
350
 
348
351
  #### Special filename handling
349
352
 
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).
353
+ 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 [docs/SPECS.md](./docs/SPECS.md).
351
354
 
352
355
  ### Plex
353
356
 
@@ -375,6 +378,12 @@ Movies
375
378
  └── Making of.mkv
376
379
  ```
377
380
 
381
+ ## Development
382
+
383
+ The pipeline architecture and internal vocabulary are documented in
384
+ [docs/DEV.md](./docs/DEV.md); the Plex/Jellyfin format details and translation
385
+ rules live in [docs/SPECS.md](./docs/SPECS.md).
386
+
378
387
  ## License
379
388
 
380
389
  This project is licensed under the [BSD 3-Clause License](./LICENSE).
@@ -2,8 +2,6 @@
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.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
-
7
5
  ## Overview
8
6
 
9
7
  The script scans the source library, parses each movie folder for metadata (title, year, optional provider ID), and reproduces the same directory structure in the target location. Rather than copying video files, it creates hard links to avoid extra storage usage. Asset folders (e.g., `extras`, `subtitles`) are also mirrored. Loose top-level files (posters, NFOs, subtitles) are synced 1:1 by default since 0.2.0. With `--delete`, any files or folders in the target that are no longer present in the source will be removed.
@@ -28,7 +26,7 @@ The easiest way to install the CLI is via [uv](https://docs.astral.sh/uv/):
28
26
  uv tool install jellyplex-sync
29
27
  ```
30
28
 
31
- This places `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
29
+ This places `jellyplex` and `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
32
30
 
33
31
  ### Docker
34
32
 
@@ -46,17 +44,19 @@ docker build -t jellyplex-sync .
46
44
 
47
45
  ## Usage
48
46
 
49
- The CLI is split into four subcommands:
47
+ The main CLI is `jellyplex` with four subcommands:
48
+
49
+ - **`jellyplex sync`** — mirror a source library into the target layout.
50
+ - **`jellyplex diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
51
+ - **`jellyplex 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.
52
+ - **`jellyplex import`** — **(experimental)** import video files from a staging area into a structured library. See below.
50
53
 
51
- - **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
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.
54
+ For backward compatibility, `jellyplex-sync` is also installed as a standalone command that does exactly what `jellyplex sync` does — same options, no subcommand needed.
55
55
 
56
56
  ### `sync`
57
57
 
58
58
  ```bash
59
- jellyplex-sync sync [OPTIONS] /path/to/source/library /path/to/target/library
59
+ jellyplex sync [OPTIONS] /path/to/source/library /path/to/target/library
60
60
  ```
61
61
 
62
62
  The first positional is the source library, the second is the target. By default the tool auto-detects whether the source is a Jellyfin or Plex layout and converts to the other format.
@@ -80,36 +80,45 @@ The first positional is the source library, the second is the target. By default
80
80
 
81
81
  Use `--copy` when source and target are on different filesystems (NAS to local disk, cross-pool moves, etc.). The size+mtime check makes re-runs cheap.
82
82
 
83
+ #### Exit codes
84
+
85
+ - `0` — sync completed (including "nothing to do").
86
+ - `1` — setup error (missing directories, undecipherable format, hardlinks across
87
+ filesystem boundaries).
88
+ - `2` — folder-level clash: the run was aborted, nothing was synced.
89
+ - `3` — sync completed, but some items marked for removal could not be removed
90
+ (permissions, busy files); they remain in the target.
91
+
83
92
  #### Examples
84
93
 
85
94
  Mirror a Jellyfin library into a new Plex structure:
86
95
 
87
96
  ```bash
88
- jellyplex-sync sync --create ~/Media/Jellyfin ~/Media/Plex
97
+ jellyplex sync --create ~/Media/Jellyfin ~/Media/Plex
89
98
  ```
90
99
 
91
100
  Migration with cleanup (target becomes a clean mirror):
92
101
 
93
102
  ```bash
94
- jellyplex-sync sync --delete --create ~/Media/Jellyfin ~/Media/Plex
103
+ jellyplex sync --delete --create ~/Media/Jellyfin ~/Media/Plex
95
104
  ```
96
105
 
97
106
  Cross-filesystem copy with safe re-runs:
98
107
 
99
108
  ```bash
100
- jellyplex-sync sync --copy --create /mnt/nas/Movies /mnt/local/Plex
109
+ jellyplex sync --copy --create /mnt/nas/Movies /mnt/local/Plex
101
110
  ```
102
111
 
103
112
  Dry-run a verbose, full sync with deletion:
104
113
 
105
114
  ```bash
106
- jellyplex-sync sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
115
+ jellyplex sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
107
116
  ```
108
117
 
109
118
  ### `diff`
110
119
 
111
120
  ```bash
112
- jellyplex-sync diff [OPTIONS] /path/to/source/library /path/to/target/library
121
+ jellyplex diff [OPTIONS] /path/to/source/library /path/to/target/library
113
122
  ```
114
123
 
115
124
  Compares the two libraries without touching anything. Reports movies that exist only on one side, file-level differences inside shared movies, translation losses (Plex labels with no Jellyfin equivalent, for example), and entries the scanner ignored.
@@ -125,13 +134,13 @@ Exit codes follow the Unix `diff` convention:
125
134
  Verify a target is a complete mirror before deleting the source:
126
135
 
127
136
  ```bash
128
- jellyplex-sync diff ~/Media/Jellyfin ~/Media/Plex
137
+ jellyplex diff ~/Media/Jellyfin ~/Media/Plex
129
138
  ```
130
139
 
131
140
  ### `plan`
132
141
 
133
142
  ```bash
134
- jellyplex-sync plan [OPTIONS] /path/to/source/library /path/to/target/library
143
+ jellyplex plan [OPTIONS] /path/to/source/library /path/to/target/library
135
144
  ```
136
145
 
137
146
  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`.
@@ -148,13 +157,13 @@ Exit codes:
148
157
  Pre-flight a sync, see exactly which target filenames would be created:
149
158
 
150
159
  ```bash
151
- jellyplex-sync plan ~/Media/Plex ~/Media/Jellyfin
160
+ jellyplex plan ~/Media/Plex ~/Media/Jellyfin
152
161
  ```
153
162
 
154
163
  Machine-readable, with `jq` to find videos that needed disambiguation:
155
164
 
156
165
  ```bash
157
- jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
166
+ jellyplex plan --json ~/Media/Plex ~/Media/Jellyfin \
158
167
  | jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
159
168
  ```
160
169
 
@@ -163,7 +172,7 @@ jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
163
172
  > ⚠️ **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
173
 
165
174
  ```bash
166
- jellyplex-sync import [OPTIONS] /path/to/staging /path/to/target/library
175
+ jellyplex import [OPTIONS] /path/to/staging /path/to/target/library
167
176
  ```
168
177
 
169
178
  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.
@@ -192,13 +201,13 @@ Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`
192
201
  Move Plex-named video files into a new Jellyfin library:
193
202
 
194
203
  ```bash
195
- jellyplex-sync import --create /mnt/staging /mnt/library/Jellyfin
204
+ jellyplex import --create /mnt/staging /mnt/library/Jellyfin
196
205
  ```
197
206
 
198
207
  Preview first, keeping the source:
199
208
 
200
209
  ```bash
201
- jellyplex-sync import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
210
+ jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
202
211
  ```
203
212
 
204
213
  ### JSON output
@@ -212,55 +221,39 @@ The schema (still evolving — pin a version when consuming):
212
221
  - `source` / `target`: `{path, format}` for each endpoint.
213
222
  - `dry_run` (sync only): whether the run was a preview.
214
223
  - `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`.
224
+ - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`.
216
225
  - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
217
226
  - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
218
227
  - `strays_in_target` (sync only): list of names found in target that aren't in source.
219
228
  - `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.
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.
229
+ - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove` — `link` covers every creation, including copies in `--copy` mode. 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
230
  - `clashes` (sync only): movie-level video clashes the disambiguator couldn't resolve at all. Normally empty since the hash-fallback resolves the practical cases.
222
231
  - `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.
232
+ - `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. Videos and movies that lose labels or attributes in translation carry their own `drops` / `folder_drops` lists (same `{kind, key, value, reason}` shape as `translation_losses`, but per file — so you can see which file loses what).
224
233
 
225
234
  #### `jq` examples
226
235
 
227
236
  Show every file that would be replaced (with full paths):
228
237
 
229
238
  ```bash
230
- jellyplex-sync sync --json --dry-run /src /dst \
239
+ jellyplex sync --json --dry-run /src /dst \
231
240
  | jq '.events[] | select(.action == "replace") | {source, target}'
232
241
  ```
233
242
 
234
243
  Verify nothing important gets deleted before running a migration with `--delete`:
235
244
 
236
245
  ```bash
237
- jellyplex-sync sync --json --dry-run --delete /src /dst \
246
+ jellyplex sync --json --dry-run --delete /src /dst \
238
247
  | jq '.events[] | select(.action == "remove")'
239
248
  ```
240
249
 
241
250
  Action distribution:
242
251
 
243
252
  ```bash
244
- jellyplex-sync sync --json --dry-run /src /dst \
253
+ jellyplex sync --json --dry-run /src /dst \
245
254
  | jq '[.events[].action] | group_by(.) | map({action: .[0], count: length})'
246
255
  ```
247
256
 
248
- ### Default subcommand
249
-
250
- For backward compatibility, omitting the subcommand is treated as an implicit `sync` (as long as the first argument is a positional, not a flag):
251
-
252
- ```bash
253
- jellyplex-sync ~/Media/Jellyfin ~/Media/Plex
254
- ```
255
-
256
- is equivalent to
257
-
258
- ```bash
259
- jellyplex-sync sync ~/Media/Jellyfin ~/Media/Plex
260
- ```
261
-
262
- If you start with a flag, you must spell out `sync` explicitly.
263
-
264
257
  ### Docker
265
258
 
266
259
  ```bash
@@ -305,7 +298,7 @@ This helper can also be used on other NAS systems or Linux servers — schedule
305
298
  - **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`.
306
299
  - **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
300
  - **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.
301
+ - **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 with exit code 2, the clashing folders are listed, and you're asked to rename one side.
309
302
 
310
303
  ## Library layouts
311
304
 
@@ -331,7 +324,7 @@ Each movie must reside in its own folder, with optional subfolders for extras. D
331
324
 
332
325
  #### Special filename handling
333
326
 
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).
327
+ 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 [docs/SPECS.md](./docs/SPECS.md).
335
328
 
336
329
  ### Plex
337
330
 
@@ -359,6 +352,12 @@ Movies
359
352
  └── Making of.mkv
360
353
  ```
361
354
 
355
+ ## Development
356
+
357
+ The pipeline architecture and internal vocabulary are documented in
358
+ [docs/DEV.md](./docs/DEV.md); the Plex/Jellyfin format details and translation
359
+ rules live in [docs/SPECS.md](./docs/SPECS.md).
360
+
362
361
  ## License
363
362
 
364
363
  This project is licensed under the [BSD 3-Clause License](./LICENSE).
@@ -0,0 +1,97 @@
1
+ [project]
2
+ name = "jellyplex-sync"
3
+ version = "0.4.0"
4
+ description = "Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror"
5
+ requires-python = ">=3.11"
6
+ readme = "README.md"
7
+ license = "BSD-3-Clause"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "plex",
11
+ "jellyfin",
12
+ "media-library",
13
+ "movies",
14
+ "sync",
15
+ "hardlink",
16
+ "mirror",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Console",
21
+ "Intended Audience :: End Users/Desktop",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Multimedia :: Video",
29
+ ]
30
+ dependencies = []
31
+
32
+ [[project.authors]]
33
+ name = "Stefan Schönberger"
34
+ email = "stefan@sniner.dev"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/sniner/jellyplex-sync"
38
+ Repository = "https://github.com/sniner/jellyplex-sync"
39
+ Issues = "https://github.com/sniner/jellyplex-sync/issues"
40
+ Changelog = "https://github.com/sniner/jellyplex-sync/blob/main/CHANGELOG.md"
41
+
42
+ [project.scripts]
43
+ jellyplex = "jellyplex_sync.cli.main:main"
44
+ jellyplex-sync = "jellyplex_sync.cli.sync:main"
45
+
46
+ [dependency-groups]
47
+ dev = [
48
+ "pytest>=8.3.5,<9",
49
+ "jellyplex-gen>=0.1.0",
50
+ "ruff>=0.15.5,<0.16",
51
+ "basedpyright>=1.38.2,<2",
52
+ ]
53
+
54
+ [tool.uv]
55
+ default-groups = "all"
56
+
57
+ [tool.pytest.ini_options]
58
+ addopts = "-ra -q"
59
+ testpaths = ["tests"]
60
+
61
+ [tool.pyright]
62
+ typeCheckingMode = "strict"
63
+ useLibraryCodeForTypes = true
64
+ venvPath = "."
65
+ venv = ".venv"
66
+
67
+ [[tool.pyright.executionEnvironments]]
68
+ root = "tests"
69
+ reportUnknownParameterType = false
70
+ reportMissingParameterType = false
71
+ reportUnknownArgumentType = false
72
+ reportUnknownMemberType = false
73
+ reportUnknownVariableType = false
74
+ reportUnknownLambdaType = false
75
+ reportMissingTypeArgument = false
76
+ reportPrivateUsage = false
77
+ reportUnusedCallResult = false
78
+ reportMissingTypeStubs = false
79
+
80
+ [tool.ruff]
81
+ line-length = 96
82
+
83
+ [tool.ruff.lint]
84
+ ignore = ["E402"]
85
+ extend-select = [
86
+ "I",
87
+ "UP",
88
+ "SIM",
89
+ "B",
90
+ ]
91
+
92
+ [tool.ruff.lint.per-file-ignores]
93
+ "__init__.py" = ["F401"]
94
+
95
+ [build-system]
96
+ requires = ["uv_build>=0.12,<0.13"]
97
+ build-backend = "uv_build"