jellyplex-sync 0.3.4__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/PKG-INFO +31 -8
  2. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/README.md +20 -7
  3. jellyplex_sync-0.4.0/pyproject.toml +97 -0
  4. jellyplex_sync-0.3.4/pyproject.toml → jellyplex_sync-0.4.0/pyproject.toml.orig +33 -3
  5. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/__init__.py +11 -3
  6. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/cli/main.py +44 -56
  7. jellyplex_sync-0.4.0/src/jellyplex_sync/cli/sync.py +22 -0
  8. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/compare.py +43 -16
  9. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/disambig.py +44 -27
  10. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/discover.py +23 -7
  11. jellyplex_sync-0.4.0/src/jellyplex_sync/formats.py +164 -0
  12. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/jellyfin.py +19 -11
  13. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/json_output.py +31 -20
  14. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/library.py +17 -54
  15. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/materializer.py +22 -15
  16. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/plan.py +30 -7
  17. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/planner.py +52 -25
  18. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/plex.py +13 -4
  19. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/realize.py +14 -11
  20. jellyplex_sync-0.4.0/src/jellyplex_sync/report.py +135 -0
  21. jellyplex_sync-0.4.0/src/jellyplex_sync/sync.py +481 -0
  22. jellyplex_sync-0.3.4/src/jellyplex_sync/cli/sync.py +0 -186
  23. jellyplex_sync-0.3.4/src/jellyplex_sync/sync.py +0 -698
  24. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/LICENSE +0 -0
  25. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/cli/__init__.py +0 -0
  26. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/model.py +0 -0
  27. {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/py.typed +0 -0
  28. {jellyplex_sync-0.3.4 → 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.4
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.
@@ -98,6 +106,15 @@ The first positional is the source library, the second is the target. By default
98
106
 
99
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.
100
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
+
101
118
  #### Examples
102
119
 
103
120
  Mirror a Jellyfin library into a new Plex structure:
@@ -230,15 +247,15 @@ The schema (still evolving — pin a version when consuming):
230
247
  - `source` / `target`: `{path, format}` for each endpoint.
231
248
  - `dry_run` (sync only): whether the run was a preview.
232
249
  - `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`.
250
+ - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`.
234
251
  - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
235
252
  - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
236
253
  - `strays_in_target` (sync only): list of names found in target that aren't in source.
237
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.
238
- - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove`. For `remove`, `context` is `library_stray` / `movie_stray` / `asset_stray`. Action names are the same in `--dry-run` and real runs; the top-level `dry_run` flag tells you which mode you were in.
255
+ - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove` — `link` covers every creation, including copies in `--copy` mode. For `remove`, `context` is `library_stray` / `movie_stray` / `asset_stray`. Action names are the same in `--dry-run` and real runs; the top-level `dry_run` flag tells you which mode you were in.
239
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.
240
257
  - `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.
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).
242
259
 
243
260
  #### `jq` examples
244
261
 
@@ -307,7 +324,7 @@ This helper can also be used on other NAS systems or Linux servers — schedule
307
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`.
308
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.
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).
@@ -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.
@@ -82,6 +80,15 @@ The first positional is the source library, the second is the target. By default
82
80
 
83
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.
84
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
+
85
92
  #### Examples
86
93
 
87
94
  Mirror a Jellyfin library into a new Plex structure:
@@ -214,15 +221,15 @@ The schema (still evolving — pin a version when consuming):
214
221
  - `source` / `target`: `{path, format}` for each endpoint.
215
222
  - `dry_run` (sync only): whether the run was a preview.
216
223
  - `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`.
224
+ - sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `remove_errors`, `items_ignored`, `strays_in_target`, `clashes`.
218
225
  - plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
219
226
  - `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
220
227
  - `strays_in_target` (sync only): list of names found in target that aren't in source.
221
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.
222
- - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove`. For `remove`, `context` is `library_stray` / `movie_stray` / `asset_stray`. Action names are the same in `--dry-run` and real runs; the top-level `dry_run` flag tells you which mode you were in.
229
+ - `events` (sync only): flat array of per-file actions, each `{action, target, source?, context?}`. Actions are `link`, `replace`, `skip`, `remove` — `link` covers every creation, including copies in `--copy` mode. For `remove`, `context` is `library_stray` / `movie_stray` / `asset_stray`. Action names are the same in `--dry-run` and real runs; the top-level `dry_run` flag tells you which mode you were in.
223
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.
224
231
  - `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.
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).
226
233
 
227
234
  #### `jq` examples
228
235
 
@@ -291,7 +298,7 @@ This helper can also be used on other NAS systems or Linux servers — schedule
291
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`.
292
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.
293
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.
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.
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.
295
302
 
296
303
  ## Library layouts
297
304
 
@@ -317,7 +324,7 @@ Each movie must reside in its own folder, with optional subfolders for extras. D
317
324
 
318
325
  #### Special filename handling
319
326
 
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).
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).
321
328
 
322
329
  ### Plex
323
330
 
@@ -345,6 +352,12 @@ Movies
345
352
  └── Making of.mkv
346
353
  ```
347
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
+
348
361
  ## License
349
362
 
350
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"
@@ -1,21 +1,33 @@
1
1
  [project]
2
2
  name = "jellyplex-sync"
3
- version = "0.3.4"
3
+ version = "0.4.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.10.4,<0.11.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 = "standard"
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
- DiffEntry,
45
- DiffResult,
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",
@@ -1,11 +1,14 @@
1
- #!/usr/bin/python3
2
1
  from __future__ import annotations
3
2
 
4
3
  import argparse
5
4
  import logging
5
+ import pathlib
6
6
  import sys
7
7
 
8
8
  import jellyplex_sync as jp
9
+ from jellyplex_sync.json_output import write_sync_json
10
+
11
+ log = logging.getLogger(__name__)
9
12
 
10
13
 
11
14
  def _build_parser() -> argparse.ArgumentParser:
@@ -14,8 +17,10 @@ def _build_parser() -> argparse.ArgumentParser:
14
17
  description="Convert a media library between Plex and Jellyfin layouts.",
15
18
  )
16
19
 
17
- # Flags shared by every subcommand. Use a parent parser so they can be
18
- # given either before or after the subcommand name.
20
+ # Flags shared by every subcommand. Parent parsers so they can be
21
+ # given either before or after the subcommand name. --json lives in
22
+ # its own parent because `import` has no JSON output yet — offering
23
+ # the flag there would silently produce nothing.
19
24
  common = argparse.ArgumentParser(add_help=False)
20
25
  common.add_argument(
21
26
  "-v",
@@ -28,7 +33,9 @@ def _build_parser() -> argparse.ArgumentParser:
28
33
  action="store_true",
29
34
  help="Show debug messages.",
30
35
  )
31
- common.add_argument(
36
+
37
+ json_opt = argparse.ArgumentParser(add_help=False)
38
+ json_opt.add_argument(
32
39
  "--json",
33
40
  action="store_true",
34
41
  help="Emit a machine-readable JSON document on stdout. Logs continue to go to stderr.",
@@ -38,7 +45,7 @@ def _build_parser() -> argparse.ArgumentParser:
38
45
 
39
46
  sync_p = sub.add_parser(
40
47
  "sync",
41
- parents=[common],
48
+ parents=[common, json_opt],
42
49
  help="Mirror a source library into the target layout.",
43
50
  )
44
51
  _add_io_args(sync_p)
@@ -83,14 +90,14 @@ def _build_parser() -> argparse.ArgumentParser:
83
90
 
84
91
  diff_p = sub.add_parser(
85
92
  "diff",
86
- parents=[common],
93
+ parents=[common, json_opt],
87
94
  help="Compare source and target libraries without writing anything.",
88
95
  )
89
96
  _add_io_args(diff_p)
90
97
 
91
98
  plan_p = sub.add_parser(
92
99
  "plan",
93
- parents=[common],
100
+ parents=[common, json_opt],
94
101
  help="Show the Plan a sync would execute, without touching the target.",
95
102
  )
96
103
  _add_io_args(plan_p)
@@ -116,7 +123,9 @@ def _build_parser() -> argparse.ArgumentParser:
116
123
  const="copy",
117
124
  help="Copy files; source is kept. On re-runs skip files whose size and mtime match.",
118
125
  )
119
- import_p.set_defaults(mode="move")
126
+ # No --json for import (no JSON output yet); the default keeps the
127
+ # shared `args.json` logging logic in main() working.
128
+ import_p.set_defaults(mode="move", json=False)
120
129
  import_p.add_argument(
121
130
  "--dry-run",
122
131
  action="store_true",
@@ -190,60 +199,41 @@ def main() -> None:
190
199
 
191
200
 
192
201
  def _do_sync(args: argparse.Namespace) -> int:
193
- import pathlib
194
-
195
- from jellyplex_sync.library import CollectingReporter
196
- from jellyplex_sync.sync import LibraryStats
197
-
198
202
  materializer = _make_materializer(args.mode)
199
- stats = LibraryStats() if args.json else None
200
- reporter = CollectingReporter() if args.json else None
201
203
  try:
202
- rc = jp.sync(
204
+ result = jp.sync(
203
205
  args.source,
204
206
  args.target,
205
207
  dry_run=args.dry_run,
206
208
  delete=args.delete,
207
209
  create=args.create,
208
210
  verbose=args.verbose,
209
- debug=args.debug,
210
211
  source_format=args.source_format,
211
212
  target_format=args.target_format,
212
213
  materializer=materializer,
213
- reporter=reporter,
214
- stats=stats,
215
214
  )
216
215
  except KeyboardInterrupt:
217
- logging.info("INTERRUPTED")
216
+ log.info("INTERRUPTED")
218
217
  return 10
219
218
  except Exception as exc:
220
- logging.error("Exception: %s", exc)
219
+ # Boundary catch: turn anything unexpected into an exit code.
220
+ # --debug adds the traceback, which the message alone loses.
221
+ log.error("Exception: %s", exc, exc_info=args.debug)
221
222
  return 99
222
223
 
223
224
  if args.json:
224
- assert stats is not None and reporter is not None
225
- from jellyplex_sync.json_output import write_sync_json
226
- from jellyplex_sync.sync import _resolve_formats
227
-
228
- resolved = _resolve_formats(
229
- pathlib.Path(args.source),
230
- args.source_format,
231
- args.target_format,
232
- )
233
- source_short, target_short = resolved if resolved else ("?", "?")
234
-
235
225
  write_sync_json(
236
226
  sys.stdout,
237
227
  source_path=pathlib.Path(args.source),
238
- source_format=source_short,
228
+ source_format=result.source_format or "?",
239
229
  target_path=pathlib.Path(args.target),
240
- target_format=target_short,
230
+ target_format=result.target_format or "?",
241
231
  dry_run=args.dry_run,
242
- exit_code=rc,
243
- stats=stats,
244
- drops=reporter.drops,
232
+ exit_code=result.exit_code,
233
+ stats=result.stats,
234
+ drops=result.drops,
245
235
  )
246
- return rc
236
+ return result.exit_code
247
237
 
248
238
 
249
239
  def _make_materializer(mode: str) -> jp.FileMaterializer:
@@ -257,68 +247,66 @@ def _make_materializer(mode: str) -> jp.FileMaterializer:
257
247
 
258
248
 
259
249
  def _do_diff(args: argparse.Namespace) -> int:
260
- from jellyplex_sync.sync import diff
261
-
262
250
  try:
263
- return diff(
251
+ return jp.diff(
264
252
  args.source,
265
253
  args.target,
266
- debug=args.debug,
267
254
  source_format=args.source_format,
268
255
  target_format=args.target_format,
269
256
  as_json=args.json,
270
257
  )
271
258
  except KeyboardInterrupt:
272
- logging.info("INTERRUPTED")
259
+ log.info("INTERRUPTED")
273
260
  return 10
274
261
  except Exception as exc:
275
- logging.error("Exception: %s", exc)
262
+ # Boundary catch: turn anything unexpected into an exit code.
263
+ # --debug adds the traceback, which the message alone loses.
264
+ log.error("Exception: %s", exc, exc_info=args.debug)
276
265
  return 99
277
266
 
278
267
 
279
268
  def _do_plan(args: argparse.Namespace) -> int:
280
- from jellyplex_sync.sync import plan as plan_fn
281
-
282
269
  try:
283
- return plan_fn(
270
+ return jp.plan(
284
271
  args.source,
285
272
  args.target,
286
- debug=args.debug,
287
273
  source_format=args.source_format,
288
274
  target_format=args.target_format,
289
275
  as_json=args.json,
290
276
  )
291
277
  except KeyboardInterrupt:
292
- logging.info("INTERRUPTED")
278
+ log.info("INTERRUPTED")
293
279
  return 10
294
280
  except Exception as exc:
295
- logging.error("Exception: %s", exc)
281
+ # Boundary catch: turn anything unexpected into an exit code.
282
+ # --debug adds the traceback, which the message alone loses.
283
+ log.error("Exception: %s", exc, exc_info=args.debug)
296
284
  return 99
297
285
 
298
286
 
299
287
  def _do_import(args: argparse.Namespace) -> int:
300
- from jellyplex_sync.sync import import_media
301
-
302
288
  materializer = jp.MoveMaterializer() if args.mode == "move" else jp.CopyMaterializer()
303
289
 
304
290
  try:
305
- return import_media(
291
+ result = jp.import_media(
306
292
  args.source,
307
293
  args.target,
308
294
  dry_run=args.dry_run,
309
295
  create=args.create,
310
296
  verbose=args.verbose,
311
- debug=args.debug,
312
297
  source_format=args.source_format,
313
298
  target_format=args.target_format,
314
299
  materializer=materializer,
315
300
  )
316
301
  except KeyboardInterrupt:
317
- logging.info("INTERRUPTED")
302
+ log.info("INTERRUPTED")
318
303
  return 10
319
304
  except Exception as exc:
320
- logging.error("Exception: %s", exc)
305
+ # Boundary catch: turn anything unexpected into an exit code.
306
+ # --debug adds the traceback, which the message alone loses.
307
+ log.error("Exception: %s", exc, exc_info=args.debug)
321
308
  return 99
309
+ return result.exit_code
322
310
 
323
311
 
324
312
  if __name__ == "__main__":
@@ -0,0 +1,22 @@
1
+ """Backward-compatible ``jellyplex-sync`` entry point.
2
+
3
+ A thin alias for ``jellyplex sync``: the ``sync`` subcommand is
4
+ inserted into the argument list and the main CLI takes over. Same
5
+ options, same behaviour, one parser to maintain instead of two
6
+ copies that would have to be kept in step by hand.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+
13
+ from jellyplex_sync.cli import main as cli_main
14
+
15
+
16
+ def main() -> None:
17
+ sys.argv = [sys.argv[0], "sync", *sys.argv[1:]]
18
+ cli_main.main()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()