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.
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/PKG-INFO +31 -8
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/README.md +20 -7
- jellyplex_sync-0.4.0/pyproject.toml +97 -0
- jellyplex_sync-0.3.4/pyproject.toml → jellyplex_sync-0.4.0/pyproject.toml.orig +33 -3
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/__init__.py +11 -3
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/cli/main.py +44 -56
- jellyplex_sync-0.4.0/src/jellyplex_sync/cli/sync.py +22 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/compare.py +43 -16
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/disambig.py +44 -27
- {jellyplex_sync-0.3.4 → 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.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/jellyfin.py +19 -11
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/json_output.py +31 -20
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/library.py +17 -54
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/materializer.py +22 -15
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/plan.py +30 -7
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/planner.py +52 -25
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/plex.py +13 -4
- {jellyplex_sync-0.3.4 → 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.4/src/jellyplex_sync/cli/sync.py +0 -186
- jellyplex_sync-0.3.4/src/jellyplex_sync/sync.py +0 -698
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/LICENSE +0 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/cli/__init__.py +0 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/model.py +0 -0
- {jellyplex_sync-0.3.4 → jellyplex_sync-0.4.0}/src/jellyplex_sync/py.typed +0 -0
- {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
|
+
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
|
|
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
|
|
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
|
+
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.
|
|
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",
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
log.info("INTERRUPTED")
|
|
218
217
|
return 10
|
|
219
218
|
except Exception as exc:
|
|
220
|
-
|
|
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=
|
|
228
|
+
source_format=result.source_format or "?",
|
|
239
229
|
target_path=pathlib.Path(args.target),
|
|
240
|
-
target_format=
|
|
230
|
+
target_format=result.target_format or "?",
|
|
241
231
|
dry_run=args.dry_run,
|
|
242
|
-
exit_code=
|
|
243
|
-
stats=stats,
|
|
244
|
-
drops=
|
|
232
|
+
exit_code=result.exit_code,
|
|
233
|
+
stats=result.stats,
|
|
234
|
+
drops=result.drops,
|
|
245
235
|
)
|
|
246
|
-
return
|
|
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
|
-
|
|
259
|
+
log.info("INTERRUPTED")
|
|
273
260
|
return 10
|
|
274
261
|
except Exception as exc:
|
|
275
|
-
|
|
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
|
|
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
|
-
|
|
278
|
+
log.info("INTERRUPTED")
|
|
293
279
|
return 10
|
|
294
280
|
except Exception as exc:
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
+
log.info("INTERRUPTED")
|
|
318
303
|
return 10
|
|
319
304
|
except Exception as exc:
|
|
320
|
-
|
|
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()
|