jellyplex-sync 0.3.1__tar.gz → 0.3.2__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.2}/PKG-INFO +46 -2
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/README.md +45 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/pyproject.toml +1 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/__init__.py +4 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/cli/sync.py +64 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/discover.py +73 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/materializer.py +54 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/sync.py +122 -1
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/LICENSE +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/cli/__init__.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/compare.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/disambig.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/jellyfin.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/json_output.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/library.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/model.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/plan.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/planner.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/plex.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/py.typed +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/realize.py +0 -0
- {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/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.2
|
|
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>
|
|
@@ -62,11 +62,12 @@ docker build -t jellyplex-sync .
|
|
|
62
62
|
|
|
63
63
|
## Usage
|
|
64
64
|
|
|
65
|
-
The CLI is split into
|
|
65
|
+
The CLI is split into four subcommands:
|
|
66
66
|
|
|
67
67
|
- **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
|
|
68
68
|
- **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
|
|
69
69
|
- **`plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
|
|
70
|
+
- **`import`** — **(experimental)** import video files from a staging area into a structured library. See below.
|
|
70
71
|
|
|
71
72
|
### `sync`
|
|
72
73
|
|
|
@@ -173,6 +174,49 @@ jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
|
173
174
|
| jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
|
|
174
175
|
```
|
|
175
176
|
|
|
177
|
+
### `import` (experimental)
|
|
178
|
+
|
|
179
|
+
> ⚠️ **This subcommand is experimental.** The exact behaviour may change in future releases as real-world use cases become clearer. Always start with `--dry-run`.
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
jellyplex-sync import [OPTIONS] /path/to/staging /path/to/target/library
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
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.
|
|
186
|
+
|
|
187
|
+
Semantically distinct from `sync`:
|
|
188
|
+
|
|
189
|
+
- `sync` mirrors a structured library; the source stays intact.
|
|
190
|
+
- `import` ingests from an unstructured dump; the source files are **moved** (deleted after successful copy) by default.
|
|
191
|
+
- `import` never removes anything from the target — it only adds.
|
|
192
|
+
|
|
193
|
+
**Materializer flags** (mutually exclusive):
|
|
194
|
+
|
|
195
|
+
- `--move` (default) — copy each file to the target, then delete the source.
|
|
196
|
+
- `--copy` — copy files; the source is kept. Safe for a test run without `--dry-run`.
|
|
197
|
+
|
|
198
|
+
Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`, `--debug`.
|
|
199
|
+
|
|
200
|
+
#### Limitations
|
|
201
|
+
|
|
202
|
+
- **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.
|
|
203
|
+
- **Asset directories** (extras, trailers) in the staging area are not collected.
|
|
204
|
+
- **No `--json` output** yet for import.
|
|
205
|
+
|
|
206
|
+
#### Example
|
|
207
|
+
|
|
208
|
+
Move Plex-named video files into a new Jellyfin library:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
jellyplex-sync import --create /mnt/staging /mnt/library/Jellyfin
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Preview first, keeping the source:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
jellyplex-sync import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
|
|
218
|
+
```
|
|
219
|
+
|
|
176
220
|
### JSON output
|
|
177
221
|
|
|
178
222
|
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.
|
|
@@ -46,11 +46,12 @@ docker build -t jellyplex-sync .
|
|
|
46
46
|
|
|
47
47
|
## Usage
|
|
48
48
|
|
|
49
|
-
The CLI is split into
|
|
49
|
+
The CLI is split into four subcommands:
|
|
50
50
|
|
|
51
51
|
- **`sync`** — mirror a source library into the target layout (the historical operation; this is also the default if you omit the subcommand).
|
|
52
52
|
- **`diff`** — read-only comparison of source and target. No filesystem changes. Useful before deleting your source after a migration.
|
|
53
53
|
- **`plan`** — show the Plan a sync would execute (text or `--json`), without touching either side. Pre-flight check before sync, or machine-readable input for tooling.
|
|
54
|
+
- **`import`** — **(experimental)** import video files from a staging area into a structured library. See below.
|
|
54
55
|
|
|
55
56
|
### `sync`
|
|
56
57
|
|
|
@@ -157,6 +158,49 @@ jellyplex-sync plan --json ~/Media/Plex ~/Media/Jellyfin \
|
|
|
157
158
|
| jq '.movies[].videos[] | select(.disambiguation) | {source, target_name, disambiguation}'
|
|
158
159
|
```
|
|
159
160
|
|
|
161
|
+
### `import` (experimental)
|
|
162
|
+
|
|
163
|
+
> ⚠️ **This subcommand is experimental.** The exact behaviour may change in future releases as real-world use cases become clearer. Always start with `--dry-run`.
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
jellyplex-sync import [OPTIONS] /path/to/staging /path/to/target/library
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
Semantically distinct from `sync`:
|
|
172
|
+
|
|
173
|
+
- `sync` mirrors a structured library; the source stays intact.
|
|
174
|
+
- `import` ingests from an unstructured dump; the source files are **moved** (deleted after successful copy) by default.
|
|
175
|
+
- `import` never removes anything from the target — it only adds.
|
|
176
|
+
|
|
177
|
+
**Materializer flags** (mutually exclusive):
|
|
178
|
+
|
|
179
|
+
- `--move` (default) — copy each file to the target, then delete the source.
|
|
180
|
+
- `--copy` — copy files; the source is kept. Safe for a test run without `--dry-run`.
|
|
181
|
+
|
|
182
|
+
Other flags: `--dry-run`, `--create`, `--source-format`, `--target-format`, `-v`, `--debug`.
|
|
183
|
+
|
|
184
|
+
#### Limitations
|
|
185
|
+
|
|
186
|
+
- **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.
|
|
187
|
+
- **Asset directories** (extras, trailers) in the staging area are not collected.
|
|
188
|
+
- **No `--json` output** yet for import.
|
|
189
|
+
|
|
190
|
+
#### Example
|
|
191
|
+
|
|
192
|
+
Move Plex-named video files into a new Jellyfin library:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
jellyplex-sync import --create /mnt/staging /mnt/library/Jellyfin
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Preview first, keeping the source:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
jellyplex-sync import --copy --dry-run /mnt/staging /mnt/library/Jellyfin
|
|
202
|
+
```
|
|
203
|
+
|
|
160
204
|
### JSON output
|
|
161
205
|
|
|
162
206
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "jellyplex-sync"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.2"
|
|
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"
|
|
@@ -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,7 +7,7 @@ import sys
|
|
|
7
7
|
|
|
8
8
|
import jellyplex_sync as jp
|
|
9
9
|
|
|
10
|
-
_SUBCOMMANDS = {"sync", "diff", "plan"}
|
|
10
|
+
_SUBCOMMANDS = {"sync", "diff", "plan", "import"}
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _build_parser() -> argparse.ArgumentParser:
|
|
@@ -97,6 +97,39 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
97
97
|
)
|
|
98
98
|
_add_io_args(plan_p)
|
|
99
99
|
|
|
100
|
+
import_p = sub.add_parser(
|
|
101
|
+
"import",
|
|
102
|
+
parents=[common],
|
|
103
|
+
help="Import video files from a staging area into a structured library.",
|
|
104
|
+
)
|
|
105
|
+
_add_io_args(import_p)
|
|
106
|
+
import_mode = import_p.add_mutually_exclusive_group()
|
|
107
|
+
import_mode.add_argument(
|
|
108
|
+
"--move",
|
|
109
|
+
dest="mode",
|
|
110
|
+
action="store_const",
|
|
111
|
+
const="move",
|
|
112
|
+
help="Move files: copy to target, then delete source (default).",
|
|
113
|
+
)
|
|
114
|
+
import_mode.add_argument(
|
|
115
|
+
"--copy",
|
|
116
|
+
dest="mode",
|
|
117
|
+
action="store_const",
|
|
118
|
+
const="copy",
|
|
119
|
+
help="Copy files; source is kept. On re-runs skip files whose size and mtime match.",
|
|
120
|
+
)
|
|
121
|
+
import_p.set_defaults(mode="move")
|
|
122
|
+
import_p.add_argument(
|
|
123
|
+
"--dry-run",
|
|
124
|
+
action="store_true",
|
|
125
|
+
help="Show what would be done, don't change anything on disk.",
|
|
126
|
+
)
|
|
127
|
+
import_p.add_argument(
|
|
128
|
+
"--create",
|
|
129
|
+
action="store_true",
|
|
130
|
+
help="Create the target library directory if it doesn't exist.",
|
|
131
|
+
)
|
|
132
|
+
|
|
100
133
|
return parser
|
|
101
134
|
|
|
102
135
|
|
|
@@ -165,6 +198,8 @@ def main() -> None:
|
|
|
165
198
|
result = _do_diff(args)
|
|
166
199
|
elif args.command == "plan":
|
|
167
200
|
result = _do_plan(args)
|
|
201
|
+
elif args.command == "import":
|
|
202
|
+
result = _do_import(args)
|
|
168
203
|
else:
|
|
169
204
|
parser.error(f"unknown command: {args.command!r}")
|
|
170
205
|
result = 2
|
|
@@ -271,6 +306,34 @@ def _do_plan(args: argparse.Namespace) -> int:
|
|
|
271
306
|
return 99
|
|
272
307
|
|
|
273
308
|
|
|
309
|
+
def _do_import(args: argparse.Namespace) -> int:
|
|
310
|
+
from jellyplex_sync.sync import import_media
|
|
311
|
+
|
|
312
|
+
if args.mode == "move":
|
|
313
|
+
materializer = jp.MoveMaterializer()
|
|
314
|
+
else:
|
|
315
|
+
materializer = jp.CopyMaterializer()
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
return import_media(
|
|
319
|
+
args.source,
|
|
320
|
+
args.target,
|
|
321
|
+
dry_run=args.dry_run,
|
|
322
|
+
create=args.create,
|
|
323
|
+
verbose=args.verbose,
|
|
324
|
+
debug=args.debug,
|
|
325
|
+
source_format=args.source_format,
|
|
326
|
+
target_format=args.target_format,
|
|
327
|
+
materializer=materializer,
|
|
328
|
+
)
|
|
329
|
+
except KeyboardInterrupt:
|
|
330
|
+
logging.info("INTERRUPTED")
|
|
331
|
+
return 10
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
logging.error("Exception: %s", exc)
|
|
334
|
+
return 99
|
|
335
|
+
|
|
336
|
+
|
|
274
337
|
def _resolve_formats_for_json(args: argparse.Namespace) -> tuple[str, str]:
|
|
275
338
|
"""Re-run the same format resolution `sync()` did so the JSON payload
|
|
276
339
|
reports the formats that were actually used. Returns ('?', '?') if
|
|
@@ -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
|
+
)
|
|
@@ -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()
|
|
@@ -19,7 +19,8 @@ from .library import (
|
|
|
19
19
|
Reporter,
|
|
20
20
|
dedupe_drops,
|
|
21
21
|
)
|
|
22
|
-
from .
|
|
22
|
+
from .discover import FlatDiscoverer
|
|
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
|
|
File without changes
|