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.
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/PKG-INFO +55 -46
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/README.md +44 -45
- jellyplex_sync-0.4.0/pyproject.toml +97 -0
- jellyplex_sync-0.4.0/pyproject.toml.orig +87 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/__init__.py +11 -3
- jellyplex_sync-0.3.2/src/jellyplex_sync/cli/sync.py → jellyplex_sync-0.4.0/src/jellyplex_sync/cli/main.py +47 -87
- jellyplex_sync-0.4.0/src/jellyplex_sync/cli/sync.py +22 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/compare.py +43 -16
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/disambig.py +44 -27
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/discover.py +23 -7
- jellyplex_sync-0.4.0/src/jellyplex_sync/formats.py +164 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/jellyfin.py +20 -12
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/json_output.py +31 -20
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/library.py +17 -54
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/materializer.py +23 -16
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/plan.py +30 -7
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/planner.py +52 -25
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/plex.py +13 -4
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/realize.py +14 -11
- jellyplex_sync-0.4.0/src/jellyplex_sync/report.py +135 -0
- jellyplex_sync-0.4.0/src/jellyplex_sync/sync.py +481 -0
- jellyplex_sync-0.3.2/pyproject.toml +0 -56
- jellyplex_sync-0.3.2/src/jellyplex_sync/sync.py +0 -698
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/LICENSE +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/cli/__init__.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/model.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.4.0}/src/jellyplex_sync/py.typed +0 -0
- {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
|
+
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
163
|
+
jellyplex diff ~/Media/Jellyfin ~/Media/Plex
|
|
145
164
|
```
|
|
146
165
|
|
|
147
166
|
### `plan`
|
|
148
167
|
|
|
149
168
|
```bash
|
|
150
|
-
jellyplex
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
137
|
+
jellyplex diff ~/Media/Jellyfin ~/Media/Plex
|
|
129
138
|
```
|
|
130
139
|
|
|
131
140
|
### `plan`
|
|
132
141
|
|
|
133
142
|
```bash
|
|
134
|
-
jellyplex
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|