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.
Files changed (23) hide show
  1. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/PKG-INFO +65 -35
  2. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/README.md +64 -34
  3. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/pyproject.toml +2 -1
  4. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/__init__.py +4 -0
  5. jellyplex_sync-0.3.1/src/jellyplex_sync/cli/sync.py → jellyplex_sync-0.3.4/src/jellyplex_sync/cli/main.py +67 -32
  6. jellyplex_sync-0.3.4/src/jellyplex_sync/cli/sync.py +186 -0
  7. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/discover.py +73 -1
  8. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/jellyfin.py +1 -1
  9. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/materializer.py +55 -1
  10. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/sync.py +122 -1
  11. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/LICENSE +0 -0
  12. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/cli/__init__.py +0 -0
  13. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/compare.py +0 -0
  14. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/disambig.py +0 -0
  15. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/json_output.py +0 -0
  16. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/library.py +0 -0
  17. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/model.py +0 -0
  18. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/plan.py +0 -0
  19. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/planner.py +0 -0
  20. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/plex.py +0 -0
  21. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/py.typed +0 -0
  22. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.4}/src/jellyplex_sync/realize.py +0 -0
  23. {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.1
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 split into three subcommands:
65
+ The main CLI is `jellyplex` with four subcommands:
66
66
 
67
- - **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
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-sync sync [OPTIONS] /path/to/source/library /path/to/target/library
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-sync sync --create ~/Media/Jellyfin ~/Media/Plex
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-sync sync --delete --create ~/Media/Jellyfin ~/Media/Plex
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-sync sync --copy --create /mnt/nas/Movies /mnt/local/Plex
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-sync sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
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-sync diff [OPTIONS] /path/to/source/library /path/to/target/library
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-sync diff ~/Media/Jellyfin ~/Media/Plex
146
+ jellyplex diff ~/Media/Jellyfin ~/Media/Plex
144
147
  ```
145
148
 
146
149
  ### `plan`
147
150
 
148
151
  ```bash
149
- jellyplex-sync plan [OPTIONS] /path/to/source/library /path/to/target/library
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-sync plan ~/Media/Plex ~/Media/Jellyfin
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-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
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-sync sync --json --dry-run /src /dst \
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-sync sync --json --dry-run --delete /src /dst \
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-sync sync --json --dry-run /src /dst \
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 split into three subcommands:
49
+ The main CLI is `jellyplex` with four subcommands:
50
50
 
51
- - **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
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-sync sync [OPTIONS] /path/to/source/library /path/to/target/library
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-sync sync --create ~/Media/Jellyfin ~/Media/Plex
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-sync sync --delete --create ~/Media/Jellyfin ~/Media/Plex
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-sync sync --copy --create /mnt/nas/Movies /mnt/local/Plex
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-sync sync --dry-run --verbose --delete --create ~/Media/Jellyfin ~/Media/Plex
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-sync diff [OPTIONS] /path/to/source/library /path/to/target/library
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-sync diff ~/Media/Jellyfin ~/Media/Plex
130
+ jellyplex diff ~/Media/Jellyfin ~/Media/Plex
128
131
  ```
129
132
 
130
133
  ### `plan`
131
134
 
132
135
  ```bash
133
- jellyplex-sync plan [OPTIONS] /path/to/source/library /path/to/target/library
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-sync plan ~/Media/Plex ~/Media/Jellyfin
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-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
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-sync sync --json --dry-run /src /dst \
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-sync sync --json --dry-run --delete /src /dst \
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-sync sync --json --dry-run /src /dst \
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.1"
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-sync",
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 (default).",
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(_inject_default_subcommand(sys.argv[1:]))
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 _resolve_formats_for_json(args: argparse.Namespace) -> tuple[str, str]:
275
- """Re-run the same format resolution `sync()` did so the JSON payload
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
- from jellyplex_sync.sync import _resolve_formats
302
+ materializer = jp.MoveMaterializer() if args.mode == "move" else jp.CopyMaterializer()
282
303
 
283
- resolved = _resolve_formats(
284
- pathlib.Path(args.source), args.source_format, args.target_format
285
- )
286
- return resolved if resolved else ("?", "?")
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-sync only
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 jellyplex-sync has always had. Copy
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