jellyplex-sync 0.2.1__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jellyplex-sync
3
- Version: 0.2.1
3
+ Version: 0.2.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jellyplex-sync"
3
- version = "0.2.1"
3
+ version = "0.2.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"
@@ -116,7 +116,10 @@ def write_diff_json(
116
116
  "source": _endpoint_payload(source_path, source_format),
117
117
  "target": _endpoint_payload(target_path, target_format),
118
118
  "in_sync": not result.has_differences,
119
- "movies_only_in_source": list(result.movies_only_in_source),
119
+ "movies_only_in_source": [
120
+ {"source_folder": m.source_folder, "expected_target": m.expected_target}
121
+ for m in result.movies_only_in_source
122
+ ],
120
123
  "movies_only_in_target": list(result.movies_only_in_target),
121
124
  "differing_movies": [
122
125
  {
@@ -98,7 +98,7 @@ class StrictReporter:
98
98
  @dataclass
99
99
  class CollectingReporter:
100
100
  """Accumulates drops and info messages for later inspection.
101
- Used by report-only flows like the planned `--diff` mode."""
101
+ Used by report-only flows like the `diff` subcommand."""
102
102
 
103
103
  drops: list[Drop] = field(default_factory=list)
104
104
  messages: list[str] = field(default_factory=list)
@@ -111,8 +111,7 @@ def scan_media_library(
111
111
  log.info("DELETE %s", entry)
112
112
  else:
113
113
  log.info("Removing stray item '%s' in target library", entry.name)
114
- utils.remove(entry)
115
- stats.items_removed += 1
114
+ stats.items_removed += utils.remove(entry, dry_run=dry_run).files
116
115
  stats.events.append(
117
116
  FileEvent(action="remove", target=entry, context="library_stray")
118
117
  )
@@ -180,9 +179,7 @@ def process_assets_folder(
180
179
  log.info("Removing stray item '%s' in target folder", entry.name)
181
180
  if dry_run:
182
181
  log.info("DELETE %s", entry.name)
183
- else:
184
- utils.remove(entry)
185
- stats.items_removed += 1
182
+ stats.items_removed += utils.remove(entry, dry_run=dry_run).files
186
183
  stats.events.append(
187
184
  FileEvent(action="remove", target=entry, context="asset_stray")
188
185
  )
@@ -235,8 +232,7 @@ def _remove_strays(
235
232
  entry.name,
236
233
  target_path.relative_to(base_dir),
237
234
  )
238
- utils.remove(entry)
239
- removed += 1
235
+ removed += utils.remove(entry, dry_run=dry_run).files
240
236
  if events is not None:
241
237
  events.append(FileEvent(action="remove", target=entry, context="movie_stray"))
242
238
  return removed
@@ -550,9 +546,21 @@ class DiffEntry:
550
546
  only_in_target: tuple[str, ...] = ()
551
547
 
552
548
 
549
+ @dataclass
550
+ class MovieOnlyInSource:
551
+ """A source movie that has no counterpart in the target. Stores both
552
+ names so the diff output can show the user what they wrote (the
553
+ source folder) AND what it would become on the other side (the
554
+ expected target name) — pre-0.2.2 only the target name was shown,
555
+ which read as a stray for users browsing their source tree."""
556
+
557
+ source_folder: str
558
+ expected_target: str
559
+
560
+
553
561
  @dataclass
554
562
  class DiffResult:
555
- movies_only_in_source: tuple[str, ...] = ()
563
+ movies_only_in_source: tuple[MovieOnlyInSource, ...] = ()
556
564
  movies_only_in_target: tuple[str, ...] = ()
557
565
  differing_movies: tuple[DiffEntry, ...] = ()
558
566
  drops: tuple = ()
@@ -625,8 +633,10 @@ def _compute_diff(source: LibraryReader, target: LibraryWriter) -> DiffResult:
625
633
  ignored: list[IgnoredEntry] = []
626
634
 
627
635
  expected: dict[str, set[str]] = {}
636
+ source_folder_for: dict[str, str] = {}
628
637
  for source_movie_path, movie in scan(source, ignored=ignored):
629
638
  target_movie_name = target.movie_name(movie, reporter)
639
+ source_folder_for[target_movie_name] = source_movie_path.name
630
640
  expected_files: set[str] = set()
631
641
  for entry in source_movie_path.glob("*"):
632
642
  if entry.name.startswith("."):
@@ -644,7 +654,13 @@ def _compute_diff(source: LibraryReader, target: LibraryWriter) -> DiffResult:
644
654
  continue
645
655
  actual[entry.name] = {sub.name for sub in entry.iterdir()}
646
656
 
647
- only_source = sorted(set(expected) - set(actual))
657
+ only_source = tuple(
658
+ MovieOnlyInSource(
659
+ source_folder=source_folder_for[name],
660
+ expected_target=name,
661
+ )
662
+ for name in sorted(set(expected) - set(actual))
663
+ )
648
664
  only_target = sorted(set(actual) - set(expected))
649
665
  differing: list[DiffEntry] = []
650
666
  for name in sorted(set(expected) & set(actual)):
@@ -660,7 +676,7 @@ def _compute_diff(source: LibraryReader, target: LibraryWriter) -> DiffResult:
660
676
  )
661
677
 
662
678
  return DiffResult(
663
- movies_only_in_source=tuple(only_source),
679
+ movies_only_in_source=only_source,
664
680
  movies_only_in_target=tuple(only_target),
665
681
  differing_movies=tuple(differing),
666
682
  drops=tuple(reporter.drops),
@@ -685,8 +701,9 @@ def _print_diff(
685
701
 
686
702
  if result.movies_only_in_source:
687
703
  print(f"Movies only in source ({len(result.movies_only_in_source)}):", file=out)
688
- for name in result.movies_only_in_source:
689
- print(f" + {name}", file=out)
704
+ for m in result.movies_only_in_source:
705
+ print(f" + '{m.source_folder}'", file=out)
706
+ print(f" → would be '{m.expected_target}'", file=out)
690
707
  print(file=out)
691
708
 
692
709
  if result.movies_only_in_target:
@@ -719,5 +736,5 @@ def _print_diff(
719
736
  print(f" ! '{i.path.name}': {i.reason}", file=out)
720
737
  print(file=out)
721
738
 
722
- if not result.has_differences and not result.drops:
739
+ if not result.has_differences:
723
740
  print("In sync. No differences found.", file=out)
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import pathlib
6
+ from dataclasses import dataclass
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ @dataclass
12
+ class RemovalCounts:
13
+ files: int = 0
14
+ dirs: int = 0
15
+ ignored: int = 0
16
+ errors: int = 0
17
+
18
+
19
+ def _count_removable(item: pathlib.Path) -> RemovalCounts:
20
+ """Predict what `remove(item)` would do. No side effects — used for
21
+ dry-run reporting so the summary matches what an actual run produces."""
22
+ counts = RemovalCounts()
23
+ if item.is_symlink() or item.is_file():
24
+ counts.files += 1
25
+ elif item.is_dir():
26
+ for root, dirs, files in os.walk(item, topdown=False):
27
+ counts.files += len(files)
28
+ root_path = pathlib.Path(root)
29
+ for name in dirs:
30
+ if (root_path / name).is_symlink():
31
+ counts.files += 1
32
+ else:
33
+ counts.dirs += 1
34
+ counts.dirs += 1
35
+ else:
36
+ counts.ignored += 1
37
+ return counts
38
+
39
+
40
+ def _remove(item: pathlib.Path) -> RemovalCounts:
41
+ """Recursively remove a file, symlink, or directory tree.
42
+
43
+ Per-entry failures (permission denied, busy file, …) are logged and
44
+ counted in `errors`; the walk continues so a single un-removable
45
+ entry doesn't strand the rest of the sync.
46
+ """
47
+ counts = RemovalCounts()
48
+ if item.is_symlink() or item.is_file():
49
+ try:
50
+ item.unlink()
51
+ counts.files += 1
52
+ except OSError as exc:
53
+ log.warning("Failed to remove '%s': %s", item, exc)
54
+ counts.errors += 1
55
+ elif item.is_dir():
56
+ for root, dirs, files in os.walk(item, topdown=False):
57
+ root_path = pathlib.Path(root)
58
+ for name in files:
59
+ p = root_path / name
60
+ try:
61
+ p.unlink()
62
+ counts.files += 1
63
+ except OSError as exc:
64
+ log.warning("Failed to remove '%s': %s", p, exc)
65
+ counts.errors += 1
66
+ for name in dirs:
67
+ p = root_path / name
68
+ try:
69
+ if p.is_symlink():
70
+ # os.walk lists dir-symlinks under `dirs`; rmdir would fail.
71
+ p.unlink()
72
+ counts.files += 1
73
+ else:
74
+ p.rmdir()
75
+ counts.dirs += 1
76
+ except OSError as exc:
77
+ log.warning("Failed to remove '%s': %s", p, exc)
78
+ counts.errors += 1
79
+ try:
80
+ item.rmdir()
81
+ counts.dirs += 1
82
+ except OSError as exc:
83
+ log.warning("Failed to remove '%s': %s", item, exc)
84
+ counts.errors += 1
85
+ else:
86
+ log.warning("Will not remove '%s'", item)
87
+ counts.ignored += 1
88
+ return counts
89
+
90
+
91
+ def remove(item: pathlib.Path, *, dry_run: bool = False) -> RemovalCounts:
92
+ if bool(dry_run):
93
+ return _count_removable(item)
94
+ return _remove(item)
@@ -1,16 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import logging
4
- import pathlib
5
- import shutil
6
-
7
- log = logging.getLogger(__name__)
8
-
9
-
10
- def remove(item: pathlib.Path) -> None:
11
- if item.is_file() or item.is_symlink():
12
- item.unlink()
13
- elif item.is_dir():
14
- shutil.rmtree(item)
15
- else:
16
- log.warning("Will not remove '%s'", item)
File without changes
File without changes