jellyplex-sync 0.3.1__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.1 → jellyplex_sync-0.3.4}/PKG-INFO +65 -35
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/README.md +64 -34
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/pyproject.toml +2 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/__init__.py +4 -0
- jellyplex_sync-0.3.1/src/jellyplex_sync/cli/sync.py → jellyplex_sync-0.3.4/src/jellyplex_sync/cli/main.py +67 -32
- jellyplex_sync-0.3.4/src/jellyplex_sync/cli/sync.py +186 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/discover.py +73 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/jellyfin.py +1 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/materializer.py +55 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/sync.py +122 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/LICENSE +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/cli/__init__.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/compare.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/disambig.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/json_output.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/library.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/model.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/plan.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/planner.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/plex.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/py.typed +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/realize.py +0 -0
- {jellyplex_sync-0.3.1 → 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,16 +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.
|
|
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.
|
|
70
73
|
|
|
71
74
|
### `sync`
|
|
72
75
|
|
|
73
76
|
```bash
|
|
74
|
-
jellyplex
|
|
77
|
+
jellyplex sync [OPTIONS] /path/to/source/library /path/to/target/library
|
|
75
78
|
```
|
|
76
79
|
|
|
77
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.
|
|
@@ -100,31 +103,31 @@ Use `--copy` when source and target are on different filesystems (NAS to local d
|
|
|
100
103
|
Mirror a Jellyfin library into a new Plex structure:
|
|
101
104
|
|
|
102
105
|
```bash
|
|
103
|
-
jellyplex
|
|
106
|
+
jellyplex sync --create ~/Media/Jellyfin ~/Media/Plex
|
|
104
107
|
```
|
|
105
108
|
|
|
106
109
|
Migration with cleanup (target becomes a clean mirror):
|
|
107
110
|
|
|
108
111
|
```bash
|
|
109
|
-
jellyplex
|
|
112
|
+
jellyplex sync --delete --create ~/Media/Jellyfin ~/Media/Plex
|
|
110
113
|
```
|
|
111
114
|
|
|
112
115
|
Cross-filesystem copy with safe re-runs:
|
|
113
116
|
|
|
114
117
|
```bash
|
|
115
|
-
jellyplex
|
|
118
|
+
jellyplex sync --copy --create /mnt/nas/Movies /mnt/local/Plex
|
|
116
119
|
```
|
|
117
120
|
|
|
118
121
|
Dry-run a verbose, full sync with deletion:
|
|
119
122
|
|
|
120
123
|
```bash
|
|
121
|
-
jellyplex
|
|
124
|
+
jellyplex sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
|
|
122
125
|
```
|
|
123
126
|
|
|
124
127
|
### `diff`
|
|
125
128
|
|
|
126
129
|
```bash
|
|
127
|
-
jellyplex
|
|
130
|
+
jellyplex diff [OPTIONS] /path/to/source/library /path/to/target/library
|
|
128
131
|
```
|
|
129
132
|
|
|
130
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.
|
|
@@ -140,13 +143,13 @@ Exit codes follow the Unix `diff` convention:
|
|
|
140
143
|
Verify a target is a complete mirror before deleting the source:
|
|
141
144
|
|
|
142
145
|
```bash
|
|
143
|
-
jellyplex
|
|
146
|
+
jellyplex diff ~/Media/Jellyfin ~/Media/Plex
|
|
144
147
|
```
|
|
145
148
|
|
|
146
149
|
### `plan`
|
|
147
150
|
|
|
148
151
|
```bash
|
|
149
|
-
jellyplex
|
|
152
|
+
jellyplex plan [OPTIONS] /path/to/source/library /path/to/target/library
|
|
150
153
|
```
|
|
151
154
|
|
|
152
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`.
|
|
@@ -163,16 +166,59 @@ Exit codes:
|
|
|
163
166
|
Pre-flight a sync, see exactly which target filenames would be created:
|
|
164
167
|
|
|
165
168
|
```bash
|
|
166
|
-
jellyplex
|
|
169
|
+
jellyplex plan ~/Media/Plex ~/Media/Jellyfin
|
|
167
170
|
```
|
|
168
171
|
|
|
169
172
|
Machine-readable, with `jq` to find videos that needed disambiguation:
|
|
170
173
|
|
|
171
174
|
```bash
|
|
172
|
-
jellyplex
|
|
175
|
+
jellyplex plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
173
176
|
| jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
|
|
174
177
|
```
|
|
175
178
|
|
|
179
|
+
### `import` (experimental)
|
|
180
|
+
|
|
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`.
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
jellyplex import [OPTIONS] /path/to/staging /path/to/target/library
|
|
185
|
+
```
|
|
186
|
+
|
|
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.
|
|
188
|
+
|
|
189
|
+
Semantically distinct from `sync`:
|
|
190
|
+
|
|
191
|
+
- `sync` mirrors a structured library; the source stays intact.
|
|
192
|
+
- `import` ingests from an unstructured dump; the source files are **moved** (deleted after successful copy) by default.
|
|
193
|
+
- `import` never removes anything from the target — it only adds.
|
|
194
|
+
|
|
195
|
+
**Materializer flags** (mutually exclusive):
|
|
196
|
+
|
|
197
|
+
- `--move` (default) — copy each file to the target, then delete the source.
|
|
198
|
+
- `--copy` — copy files; the source is kept. Safe for a test run without `--dry-run`.
|
|
199
|
+
|
|
200
|
+
Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`, `--debug`.
|
|
201
|
+
|
|
202
|
+
#### Limitations
|
|
203
|
+
|
|
204
|
+
- **Loose non-video files** (posters, `.nfo`, subtitles) in the staging area are not imported — there is no folder to assign them to. The target media server can download its own metadata.
|
|
205
|
+
- **Asset directories** (extras, trailers) in the staging area are not collected.
|
|
206
|
+
- **No `--json` output** yet for import.
|
|
207
|
+
|
|
208
|
+
#### Example
|
|
209
|
+
|
|
210
|
+
Move Plex-named video files into a new Jellyfin library:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
jellyplex import --create /mnt/staging /mnt/library/Jellyfin
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Preview first, keeping the source:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
|
|
220
|
+
```
|
|
221
|
+
|
|
176
222
|
### JSON output
|
|
177
223
|
|
|
178
224
|
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.
|
|
@@ -199,40 +245,24 @@ The schema (still evolving — pin a version when consuming):
|
|
|
199
245
|
Show every file that would be replaced (with full paths):
|
|
200
246
|
|
|
201
247
|
```bash
|
|
202
|
-
jellyplex
|
|
248
|
+
jellyplex sync --json --dry-run /src /dst \
|
|
203
249
|
| jq '.events[] | select(.action == "replace") | {source, target}'
|
|
204
250
|
```
|
|
205
251
|
|
|
206
252
|
Verify nothing important gets deleted before running a migration with `--delete`:
|
|
207
253
|
|
|
208
254
|
```bash
|
|
209
|
-
jellyplex
|
|
255
|
+
jellyplex sync --json --dry-run --delete /src /dst \
|
|
210
256
|
| jq '.events[] | select(.action == "remove")'
|
|
211
257
|
```
|
|
212
258
|
|
|
213
259
|
Action distribution:
|
|
214
260
|
|
|
215
261
|
```bash
|
|
216
|
-
jellyplex
|
|
262
|
+
jellyplex sync --json --dry-run /src /dst \
|
|
217
263
|
| jq '[.events[].action] | group_by(.) | map({action: .[0], count: length})'
|
|
218
264
|
```
|
|
219
265
|
|
|
220
|
-
### Default subcommand
|
|
221
|
-
|
|
222
|
-
For backward compatibility, omitting the subcommand is treated as an implicit `sync` (as long as the first argument is a positional, not a flag):
|
|
223
|
-
|
|
224
|
-
```bash
|
|
225
|
-
jellyplex-sync ~/Media/Jellyfin ~/Media/Plex
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
is equivalent to
|
|
229
|
-
|
|
230
|
-
```bash
|
|
231
|
-
jellyplex-sync sync ~/Media/Jellyfin ~/Media/Plex
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
If you start with a flag, you must spell out `sync` explicitly.
|
|
235
|
-
|
|
236
266
|
### Docker
|
|
237
267
|
|
|
238
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,16 +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.
|
|
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.
|
|
54
57
|
|
|
55
58
|
### `sync`
|
|
56
59
|
|
|
57
60
|
```bash
|
|
58
|
-
jellyplex
|
|
61
|
+
jellyplex sync [OPTIONS] /path/to/source/library /path/to/target/library
|
|
59
62
|
```
|
|
60
63
|
|
|
61
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.
|
|
@@ -84,31 +87,31 @@ Use `--copy` when source and target are on different filesystems (NAS to local d
|
|
|
84
87
|
Mirror a Jellyfin library into a new Plex structure:
|
|
85
88
|
|
|
86
89
|
```bash
|
|
87
|
-
jellyplex
|
|
90
|
+
jellyplex sync --create ~/Media/Jellyfin ~/Media/Plex
|
|
88
91
|
```
|
|
89
92
|
|
|
90
93
|
Migration with cleanup (target becomes a clean mirror):
|
|
91
94
|
|
|
92
95
|
```bash
|
|
93
|
-
jellyplex
|
|
96
|
+
jellyplex sync --delete --create ~/Media/Jellyfin ~/Media/Plex
|
|
94
97
|
```
|
|
95
98
|
|
|
96
99
|
Cross-filesystem copy with safe re-runs:
|
|
97
100
|
|
|
98
101
|
```bash
|
|
99
|
-
jellyplex
|
|
102
|
+
jellyplex sync --copy --create /mnt/nas/Movies /mnt/local/Plex
|
|
100
103
|
```
|
|
101
104
|
|
|
102
105
|
Dry-run a verbose, full sync with deletion:
|
|
103
106
|
|
|
104
107
|
```bash
|
|
105
|
-
jellyplex
|
|
108
|
+
jellyplex sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
|
|
106
109
|
```
|
|
107
110
|
|
|
108
111
|
### `diff`
|
|
109
112
|
|
|
110
113
|
```bash
|
|
111
|
-
jellyplex
|
|
114
|
+
jellyplex diff [OPTIONS] /path/to/source/library /path/to/target/library
|
|
112
115
|
```
|
|
113
116
|
|
|
114
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.
|
|
@@ -124,13 +127,13 @@ Exit codes follow the Unix `diff` convention:
|
|
|
124
127
|
Verify a target is a complete mirror before deleting the source:
|
|
125
128
|
|
|
126
129
|
```bash
|
|
127
|
-
jellyplex
|
|
130
|
+
jellyplex diff ~/Media/Jellyfin ~/Media/Plex
|
|
128
131
|
```
|
|
129
132
|
|
|
130
133
|
### `plan`
|
|
131
134
|
|
|
132
135
|
```bash
|
|
133
|
-
jellyplex
|
|
136
|
+
jellyplex plan [OPTIONS] /path/to/source/library /path/to/target/library
|
|
134
137
|
```
|
|
135
138
|
|
|
136
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`.
|
|
@@ -147,16 +150,59 @@ Exit codes:
|
|
|
147
150
|
Pre-flight a sync, see exactly which target filenames would be created:
|
|
148
151
|
|
|
149
152
|
```bash
|
|
150
|
-
jellyplex
|
|
153
|
+
jellyplex plan ~/Media/Plex ~/Media/Jellyfin
|
|
151
154
|
```
|
|
152
155
|
|
|
153
156
|
Machine-readable, with `jq` to find videos that needed disambiguation:
|
|
154
157
|
|
|
155
158
|
```bash
|
|
156
|
-
jellyplex
|
|
159
|
+
jellyplex plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
157
160
|
| jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
|
|
158
161
|
```
|
|
159
162
|
|
|
163
|
+
### `import` (experimental)
|
|
164
|
+
|
|
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`.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
jellyplex import [OPTIONS] /path/to/staging /path/to/target/library
|
|
169
|
+
```
|
|
170
|
+
|
|
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.
|
|
172
|
+
|
|
173
|
+
Semantically distinct from `sync`:
|
|
174
|
+
|
|
175
|
+
- `sync` mirrors a structured library; the source stays intact.
|
|
176
|
+
- `import` ingests from an unstructured dump; the source files are **moved** (deleted after successful copy) by default.
|
|
177
|
+
- `import` never removes anything from the target — it only adds.
|
|
178
|
+
|
|
179
|
+
**Materializer flags** (mutually exclusive):
|
|
180
|
+
|
|
181
|
+
- `--move` (default) — copy each file to the target, then delete the source.
|
|
182
|
+
- `--copy` — copy files; the source is kept. Safe for a test run without `--dry-run`.
|
|
183
|
+
|
|
184
|
+
Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`, `--debug`.
|
|
185
|
+
|
|
186
|
+
#### Limitations
|
|
187
|
+
|
|
188
|
+
- **Loose non-video files** (posters, `.nfo`, subtitles) in the staging area are not imported — there is no folder to assign them to. The target media server can download its own metadata.
|
|
189
|
+
- **Asset directories** (extras, trailers) in the staging area are not collected.
|
|
190
|
+
- **No `--json` output** yet for import.
|
|
191
|
+
|
|
192
|
+
#### Example
|
|
193
|
+
|
|
194
|
+
Move Plex-named video files into a new Jellyfin library:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
jellyplex import --create /mnt/staging /mnt/library/Jellyfin
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Preview first, keeping the source:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
jellyplex import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
|
|
204
|
+
```
|
|
205
|
+
|
|
160
206
|
### JSON output
|
|
161
207
|
|
|
162
208
|
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.
|
|
@@ -183,40 +229,24 @@ The schema (still evolving — pin a version when consuming):
|
|
|
183
229
|
Show every file that would be replaced (with full paths):
|
|
184
230
|
|
|
185
231
|
```bash
|
|
186
|
-
jellyplex
|
|
232
|
+
jellyplex sync --json --dry-run /src /dst \
|
|
187
233
|
| jq '.events[] | select(.action == "replace") | {source, target}'
|
|
188
234
|
```
|
|
189
235
|
|
|
190
236
|
Verify nothing important gets deleted before running a migration with `--delete`:
|
|
191
237
|
|
|
192
238
|
```bash
|
|
193
|
-
jellyplex
|
|
239
|
+
jellyplex sync --json --dry-run --delete /src /dst \
|
|
194
240
|
| jq '.events[] | select(.action == "remove")'
|
|
195
241
|
```
|
|
196
242
|
|
|
197
243
|
Action distribution:
|
|
198
244
|
|
|
199
245
|
```bash
|
|
200
|
-
jellyplex
|
|
246
|
+
jellyplex sync --json --dry-run /src /dst \
|
|
201
247
|
| jq '[.events[].action] | group_by(.) | map({action: .[0], count: length})'
|
|
202
248
|
```
|
|
203
249
|
|
|
204
|
-
### Default subcommand
|
|
205
|
-
|
|
206
|
-
For backward compatibility, omitting the subcommand is treated as an implicit `sync` (as long as the first argument is a positional, not a flag):
|
|
207
|
-
|
|
208
|
-
```bash
|
|
209
|
-
jellyplex-sync ~/Media/Jellyfin ~/Media/Plex
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
is equivalent to
|
|
213
|
-
|
|
214
|
-
```bash
|
|
215
|
-
jellyplex-sync sync ~/Media/Jellyfin ~/Media/Plex
|
|
216
|
-
```
|
|
217
|
-
|
|
218
|
-
If you start with a flag, you must spell out `sync` explicitly.
|
|
219
|
-
|
|
220
250
|
### Docker
|
|
221
251
|
|
|
222
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]
|
|
@@ -23,6 +23,7 @@ from .materializer import (
|
|
|
23
23
|
FileMaterializer,
|
|
24
24
|
ForceCopyMaterializer,
|
|
25
25
|
HardlinkMaterializer,
|
|
26
|
+
MoveMaterializer,
|
|
26
27
|
)
|
|
27
28
|
from .model import (
|
|
28
29
|
MovieInfo,
|
|
@@ -43,6 +44,7 @@ from .sync import (
|
|
|
43
44
|
DiffEntry,
|
|
44
45
|
DiffResult,
|
|
45
46
|
diff,
|
|
47
|
+
import_media,
|
|
46
48
|
plan,
|
|
47
49
|
sync,
|
|
48
50
|
)
|
|
@@ -61,6 +63,7 @@ __all__ = [
|
|
|
61
63
|
"FolderClash",
|
|
62
64
|
"ForceCopyMaterializer",
|
|
63
65
|
"HardlinkMaterializer",
|
|
66
|
+
"MoveMaterializer",
|
|
64
67
|
"IgnoredEntry",
|
|
65
68
|
"JellyfinLibraryReader",
|
|
66
69
|
"JellyfinLibraryWriter",
|
|
@@ -80,6 +83,7 @@ __all__ = [
|
|
|
80
83
|
"VideoInfo",
|
|
81
84
|
"dedupe_drops",
|
|
82
85
|
"diff",
|
|
86
|
+
"import_media",
|
|
83
87
|
"plan",
|
|
84
88
|
"sync",
|
|
85
89
|
]
|
|
@@ -7,12 +7,10 @@ import sys
|
|
|
7
7
|
|
|
8
8
|
import jellyplex_sync as jp
|
|
9
9
|
|
|
10
|
-
_SUBCOMMANDS = {"sync", "diff", "plan"}
|
|
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()
|
|
@@ -97,6 +95,39 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
97
95
|
)
|
|
98
96
|
_add_io_args(plan_p)
|
|
99
97
|
|
|
98
|
+
import_p = sub.add_parser(
|
|
99
|
+
"import",
|
|
100
|
+
parents=[common],
|
|
101
|
+
help="Import video files from a staging area into a structured library.",
|
|
102
|
+
)
|
|
103
|
+
_add_io_args(import_p)
|
|
104
|
+
import_mode = import_p.add_mutually_exclusive_group()
|
|
105
|
+
import_mode.add_argument(
|
|
106
|
+
"--move",
|
|
107
|
+
dest="mode",
|
|
108
|
+
action="store_const",
|
|
109
|
+
const="move",
|
|
110
|
+
help="Move files: copy to target, then delete source (default).",
|
|
111
|
+
)
|
|
112
|
+
import_mode.add_argument(
|
|
113
|
+
"--copy",
|
|
114
|
+
dest="mode",
|
|
115
|
+
action="store_const",
|
|
116
|
+
const="copy",
|
|
117
|
+
help="Copy files; source is kept. On re-runs skip files whose size and mtime match.",
|
|
118
|
+
)
|
|
119
|
+
import_p.set_defaults(mode="move")
|
|
120
|
+
import_p.add_argument(
|
|
121
|
+
"--dry-run",
|
|
122
|
+
action="store_true",
|
|
123
|
+
help="Show what would be done, don't change anything on disk.",
|
|
124
|
+
)
|
|
125
|
+
import_p.add_argument(
|
|
126
|
+
"--create",
|
|
127
|
+
action="store_true",
|
|
128
|
+
help="Create the target library directory if it doesn't exist.",
|
|
129
|
+
)
|
|
130
|
+
|
|
100
131
|
return parser
|
|
101
132
|
|
|
102
133
|
|
|
@@ -124,24 +155,9 @@ def _add_io_args(p: argparse.ArgumentParser) -> None:
|
|
|
124
155
|
)
|
|
125
156
|
|
|
126
157
|
|
|
127
|
-
def _inject_default_subcommand(argv: list[str]) -> list[str]:
|
|
128
|
-
"""If the first positional argument isn't a known subcommand, treat the
|
|
129
|
-
call as `sync ...`. Keeps the historical invocation `jellyplex-sync
|
|
130
|
-
<source> <target>` working alongside the new `jellyplex-sync sync ...`.
|
|
131
|
-
"""
|
|
132
|
-
if not argv:
|
|
133
|
-
return argv
|
|
134
|
-
first = argv[0]
|
|
135
|
-
if first.startswith("-"):
|
|
136
|
-
return argv
|
|
137
|
-
if first in _SUBCOMMANDS:
|
|
138
|
-
return argv
|
|
139
|
-
return ["sync", *argv]
|
|
140
|
-
|
|
141
|
-
|
|
142
158
|
def main() -> None:
|
|
143
159
|
parser = _build_parser()
|
|
144
|
-
args = parser.parse_args(
|
|
160
|
+
args = parser.parse_args()
|
|
145
161
|
|
|
146
162
|
# Under --json, INFO chatter on stderr distracts from the JSON document
|
|
147
163
|
# on stdout. Quiet stderr to WARNING by default; --verbose or --debug
|
|
@@ -165,6 +181,8 @@ def main() -> None:
|
|
|
165
181
|
result = _do_diff(args)
|
|
166
182
|
elif args.command == "plan":
|
|
167
183
|
result = _do_plan(args)
|
|
184
|
+
elif args.command == "import":
|
|
185
|
+
result = _do_import(args)
|
|
168
186
|
else:
|
|
169
187
|
parser.error(f"unknown command: {args.command!r}")
|
|
170
188
|
result = 2
|
|
@@ -204,8 +222,15 @@ def _do_sync(args: argparse.Namespace) -> int:
|
|
|
204
222
|
|
|
205
223
|
if args.json:
|
|
206
224
|
assert stats is not None and reporter is not None
|
|
207
|
-
source_short, target_short = _resolve_formats_for_json(args)
|
|
208
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 ("?", "?")
|
|
209
234
|
|
|
210
235
|
write_sync_json(
|
|
211
236
|
sys.stdout,
|
|
@@ -271,19 +296,29 @@ def _do_plan(args: argparse.Namespace) -> int:
|
|
|
271
296
|
return 99
|
|
272
297
|
|
|
273
298
|
|
|
274
|
-
def
|
|
275
|
-
|
|
276
|
-
reports the formats that were actually used. Returns ('?', '?') if
|
|
277
|
-
resolution fails — sync() will already have logged the error and the
|
|
278
|
-
caller's exit code reflects it."""
|
|
279
|
-
import pathlib
|
|
299
|
+
def _do_import(args: argparse.Namespace) -> int:
|
|
300
|
+
from jellyplex_sync.sync import import_media
|
|
280
301
|
|
|
281
|
-
|
|
302
|
+
materializer = jp.MoveMaterializer() if args.mode == "move" else jp.CopyMaterializer()
|
|
282
303
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
304
|
+
try:
|
|
305
|
+
return import_media(
|
|
306
|
+
args.source,
|
|
307
|
+
args.target,
|
|
308
|
+
dry_run=args.dry_run,
|
|
309
|
+
create=args.create,
|
|
310
|
+
verbose=args.verbose,
|
|
311
|
+
debug=args.debug,
|
|
312
|
+
source_format=args.source_format,
|
|
313
|
+
target_format=args.target_format,
|
|
314
|
+
materializer=materializer,
|
|
315
|
+
)
|
|
316
|
+
except KeyboardInterrupt:
|
|
317
|
+
logging.info("INTERRUPTED")
|
|
318
|
+
return 10
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
logging.error("Exception: %s", exc)
|
|
321
|
+
return 99
|
|
287
322
|
|
|
288
323
|
|
|
289
324
|
if __name__ == "__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()
|
|
@@ -19,7 +19,8 @@ from collections.abc import Iterable
|
|
|
19
19
|
from dataclasses import dataclass
|
|
20
20
|
from typing import Protocol
|
|
21
21
|
|
|
22
|
-
from .library import ACCEPTED_VIDEO_SUFFIXES, IgnoredEntry
|
|
22
|
+
from .library import ACCEPTED_VIDEO_SUFFIXES, IgnoredEntry, LibraryReader
|
|
23
|
+
from .model import MovieInfo
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
@dataclass(frozen=True)
|
|
@@ -86,3 +87,74 @@ class TwoLevelDiscoverer:
|
|
|
86
87
|
asset_dirs=tuple(sorted(assets)),
|
|
87
88
|
loose_files=tuple(sorted(loose)),
|
|
88
89
|
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _movie_key(movie: MovieInfo) -> tuple:
|
|
93
|
+
"""Hashable grouping key derived from a MovieInfo. Two videos that
|
|
94
|
+
parse to the same title, year, and provider IDs end up in the same
|
|
95
|
+
group — labels and other ephemeral fields are excluded because they
|
|
96
|
+
vary per video, not per movie."""
|
|
97
|
+
return (movie.title, movie.year, tuple(sorted(movie.attributes.items())))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FlatDiscoverer:
|
|
101
|
+
"""Discovers movies from individual video files, regardless of
|
|
102
|
+
directory structure. Every video file under `root` is parsed via
|
|
103
|
+
the supplied Reader to determine which movie it belongs to; files
|
|
104
|
+
with the same title, year, and provider IDs are grouped together.
|
|
105
|
+
|
|
106
|
+
Designed for staging-area workflows where video files are dumped
|
|
107
|
+
without the standard one-folder-per-movie layout. Scans
|
|
108
|
+
recursively, so half-sorted structures (files in random
|
|
109
|
+
subdirectories) are handled too.
|
|
110
|
+
|
|
111
|
+
Takes a `LibraryReader` because grouping requires format-aware
|
|
112
|
+
parsing — a deliberate trade-off: the TwoLevelDiscoverer is
|
|
113
|
+
format-agnostic (folders are the grouping), but flat sources
|
|
114
|
+
need filename parsing to find group boundaries.
|
|
115
|
+
|
|
116
|
+
Loose files and asset directories are not collected — in a flat
|
|
117
|
+
staging area there's no folder to assign them to. The target media
|
|
118
|
+
server can download its own metadata after the sync."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, reader: LibraryReader) -> None:
|
|
121
|
+
self._reader = reader
|
|
122
|
+
|
|
123
|
+
def discover(
|
|
124
|
+
self,
|
|
125
|
+
root: pathlib.Path,
|
|
126
|
+
*,
|
|
127
|
+
ignored: list[IgnoredEntry] | None = None,
|
|
128
|
+
) -> Iterable[DiscoveredGroup]:
|
|
129
|
+
groups: dict[tuple, list[pathlib.Path]] = {}
|
|
130
|
+
source_paths: dict[tuple, pathlib.Path] = {}
|
|
131
|
+
|
|
132
|
+
for path in sorted(root.rglob("*")):
|
|
133
|
+
if not path.is_file():
|
|
134
|
+
continue
|
|
135
|
+
if path.name.startswith("."):
|
|
136
|
+
continue
|
|
137
|
+
if path.suffix.lower() not in ACCEPTED_VIDEO_SUFFIXES:
|
|
138
|
+
if ignored is not None:
|
|
139
|
+
ignored.append(IgnoredEntry(path, "not a video file"))
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
synthetic_folder = pathlib.Path(root / path.stem)
|
|
143
|
+
movie = self._reader.parse_movie(synthetic_folder)
|
|
144
|
+
if movie is None:
|
|
145
|
+
if ignored is not None:
|
|
146
|
+
ignored.append(IgnoredEntry(path, "unparseable video filename"))
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
key = _movie_key(movie)
|
|
150
|
+
groups.setdefault(key, []).append(path)
|
|
151
|
+
if key not in source_paths:
|
|
152
|
+
source_paths[key] = synthetic_folder
|
|
153
|
+
|
|
154
|
+
for key, video_files in groups.items():
|
|
155
|
+
yield DiscoveredGroup(
|
|
156
|
+
source_path=source_paths[key],
|
|
157
|
+
video_files=tuple(sorted(video_files)),
|
|
158
|
+
asset_dirs=(),
|
|
159
|
+
loose_files=(),
|
|
160
|
+
)
|
|
@@ -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`,
|
|
@@ -146,6 +146,60 @@ class ForceCopyMaterializer:
|
|
|
146
146
|
)
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
class MoveMaterializer:
|
|
150
|
+
"""Copy bytes from `src` to `dst`, then delete `src` on success.
|
|
151
|
+
|
|
152
|
+
Designed for staging-area workflows where the source is a temporary
|
|
153
|
+
dump and the target is the permanent library. Once the target file
|
|
154
|
+
is safely written (via `shutil.copy2`), the source is removed.
|
|
155
|
+
|
|
156
|
+
Re-run safety: if the target already exists with matching size and
|
|
157
|
+
mtime, the source is assumed to have been moved in a previous run
|
|
158
|
+
(or the copy was already successful) and is left alone.
|
|
159
|
+
|
|
160
|
+
If the source no longer exists but the target does, the file is
|
|
161
|
+
treated as already moved and silently skipped.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
name = "move"
|
|
165
|
+
|
|
166
|
+
def materialize(
|
|
167
|
+
self,
|
|
168
|
+
src: pathlib.Path,
|
|
169
|
+
dst: pathlib.Path,
|
|
170
|
+
*,
|
|
171
|
+
dry_run: bool = False,
|
|
172
|
+
verbose: bool = False,
|
|
173
|
+
events: list[FileEvent] | None = None,
|
|
174
|
+
) -> bool:
|
|
175
|
+
if not src.exists():
|
|
176
|
+
if dst.exists():
|
|
177
|
+
if verbose:
|
|
178
|
+
log.info("Already moved '%s'", dst.name)
|
|
179
|
+
if events is not None:
|
|
180
|
+
events.append(FileEvent(action="skip", target=dst))
|
|
181
|
+
return False
|
|
182
|
+
log.warning("Source '%s' does not exist, skipping", src)
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
if dst.exists() and _same_size_and_mtime(src, dst):
|
|
186
|
+
if verbose:
|
|
187
|
+
log.info("Target '%s' is up to date, removing source", dst.name)
|
|
188
|
+
if events is not None:
|
|
189
|
+
events.append(FileEvent(action="skip", target=dst, source=src))
|
|
190
|
+
if not dry_run:
|
|
191
|
+
src.unlink()
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
result = _copy(
|
|
195
|
+
src, dst, dry_run=dry_run, replaces_existing=dst.exists(), events=events
|
|
196
|
+
)
|
|
197
|
+
if result and not dry_run:
|
|
198
|
+
src.unlink()
|
|
199
|
+
log.info("Removed source '%s'", src.name)
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
149
203
|
def _same_size_and_mtime(a: pathlib.Path, b: pathlib.Path) -> bool:
|
|
150
204
|
a_stat = a.stat()
|
|
151
205
|
b_stat = b.stat()
|
|
@@ -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,7 @@ from .library import (
|
|
|
19
20
|
Reporter,
|
|
20
21
|
dedupe_drops,
|
|
21
22
|
)
|
|
22
|
-
from .materializer import FileMaterializer, HardlinkMaterializer
|
|
23
|
+
from .materializer import FileMaterializer, HardlinkMaterializer, MoveMaterializer
|
|
23
24
|
from .planner import Planner
|
|
24
25
|
from .plex import PlexLibraryReader, PlexLibraryWriter
|
|
25
26
|
from .realize import Realizer, RealizeStats
|
|
@@ -575,3 +576,123 @@ def _print_plan(
|
|
|
575
576
|
|
|
576
577
|
def _count_asset_files(asset) -> int:
|
|
577
578
|
return len(asset.files) + sum(_count_asset_files(sf) for sf in asset.subfolders)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# import: move files from a staging area into a structured library
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def import_media(
|
|
587
|
+
source: str,
|
|
588
|
+
target: str,
|
|
589
|
+
*,
|
|
590
|
+
dry_run: bool = False,
|
|
591
|
+
create: bool = False,
|
|
592
|
+
verbose: bool = False,
|
|
593
|
+
debug: bool = False,
|
|
594
|
+
source_format: str | None = None,
|
|
595
|
+
target_format: str | None = None,
|
|
596
|
+
reporter: Reporter | None = None,
|
|
597
|
+
materializer: FileMaterializer | None = None,
|
|
598
|
+
stats: LibraryStats | None = None,
|
|
599
|
+
) -> int:
|
|
600
|
+
"""Import video files from a staging area into a structured library.
|
|
601
|
+
|
|
602
|
+
Unlike `sync`, this uses `FlatDiscoverer` (groups by filename
|
|
603
|
+
parsing, not folder structure) and defaults to `MoveMaterializer`
|
|
604
|
+
(copy + delete source). The source can be a flat dump of video
|
|
605
|
+
files, a partially organised directory tree, or a mix.
|
|
606
|
+
|
|
607
|
+
Does not touch existing content in the target — it only adds.
|
|
608
|
+
"""
|
|
609
|
+
if debug:
|
|
610
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
611
|
+
|
|
612
|
+
reporter = reporter or LoggingReporter(verbose=verbose)
|
|
613
|
+
materializer = materializer or MoveMaterializer()
|
|
614
|
+
source_path = pathlib.Path(source)
|
|
615
|
+
target_path = pathlib.Path(target)
|
|
616
|
+
|
|
617
|
+
resolved = _resolve_formats(source_path, source_format, target_format)
|
|
618
|
+
if resolved is None:
|
|
619
|
+
return 1
|
|
620
|
+
source_short, target_short = resolved
|
|
621
|
+
|
|
622
|
+
source_reader_cls, _ = _LIBRARY_TYPES[source_short]
|
|
623
|
+
_, target_writer_cls = _LIBRARY_TYPES[target_short]
|
|
624
|
+
source_reader = source_reader_cls(source_path)
|
|
625
|
+
target_writer = target_writer_cls(target_path)
|
|
626
|
+
|
|
627
|
+
if dry_run:
|
|
628
|
+
log.info("SOURCE %s (staging)", source_reader.base_dir)
|
|
629
|
+
log.info("TARGET %s", target_writer.base_dir)
|
|
630
|
+
log.info("IMPORTING %s → %s", source_short.capitalize(), target_short.capitalize())
|
|
631
|
+
else:
|
|
632
|
+
log.info(
|
|
633
|
+
"Importing from '%s' (%s) into '%s' (%s)",
|
|
634
|
+
source_reader.base_dir,
|
|
635
|
+
source_short.capitalize(),
|
|
636
|
+
target_writer.base_dir,
|
|
637
|
+
target_short.capitalize(),
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if not source_reader.base_dir.is_dir():
|
|
641
|
+
log.error("Source directory '%s' does not exist", source_reader.base_dir)
|
|
642
|
+
return 1
|
|
643
|
+
|
|
644
|
+
if not target_writer.base_dir.is_dir():
|
|
645
|
+
if create:
|
|
646
|
+
target_writer.base_dir.mkdir(parents=True)
|
|
647
|
+
else:
|
|
648
|
+
log.error("Target directory '%s' does not exist", target_writer.base_dir)
|
|
649
|
+
return 1
|
|
650
|
+
|
|
651
|
+
lib_stats = stats if stats is not None else LibraryStats()
|
|
652
|
+
|
|
653
|
+
planner = Planner(
|
|
654
|
+
reader=source_reader,
|
|
655
|
+
writer=target_writer,
|
|
656
|
+
discoverer=FlatDiscoverer(source_reader),
|
|
657
|
+
reporter=reporter,
|
|
658
|
+
)
|
|
659
|
+
plan = planner.plan()
|
|
660
|
+
|
|
661
|
+
realize_stats = RealizeStats()
|
|
662
|
+
Realizer(materializer=materializer).apply(
|
|
663
|
+
plan,
|
|
664
|
+
dry_run=dry_run,
|
|
665
|
+
delete=False,
|
|
666
|
+
verbose=verbose,
|
|
667
|
+
stats=realize_stats,
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
lib_stats.movies_total += len(plan.movies)
|
|
671
|
+
lib_stats.movies_processed += realize_stats.movies_processed
|
|
672
|
+
lib_stats.items_linked += realize_stats.files_linked
|
|
673
|
+
lib_stats.ignored.extend(plan.ignored)
|
|
674
|
+
lib_stats.events.extend(realize_stats.events)
|
|
675
|
+
lib_stats.clashes.extend(plan.clashes)
|
|
676
|
+
|
|
677
|
+
verb = "moved" if isinstance(materializer, MoveMaterializer) else "copied"
|
|
678
|
+
summary = (
|
|
679
|
+
f"Summary: {lib_stats.movies_processed} movies imported, "
|
|
680
|
+
f"{lib_stats.items_linked} files {verb}, "
|
|
681
|
+
f"{len(lib_stats.ignored)} ignored, "
|
|
682
|
+
f"{len(lib_stats.clashes)} skipped due to clash."
|
|
683
|
+
)
|
|
684
|
+
log.info(summary)
|
|
685
|
+
|
|
686
|
+
if lib_stats.ignored:
|
|
687
|
+
log.info("Ignored item(s) in source (not imported):")
|
|
688
|
+
for item in lib_stats.ignored:
|
|
689
|
+
log.info(" '%s' (%s)", item.path.name, item.reason)
|
|
690
|
+
|
|
691
|
+
if lib_stats.clashes:
|
|
692
|
+
log.warning(
|
|
693
|
+
"%d movie(s) skipped because two or more source files map to the "
|
|
694
|
+
"same target name. Rename and re-run.",
|
|
695
|
+
len(lib_stats.clashes),
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
return 0
|
|
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
|