jellyplex-sync 0.3.2__tar.gz → 0.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/PKG-INFO +25 -39
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/README.md +24 -38
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/pyproject.toml +2 -1
- jellyplex_sync-0.3.2/src/jellyplex_sync/cli/sync.py → jellyplex_sync-0.3.4/src/jellyplex_sync/cli/main.py +12 -40
- jellyplex_sync-0.3.4/src/jellyplex_sync/cli/sync.py +186 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/jellyfin.py +1 -1
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/materializer.py +1 -1
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/LICENSE +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/__init__.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/cli/__init__.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/compare.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/disambig.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/discover.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/json_output.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/library.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/model.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/plan.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/planner.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/plex.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/py.typed +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/realize.py +0 -0
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/src/jellyplex_sync/sync.py +1 -1
- {jellyplex_sync-0.3.2 → jellyplex_sync-0.3.4}/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.
|
|
3
|
+
Version: 0.3.4
|
|
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>
|
|
@@ -44,7 +44,7 @@ The easiest way to install the CLI is via [uv](https://docs.astral.sh/uv/):
|
|
|
44
44
|
uv tool install jellyplex-sync
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
This places `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
|
|
47
|
+
This places `jellyplex` and `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
|
|
48
48
|
|
|
49
49
|
### Docker
|
|
50
50
|
|
|
@@ -62,17 +62,19 @@ docker build -t jellyplex-sync .
|
|
|
62
62
|
|
|
63
63
|
## Usage
|
|
64
64
|
|
|
65
|
-
The CLI is
|
|
65
|
+
The main CLI is `jellyplex` with four subcommands:
|
|
66
66
|
|
|
67
|
-
- **`sync`** — mirror a source library into the target layout
|
|
68
|
-
- **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
|
|
69
|
-
- **`plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
|
|
70
|
-
- **`import`** — **(experimental)** import video files from a staging area into a structured library. See below.
|
|
67
|
+
- **`jellyplex sync`** — mirror a source library into the target layout.
|
|
68
|
+
- **`jellyplex diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
|
|
69
|
+
- **`jellyplex plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
|
|
70
|
+
- **`jellyplex import`** — **(experimental)** import video files from a staging area into a structured library. See below.
|
|
71
|
+
|
|
72
|
+
For backward compatibility, `jellyplex-sync` is also installed as a standalone command that does exactly what `jellyplex sync` does — same options, no subcommand needed.
|
|
71
73
|
|
|
72
74
|
### `sync`
|
|
73
75
|
|
|
74
76
|
```bash
|
|
75
|
-
jellyplex
|
|
77
|
+
jellyplex sync [OPTIONS] /path/to/source/library /path/to/target/library
|
|
76
78
|
```
|
|
77
79
|
|
|
78
80
|
The first positional is the source library, the second is the target. By default the tool auto-detects whether the source is a Jellyfin or Plex layout and converts to the other format.
|
|
@@ -101,31 +103,31 @@ Use `--copy` when source and target are on different filesystems (NAS to local d
|
|
|
101
103
|
Mirror a Jellyfin library into a new Plex structure:
|
|
102
104
|
|
|
103
105
|
```bash
|
|
104
|
-
jellyplex
|
|
106
|
+
jellyplex sync --create ~/Media/Jellyfin ~/Media/Plex
|
|
105
107
|
```
|
|
106
108
|
|
|
107
109
|
Migration with cleanup (target becomes a clean mirror):
|
|
108
110
|
|
|
109
111
|
```bash
|
|
110
|
-
jellyplex
|
|
112
|
+
jellyplex sync --delete --create ~/Media/Jellyfin ~/Media/Plex
|
|
111
113
|
```
|
|
112
114
|
|
|
113
115
|
Cross-filesystem copy with safe re-runs:
|
|
114
116
|
|
|
115
117
|
```bash
|
|
116
|
-
jellyplex
|
|
118
|
+
jellyplex sync --copy --create /mnt/nas/Movies /mnt/local/Plex
|
|
117
119
|
```
|
|
118
120
|
|
|
119
121
|
Dry-run a verbose, full sync with deletion:
|
|
120
122
|
|
|
121
123
|
```bash
|
|
122
|
-
jellyplex
|
|
124
|
+
jellyplex sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
|
|
123
125
|
```
|
|
124
126
|
|
|
125
127
|
### `diff`
|
|
126
128
|
|
|
127
129
|
```bash
|
|
128
|
-
jellyplex
|
|
130
|
+
jellyplex diff [OPTIONS] /path/to/source/library /path/to/target/library
|
|
129
131
|
```
|
|
130
132
|
|
|
131
133
|
Compares the two libraries without touching anything. Reports movies that exist only on one side, file-level differences inside shared movies, translation losses (Plex labels with no Jellyfin equivalent, for example), and entries the scanner ignored.
|
|
@@ -141,13 +143,13 @@ Exit codes follow the Unix `diff` convention:
|
|
|
141
143
|
Verify a target is a complete mirror before deleting the source:
|
|
142
144
|
|
|
143
145
|
```bash
|
|
144
|
-
jellyplex
|
|
146
|
+
jellyplex diff ~/Media/Jellyfin ~/Media/Plex
|
|
145
147
|
```
|
|
146
148
|
|
|
147
149
|
### `plan`
|
|
148
150
|
|
|
149
151
|
```bash
|
|
150
|
-
jellyplex
|
|
152
|
+
jellyplex plan [OPTIONS] /path/to/source/library /path/to/target/library
|
|
151
153
|
```
|
|
152
154
|
|
|
153
155
|
Shows the Plan a sync would execute — every movie folder, every translated video filename, every loose file, every asset subdirectory — without touching either filesystem. Useful as a pre-flight check ("will any movies clash? what gets translated?") or as a machine-readable input for tooling with `--json`.
|
|
@@ -164,13 +166,13 @@ Exit codes:
|
|
|
164
166
|
Pre-flight a sync, see exactly which target filenames would be created:
|
|
165
167
|
|
|
166
168
|
```bash
|
|
167
|
-
jellyplex
|
|
169
|
+
jellyplex plan ~/Media/Plex ~/Media/Jellyfin
|
|
168
170
|
```
|
|
169
171
|
|
|
170
172
|
Machine-readable, with `jq` to find videos that needed disambiguation:
|
|
171
173
|
|
|
172
174
|
```bash
|
|
173
|
-
jellyplex
|
|
175
|
+
jellyplex plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
174
176
|
| jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
|
|
175
177
|
```
|
|
176
178
|
|
|
@@ -179,7 +181,7 @@ jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
|
179
181
|
> ⚠️ **This subcommand is experimental.** The exact behaviour may change in future releases as real-world use cases become clearer. Always start with `--dry-run`.
|
|
180
182
|
|
|
181
183
|
```bash
|
|
182
|
-
jellyplex
|
|
184
|
+
jellyplex import [OPTIONS] /path/to/staging /path/to/target/library
|
|
183
185
|
```
|
|
184
186
|
|
|
185
187
|
Imports video files from a staging area — a flat directory (or directory tree) where properly named video files have been dumped without the one-folder-per-movie structure that `sync` expects. Files are grouped by their parsed movie identity (title, year, provider ID) and placed into the correct movie folder in the target library.
|
|
@@ -208,13 +210,13 @@ Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`
|
|
|
208
210
|
Move Plex-named video files into a new Jellyfin library:
|
|
209
211
|
|
|
210
212
|
```bash
|
|
211
|
-
jellyplex
|
|
213
|
+
jellyplex import --create /mnt/staging /mnt/library/Jellyfin
|
|
212
214
|
```
|
|
213
215
|
|
|
214
216
|
Preview first, keeping the source:
|
|
215
217
|
|
|
216
218
|
```bash
|
|
217
|
-
jellyplex
|
|
219
|
+
jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
|
|
218
220
|
```
|
|
219
221
|
|
|
220
222
|
### JSON output
|
|
@@ -243,40 +245,24 @@ The schema (still evolving — pin a version when consuming):
|
|
|
243
245
|
Show every file that would be replaced (with full paths):
|
|
244
246
|
|
|
245
247
|
```bash
|
|
246
|
-
jellyplex
|
|
248
|
+
jellyplex sync --json --dry-run /src /dst \
|
|
247
249
|
| jq '.events[] | select(.action == "replace") | {source, target}'
|
|
248
250
|
```
|
|
249
251
|
|
|
250
252
|
Verify nothing important gets deleted before running a migration with `--delete`:
|
|
251
253
|
|
|
252
254
|
```bash
|
|
253
|
-
jellyplex
|
|
255
|
+
jellyplex sync --json --dry-run --delete /src /dst \
|
|
254
256
|
| jq '.events[] | select(.action == "remove")'
|
|
255
257
|
```
|
|
256
258
|
|
|
257
259
|
Action distribution:
|
|
258
260
|
|
|
259
261
|
```bash
|
|
260
|
-
jellyplex
|
|
262
|
+
jellyplex sync --json --dry-run /src /dst \
|
|
261
263
|
| jq '[.events[].action] | group_by(.) | map({action: .[0], count: length})'
|
|
262
264
|
```
|
|
263
265
|
|
|
264
|
-
### Default subcommand
|
|
265
|
-
|
|
266
|
-
For backward compatibility, omitting the subcommand is treated as an implicit `sync` (as long as the first argument is a positional, not a flag):
|
|
267
|
-
|
|
268
|
-
```bash
|
|
269
|
-
jellyplex-sync ~/Media/Jellyfin ~/Media/Plex
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
is equivalent to
|
|
273
|
-
|
|
274
|
-
```bash
|
|
275
|
-
jellyplex-sync sync ~/Media/Jellyfin ~/Media/Plex
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
If you start with a flag, you must spell out `sync` explicitly.
|
|
279
|
-
|
|
280
266
|
### Docker
|
|
281
267
|
|
|
282
268
|
```bash
|
|
@@ -28,7 +28,7 @@ The easiest way to install the CLI is via [uv](https://docs.astral.sh/uv/):
|
|
|
28
28
|
uv tool install jellyplex-sync
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
This places `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
|
|
31
|
+
This places `jellyplex` and `jellyplex-sync` on your `PATH` in an isolated environment. `pipx install jellyplex-sync` works the same way if you prefer pipx.
|
|
32
32
|
|
|
33
33
|
### Docker
|
|
34
34
|
|
|
@@ -46,17 +46,19 @@ docker build -t jellyplex-sync .
|
|
|
46
46
|
|
|
47
47
|
## Usage
|
|
48
48
|
|
|
49
|
-
The CLI is
|
|
49
|
+
The main CLI is `jellyplex` with four subcommands:
|
|
50
50
|
|
|
51
|
-
- **`sync`** — mirror a source library into the target layout
|
|
52
|
-
- **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
|
|
53
|
-
- **`plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
|
|
54
|
-
- **`import`** — **(experimental)** import video files from a staging area into a structured library. See below.
|
|
51
|
+
- **`jellyplex sync`** — mirror a source library into the target layout.
|
|
52
|
+
- **`jellyplex diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
|
|
53
|
+
- **`jellyplex plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
|
|
54
|
+
- **`jellyplex import`** — **(experimental)** import video files from a staging area into a structured library. See below.
|
|
55
|
+
|
|
56
|
+
For backward compatibility, `jellyplex-sync` is also installed as a standalone command that does exactly what `jellyplex sync` does — same options, no subcommand needed.
|
|
55
57
|
|
|
56
58
|
### `sync`
|
|
57
59
|
|
|
58
60
|
```bash
|
|
59
|
-
jellyplex
|
|
61
|
+
jellyplex sync [OPTIONS] /path/to/source/library /path/to/target/library
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
The first positional is the source library, the second is the target. By default the tool auto-detects whether the source is a Jellyfin or Plex layout and converts to the other format.
|
|
@@ -85,31 +87,31 @@ Use `--copy` when source and target are on different filesystems (NAS to local d
|
|
|
85
87
|
Mirror a Jellyfin library into a new Plex structure:
|
|
86
88
|
|
|
87
89
|
```bash
|
|
88
|
-
jellyplex
|
|
90
|
+
jellyplex sync --create ~/Media/Jellyfin ~/Media/Plex
|
|
89
91
|
```
|
|
90
92
|
|
|
91
93
|
Migration with cleanup (target becomes a clean mirror):
|
|
92
94
|
|
|
93
95
|
```bash
|
|
94
|
-
jellyplex
|
|
96
|
+
jellyplex sync --delete --create ~/Media/Jellyfin ~/Media/Plex
|
|
95
97
|
```
|
|
96
98
|
|
|
97
99
|
Cross-filesystem copy with safe re-runs:
|
|
98
100
|
|
|
99
101
|
```bash
|
|
100
|
-
jellyplex
|
|
102
|
+
jellyplex sync --copy --create /mnt/nas/Movies /mnt/local/Plex
|
|
101
103
|
```
|
|
102
104
|
|
|
103
105
|
Dry-run a verbose, full sync with deletion:
|
|
104
106
|
|
|
105
107
|
```bash
|
|
106
|
-
jellyplex
|
|
108
|
+
jellyplex sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
|
|
107
109
|
```
|
|
108
110
|
|
|
109
111
|
### `diff`
|
|
110
112
|
|
|
111
113
|
```bash
|
|
112
|
-
jellyplex
|
|
114
|
+
jellyplex diff [OPTIONS] /path/to/source/library /path/to/target/library
|
|
113
115
|
```
|
|
114
116
|
|
|
115
117
|
Compares the two libraries without touching anything. Reports movies that exist only on one side, file-level differences inside shared movies, translation losses (Plex labels with no Jellyfin equivalent, for example), and entries the scanner ignored.
|
|
@@ -125,13 +127,13 @@ Exit codes follow the Unix `diff` convention:
|
|
|
125
127
|
Verify a target is a complete mirror before deleting the source:
|
|
126
128
|
|
|
127
129
|
```bash
|
|
128
|
-
jellyplex
|
|
130
|
+
jellyplex diff ~/Media/Jellyfin ~/Media/Plex
|
|
129
131
|
```
|
|
130
132
|
|
|
131
133
|
### `plan`
|
|
132
134
|
|
|
133
135
|
```bash
|
|
134
|
-
jellyplex
|
|
136
|
+
jellyplex plan [OPTIONS] /path/to/source/library /path/to/target/library
|
|
135
137
|
```
|
|
136
138
|
|
|
137
139
|
Shows the Plan a sync would execute — every movie folder, every translated video filename, every loose file, every asset subdirectory — without touching either filesystem. Useful as a pre-flight check ("will any movies clash? what gets translated?") or as a machine-readable input for tooling with `--json`.
|
|
@@ -148,13 +150,13 @@ Exit codes:
|
|
|
148
150
|
Pre-flight a sync, see exactly which target filenames would be created:
|
|
149
151
|
|
|
150
152
|
```bash
|
|
151
|
-
jellyplex
|
|
153
|
+
jellyplex plan ~/Media/Plex ~/Media/Jellyfin
|
|
152
154
|
```
|
|
153
155
|
|
|
154
156
|
Machine-readable, with `jq` to find videos that needed disambiguation:
|
|
155
157
|
|
|
156
158
|
```bash
|
|
157
|
-
jellyplex
|
|
159
|
+
jellyplex plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
158
160
|
| jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
|
|
159
161
|
```
|
|
160
162
|
|
|
@@ -163,7 +165,7 @@ jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
|
163
165
|
> ⚠️ **This subcommand is experimental.** The exact behaviour may change in future releases as real-world use cases become clearer. Always start with `--dry-run`.
|
|
164
166
|
|
|
165
167
|
```bash
|
|
166
|
-
jellyplex
|
|
168
|
+
jellyplex import [OPTIONS] /path/to/staging /path/to/target/library
|
|
167
169
|
```
|
|
168
170
|
|
|
169
171
|
Imports video files from a staging area — a flat directory (or directory tree) where properly named video files have been dumped without the one-folder-per-movie structure that `sync` expects. Files are grouped by their parsed movie identity (title, year, provider ID) and placed into the correct movie folder in the target library.
|
|
@@ -192,13 +194,13 @@ Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`
|
|
|
192
194
|
Move Plex-named video files into a new Jellyfin library:
|
|
193
195
|
|
|
194
196
|
```bash
|
|
195
|
-
jellyplex
|
|
197
|
+
jellyplex import --create /mnt/staging /mnt/library/Jellyfin
|
|
196
198
|
```
|
|
197
199
|
|
|
198
200
|
Preview first, keeping the source:
|
|
199
201
|
|
|
200
202
|
```bash
|
|
201
|
-
jellyplex
|
|
203
|
+
jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
|
|
202
204
|
```
|
|
203
205
|
|
|
204
206
|
### JSON output
|
|
@@ -227,40 +229,24 @@ The schema (still evolving — pin a version when consuming):
|
|
|
227
229
|
Show every file that would be replaced (with full paths):
|
|
228
230
|
|
|
229
231
|
```bash
|
|
230
|
-
jellyplex
|
|
232
|
+
jellyplex sync --json --dry-run /src /dst \
|
|
231
233
|
| jq '.events[] | select(.action == "replace") | {source, target}'
|
|
232
234
|
```
|
|
233
235
|
|
|
234
236
|
Verify nothing important gets deleted before running a migration with `--delete`:
|
|
235
237
|
|
|
236
238
|
```bash
|
|
237
|
-
jellyplex
|
|
239
|
+
jellyplex sync --json --dry-run --delete /src /dst \
|
|
238
240
|
| jq '.events[] | select(.action == "remove")'
|
|
239
241
|
```
|
|
240
242
|
|
|
241
243
|
Action distribution:
|
|
242
244
|
|
|
243
245
|
```bash
|
|
244
|
-
jellyplex
|
|
246
|
+
jellyplex sync --json --dry-run /src /dst \
|
|
245
247
|
| jq '[.events[].action] | group_by(.) | map({action: .[0], count: length})'
|
|
246
248
|
```
|
|
247
249
|
|
|
248
|
-
### Default subcommand
|
|
249
|
-
|
|
250
|
-
For backward compatibility, omitting the subcommand is treated as an implicit `sync` (as long as the first argument is a positional, not a flag):
|
|
251
|
-
|
|
252
|
-
```bash
|
|
253
|
-
jellyplex-sync ~/Media/Jellyfin ~/Media/Plex
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
is equivalent to
|
|
257
|
-
|
|
258
|
-
```bash
|
|
259
|
-
jellyplex-sync sync ~/Media/Jellyfin ~/Media/Plex
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
If you start with a flag, you must spell out `sync` explicitly.
|
|
263
|
-
|
|
264
250
|
### Docker
|
|
265
251
|
|
|
266
252
|
```bash
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "jellyplex-sync"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.4"
|
|
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"
|
|
@@ -17,6 +17,7 @@ classifiers = [
|
|
|
17
17
|
dependencies = []
|
|
18
18
|
|
|
19
19
|
[project.scripts]
|
|
20
|
+
jellyplex = "jellyplex_sync.cli.main:main"
|
|
20
21
|
jellyplex-sync = "jellyplex_sync.cli.sync:main"
|
|
21
22
|
|
|
22
23
|
[dependency-groups]
|
|
@@ -7,12 +7,10 @@ import sys
|
|
|
7
7
|
|
|
8
8
|
import jellyplex_sync as jp
|
|
9
9
|
|
|
10
|
-
_SUBCOMMANDS = {"sync", "diff", "plan", "import"}
|
|
11
|
-
|
|
12
10
|
|
|
13
11
|
def _build_parser() -> argparse.ArgumentParser:
|
|
14
12
|
parser = argparse.ArgumentParser(
|
|
15
|
-
prog="jellyplex
|
|
13
|
+
prog="jellyplex",
|
|
16
14
|
description="Convert a media library between Plex and Jellyfin layouts.",
|
|
17
15
|
)
|
|
18
16
|
|
|
@@ -41,7 +39,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
41
39
|
sync_p = sub.add_parser(
|
|
42
40
|
"sync",
|
|
43
41
|
parents=[common],
|
|
44
|
-
help="Mirror a source library into the target layout
|
|
42
|
+
help="Mirror a source library into the target layout.",
|
|
45
43
|
)
|
|
46
44
|
_add_io_args(sync_p)
|
|
47
45
|
mode = sync_p.add_mutually_exclusive_group()
|
|
@@ -157,24 +155,9 @@ def _add_io_args(p: argparse.ArgumentParser) -> None:
|
|
|
157
155
|
)
|
|
158
156
|
|
|
159
157
|
|
|
160
|
-
def _inject_default_subcommand(argv: list[str]) -> list[str]:
|
|
161
|
-
"""If the first positional argument isn't a known subcommand, treat the
|
|
162
|
-
call as `sync ...`. Keeps the historical invocation `jellyplex-sync
|
|
163
|
-
<source> <target>` working alongside the new `jellyplex-sync sync ...`.
|
|
164
|
-
"""
|
|
165
|
-
if not argv:
|
|
166
|
-
return argv
|
|
167
|
-
first = argv[0]
|
|
168
|
-
if first.startswith("-"):
|
|
169
|
-
return argv
|
|
170
|
-
if first in _SUBCOMMANDS:
|
|
171
|
-
return argv
|
|
172
|
-
return ["sync", *argv]
|
|
173
|
-
|
|
174
|
-
|
|
175
158
|
def main() -> None:
|
|
176
159
|
parser = _build_parser()
|
|
177
|
-
args = parser.parse_args(
|
|
160
|
+
args = parser.parse_args()
|
|
178
161
|
|
|
179
162
|
# Under --json, INFO chatter on stderr distracts from the JSON document
|
|
180
163
|
# on stdout. Quiet stderr to WARNING by default; --verbose or --debug
|
|
@@ -239,8 +222,15 @@ def _do_sync(args: argparse.Namespace) -> int:
|
|
|
239
222
|
|
|
240
223
|
if args.json:
|
|
241
224
|
assert stats is not None and reporter is not None
|
|
242
|
-
source_short, target_short = _resolve_formats_for_json(args)
|
|
243
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 ("?", "?")
|
|
244
234
|
|
|
245
235
|
write_sync_json(
|
|
246
236
|
sys.stdout,
|
|
@@ -309,10 +299,7 @@ def _do_plan(args: argparse.Namespace) -> int:
|
|
|
309
299
|
def _do_import(args: argparse.Namespace) -> int:
|
|
310
300
|
from jellyplex_sync.sync import import_media
|
|
311
301
|
|
|
312
|
-
if args.mode == "move"
|
|
313
|
-
materializer = jp.MoveMaterializer()
|
|
314
|
-
else:
|
|
315
|
-
materializer = jp.CopyMaterializer()
|
|
302
|
+
materializer = jp.MoveMaterializer() if args.mode == "move" else jp.CopyMaterializer()
|
|
316
303
|
|
|
317
304
|
try:
|
|
318
305
|
return import_media(
|
|
@@ -334,20 +321,5 @@ def _do_import(args: argparse.Namespace) -> int:
|
|
|
334
321
|
return 99
|
|
335
322
|
|
|
336
323
|
|
|
337
|
-
def _resolve_formats_for_json(args: argparse.Namespace) -> tuple[str, str]:
|
|
338
|
-
"""Re-run the same format resolution `sync()` did so the JSON payload
|
|
339
|
-
reports the formats that were actually used. Returns ('?', '?') if
|
|
340
|
-
resolution fails — sync() will already have logged the error and the
|
|
341
|
-
caller's exit code reflects it."""
|
|
342
|
-
import pathlib
|
|
343
|
-
|
|
344
|
-
from jellyplex_sync.sync import _resolve_formats
|
|
345
|
-
|
|
346
|
-
resolved = _resolve_formats(
|
|
347
|
-
pathlib.Path(args.source), args.source_format, args.target_format
|
|
348
|
-
)
|
|
349
|
-
return resolved if resolved else ("?", "?")
|
|
350
|
-
|
|
351
|
-
|
|
352
324
|
if __name__ == "__main__":
|
|
353
325
|
main()
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
"""Backward-compatible ``jellyplex-sync`` entry point.
|
|
3
|
+
|
|
4
|
+
Provides the same interface as the original single-command CLI: flat
|
|
5
|
+
argument list, no subcommands.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import logging
|
|
12
|
+
import pathlib
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import jellyplex_sync as jp
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
format_choices = [
|
|
20
|
+
jp.JellyfinLibraryReader.shortname(),
|
|
21
|
+
jp.PlexLibraryReader.shortname(),
|
|
22
|
+
"auto",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="jellyplex-sync",
|
|
27
|
+
description="Mirror a source library into the target layout.",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"-v",
|
|
31
|
+
"--verbose",
|
|
32
|
+
action="store_true",
|
|
33
|
+
help="Show more information messages.",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--debug",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Show debug messages.",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--json",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Emit a machine-readable JSON document on stdout. Logs continue to go to stderr.",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument("source", help="Source media library path.")
|
|
46
|
+
parser.add_argument("target", help="Target media library path.")
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--source-format",
|
|
49
|
+
type=str,
|
|
50
|
+
choices=format_choices,
|
|
51
|
+
default="auto",
|
|
52
|
+
help="Source library format ('auto' detects from the source layout).",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--target-format",
|
|
56
|
+
type=str,
|
|
57
|
+
choices=format_choices,
|
|
58
|
+
default="auto",
|
|
59
|
+
help="Target library format ('auto' picks the opposite of the source).",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
mode = parser.add_mutually_exclusive_group()
|
|
63
|
+
mode.add_argument(
|
|
64
|
+
"--hardlink",
|
|
65
|
+
dest="mode",
|
|
66
|
+
action="store_const",
|
|
67
|
+
const="hardlink",
|
|
68
|
+
help="Use hardlinks (default; needs source and target on the same filesystem).",
|
|
69
|
+
)
|
|
70
|
+
mode.add_argument(
|
|
71
|
+
"--copy",
|
|
72
|
+
dest="mode",
|
|
73
|
+
action="store_const",
|
|
74
|
+
const="copy",
|
|
75
|
+
help="Copy files; on re-runs skip files whose size and mtime already match.",
|
|
76
|
+
)
|
|
77
|
+
mode.add_argument(
|
|
78
|
+
"--force-copy",
|
|
79
|
+
dest="mode",
|
|
80
|
+
action="store_const",
|
|
81
|
+
const="force-copy",
|
|
82
|
+
help="Always copy and overwrite, regardless of existing target state.",
|
|
83
|
+
)
|
|
84
|
+
parser.set_defaults(mode="hardlink")
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--dry-run",
|
|
87
|
+
action="store_true",
|
|
88
|
+
help="Show what would be done, don't change anything on disk.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--delete",
|
|
92
|
+
action="store_true",
|
|
93
|
+
help="Remove stray folders from the target library.",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--create",
|
|
97
|
+
action="store_true",
|
|
98
|
+
help="Create the target library directory if it doesn't exist.",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return parser
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> None:
|
|
105
|
+
parser = _build_parser()
|
|
106
|
+
args = parser.parse_args()
|
|
107
|
+
|
|
108
|
+
if args.debug:
|
|
109
|
+
level = logging.DEBUG
|
|
110
|
+
elif args.json and not args.verbose:
|
|
111
|
+
level = logging.WARNING
|
|
112
|
+
else:
|
|
113
|
+
level = logging.INFO
|
|
114
|
+
|
|
115
|
+
logging.basicConfig(
|
|
116
|
+
level=level,
|
|
117
|
+
stream=sys.stderr,
|
|
118
|
+
format="%(levelname)s: %(asctime)s -- %(message)s",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if args.mode == "hardlink":
|
|
122
|
+
materializer: jp.FileMaterializer = jp.HardlinkMaterializer()
|
|
123
|
+
elif args.mode == "copy":
|
|
124
|
+
materializer = jp.CopyMaterializer()
|
|
125
|
+
elif args.mode == "force-copy":
|
|
126
|
+
materializer = jp.ForceCopyMaterializer()
|
|
127
|
+
else:
|
|
128
|
+
raise ValueError(f"unknown materialization mode: {args.mode!r}")
|
|
129
|
+
|
|
130
|
+
from jellyplex_sync.library import CollectingReporter
|
|
131
|
+
from jellyplex_sync.sync import LibraryStats
|
|
132
|
+
|
|
133
|
+
stats = LibraryStats() if args.json else None
|
|
134
|
+
reporter = CollectingReporter() if args.json else None
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
rc = jp.sync(
|
|
138
|
+
args.source,
|
|
139
|
+
args.target,
|
|
140
|
+
dry_run=args.dry_run,
|
|
141
|
+
delete=args.delete,
|
|
142
|
+
create=args.create,
|
|
143
|
+
verbose=args.verbose,
|
|
144
|
+
debug=args.debug,
|
|
145
|
+
source_format=args.source_format,
|
|
146
|
+
target_format=args.target_format,
|
|
147
|
+
materializer=materializer,
|
|
148
|
+
reporter=reporter,
|
|
149
|
+
stats=stats,
|
|
150
|
+
)
|
|
151
|
+
except KeyboardInterrupt:
|
|
152
|
+
logging.info("INTERRUPTED")
|
|
153
|
+
sys.exit(10)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
logging.error("Exception: %s", exc)
|
|
156
|
+
sys.exit(99)
|
|
157
|
+
|
|
158
|
+
if args.json:
|
|
159
|
+
assert stats is not None and reporter is not None
|
|
160
|
+
from jellyplex_sync.json_output import write_sync_json
|
|
161
|
+
from jellyplex_sync.sync import _resolve_formats
|
|
162
|
+
|
|
163
|
+
resolved = _resolve_formats(
|
|
164
|
+
pathlib.Path(args.source),
|
|
165
|
+
args.source_format,
|
|
166
|
+
args.target_format,
|
|
167
|
+
)
|
|
168
|
+
source_short, target_short = resolved if resolved else ("?", "?")
|
|
169
|
+
|
|
170
|
+
write_sync_json(
|
|
171
|
+
sys.stdout,
|
|
172
|
+
source_path=pathlib.Path(args.source),
|
|
173
|
+
source_format=source_short,
|
|
174
|
+
target_path=pathlib.Path(args.target),
|
|
175
|
+
target_format=target_short,
|
|
176
|
+
dry_run=args.dry_run,
|
|
177
|
+
exit_code=rc,
|
|
178
|
+
stats=stats,
|
|
179
|
+
drops=reporter.drops,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
sys.exit(rc)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
main()
|
|
@@ -125,7 +125,7 @@ class JellyfinLibraryWriter(_JellyfinBase):
|
|
|
125
125
|
parts.append(f"[{key}id-{value}]")
|
|
126
126
|
|
|
127
127
|
# Report any other provider IDs we couldn't fit; Jellyfin tolerates
|
|
128
|
-
# multiple bracket-IDs in the folder name, but jellyplex
|
|
128
|
+
# multiple bracket-IDs in the folder name, but jellyplex only
|
|
129
129
|
# emits one for now.
|
|
130
130
|
for key, value in movie.attributes.items():
|
|
131
131
|
if primary and key == primary[0]:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A `FileMaterializer` knows how to make a target path reflect a source path.
|
|
4
4
|
The first (and currently only) implementation, `HardlinkMaterializer`, uses
|
|
5
|
-
filesystem hardlinks — the behavior
|
|
5
|
+
filesystem hardlinks — the behavior this tool has always had. Copy
|
|
6
6
|
backends arrive in a later step of Paket 4.
|
|
7
7
|
|
|
8
8
|
The seam exists so the CLI can swap materialization strategies (`--copy`,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -6,6 +6,7 @@ import re
|
|
|
6
6
|
from collections.abc import Callable
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
8
|
|
|
9
|
+
from .discover import FlatDiscoverer
|
|
9
10
|
from .jellyfin import JellyfinLibraryReader, JellyfinLibraryWriter
|
|
10
11
|
from .library import (
|
|
11
12
|
ACCEPTED_VIDEO_SUFFIXES,
|
|
@@ -19,7 +20,6 @@ from .library import (
|
|
|
19
20
|
Reporter,
|
|
20
21
|
dedupe_drops,
|
|
21
22
|
)
|
|
22
|
-
from .discover import FlatDiscoverer
|
|
23
23
|
from .materializer import FileMaterializer, HardlinkMaterializer, MoveMaterializer
|
|
24
24
|
from .planner import Planner
|
|
25
25
|
from .plex import PlexLibraryReader, PlexLibraryWriter
|
|
File without changes
|