jellyplex-sync 0.2.2__tar.gz → 0.3.1__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.2.2 → jellyplex_sync-0.3.1}/PKG-INFO +47 -8
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/README.md +46 -7
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/pyproject.toml +1 -1
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/__init__.py +16 -0
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/cli/sync.py +30 -1
- jellyplex_sync-0.3.1/src/jellyplex_sync/compare.py +82 -0
- jellyplex_sync-0.3.1/src/jellyplex_sync/disambig.py +190 -0
- jellyplex_sync-0.3.1/src/jellyplex_sync/discover.py +88 -0
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/jellyfin.py +13 -1
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/json_output.py +77 -1
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/library.py +16 -1
- jellyplex_sync-0.3.1/src/jellyplex_sync/plan.py +88 -0
- jellyplex_sync-0.3.1/src/jellyplex_sync/planner.py +212 -0
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/plex.py +8 -1
- jellyplex_sync-0.3.1/src/jellyplex_sync/realize.py +223 -0
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/sync.py +201 -364
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/LICENSE +0 -0
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/cli/__init__.py +0 -0
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/materializer.py +0 -0
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/model.py +0 -0
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/py.typed +0 -0
- {jellyplex_sync-0.2.2 → jellyplex_sync-0.3.1}/src/jellyplex_sync/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jellyplex-sync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror
|
|
5
5
|
Author: Stefan Schönberger
|
|
6
6
|
Author-email: Stefan Schönberger <stefan@sniner.dev>
|
|
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
|
|
19
19
|
Can't decide between Jellyfin and Plex? This tool might help. It synchronizes your **movie library** between Jellyfin and Plex formats in **both directions** — without duplicating any files. Instead, it uses **hardlinks** to mirror your collection efficiently, saving storage while keeping both libraries in sync.
|
|
20
20
|
|
|
21
|
-
> ⚠️ **0.
|
|
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
22
|
|
|
23
23
|
## Overview
|
|
24
24
|
|
|
@@ -30,6 +30,8 @@ The script scans the source library, parses each movie folder for metadata (titl
|
|
|
30
30
|
|
|
31
31
|
> ⚠️ **Movies only:** This script is designed exclusively for **movie libraries**. It does **not** support TV shows or miniseries. However, this is usually not a limitation in practice: for shows, Jellyfin and Plex use very similar directory structures, so you can typically point both apps to the same library without issues.
|
|
32
32
|
|
|
33
|
+
> ⚠️ **Stacked / multi-part movies are not supported:** Plex's multi-part filename suffixes (`pt1`/`pt2`, `cd1`/`cd2`, `disc1`/`disc2`) are not recognized — each part is treated as a separate video, which will likely confuse the target scanner. Pre-join multi-disc releases with `mkvmerge` before adding them to your library.
|
|
34
|
+
|
|
33
35
|
> ⚠️ **Hardlinks require a shared filesystem:** Source and target paths must live on the **same filesystem**. Hardlinks cannot span filesystems or physical disks. If they don't, switch to `--copy`. On Unraid's classic array this is a real concern — see the [Unraid section](#unraid-user-scripts) for details before running this tool there.
|
|
34
36
|
|
|
35
37
|
## Installation
|
|
@@ -60,10 +62,11 @@ docker build -t jellyplex-sync .
|
|
|
60
62
|
|
|
61
63
|
## Usage
|
|
62
64
|
|
|
63
|
-
The CLI is split into
|
|
65
|
+
The CLI is split into three subcommands:
|
|
64
66
|
|
|
65
67
|
- **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
|
|
66
68
|
- **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
|
|
69
|
+
- **`plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
|
|
67
70
|
|
|
68
71
|
### `sync`
|
|
69
72
|
|
|
@@ -140,22 +143,56 @@ Verify a target is a complete mirror before deleting the source:
|
|
|
140
143
|
jellyplex-sync diff ~/Media/Jellyfin ~/Media/Plex
|
|
141
144
|
```
|
|
142
145
|
|
|
146
|
+
### `plan`
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
jellyplex-sync plan [OPTIONS] /path/to/source/library /path/to/target/library
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Shows the Plan a sync would execute — every movie folder, every translated video filename, every loose file, every asset subdirectory — without touching either filesystem. Useful as a pre-flight check ("will any movies clash? what gets translated?") or as a machine-readable input for tooling with `--json`.
|
|
153
|
+
|
|
154
|
+
Unlike `sync` and `diff`, the target directory does **not** need to exist. The whole point of `plan` is to answer "what WOULD happen" *before* any setup.
|
|
155
|
+
|
|
156
|
+
Exit codes:
|
|
157
|
+
|
|
158
|
+
- `0` — Plan produced successfully. Clashes and translation losses are reported but counted as informational, not failures.
|
|
159
|
+
- `2` — setup error (source missing, format undetectable).
|
|
160
|
+
|
|
161
|
+
#### Example
|
|
162
|
+
|
|
163
|
+
Pre-flight a sync, see exactly which target filenames would be created:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
jellyplex-sync plan ~/Media/Plex ~/Media/Jellyfin
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Machine-readable, with `jq` to find videos that needed disambiguation:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
173
|
+
| jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
|
|
174
|
+
```
|
|
175
|
+
|
|
143
176
|
### JSON output
|
|
144
177
|
|
|
145
|
-
|
|
178
|
+
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.
|
|
146
179
|
|
|
147
180
|
The schema (still evolving — pin a version when consuming):
|
|
148
181
|
|
|
149
|
-
- `operation`: `"sync"` or `"
|
|
150
|
-
- `exit_code
|
|
182
|
+
- `operation`: `"sync"`, `"diff"`, or `"plan"`.
|
|
183
|
+
- `exit_code` (sync, diff): the process exit code, mirrored in the document for convenience.
|
|
151
184
|
- `source` / `target`: `{path, format}` for each endpoint.
|
|
152
185
|
- `dry_run` (sync only): whether the run was a preview.
|
|
153
|
-
- `summary
|
|
186
|
+
- `summary`: counters; the set of fields depends on the operation.
|
|
187
|
+
- sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `items_ignored`, `strays_in_target`, `clashes`.
|
|
188
|
+
- plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
|
|
154
189
|
- `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
|
|
155
190
|
- `strays_in_target` (sync only): list of names found in target that aren't in source.
|
|
156
191
|
- `translation_losses`: distinct `{kind, key, value, reason}` tuples for labels/attributes the target format can't express. Deduplicated — one entry per distinct loss, not per affected file.
|
|
157
192
|
- `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.
|
|
193
|
+
- `clashes` (sync only): movie-level video clashes the disambiguator couldn't resolve at all. Normally empty since the hash-fallback resolves the practical cases.
|
|
158
194
|
- `diff`-specific fields: `movies_only_in_source`, `movies_only_in_target`, `differing_movies`, `in_sync`.
|
|
195
|
+
- `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.
|
|
159
196
|
|
|
160
197
|
#### `jq` examples
|
|
161
198
|
|
|
@@ -239,6 +276,8 @@ This helper can also be used on other NAS systems or Linux servers — schedule
|
|
|
239
276
|
- **Loose top-level files** — Sidecar files (`.nfo`, posters, external subtitles, plain notes) at the top of a movie folder are synced 1:1 by default since 0.2.0. Pre-0.2.0 they were silently dropped — which made the tool unsafe for migrations. Dot-files (`.DS_Store`, `.stversions`, …) stay excluded.
|
|
240
277
|
- **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed. Without `--delete`, strays are still reported in the summary and the `--json` output, plus a warning at the end of the run points at `--delete`.
|
|
241
278
|
- **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.
|
|
279
|
+
- **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.
|
|
280
|
+
- **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.
|
|
242
281
|
|
|
243
282
|
## Library layouts
|
|
244
283
|
|
|
@@ -264,7 +303,7 @@ Each movie must reside in its own folder, with optional subfolders for extras. D
|
|
|
264
303
|
|
|
265
304
|
#### Special filename handling
|
|
266
305
|
|
|
267
|
-
Jellyfin doesn't distinguish between editions (e.g., Director's Cut) and versions (e.g., 1080p vs. 4K). To work around this, I appended labels like "DVD", "BD", or "4K" to filenames in my personal library, ensuring the highest quality appears first and is selected by default in Jellyfin. Plex, on the other hand, supports editions natively and handles different versions via naming patterns and its internal version management. These specific labels are converted into Plex versions on the way over; other suffixes are treated as editions. The detailed mapping rules (and why DVD/BD/4k beats DVD/SDR/FHD/UHD despite the naming inconsistency) live in [
|
|
306
|
+
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).
|
|
268
307
|
|
|
269
308
|
### Plex
|
|
270
309
|
|
|
@@ -2,7 +2,7 @@
|
|
|
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.
|
|
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
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
@@ -14,6 +14,8 @@ The script scans the source library, parses each movie folder for metadata (titl
|
|
|
14
14
|
|
|
15
15
|
> ⚠️ **Movies only:** This script is designed exclusively for **movie libraries**. It does **not** support TV shows or miniseries. However, this is usually not a limitation in practice: for shows, Jellyfin and Plex use very similar directory structures, so you can typically point both apps to the same library without issues.
|
|
16
16
|
|
|
17
|
+
> ⚠️ **Stacked / multi-part movies are not supported:** Plex's multi-part filename suffixes (`pt1`/`pt2`, `cd1`/`cd2`, `disc1`/`disc2`) are not recognized — each part is treated as a separate video, which will likely confuse the target scanner. Pre-join multi-disc releases with `mkvmerge` before adding them to your library.
|
|
18
|
+
|
|
17
19
|
> ⚠️ **Hardlinks require a shared filesystem:** Source and target paths must live on the **same filesystem**. Hardlinks cannot span filesystems or physical disks. If they don't, switch to `--copy`. On Unraid's classic array this is a real concern — see the [Unraid section](#unraid-user-scripts) for details before running this tool there.
|
|
18
20
|
|
|
19
21
|
## Installation
|
|
@@ -44,10 +46,11 @@ docker build -t jellyplex-sync .
|
|
|
44
46
|
|
|
45
47
|
## Usage
|
|
46
48
|
|
|
47
|
-
The CLI is split into
|
|
49
|
+
The CLI is split into three subcommands:
|
|
48
50
|
|
|
49
51
|
- **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
|
|
50
52
|
- **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
|
|
53
|
+
- **`plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
|
|
51
54
|
|
|
52
55
|
### `sync`
|
|
53
56
|
|
|
@@ -124,22 +127,56 @@ Verify a target is a complete mirror before deleting the source:
|
|
|
124
127
|
jellyplex-sync diff ~/Media/Jellyfin ~/Media/Plex
|
|
125
128
|
```
|
|
126
129
|
|
|
130
|
+
### `plan`
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
jellyplex-sync plan [OPTIONS] /path/to/source/library /path/to/target/library
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Shows the Plan a sync would execute — every movie folder, every translated video filename, every loose file, every asset subdirectory — without touching either filesystem. Useful as a pre-flight check ("will any movies clash? what gets translated?") or as a machine-readable input for tooling with `--json`.
|
|
137
|
+
|
|
138
|
+
Unlike `sync` and `diff`, the target directory does **not** need to exist. The whole point of `plan` is to answer "what WOULD happen" *before* any setup.
|
|
139
|
+
|
|
140
|
+
Exit codes:
|
|
141
|
+
|
|
142
|
+
- `0` — Plan produced successfully. Clashes and translation losses are reported but counted as informational, not failures.
|
|
143
|
+
- `2` — setup error (source missing, format undetectable).
|
|
144
|
+
|
|
145
|
+
#### Example
|
|
146
|
+
|
|
147
|
+
Pre-flight a sync, see exactly which target filenames would be created:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
jellyplex-sync plan ~/Media/Plex ~/Media/Jellyfin
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Machine-readable, with `jq` to find videos that needed disambiguation:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
157
|
+
| jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
|
|
158
|
+
```
|
|
159
|
+
|
|
127
160
|
### JSON output
|
|
128
161
|
|
|
129
|
-
|
|
162
|
+
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.
|
|
130
163
|
|
|
131
164
|
The schema (still evolving — pin a version when consuming):
|
|
132
165
|
|
|
133
|
-
- `operation`: `"sync"` or `"
|
|
134
|
-
- `exit_code
|
|
166
|
+
- `operation`: `"sync"`, `"diff"`, or `"plan"`.
|
|
167
|
+
- `exit_code` (sync, diff): the process exit code, mirrored in the document for convenience.
|
|
135
168
|
- `source` / `target`: `{path, format}` for each endpoint.
|
|
136
169
|
- `dry_run` (sync only): whether the run was a preview.
|
|
137
|
-
- `summary
|
|
170
|
+
- `summary`: counters; the set of fields depends on the operation.
|
|
171
|
+
- sync: `movies_total`, `movies_processed`, `files_updated`, `files_removed`, `items_ignored`, `strays_in_target`, `clashes`.
|
|
172
|
+
- plan: `movies`, `folder_clashes`, `movie_clashes`, `translation_losses`, `ignored`.
|
|
138
173
|
- `ignored`: list of source-side entries the scanner skipped, each `{path, name, reason}`.
|
|
139
174
|
- `strays_in_target` (sync only): list of names found in target that aren't in source.
|
|
140
175
|
- `translation_losses`: distinct `{kind, key, value, reason}` tuples for labels/attributes the target format can't express. Deduplicated — one entry per distinct loss, not per affected file.
|
|
141
176
|
- `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.
|
|
177
|
+
- `clashes` (sync only): movie-level video clashes the disambiguator couldn't resolve at all. Normally empty since the hash-fallback resolves the practical cases.
|
|
142
178
|
- `diff`-specific fields: `movies_only_in_source`, `movies_only_in_target`, `differing_movies`, `in_sync`.
|
|
179
|
+
- `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.
|
|
143
180
|
|
|
144
181
|
#### `jq` examples
|
|
145
182
|
|
|
@@ -223,6 +260,8 @@ This helper can also be used on other NAS systems or Linux servers — schedule
|
|
|
223
260
|
- **Loose top-level files** — Sidecar files (`.nfo`, posters, external subtitles, plain notes) at the top of a movie folder are synced 1:1 by default since 0.2.0. Pre-0.2.0 they were silently dropped — which made the tool unsafe for migrations. Dot-files (`.DS_Store`, `.stversions`, …) stay excluded.
|
|
224
261
|
- **Stray items** — With `--delete`, any file or folder in the target that has no counterpart in the source is removed. Without `--delete`, strays are still reported in the summary and the `--json` output, plus a warning at the end of the run points at `--delete`.
|
|
225
262
|
- **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.
|
|
263
|
+
- **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.
|
|
264
|
+
- **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.
|
|
226
265
|
|
|
227
266
|
## Library layouts
|
|
228
267
|
|
|
@@ -248,7 +287,7 @@ Each movie must reside in its own folder, with optional subfolders for extras. D
|
|
|
248
287
|
|
|
249
288
|
#### Special filename handling
|
|
250
289
|
|
|
251
|
-
Jellyfin doesn't distinguish between editions (e.g., Director's Cut) and versions (e.g., 1080p vs. 4K). To work around this, I appended labels like "DVD", "BD", or "4K" to filenames in my personal library, ensuring the highest quality appears first and is selected by default in Jellyfin. Plex, on the other hand, supports editions natively and handles different versions via naming patterns and its internal version management. These specific labels are converted into Plex versions on the way over; other suffixes are treated as editions. The detailed mapping rules (and why DVD/BD/4k beats DVD/SDR/FHD/UHD despite the naming inconsistency) live in [
|
|
290
|
+
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).
|
|
252
291
|
|
|
253
292
|
### Plex
|
|
254
293
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "jellyplex-sync"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
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"
|
|
@@ -8,6 +8,7 @@ from .library import (
|
|
|
8
8
|
Drop,
|
|
9
9
|
DropError,
|
|
10
10
|
FileEvent,
|
|
11
|
+
FolderClash,
|
|
11
12
|
IgnoredEntry,
|
|
12
13
|
LibraryReader,
|
|
13
14
|
LibraryWriter,
|
|
@@ -27,6 +28,13 @@ from .model import (
|
|
|
27
28
|
MovieInfo,
|
|
28
29
|
VideoInfo,
|
|
29
30
|
)
|
|
31
|
+
from .plan import (
|
|
32
|
+
DisambiguationNote,
|
|
33
|
+
Plan,
|
|
34
|
+
PlannedAsset,
|
|
35
|
+
PlannedFile,
|
|
36
|
+
PlannedMovie,
|
|
37
|
+
)
|
|
30
38
|
from .plex import (
|
|
31
39
|
PlexLibraryReader,
|
|
32
40
|
PlexLibraryWriter,
|
|
@@ -35,6 +43,7 @@ from .sync import (
|
|
|
35
43
|
DiffEntry,
|
|
36
44
|
DiffResult,
|
|
37
45
|
diff,
|
|
46
|
+
plan,
|
|
38
47
|
sync,
|
|
39
48
|
)
|
|
40
49
|
|
|
@@ -44,10 +53,12 @@ __all__ = [
|
|
|
44
53
|
"CopyMaterializer",
|
|
45
54
|
"DiffEntry",
|
|
46
55
|
"DiffResult",
|
|
56
|
+
"DisambiguationNote",
|
|
47
57
|
"Drop",
|
|
48
58
|
"DropError",
|
|
49
59
|
"FileEvent",
|
|
50
60
|
"FileMaterializer",
|
|
61
|
+
"FolderClash",
|
|
51
62
|
"ForceCopyMaterializer",
|
|
52
63
|
"HardlinkMaterializer",
|
|
53
64
|
"IgnoredEntry",
|
|
@@ -58,6 +69,10 @@ __all__ = [
|
|
|
58
69
|
"LoggingReporter",
|
|
59
70
|
"MovieClash",
|
|
60
71
|
"MovieInfo",
|
|
72
|
+
"Plan",
|
|
73
|
+
"PlannedAsset",
|
|
74
|
+
"PlannedFile",
|
|
75
|
+
"PlannedMovie",
|
|
61
76
|
"PlexLibraryReader",
|
|
62
77
|
"PlexLibraryWriter",
|
|
63
78
|
"Reporter",
|
|
@@ -65,5 +80,6 @@ __all__ = [
|
|
|
65
80
|
"VideoInfo",
|
|
66
81
|
"dedupe_drops",
|
|
67
82
|
"diff",
|
|
83
|
+
"plan",
|
|
68
84
|
"sync",
|
|
69
85
|
]
|
|
@@ -7,7 +7,7 @@ import sys
|
|
|
7
7
|
|
|
8
8
|
import jellyplex_sync as jp
|
|
9
9
|
|
|
10
|
-
_SUBCOMMANDS = {"sync", "diff"}
|
|
10
|
+
_SUBCOMMANDS = {"sync", "diff", "plan"}
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _build_parser() -> argparse.ArgumentParser:
|
|
@@ -90,6 +90,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
90
90
|
)
|
|
91
91
|
_add_io_args(diff_p)
|
|
92
92
|
|
|
93
|
+
plan_p = sub.add_parser(
|
|
94
|
+
"plan",
|
|
95
|
+
parents=[common],
|
|
96
|
+
help="Show the Plan a sync would execute, without touching the target.",
|
|
97
|
+
)
|
|
98
|
+
_add_io_args(plan_p)
|
|
99
|
+
|
|
93
100
|
return parser
|
|
94
101
|
|
|
95
102
|
|
|
@@ -156,6 +163,8 @@ def main() -> None:
|
|
|
156
163
|
result = _do_sync(args)
|
|
157
164
|
elif args.command == "diff":
|
|
158
165
|
result = _do_diff(args)
|
|
166
|
+
elif args.command == "plan":
|
|
167
|
+
result = _do_plan(args)
|
|
159
168
|
else:
|
|
160
169
|
parser.error(f"unknown command: {args.command!r}")
|
|
161
170
|
result = 2
|
|
@@ -242,6 +251,26 @@ def _do_diff(args: argparse.Namespace) -> int:
|
|
|
242
251
|
return 99
|
|
243
252
|
|
|
244
253
|
|
|
254
|
+
def _do_plan(args: argparse.Namespace) -> int:
|
|
255
|
+
from jellyplex_sync.sync import plan as plan_fn
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
return plan_fn(
|
|
259
|
+
args.source,
|
|
260
|
+
args.target,
|
|
261
|
+
debug=args.debug,
|
|
262
|
+
source_format=args.source_format,
|
|
263
|
+
target_format=args.target_format,
|
|
264
|
+
as_json=args.json,
|
|
265
|
+
)
|
|
266
|
+
except KeyboardInterrupt:
|
|
267
|
+
logging.info("INTERRUPTED")
|
|
268
|
+
return 10
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
logging.error("Exception: %s", exc)
|
|
271
|
+
return 99
|
|
272
|
+
|
|
273
|
+
|
|
245
274
|
def _resolve_formats_for_json(args: argparse.Namespace) -> tuple[str, str]:
|
|
246
275
|
"""Re-run the same format resolution `sync()` did so the JSON payload
|
|
247
276
|
reports the formats that were actually used. Returns ('?', '?') if
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Plan-vs.-actual comparison: derive a DiffResult from a Plan.
|
|
2
|
+
|
|
3
|
+
The classic `diff` subcommand answers "is the target in sync with what
|
|
4
|
+
the source would produce?" In the 0.3 pipeline this becomes trivially
|
|
5
|
+
expressible: build the Plan (no I/O on target), then compare it to
|
|
6
|
+
whatever currently sits on the target filesystem.
|
|
7
|
+
|
|
8
|
+
The shape of `DiffResult` is preserved from sync.py so the public CLI
|
|
9
|
+
output and `--json` schema don't move. `drops` is left empty here —
|
|
10
|
+
they belong to the Reporter the caller fed to the Planner; the caller
|
|
11
|
+
can stitch them onto the DiffResult if they want them in the document.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import pathlib
|
|
17
|
+
|
|
18
|
+
from .plan import Plan
|
|
19
|
+
from .sync import DiffEntry, DiffResult, MovieOnlyInSource
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compare(plan: Plan) -> DiffResult:
|
|
23
|
+
"""Compare `plan` against the actual contents of `plan.target_root`.
|
|
24
|
+
|
|
25
|
+
Pure: reads the target filesystem, returns a DiffResult, mutates
|
|
26
|
+
nothing. Walks one level deep into each movie folder — same depth
|
|
27
|
+
as the pre-0.3 diff implementation."""
|
|
28
|
+
planned_folder_names = {m.target_folder.name for m in plan.movies}
|
|
29
|
+
planned_by_name = {m.target_folder.name: m for m in plan.movies}
|
|
30
|
+
|
|
31
|
+
actual: dict[str, set[str]] = {}
|
|
32
|
+
if plan.target_root.is_dir():
|
|
33
|
+
for entry in plan.target_root.iterdir():
|
|
34
|
+
if not entry.is_dir():
|
|
35
|
+
continue
|
|
36
|
+
actual[entry.name] = {sub.name for sub in entry.iterdir()}
|
|
37
|
+
|
|
38
|
+
only_in_source_names = sorted(planned_folder_names - actual.keys())
|
|
39
|
+
only_in_source = tuple(
|
|
40
|
+
MovieOnlyInSource(
|
|
41
|
+
source_folder=planned_by_name[name].source_path.name,
|
|
42
|
+
expected_target=name,
|
|
43
|
+
)
|
|
44
|
+
for name in only_in_source_names
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
only_in_target = tuple(sorted(actual.keys() - planned_folder_names))
|
|
48
|
+
|
|
49
|
+
differing: list[DiffEntry] = []
|
|
50
|
+
for name in sorted(planned_folder_names & actual.keys()):
|
|
51
|
+
pm = planned_by_name[name]
|
|
52
|
+
expected_files = (
|
|
53
|
+
{pf.target_name for pf in pm.videos}
|
|
54
|
+
| {pf.target_name for pf in pm.loose_files}
|
|
55
|
+
| {a.folder_name for a in pm.assets}
|
|
56
|
+
)
|
|
57
|
+
src_only = tuple(sorted(expected_files - actual[name]))
|
|
58
|
+
tgt_only = tuple(sorted(actual[name] - expected_files))
|
|
59
|
+
if src_only or tgt_only:
|
|
60
|
+
differing.append(
|
|
61
|
+
DiffEntry(
|
|
62
|
+
target_movie_name=name,
|
|
63
|
+
only_in_source=src_only,
|
|
64
|
+
only_in_target=tgt_only,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return DiffResult(
|
|
69
|
+
movies_only_in_source=only_in_source,
|
|
70
|
+
movies_only_in_target=only_in_target,
|
|
71
|
+
differing_movies=tuple(differing),
|
|
72
|
+
ignored=tuple(plan.ignored),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _planned_target_folder(plan: Plan, name: str) -> pathlib.Path | None:
|
|
77
|
+
"""Helper: find the absolute target folder for a planned-movie name,
|
|
78
|
+
if any. Convenience for callers that want to print full paths."""
|
|
79
|
+
for m in plan.movies:
|
|
80
|
+
if m.target_folder.name == name:
|
|
81
|
+
return m.target_folder
|
|
82
|
+
return None
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Disambiguation: turn potentially-colliding target names into unique ones.
|
|
2
|
+
|
|
3
|
+
A Disambiguator is the layer that owns the answer to "what happens when
|
|
4
|
+
two source videos in the same movie folder want to write to the same
|
|
5
|
+
target filename?" — a real concern for the lossy Plex→Jellyfin
|
|
6
|
+
translation, where `[1080p] [remux].mkv` and `[1080p].mkv` both collapse
|
|
7
|
+
to `- BD.mkv`.
|
|
8
|
+
|
|
9
|
+
Two implementations ship today:
|
|
10
|
+
|
|
11
|
+
- `NaiveDisambiguator` preserves the pre-0.3 behaviour: if two videos
|
|
12
|
+
collide, the whole movie is unresolvable and gets reported as a
|
|
13
|
+
`MovieClash`. Useful for strict modes where the user wants the abort.
|
|
14
|
+
|
|
15
|
+
- `HashFallbackDisambiguator` is the always-succeeds default: on
|
|
16
|
+
collision, it asks the Writer for the same names again with a short
|
|
17
|
+
hash of the source filename appended. Source filenames are
|
|
18
|
+
filesystem-unique within their folder, so a hash derived from them
|
|
19
|
+
is unique too (modulo a vanishingly small SHA-256 collision).
|
|
20
|
+
|
|
21
|
+
A future `LabelPullbackDisambiguator` could be added that progressively
|
|
22
|
+
keeps dropped labels in priority order before falling through to the
|
|
23
|
+
hash — see planning/0.3-ARCHITECTURE.md.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import pathlib
|
|
30
|
+
from collections import defaultdict
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Protocol
|
|
33
|
+
|
|
34
|
+
from .library import CollectingReporter, LibraryWriter, MovieClash, Reporter
|
|
35
|
+
from .model import MovieInfo, VideoInfo
|
|
36
|
+
from .plan import DisambiguationNote
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class DisambiguationResult:
|
|
41
|
+
"""Outcome of disambiguating one movie's videos.
|
|
42
|
+
|
|
43
|
+
`names` maps each accepted source path to its final target filename;
|
|
44
|
+
`notes` records why a name deviates from the Writer's naive output
|
|
45
|
+
(None if the naive name was already unique). `unresolved` lists any
|
|
46
|
+
clashes the disambiguator could not solve — affected sources are
|
|
47
|
+
absent from `names`."""
|
|
48
|
+
|
|
49
|
+
names: dict[pathlib.Path, str]
|
|
50
|
+
notes: dict[pathlib.Path, DisambiguationNote | None]
|
|
51
|
+
unresolved: tuple[MovieClash, ...] = ()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Disambiguator(Protocol):
|
|
55
|
+
def disambiguate(
|
|
56
|
+
self,
|
|
57
|
+
movie: MovieInfo,
|
|
58
|
+
videos: list[tuple[VideoInfo, pathlib.Path]],
|
|
59
|
+
writer: LibraryWriter,
|
|
60
|
+
reporter: Reporter,
|
|
61
|
+
*,
|
|
62
|
+
movie_folder: str,
|
|
63
|
+
) -> DisambiguationResult: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class NaiveDisambiguator:
|
|
67
|
+
"""Calls `writer.video_name()` once per video. If two videos produce
|
|
68
|
+
the same name, those videos are excluded from `names` and reported
|
|
69
|
+
as a `MovieClash` — matching pre-0.3 behaviour where the whole
|
|
70
|
+
movie was skipped on clash."""
|
|
71
|
+
|
|
72
|
+
def disambiguate(
|
|
73
|
+
self,
|
|
74
|
+
movie: MovieInfo,
|
|
75
|
+
videos: list[tuple[VideoInfo, pathlib.Path]],
|
|
76
|
+
writer: LibraryWriter,
|
|
77
|
+
reporter: Reporter,
|
|
78
|
+
*,
|
|
79
|
+
movie_folder: str,
|
|
80
|
+
) -> DisambiguationResult:
|
|
81
|
+
rendered: dict[pathlib.Path, str] = {}
|
|
82
|
+
for info, source in videos:
|
|
83
|
+
rendered[source] = writer.video_name(movie, info, reporter)
|
|
84
|
+
|
|
85
|
+
groups: dict[str, list[pathlib.Path]] = defaultdict(list)
|
|
86
|
+
for source, name in rendered.items():
|
|
87
|
+
groups[name].append(source)
|
|
88
|
+
|
|
89
|
+
names: dict[pathlib.Path, str] = {}
|
|
90
|
+
notes: dict[pathlib.Path, DisambiguationNote | None] = {}
|
|
91
|
+
unresolved: list[MovieClash] = []
|
|
92
|
+
for name, sources in groups.items():
|
|
93
|
+
if len(sources) == 1:
|
|
94
|
+
names[sources[0]] = name
|
|
95
|
+
notes[sources[0]] = None
|
|
96
|
+
else:
|
|
97
|
+
unresolved.append(
|
|
98
|
+
MovieClash(
|
|
99
|
+
movie_folder=movie_folder,
|
|
100
|
+
target_filename=name,
|
|
101
|
+
source_filenames=tuple(s.name for s in sources),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return DisambiguationResult(
|
|
105
|
+
names=names, notes=notes, unresolved=tuple(unresolved)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class HashFallbackDisambiguator:
|
|
110
|
+
"""On collision, re-renders the colliding videos with a short hash of
|
|
111
|
+
their source filename appended. Source filenames are unique within a
|
|
112
|
+
folder (FS guarantee), so the rendered names are unique modulo a
|
|
113
|
+
SHA-256 collision in the truncated prefix — negligible at the
|
|
114
|
+
default 8 hex chars (~10⁻⁹ for typical folder sizes).
|
|
115
|
+
|
|
116
|
+
The hash strategy lives on a per-file `DisambiguationNote` so the
|
|
117
|
+
--json output and `plan` subcommand can surface "this name was
|
|
118
|
+
hashed because of a clash" without the user having to compare names
|
|
119
|
+
by hand."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, *, hash_length: int = 8):
|
|
122
|
+
if hash_length < 4 or hash_length > 64:
|
|
123
|
+
raise ValueError("hash_length must be between 4 and 64 hex chars")
|
|
124
|
+
self._hash_length = hash_length
|
|
125
|
+
|
|
126
|
+
def disambiguate(
|
|
127
|
+
self,
|
|
128
|
+
movie: MovieInfo,
|
|
129
|
+
videos: list[tuple[VideoInfo, pathlib.Path]],
|
|
130
|
+
writer: LibraryWriter,
|
|
131
|
+
reporter: Reporter,
|
|
132
|
+
*,
|
|
133
|
+
movie_folder: str,
|
|
134
|
+
) -> DisambiguationResult:
|
|
135
|
+
# First pass: probe naive names with a throwaway reporter so we
|
|
136
|
+
# can detect collisions without double-counting drops.
|
|
137
|
+
probe = CollectingReporter()
|
|
138
|
+
naive: dict[pathlib.Path, str] = {}
|
|
139
|
+
for info, source in videos:
|
|
140
|
+
naive[source] = writer.video_name(movie, info, probe)
|
|
141
|
+
|
|
142
|
+
groups: dict[str, list[tuple[VideoInfo, pathlib.Path]]] = defaultdict(list)
|
|
143
|
+
for info, source in videos:
|
|
144
|
+
groups[naive[source]].append((info, source))
|
|
145
|
+
|
|
146
|
+
names: dict[pathlib.Path, str] = {}
|
|
147
|
+
notes: dict[pathlib.Path, DisambiguationNote | None] = {}
|
|
148
|
+
unresolved: list[MovieClash] = []
|
|
149
|
+
|
|
150
|
+
for naive_name, group in groups.items():
|
|
151
|
+
if len(group) == 1:
|
|
152
|
+
info, source = group[0]
|
|
153
|
+
# Re-render with the real reporter to deliver the drops.
|
|
154
|
+
names[source] = writer.video_name(movie, info, reporter)
|
|
155
|
+
notes[source] = None
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
rendered: dict[pathlib.Path, str] = {}
|
|
159
|
+
for info, source in group:
|
|
160
|
+
h = _short_hash(source.name, self._hash_length)
|
|
161
|
+
rendered[source] = writer.video_name(
|
|
162
|
+
movie, info, reporter, hash_suffix=h
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if len(set(rendered.values())) != len(rendered):
|
|
166
|
+
# Hash itself collided — pathological, but still surface
|
|
167
|
+
# it cleanly rather than silently corrupting the plan.
|
|
168
|
+
unresolved.append(
|
|
169
|
+
MovieClash(
|
|
170
|
+
movie_folder=movie_folder,
|
|
171
|
+
target_filename=naive_name,
|
|
172
|
+
source_filenames=tuple(s.name for _, s in group),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
for _info, source in group:
|
|
178
|
+
names[source] = rendered[source]
|
|
179
|
+
notes[source] = DisambiguationNote(
|
|
180
|
+
strategy="hash_suffix",
|
|
181
|
+
detail=f"hash from source filename '{source.name}'",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return DisambiguationResult(
|
|
185
|
+
names=names, notes=notes, unresolved=tuple(unresolved)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _short_hash(s: str, length: int) -> str:
|
|
190
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:length]
|