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.
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/PKG-INFO +1 -1
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/pyproject.toml +1 -1
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/json_output.py +4 -1
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/library.py +1 -1
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/sync.py +30 -13
- jellyplex_sync-0.2.2/src/jellyplex_sync/utils.py +94 -0
- jellyplex_sync-0.2.1/src/jellyplex_sync/utils.py +0 -16
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/LICENSE +0 -0
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/README.md +0 -0
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/__init__.py +0 -0
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/cli/__init__.py +0 -0
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/cli/sync.py +0 -0
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/jellyfin.py +0 -0
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/materializer.py +0 -0
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/model.py +0 -0
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/plex.py +0 -0
- {jellyplex_sync-0.2.1 → jellyplex_sync-0.2.2}/src/jellyplex_sync/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "jellyplex-sync"
|
|
3
|
-
version = "0.2.
|
|
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":
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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 =
|
|
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=
|
|
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
|
|
689
|
-
print(f" + {
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|