jellyplex-sync 0.1.6__tar.gz → 0.2.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.
@@ -0,0 +1,301 @@
1
+ Metadata-Version: 2.4
2
+ Name: jellyplex-sync
3
+ Version: 0.2.2
4
+ Summary: Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror
5
+ Author: Stefan Schönberger
6
+ Author-email: Stefan Schönberger <stefan@sniner.dev>
7
+ License-Expression: BSD-3-Clause
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Bidirectional Movie Library Sync for Plex and Jellyfin
18
+
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
+
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.
22
+
23
+ ## Overview
24
+
25
+ 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.
26
+
27
+ > **Warning:** This script will **overwrite the entire target directory**. Do not store or edit anything manually in the target library path. The source library is treated as the **only source of truth**, and any unmatched content in the target folder may be deleted without warning.
28
+
29
+ > **Note:** This tool is only useful if your media library is well-maintained and each movie resides in its own folder.
30
+
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
+
33
+ > ⚠️ **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
+
35
+ ## Installation
36
+
37
+ ### Python package (recommended)
38
+
39
+ The easiest way to install the CLI is via [uv](https://docs.astral.sh/uv/):
40
+
41
+ ```bash
42
+ uv tool install jellyplex-sync
43
+ ```
44
+
45
+ This places `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
46
+
47
+ ### Docker
48
+
49
+ A prebuilt container image is published to GitHub Container Registry:
50
+
51
+ ```
52
+ ghcr.io/sniner/jellyplex-sync:latest
53
+ ```
54
+
55
+ If you'd rather build it yourself:
56
+
57
+ ```bash
58
+ docker build -t jellyplex-sync .
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ The CLI is split into two subcommands:
64
+
65
+ - **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
66
+ - **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
67
+
68
+ ### `sync`
69
+
70
+ ```bash
71
+ jellyplex-sync sync [OPTIONS] /path/to/source/library /path/to/target/library
72
+ ```
73
+
74
+ 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.
75
+
76
+ #### Options
77
+
78
+ - `--create` — create the target directory if it doesn't exist.
79
+ - `--delete` — remove stray folders/files in the target that have no counterpart in the source. Without this, strays are reported in the summary but kept on disk.
80
+ - `--dry-run` — show what would happen without touching the filesystem.
81
+ - `-v`, `--verbose` — log every processed movie.
82
+ - `--debug` — enable debug-level logging.
83
+ - `--json` — emit a machine-readable JSON document on stdout (see [JSON output](#json-output) below). Stderr is automatically quieted to WARNING unless `--verbose`/`--debug` is set, so the document pipes cleanly into `jq`.
84
+ - `--source-format {jellyfin,plex,auto}` — declare the source library format. `auto` (default) inspects the source layout.
85
+ - `--target-format {jellyfin,plex,auto}` — declare the target library format. `auto` (default) picks the opposite of the source. Setting both flags to the same value puts the tool into lint/normalize mode (rewrite a library in its own format, e.g. to canonicalize Plex labels).
86
+
87
+ **Materializer flags** (mutually exclusive — pick at most one):
88
+
89
+ - `--hardlink` (default) — create a hardlink at the target pointing to the source inode. Requires source and target on the same filesystem.
90
+ - `--copy` — copy bytes. On re-runs, skips files whose target already has the same size **and** mtime as the source.
91
+ - `--force-copy` — always copy, no skip heuristic.
92
+
93
+ 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.
94
+
95
+ #### Examples
96
+
97
+ Mirror a Jellyfin library into a new Plex structure:
98
+
99
+ ```bash
100
+ jellyplex-sync sync --create ~/Media/Jellyfin ~/Media/Plex
101
+ ```
102
+
103
+ Migration with cleanup (target becomes a clean mirror):
104
+
105
+ ```bash
106
+ jellyplex-sync sync --delete --create ~/Media/Jellyfin ~/Media/Plex
107
+ ```
108
+
109
+ Cross-filesystem copy with safe re-runs:
110
+
111
+ ```bash
112
+ jellyplex-sync sync --copy --create /mnt/nas/Movies /mnt/local/Plex
113
+ ```
114
+
115
+ Dry-run a verbose, full sync with deletion:
116
+
117
+ ```bash
118
+ jellyplex-sync sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
119
+ ```
120
+
121
+ ### `diff`
122
+
123
+ ```bash
124
+ jellyplex-sync diff [OPTIONS] /path/to/source/library /path/to/target/library
125
+ ```
126
+
127
+ 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.
128
+
129
+ Exit codes follow the Unix `diff` convention:
130
+
131
+ - `0` — in sync.
132
+ - `1` — differences found.
133
+ - `2` — setup error (missing directories, indecipherable format).
134
+
135
+ #### Example
136
+
137
+ Verify a target is a complete mirror before deleting the source:
138
+
139
+ ```bash
140
+ jellyplex-sync diff ~/Media/Jellyfin ~/Media/Plex
141
+ ```
142
+
143
+ ### JSON output
144
+
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.
146
+
147
+ The schema (still evolving — pin a version when consuming):
148
+
149
+ - `operation`: `"sync"` or `"diff"`.
150
+ - `exit_code`: the process exit code, mirrored in the document for convenience.
151
+ - `source` / `target`: `{path, format}` for each endpoint.
152
+ - `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`.
154
+ - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
155
+ - `strays_in_target` (sync only): list of names found in target that aren't in source.
156
+ - `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
+ - `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.
158
+ - `diff`-specific fields: `movies_only_in_source`, `movies_only_in_target`, `differing_movies`, `in_sync`.
159
+
160
+ #### `jq` examples
161
+
162
+ Show every file that would be replaced (with full paths):
163
+
164
+ ```bash
165
+ jellyplex-sync sync --json --dry-run /src /dst \
166
+ | jq '.events[] | select(.action == "replace") | {source, target}'
167
+ ```
168
+
169
+ Verify nothing important gets deleted before running a migration with `--delete`:
170
+
171
+ ```bash
172
+ jellyplex-sync sync --json --dry-run --delete /src /dst \
173
+ | jq '.events[] | select(.action == "remove")'
174
+ ```
175
+
176
+ Action distribution:
177
+
178
+ ```bash
179
+ jellyplex-sync sync --json --dry-run /src /dst \
180
+ | jq '[.events[].action] | group_by(.) | map({action: .[0], count: length})'
181
+ ```
182
+
183
+ ### Default subcommand
184
+
185
+ For backward compatibility, omitting the subcommand is treated as an implicit `sync` (as long as the first argument is a positional, not a flag):
186
+
187
+ ```bash
188
+ jellyplex-sync ~/Media/Jellyfin ~/Media/Plex
189
+ ```
190
+
191
+ is equivalent to
192
+
193
+ ```bash
194
+ jellyplex-sync sync ~/Media/Jellyfin ~/Media/Plex
195
+ ```
196
+
197
+ If you start with a flag, you must spell out `sync` explicitly.
198
+
199
+ ### Docker
200
+
201
+ ```bash
202
+ docker run --rm -v /your/media:/mnt ghcr.io/sniner/jellyplex-sync:latest sync /mnt/source /mnt/target
203
+ ```
204
+
205
+ To try the tool, generate a small Plex-format library with
206
+ [jellyplex-gen](https://pypi.org/project/jellyplex-gen/) and point the
207
+ sync at it:
208
+
209
+ ```bash
210
+ uvx jellyplex-gen plex --seed=demo --movies=20 --out=./demo-plex
211
+ mkdir ./demo-jellyfin
212
+ docker run --rm -v .:/mnt ghcr.io/sniner/jellyplex-sync:latest \
213
+ sync /mnt/demo-plex /mnt/demo-jellyfin
214
+ ```
215
+
216
+ > **Bind-mount note:** With `--hardlink` (default), both source and target paths must be reachable inside the container **through the same bind mount**, otherwise hardlinks between them cannot be created. With `--copy`, this constraint goes away.
217
+
218
+ ### Unraid (User Scripts)
219
+
220
+ The repository includes a `jellyplex-sync.sh` helper you can add to the Unraid User Scripts plugin. It pulls the latest container image, removes outdated ones, and runs the sync. Adjust the source and target paths at the bottom of the script to match your library locations.
221
+
222
+ > ⚠️ **Dry-run by default:** The script ships with `--dry-run` enabled. It will only print what it would do — nothing changes on disk until you remove that flag.
223
+
224
+ > ⚠️ **Array layout matters:** Hardlinks only work within a single filesystem. On Unraid's classic md-array, paths under `/mnt/user/...` are served by **shfs**, which can spread files across multiple disks. The result is that hardlinks created across `/mnt/user/...` paths can silently fall back to copies, get broken when the mover relocates files between cache and array, or fail outright. For reliable operation on Unraid, use one of these layouts:
225
+ >
226
+ > - **Same disk:** Put both source and target under the same `/mnt/diskX/...` path so the kernel sees one filesystem.
227
+ > - **Cache pool:** Keep both libraries on a single cache pool with no mover involvement.
228
+ > - **ZFS pool (recommended):** ZFS-backed pools present a single filesystem and handle hardlinks cleanly.
229
+ > - **Different filesystems:** Switch to `--copy` instead of the default `--hardlink`. Bytes get duplicated, but the layout works.
230
+ >
231
+ > If you used the legacy single-file script from the `unraid_user_scripts` branch in the past, the same constraint applied there.
232
+
233
+ This helper can also be used on other NAS systems or Linux servers — schedule it via cron for unattended syncs. Docker must be installed.
234
+
235
+ ## Behavior
236
+
237
+ - **Hardlinks (default)** — Video files are linked, not copied. Both libraries reference the same physical files on disk. Switch to `--copy` for cross-filesystem setups.
238
+ - **Asset folders** — Subdirectories (e.g., `other`, `interviews`) are processed recursively with the same materialization strategy. Note: rename your Jellyfin `extras` folder to `other`, since Plex does not recognize `extras`.
239
+ - **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
+ - **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
+ - **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.
242
+
243
+ ## Library layouts
244
+
245
+ ### Jellyfin
246
+
247
+ This is the expected folder structure in a Jellyfin movie library. The parser relies on it being consistent:
248
+
249
+ ```
250
+ Movies
251
+ ├── A Bridge Too Far (1977) [imdbid-tt0075784]
252
+ │ ├── A Bridge Too Far (1977) [imdbid-tt0075784].mkv
253
+ │ └── trailers
254
+ │ └── A Bridge Too Far.mkv
255
+ └── Das Boot (1981) [imdbid-tt0082096]
256
+ ├── Das Boot (1981) [imdbid-tt0082096] - Director's Cut.mkv
257
+ ├── Das Boot (1981) [imdbid-tt0082096] - Theatrical Cut.mkv
258
+ └── other
259
+ ├── Production Photos.mkv
260
+ └── Making of.mkv
261
+ ```
262
+
263
+ Each movie must reside in its own folder, with optional subfolders for extras. Different editions (e.g., Director's Cut, Theatrical Cut) must be named accordingly.
264
+
265
+ #### Special filename handling
266
+
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).
268
+
269
+ ### Plex
270
+
271
+ Plex follows a more structured naming convention than Jellyfin. While Jellyfin typically appends edition or variant information using a ` - ` (space-hyphen-space) pattern, Plex supports additional metadata inside **curly braces** for editions and **square brackets** for versions or other details.
272
+
273
+ Unlike Jellyfin, Plex's naming system allows you to embed extra labels such as release source (`[BluRay]`), quality (`[4K]`), or codec (`[HEVC]`) directly in the filename. These labels are ignored by the default Plex scanners during media recognition, but remain visible in the interface — which makes them useful for organizing your collection without affecting playback or matching.
274
+
275
+ > Note: This behavior applies to Plex's default scanner. If you use custom scanners or agents, they may treat these labels differently.
276
+
277
+ I originally started with a Jellyfin-style library and converted it to be Plex-compatible. Over time, I came to prefer Plex's more expressive naming conventions and switched my personal collection to follow the Plex format. I now use Jellyfin mainly as a fallback for long-term archival and offline use.
278
+
279
+ This is the expected folder structure in Plex format (with some demo labels):
280
+
281
+ ```
282
+ Movies
283
+ ├── A Bridge Too Far (1977) {imdb-tt0075784}
284
+ │ ├── A Bridge Too Far (1977) {imdb-tt0075784}.mkv
285
+ │ └── trailers
286
+ │ └── A Bridge Too Far.mkv
287
+ └── Das Boot (1981) {imdb-tt0082096}
288
+ ├── Das Boot (1981) {imdb-tt0082096} {edition-Director's Cut} [1080p].mkv
289
+ ├── Das Boot (1981) {imdb-tt0082096} {edition-Theatrical Cut} [1080p][remux].mkv
290
+ └── other
291
+ ├── Production Photos.mkv
292
+ └── Making of.mkv
293
+ ```
294
+
295
+ ## License
296
+
297
+ This project is licensed under the [BSD 3-Clause License](./LICENSE).
298
+
299
+ ## Disclaimer
300
+
301
+ This is a private project written for personal use. It doesn't cover all use cases or environments. Use at your own risk. Contributions or forks are welcome if you want to adapt it to your own setup.
@@ -0,0 +1,285 @@
1
+ # Bidirectional Movie Library Sync for Plex and Jellyfin
2
+
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
+
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.
6
+
7
+ ## Overview
8
+
9
+ 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.
10
+
11
+ > **Warning:** This script will **overwrite the entire target directory**. Do not store or edit anything manually in the target library path. The source library is treated as the **only source of truth**, and any unmatched content in the target folder may be deleted without warning.
12
+
13
+ > **Note:** This tool is only useful if your media library is well-maintained and each movie resides in its own folder.
14
+
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
+
17
+ > ⚠️ **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
+
19
+ ## Installation
20
+
21
+ ### Python package (recommended)
22
+
23
+ The easiest way to install the CLI is via [uv](https://docs.astral.sh/uv/):
24
+
25
+ ```bash
26
+ uv tool install jellyplex-sync
27
+ ```
28
+
29
+ This places `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
30
+
31
+ ### Docker
32
+
33
+ A prebuilt container image is published to GitHub Container Registry:
34
+
35
+ ```
36
+ ghcr.io/sniner/jellyplex-sync:latest
37
+ ```
38
+
39
+ If you'd rather build it yourself:
40
+
41
+ ```bash
42
+ docker build -t jellyplex-sync .
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ The CLI is split into two subcommands:
48
+
49
+ - **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
50
+ - **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
51
+
52
+ ### `sync`
53
+
54
+ ```bash
55
+ jellyplex-sync sync [OPTIONS] /path/to/source/library /path/to/target/library
56
+ ```
57
+
58
+ 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.
59
+
60
+ #### Options
61
+
62
+ - `--create` — create the target directory if it doesn't exist.
63
+ - `--delete` — remove stray folders/files in the target that have no counterpart in the source. Without this, strays are reported in the summary but kept on disk.
64
+ - `--dry-run` — show what would happen without touching the filesystem.
65
+ - `-v`, `--verbose` — log every processed movie.
66
+ - `--debug` — enable debug-level logging.
67
+ - `--json` — emit a machine-readable JSON document on stdout (see [JSON output](#json-output) below). Stderr is automatically quieted to WARNING unless `--verbose`/`--debug` is set, so the document pipes cleanly into `jq`.
68
+ - `--source-format {jellyfin,plex,auto}` — declare the source library format. `auto` (default) inspects the source layout.
69
+ - `--target-format {jellyfin,plex,auto}` — declare the target library format. `auto` (default) picks the opposite of the source. Setting both flags to the same value puts the tool into lint/normalize mode (rewrite a library in its own format, e.g. to canonicalize Plex labels).
70
+
71
+ **Materializer flags** (mutually exclusive — pick at most one):
72
+
73
+ - `--hardlink` (default) — create a hardlink at the target pointing to the source inode. Requires source and target on the same filesystem.
74
+ - `--copy` — copy bytes. On re-runs, skips files whose target already has the same size **and** mtime as the source.
75
+ - `--force-copy` — always copy, no skip heuristic.
76
+
77
+ 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.
78
+
79
+ #### Examples
80
+
81
+ Mirror a Jellyfin library into a new Plex structure:
82
+
83
+ ```bash
84
+ jellyplex-sync sync --create ~/Media/Jellyfin ~/Media/Plex
85
+ ```
86
+
87
+ Migration with cleanup (target becomes a clean mirror):
88
+
89
+ ```bash
90
+ jellyplex-sync sync --delete --create ~/Media/Jellyfin ~/Media/Plex
91
+ ```
92
+
93
+ Cross-filesystem copy with safe re-runs:
94
+
95
+ ```bash
96
+ jellyplex-sync sync --copy --create /mnt/nas/Movies /mnt/local/Plex
97
+ ```
98
+
99
+ Dry-run a verbose, full sync with deletion:
100
+
101
+ ```bash
102
+ jellyplex-sync sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
103
+ ```
104
+
105
+ ### `diff`
106
+
107
+ ```bash
108
+ jellyplex-sync diff [OPTIONS] /path/to/source/library /path/to/target/library
109
+ ```
110
+
111
+ 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.
112
+
113
+ Exit codes follow the Unix `diff` convention:
114
+
115
+ - `0` — in sync.
116
+ - `1` — differences found.
117
+ - `2` — setup error (missing directories, indecipherable format).
118
+
119
+ #### Example
120
+
121
+ Verify a target is a complete mirror before deleting the source:
122
+
123
+ ```bash
124
+ jellyplex-sync diff ~/Media/Jellyfin ~/Media/Plex
125
+ ```
126
+
127
+ ### JSON output
128
+
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.
130
+
131
+ The schema (still evolving — pin a version when consuming):
132
+
133
+ - `operation`: `"sync"` or `"diff"`.
134
+ - `exit_code`: the process exit code, mirrored in the document for convenience.
135
+ - `source` / `target`: `{path, format}` for each endpoint.
136
+ - `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`.
138
+ - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
139
+ - `strays_in_target` (sync only): list of names found in target that aren't in source.
140
+ - `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
+ - `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.
142
+ - `diff`-specific fields: `movies_only_in_source`, `movies_only_in_target`, `differing_movies`, `in_sync`.
143
+
144
+ #### `jq` examples
145
+
146
+ Show every file that would be replaced (with full paths):
147
+
148
+ ```bash
149
+ jellyplex-sync sync --json --dry-run /src /dst \
150
+ | jq '.events[] | select(.action == "replace") | {source, target}'
151
+ ```
152
+
153
+ Verify nothing important gets deleted before running a migration with `--delete`:
154
+
155
+ ```bash
156
+ jellyplex-sync sync --json --dry-run --delete /src /dst \
157
+ | jq '.events[] | select(.action == "remove")'
158
+ ```
159
+
160
+ Action distribution:
161
+
162
+ ```bash
163
+ jellyplex-sync sync --json --dry-run /src /dst \
164
+ | jq '[.events[].action] | group_by(.) | map({action: .[0], count: length})'
165
+ ```
166
+
167
+ ### Default subcommand
168
+
169
+ For backward compatibility, omitting the subcommand is treated as an implicit `sync` (as long as the first argument is a positional, not a flag):
170
+
171
+ ```bash
172
+ jellyplex-sync ~/Media/Jellyfin ~/Media/Plex
173
+ ```
174
+
175
+ is equivalent to
176
+
177
+ ```bash
178
+ jellyplex-sync sync ~/Media/Jellyfin ~/Media/Plex
179
+ ```
180
+
181
+ If you start with a flag, you must spell out `sync` explicitly.
182
+
183
+ ### Docker
184
+
185
+ ```bash
186
+ docker run --rm -v /your/media:/mnt ghcr.io/sniner/jellyplex-sync:latest sync /mnt/source /mnt/target
187
+ ```
188
+
189
+ To try the tool, generate a small Plex-format library with
190
+ [jellyplex-gen](https://pypi.org/project/jellyplex-gen/) and point the
191
+ sync at it:
192
+
193
+ ```bash
194
+ uvx jellyplex-gen plex --seed=demo --movies=20 --out=./demo-plex
195
+ mkdir ./demo-jellyfin
196
+ docker run --rm -v .:/mnt ghcr.io/sniner/jellyplex-sync:latest \
197
+ sync /mnt/demo-plex /mnt/demo-jellyfin
198
+ ```
199
+
200
+ > **Bind-mount note:** With `--hardlink` (default), both source and target paths must be reachable inside the container **through the same bind mount**, otherwise hardlinks between them cannot be created. With `--copy`, this constraint goes away.
201
+
202
+ ### Unraid (User Scripts)
203
+
204
+ The repository includes a `jellyplex-sync.sh` helper you can add to the Unraid User Scripts plugin. It pulls the latest container image, removes outdated ones, and runs the sync. Adjust the source and target paths at the bottom of the script to match your library locations.
205
+
206
+ > ⚠️ **Dry-run by default:** The script ships with `--dry-run` enabled. It will only print what it would do — nothing changes on disk until you remove that flag.
207
+
208
+ > ⚠️ **Array layout matters:** Hardlinks only work within a single filesystem. On Unraid's classic md-array, paths under `/mnt/user/...` are served by **shfs**, which can spread files across multiple disks. The result is that hardlinks created across `/mnt/user/...` paths can silently fall back to copies, get broken when the mover relocates files between cache and array, or fail outright. For reliable operation on Unraid, use one of these layouts:
209
+ >
210
+ > - **Same disk:** Put both source and target under the same `/mnt/diskX/...` path so the kernel sees one filesystem.
211
+ > - **Cache pool:** Keep both libraries on a single cache pool with no mover involvement.
212
+ > - **ZFS pool (recommended):** ZFS-backed pools present a single filesystem and handle hardlinks cleanly.
213
+ > - **Different filesystems:** Switch to `--copy` instead of the default `--hardlink`. Bytes get duplicated, but the layout works.
214
+ >
215
+ > If you used the legacy single-file script from the `unraid_user_scripts` branch in the past, the same constraint applied there.
216
+
217
+ This helper can also be used on other NAS systems or Linux servers — schedule it via cron for unattended syncs. Docker must be installed.
218
+
219
+ ## Behavior
220
+
221
+ - **Hardlinks (default)** — Video files are linked, not copied. Both libraries reference the same physical files on disk. Switch to `--copy` for cross-filesystem setups.
222
+ - **Asset folders** — Subdirectories (e.g., `other`, `interviews`) are processed recursively with the same materialization strategy. Note: rename your Jellyfin `extras` folder to `other`, since Plex does not recognize `extras`.
223
+ - **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
+ - **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
+ - **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.
226
+
227
+ ## Library layouts
228
+
229
+ ### Jellyfin
230
+
231
+ This is the expected folder structure in a Jellyfin movie library. The parser relies on it being consistent:
232
+
233
+ ```
234
+ Movies
235
+ ├── A Bridge Too Far (1977) [imdbid-tt0075784]
236
+ │ ├── A Bridge Too Far (1977) [imdbid-tt0075784].mkv
237
+ │ └── trailers
238
+ │ └── A Bridge Too Far.mkv
239
+ └── Das Boot (1981) [imdbid-tt0082096]
240
+ ├── Das Boot (1981) [imdbid-tt0082096] - Director's Cut.mkv
241
+ ├── Das Boot (1981) [imdbid-tt0082096] - Theatrical Cut.mkv
242
+ └── other
243
+ ├── Production Photos.mkv
244
+ └── Making of.mkv
245
+ ```
246
+
247
+ Each movie must reside in its own folder, with optional subfolders for extras. Different editions (e.g., Director's Cut, Theatrical Cut) must be named accordingly.
248
+
249
+ #### Special filename handling
250
+
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).
252
+
253
+ ### Plex
254
+
255
+ Plex follows a more structured naming convention than Jellyfin. While Jellyfin typically appends edition or variant information using a ` - ` (space-hyphen-space) pattern, Plex supports additional metadata inside **curly braces** for editions and **square brackets** for versions or other details.
256
+
257
+ Unlike Jellyfin, Plex's naming system allows you to embed extra labels such as release source (`[BluRay]`), quality (`[4K]`), or codec (`[HEVC]`) directly in the filename. These labels are ignored by the default Plex scanners during media recognition, but remain visible in the interface — which makes them useful for organizing your collection without affecting playback or matching.
258
+
259
+ > Note: This behavior applies to Plex's default scanner. If you use custom scanners or agents, they may treat these labels differently.
260
+
261
+ I originally started with a Jellyfin-style library and converted it to be Plex-compatible. Over time, I came to prefer Plex's more expressive naming conventions and switched my personal collection to follow the Plex format. I now use Jellyfin mainly as a fallback for long-term archival and offline use.
262
+
263
+ This is the expected folder structure in Plex format (with some demo labels):
264
+
265
+ ```
266
+ Movies
267
+ ├── A Bridge Too Far (1977) {imdb-tt0075784}
268
+ │ ├── A Bridge Too Far (1977) {imdb-tt0075784}.mkv
269
+ │ └── trailers
270
+ │ └── A Bridge Too Far.mkv
271
+ └── Das Boot (1981) {imdb-tt0082096}
272
+ ├── Das Boot (1981) {imdb-tt0082096} {edition-Director's Cut} [1080p].mkv
273
+ ├── Das Boot (1981) {imdb-tt0082096} {edition-Theatrical Cut} [1080p][remux].mkv
274
+ └── other
275
+ ├── Production Photos.mkv
276
+ └── Making of.mkv
277
+ ```
278
+
279
+ ## License
280
+
281
+ This project is licensed under the [BSD 3-Clause License](./LICENSE).
282
+
283
+ ## Disclaimer
284
+
285
+ This is a private project written for personal use. It doesn't cover all use cases or environments. Use at your own risk. Contributions or forks are welcome if you want to adapt it to your own setup.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jellyplex-sync"
3
- version = "0.1.6"
3
+ version = "0.2.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"
@@ -20,7 +20,10 @@ dependencies = []
20
20
  jellyplex-sync = "jellyplex_sync.cli.sync:main"
21
21
 
22
22
  [dependency-groups]
23
- dev = ["pytest>=8.3.5,<9"]
23
+ dev = [
24
+ "pytest>=8.3.5,<9",
25
+ "jellyplex-gen>=0.1.0",
26
+ ]
24
27
 
25
28
  [tool.uv]
26
29
  default-groups = "all"
@@ -0,0 +1,69 @@
1
+ from .jellyfin import (
2
+ JellyfinLibraryReader,
3
+ JellyfinLibraryWriter,
4
+ )
5
+ from .library import (
6
+ ACCEPTED_VIDEO_SUFFIXES,
7
+ CollectingReporter,
8
+ Drop,
9
+ DropError,
10
+ FileEvent,
11
+ IgnoredEntry,
12
+ LibraryReader,
13
+ LibraryWriter,
14
+ LoggingReporter,
15
+ MovieClash,
16
+ Reporter,
17
+ StrictReporter,
18
+ dedupe_drops,
19
+ )
20
+ from .materializer import (
21
+ CopyMaterializer,
22
+ FileMaterializer,
23
+ ForceCopyMaterializer,
24
+ HardlinkMaterializer,
25
+ )
26
+ from .model import (
27
+ MovieInfo,
28
+ VideoInfo,
29
+ )
30
+ from .plex import (
31
+ PlexLibraryReader,
32
+ PlexLibraryWriter,
33
+ )
34
+ from .sync import (
35
+ DiffEntry,
36
+ DiffResult,
37
+ diff,
38
+ sync,
39
+ )
40
+
41
+ __all__ = [
42
+ "ACCEPTED_VIDEO_SUFFIXES",
43
+ "CollectingReporter",
44
+ "CopyMaterializer",
45
+ "DiffEntry",
46
+ "DiffResult",
47
+ "Drop",
48
+ "DropError",
49
+ "FileEvent",
50
+ "FileMaterializer",
51
+ "ForceCopyMaterializer",
52
+ "HardlinkMaterializer",
53
+ "IgnoredEntry",
54
+ "JellyfinLibraryReader",
55
+ "JellyfinLibraryWriter",
56
+ "LibraryReader",
57
+ "LibraryWriter",
58
+ "LoggingReporter",
59
+ "MovieClash",
60
+ "MovieInfo",
61
+ "PlexLibraryReader",
62
+ "PlexLibraryWriter",
63
+ "Reporter",
64
+ "StrictReporter",
65
+ "VideoInfo",
66
+ "dedupe_drops",
67
+ "diff",
68
+ "sync",
69
+ ]