jellyplex-sync 0.3.4__tar.gz → 0.5.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.4/README.md → jellyplex_sync-0.5.0/PKG-INFO +76 -11
- jellyplex_sync-0.3.4/PKG-INFO → jellyplex_sync-0.5.0/README.md +50 -27
- jellyplex_sync-0.5.0/pyproject.toml +97 -0
- jellyplex_sync-0.3.4/pyproject.toml → jellyplex_sync-0.5.0/pyproject.toml.orig +33 -3
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/__init__.py +11 -3
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/cli/main.py +44 -56
- jellyplex_sync-0.5.0/src/jellyplex_sync/cli/sync.py +22 -0
- jellyplex_sync-0.5.0/src/jellyplex_sync/compare.py +135 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/disambig.py +44 -27
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/discover.py +75 -12
- jellyplex_sync-0.5.0/src/jellyplex_sync/formats.py +164 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/jellyfin.py +19 -11
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/json_output.py +45 -20
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/library.py +40 -54
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/materializer.py +22 -15
- jellyplex_sync-0.5.0/src/jellyplex_sync/plan.py +135 -0
- jellyplex_sync-0.5.0/src/jellyplex_sync/planner.py +328 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/plex.py +13 -4
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/realize.py +62 -12
- jellyplex_sync-0.5.0/src/jellyplex_sync/report.py +178 -0
- jellyplex_sync-0.5.0/src/jellyplex_sync/sync.py +593 -0
- jellyplex_sync-0.3.4/src/jellyplex_sync/cli/sync.py +0 -186
- jellyplex_sync-0.3.4/src/jellyplex_sync/compare.py +0 -82
- jellyplex_sync-0.3.4/src/jellyplex_sync/plan.py +0 -88
- jellyplex_sync-0.3.4/src/jellyplex_sync/planner.py +0 -212
- jellyplex_sync-0.3.4/src/jellyplex_sync/sync.py +0 -698
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/LICENSE +0 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/cli/__init__.py +0 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/model.py +0 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/py.typed +0 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.5.0}/src/jellyplex_sync/utils.py +0 -0
|
@@ -1,14 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jellyplex-sync
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
6
|
+
Author: Stefan Schönberger
|
|
7
|
+
Author-email: Stefan Schönberger <stefan@sniner.dev>
|
|
8
|
+
License-Expression: BSD-3-Clause
|
|
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
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Multimedia :: Video
|
|
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
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
1
27
|
# Bidirectional Movie Library Sync for Plex and Jellyfin
|
|
2
28
|
|
|
3
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.
|
|
4
30
|
|
|
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
31
|
## Overview
|
|
8
32
|
|
|
9
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.
|
|
10
34
|
|
|
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.
|
|
35
|
+
> **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. Folders you mark with `.jellyplex-sync-ignore` are the exception, see [Excluding folders](#excluding-folders).
|
|
12
36
|
|
|
13
37
|
> **Note:** This tool is only useful if your media library is well-maintained and each movie resides in its own folder.
|
|
14
38
|
|
|
@@ -82,6 +106,15 @@ The first positional is the source library, the second is the target. By default
|
|
|
82
106
|
|
|
83
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.
|
|
84
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
|
+
|
|
85
118
|
#### Examples
|
|
86
119
|
|
|
87
120
|
Mirror a Jellyfin library into a new Plex structure:
|
|
@@ -120,7 +153,8 @@ Exit codes follow the Unix `diff` convention:
|
|
|
120
153
|
|
|
121
154
|
- `0` — in sync.
|
|
122
155
|
- `1` — differences found.
|
|
123
|
-
- `2` — setup error (missing directories, indecipherable format)
|
|
156
|
+
- `2` — setup error (missing directories, indecipherable format), or one of the
|
|
157
|
+
library folders contains `.jellyplex-sync-ignore`.
|
|
124
158
|
|
|
125
159
|
#### Example
|
|
126
160
|
|
|
@@ -203,6 +237,28 @@ Preview first, keeping the source:
|
|
|
203
237
|
jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
|
|
204
238
|
```
|
|
205
239
|
|
|
240
|
+
### Excluding folders
|
|
241
|
+
|
|
242
|
+
To exclude a folder and everything below it, put an empty file named `.jellyplex-sync-ignore` into it:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
touch ~/"Media/Plex/Some Movie (2001) {imdb-tt0000000}/.jellyplex-sync-ignore"
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The marker means the same in both directions, so it does not matter whether the library is the source or the target of a run:
|
|
249
|
+
|
|
250
|
+
- **In the source**, the folder is treated as if it did not exist. Nothing in it is synced or imported.
|
|
251
|
+
- **In the target**, nothing is written into the folder and nothing is removed from it, not even with `--delete`. A marked folder that has no counterpart in the source is not reported as a stray.
|
|
252
|
+
|
|
253
|
+
The marker works at any level: in a movie folder, in an asset folder such as `other`, or further down. The marker file itself is never copied to the other library.
|
|
254
|
+
|
|
255
|
+
Things to know:
|
|
256
|
+
|
|
257
|
+
- **Marking a source folder does not keep its copy in the target.** If the folder was synced before, its copy in the target becomes a stray: it is reported, and `--delete` removes it. The source folder is not touched. To keep the copy, put a marker into the target folder as well.
|
|
258
|
+
- **A marked folder is also kept when `--delete` would remove a folder above it.** If a folder in the target has no counterpart in the source but contains a marked folder at any depth, the whole folder is kept.
|
|
259
|
+
- **A marker in the library folder itself stops the run.** `sync` and `import` change nothing, print a warning and exit with code 0. `diff` exits with code 2, and `plan` shows an empty plan. A marked source library is not treated as an empty library, so `--delete` cannot clear the target this way.
|
|
260
|
+
- At the end of a `sync` or `import` run, the number of marked folders is printed; `--verbose` lists them. `diff` and `plan` always list them. In the `--json` output they are in `marked_in_source` and `marked_in_target`.
|
|
261
|
+
|
|
206
262
|
### JSON output
|
|
207
263
|
|
|
208
264
|
All three subcommands accept `--json` for machine-readable output. The document goes to **stdout**; logs continue to go to stderr. Under `--json`, INFO logs are suppressed unless you pass `--verbose` or `--debug`, so the output pipes cleanly into tools like `jq` without filtering.
|
|
@@ -214,15 +270,16 @@ The schema (still evolving — pin a version when consuming):
|
|
|
214
270
|
- `source` / `target`: `{path, format}` for each endpoint.
|
|
215
271
|
- `dry_run` (sync only): whether the run was a preview.
|
|
216
272
|
- `summary`: counters; the set of fields depends on the operation.
|
|
217
|
-
- sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `items_ignored`, `strays_in_target`, `clashes`.
|
|
218
|
-
- plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
|
|
273
|
+
- sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`, `marked_in_source`, `marked_in_target`.
|
|
274
|
+
- plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`, `marked_in_source`, `marked_in_target`.
|
|
219
275
|
- `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
|
|
276
|
+
- `marked_in_source` / `marked_in_target`: full paths of the folders skipped because they contain `.jellyplex-sync-ignore` (see [Excluding folders](#excluding-folders)). A library folder in one of these lists means the run was stopped.
|
|
220
277
|
- `strays_in_target` (sync only): list of names found in target that aren't in source.
|
|
221
278
|
- `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.
|
|
222
|
-
- `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove
|
|
279
|
+
- `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.
|
|
223
280
|
- `clashes` (sync only): movie-level video clashes the disambiguator couldn't resolve at all. Normally empty since the hash-fallback resolves the practical cases.
|
|
224
281
|
- `diff`-specific fields: `movies_only_in_source`, `movies_only_in_target`, `differing_movies`, `in_sync`.
|
|
225
|
-
- `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.
|
|
282
|
+
- `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).
|
|
226
283
|
|
|
227
284
|
#### `jq` examples
|
|
228
285
|
|
|
@@ -288,10 +345,12 @@ This helper can also be used on other NAS systems or Linux servers — schedule
|
|
|
288
345
|
- **Hardlinks (default)** — Video files are linked, not copied. Both libraries reference the same physical files on disk. Switch to `--copy` for cross-filesystem setups.
|
|
289
346
|
- **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`.
|
|
290
347
|
- **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.
|
|
291
|
-
- **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`.
|
|
348
|
+
- **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed, except folders marked with `.jellyplex-sync-ignore` and folders that contain one. 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`.
|
|
292
349
|
- **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.
|
|
350
|
+
- **Hidden files and folders are skipped.** Names starting with a dot (`.DS_Store`, `.stversions`, `.Trash`) are skipped at every level of the source library, by `import` as well. They do not appear in the list of ignored items.
|
|
351
|
+
- **Marked folders are skipped** on both sides, see [Excluding folders](#excluding-folders).
|
|
293
352
|
- **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.
|
|
294
|
-
- **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.
|
|
353
|
+
- **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.
|
|
295
354
|
|
|
296
355
|
## Library layouts
|
|
297
356
|
|
|
@@ -317,7 +376,7 @@ Each movie must reside in its own folder, with optional subfolders for extras. D
|
|
|
317
376
|
|
|
318
377
|
#### Special filename handling
|
|
319
378
|
|
|
320
|
-
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).
|
|
379
|
+
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).
|
|
321
380
|
|
|
322
381
|
### Plex
|
|
323
382
|
|
|
@@ -345,6 +404,12 @@ Movies
|
|
|
345
404
|
└── Making of.mkv
|
|
346
405
|
```
|
|
347
406
|
|
|
407
|
+
## Development
|
|
408
|
+
|
|
409
|
+
The pipeline architecture and internal vocabulary are documented in
|
|
410
|
+
[docs/DEV.md](./docs/DEV.md); the Plex/Jellyfin format details and translation
|
|
411
|
+
rules live in [docs/SPECS.md](./docs/SPECS.md).
|
|
412
|
+
|
|
348
413
|
## License
|
|
349
414
|
|
|
350
415
|
This project is licensed under the [BSD 3-Clause License](./LICENSE).
|
|
@@ -1,30 +1,12 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: jellyplex-sync
|
|
3
|
-
Version: 0.3.4
|
|
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
1
|
# Bidirectional Movie Library Sync for Plex and Jellyfin
|
|
18
2
|
|
|
19
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.
|
|
20
4
|
|
|
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
5
|
## Overview
|
|
24
6
|
|
|
25
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.
|
|
26
8
|
|
|
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.
|
|
9
|
+
> **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. Folders you mark with `.jellyplex-sync-ignore` are the exception, see [Excluding folders](#excluding-folders).
|
|
28
10
|
|
|
29
11
|
> **Note:** This tool is only useful if your media library is well-maintained and each movie resides in its own folder.
|
|
30
12
|
|
|
@@ -98,6 +80,15 @@ The first positional is the source library, the second is the target. By default
|
|
|
98
80
|
|
|
99
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.
|
|
100
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
|
+
|
|
101
92
|
#### Examples
|
|
102
93
|
|
|
103
94
|
Mirror a Jellyfin library into a new Plex structure:
|
|
@@ -136,7 +127,8 @@ Exit codes follow the Unix `diff` convention:
|
|
|
136
127
|
|
|
137
128
|
- `0` — in sync.
|
|
138
129
|
- `1` — differences found.
|
|
139
|
-
- `2` — setup error (missing directories, indecipherable format)
|
|
130
|
+
- `2` — setup error (missing directories, indecipherable format), or one of the
|
|
131
|
+
library folders contains `.jellyplex-sync-ignore`.
|
|
140
132
|
|
|
141
133
|
#### Example
|
|
142
134
|
|
|
@@ -219,6 +211,28 @@ Preview first, keeping the source:
|
|
|
219
211
|
jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
|
|
220
212
|
```
|
|
221
213
|
|
|
214
|
+
### Excluding folders
|
|
215
|
+
|
|
216
|
+
To exclude a folder and everything below it, put an empty file named `.jellyplex-sync-ignore` into it:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
touch ~/"Media/Plex/Some Movie (2001) {imdb-tt0000000}/.jellyplex-sync-ignore"
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The marker means the same in both directions, so it does not matter whether the library is the source or the target of a run:
|
|
223
|
+
|
|
224
|
+
- **In the source**, the folder is treated as if it did not exist. Nothing in it is synced or imported.
|
|
225
|
+
- **In the target**, nothing is written into the folder and nothing is removed from it, not even with `--delete`. A marked folder that has no counterpart in the source is not reported as a stray.
|
|
226
|
+
|
|
227
|
+
The marker works at any level: in a movie folder, in an asset folder such as `other`, or further down. The marker file itself is never copied to the other library.
|
|
228
|
+
|
|
229
|
+
Things to know:
|
|
230
|
+
|
|
231
|
+
- **Marking a source folder does not keep its copy in the target.** If the folder was synced before, its copy in the target becomes a stray: it is reported, and `--delete` removes it. The source folder is not touched. To keep the copy, put a marker into the target folder as well.
|
|
232
|
+
- **A marked folder is also kept when `--delete` would remove a folder above it.** If a folder in the target has no counterpart in the source but contains a marked folder at any depth, the whole folder is kept.
|
|
233
|
+
- **A marker in the library folder itself stops the run.** `sync` and `import` change nothing, print a warning and exit with code 0. `diff` exits with code 2, and `plan` shows an empty plan. A marked source library is not treated as an empty library, so `--delete` cannot clear the target this way.
|
|
234
|
+
- At the end of a `sync` or `import` run, the number of marked folders is printed; `--verbose` lists them. `diff` and `plan` always list them. In the `--json` output they are in `marked_in_source` and `marked_in_target`.
|
|
235
|
+
|
|
222
236
|
### JSON output
|
|
223
237
|
|
|
224
238
|
All three subcommands accept `--json` for machine-readable output. The document goes to **stdout**; logs continue to go to stderr. Under `--json`, INFO logs are suppressed unless you pass `--verbose` or `--debug`, so the output pipes cleanly into tools like `jq` without filtering.
|
|
@@ -230,15 +244,16 @@ The schema (still evolving — pin a version when consuming):
|
|
|
230
244
|
- `source` / `target`: `{path, format}` for each endpoint.
|
|
231
245
|
- `dry_run` (sync only): whether the run was a preview.
|
|
232
246
|
- `summary`: counters; the set of fields depends on the operation.
|
|
233
|
-
- sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `items_ignored`, `strays_in_target`, `clashes`.
|
|
234
|
-
- plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
|
|
247
|
+
- sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`, `marked_in_source`, `marked_in_target`.
|
|
248
|
+
- plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`, `marked_in_source`, `marked_in_target`.
|
|
235
249
|
- `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
|
|
250
|
+
- `marked_in_source` / `marked_in_target`: full paths of the folders skipped because they contain `.jellyplex-sync-ignore` (see [Excluding folders](#excluding-folders)). A library folder in one of these lists means the run was stopped.
|
|
236
251
|
- `strays_in_target` (sync only): list of names found in target that aren't in source.
|
|
237
252
|
- `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.
|
|
238
|
-
- `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove
|
|
253
|
+
- `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.
|
|
239
254
|
- `clashes` (sync only): movie-level video clashes the disambiguator couldn't resolve at all. Normally empty since the hash-fallback resolves the practical cases.
|
|
240
255
|
- `diff`-specific fields: `movies_only_in_source`, `movies_only_in_target`, `differing_movies`, `in_sync`.
|
|
241
|
-
- `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.
|
|
256
|
+
- `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).
|
|
242
257
|
|
|
243
258
|
#### `jq` examples
|
|
244
259
|
|
|
@@ -304,10 +319,12 @@ This helper can also be used on other NAS systems or Linux servers — schedule
|
|
|
304
319
|
- **Hardlinks (default)** — Video files are linked, not copied. Both libraries reference the same physical files on disk. Switch to `--copy` for cross-filesystem setups.
|
|
305
320
|
- **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`.
|
|
306
321
|
- **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.
|
|
307
|
-
- **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
|
+
- **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed, except folders marked with `.jellyplex-sync-ignore` and folders that contain one. 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`.
|
|
308
323
|
- **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.
|
|
324
|
+
- **Hidden files and folders are skipped.** Names starting with a dot (`.DS_Store`, `.stversions`, `.Trash`) are skipped at every level of the source library, by `import` as well. They do not appear in the list of ignored items.
|
|
325
|
+
- **Marked folders are skipped** on both sides, see [Excluding folders](#excluding-folders).
|
|
309
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.
|
|
310
|
-
- **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.
|
|
311
328
|
|
|
312
329
|
## Library layouts
|
|
313
330
|
|
|
@@ -333,7 +350,7 @@ Each movie must reside in its own folder, with optional subfolders for extras. D
|
|
|
333
350
|
|
|
334
351
|
#### Special filename handling
|
|
335
352
|
|
|
336
|
-
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).
|
|
337
354
|
|
|
338
355
|
### Plex
|
|
339
356
|
|
|
@@ -361,6 +378,12 @@ Movies
|
|
|
361
378
|
└── Making of.mkv
|
|
362
379
|
```
|
|
363
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
|
+
|
|
364
387
|
## License
|
|
365
388
|
|
|
366
389
|
This project is licensed under the [BSD 3-Clause License](./LICENSE).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jellyplex-sync"
|
|
3
|
+
version = "0.5.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"
|
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "jellyplex-sync"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
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"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
license = "BSD-3-Clause"
|
|
9
9
|
license-files = ["LICENSE"]
|
|
10
|
+
keywords = ["plex", "jellyfin", "media-library", "movies", "sync", "hardlink", "mirror"]
|
|
10
11
|
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: End Users/Desktop",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
11
16
|
"Programming Language :: Python :: 3",
|
|
12
17
|
"Programming Language :: Python :: 3.11",
|
|
13
18
|
"Programming Language :: Python :: 3.12",
|
|
14
19
|
"Programming Language :: Python :: 3.13",
|
|
15
20
|
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Multimedia :: Video",
|
|
16
22
|
]
|
|
17
23
|
dependencies = []
|
|
18
24
|
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/sniner/jellyplex-sync"
|
|
27
|
+
Repository = "https://github.com/sniner/jellyplex-sync"
|
|
28
|
+
Issues = "https://github.com/sniner/jellyplex-sync/issues"
|
|
29
|
+
Changelog = "https://github.com/sniner/jellyplex-sync/blob/main/CHANGELOG.md"
|
|
30
|
+
|
|
19
31
|
[project.scripts]
|
|
20
32
|
jellyplex = "jellyplex_sync.cli.main:main"
|
|
21
33
|
jellyplex-sync = "jellyplex_sync.cli.sync:main"
|
|
@@ -24,13 +36,15 @@ jellyplex-sync = "jellyplex_sync.cli.sync:main"
|
|
|
24
36
|
dev = [
|
|
25
37
|
"pytest>=8.3.5,<9",
|
|
26
38
|
"jellyplex-gen>=0.1.0",
|
|
39
|
+
"ruff>=0.15.5,<0.16",
|
|
40
|
+
"basedpyright>=1.38.2,<2",
|
|
27
41
|
]
|
|
28
42
|
|
|
29
43
|
[tool.uv]
|
|
30
44
|
default-groups = "all"
|
|
31
45
|
|
|
32
46
|
[build-system]
|
|
33
|
-
requires = ["uv_build>=0.
|
|
47
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
34
48
|
build-backend = "uv_build"
|
|
35
49
|
|
|
36
50
|
[tool.pytest.ini_options]
|
|
@@ -38,11 +52,27 @@ addopts = "-ra -q"
|
|
|
38
52
|
testpaths = ["tests"]
|
|
39
53
|
|
|
40
54
|
[tool.pyright]
|
|
41
|
-
typeCheckingMode = "
|
|
55
|
+
typeCheckingMode = "strict"
|
|
42
56
|
useLibraryCodeForTypes = true
|
|
43
57
|
venvPath = "."
|
|
44
58
|
venv = ".venv"
|
|
45
59
|
|
|
60
|
+
# Tests stay at standard-level strictness: annotating every pytest
|
|
61
|
+
# fixture parameter adds noise, not safety. The library itself is
|
|
62
|
+
# checked strict.
|
|
63
|
+
[[tool.pyright.executionEnvironments]]
|
|
64
|
+
root = "tests"
|
|
65
|
+
reportUnknownParameterType = false
|
|
66
|
+
reportMissingParameterType = false
|
|
67
|
+
reportUnknownArgumentType = false
|
|
68
|
+
reportUnknownMemberType = false
|
|
69
|
+
reportUnknownVariableType = false
|
|
70
|
+
reportUnknownLambdaType = false
|
|
71
|
+
reportMissingTypeArgument = false
|
|
72
|
+
reportPrivateUsage = false
|
|
73
|
+
reportUnusedCallResult = false
|
|
74
|
+
reportMissingTypeStubs = false
|
|
75
|
+
|
|
46
76
|
[tool.ruff]
|
|
47
77
|
line-length = 96
|
|
48
78
|
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
from .compare import (
|
|
2
|
+
DiffEntry,
|
|
3
|
+
DiffResult,
|
|
4
|
+
)
|
|
1
5
|
from .jellyfin import (
|
|
2
6
|
JellyfinLibraryReader,
|
|
3
7
|
JellyfinLibraryWriter,
|
|
@@ -14,6 +18,7 @@ from .library import (
|
|
|
14
18
|
LibraryWriter,
|
|
15
19
|
LoggingReporter,
|
|
16
20
|
MovieClash,
|
|
21
|
+
NullReporter,
|
|
17
22
|
Reporter,
|
|
18
23
|
StrictReporter,
|
|
19
24
|
dedupe_drops,
|
|
@@ -41,8 +46,8 @@ from .plex import (
|
|
|
41
46
|
PlexLibraryWriter,
|
|
42
47
|
)
|
|
43
48
|
from .sync import (
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
LibraryStats,
|
|
50
|
+
SyncResult,
|
|
46
51
|
diff,
|
|
47
52
|
import_media,
|
|
48
53
|
plan,
|
|
@@ -63,15 +68,17 @@ __all__ = [
|
|
|
63
68
|
"FolderClash",
|
|
64
69
|
"ForceCopyMaterializer",
|
|
65
70
|
"HardlinkMaterializer",
|
|
66
|
-
"MoveMaterializer",
|
|
67
71
|
"IgnoredEntry",
|
|
68
72
|
"JellyfinLibraryReader",
|
|
69
73
|
"JellyfinLibraryWriter",
|
|
70
74
|
"LibraryReader",
|
|
75
|
+
"LibraryStats",
|
|
71
76
|
"LibraryWriter",
|
|
72
77
|
"LoggingReporter",
|
|
78
|
+
"MoveMaterializer",
|
|
73
79
|
"MovieClash",
|
|
74
80
|
"MovieInfo",
|
|
81
|
+
"NullReporter",
|
|
75
82
|
"Plan",
|
|
76
83
|
"PlannedAsset",
|
|
77
84
|
"PlannedFile",
|
|
@@ -80,6 +87,7 @@ __all__ = [
|
|
|
80
87
|
"PlexLibraryWriter",
|
|
81
88
|
"Reporter",
|
|
82
89
|
"StrictReporter",
|
|
90
|
+
"SyncResult",
|
|
83
91
|
"VideoInfo",
|
|
84
92
|
"dedupe_drops",
|
|
85
93
|
"diff",
|