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.
Files changed (22) hide show
  1. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/PKG-INFO +46 -2
  2. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/README.md +45 -1
  3. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/pyproject.toml +1 -1
  4. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/__init__.py +4 -0
  5. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/cli/sync.py +64 -1
  6. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/discover.py +73 -1
  7. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/materializer.py +54 -0
  8. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/sync.py +122 -1
  9. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/LICENSE +0 -0
  10. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/cli/__init__.py +0 -0
  11. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/compare.py +0 -0
  12. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/disambig.py +0 -0
  13. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/jellyfin.py +0 -0
  14. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/json_output.py +0 -0
  15. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/library.py +0 -0
  16. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/model.py +0 -0
  17. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/plan.py +0 -0
  18. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/planner.py +0 -0
  19. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/plex.py +0 -0
  20. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/py.typed +0 -0
  21. {jellyplex_sync-0.3.1 → jellyplex_sync-0.3.2}/src/jellyplex_sync/realize.py +0 -0
  22. {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.1
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 three subcommands:
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 three subcommands:
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.1"
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 .materializer import FileMaterializer, HardlinkMaterializer
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