jellyplex-sync 0.1.5__py3-none-any.whl
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/__init__.py +25 -0
- jellyplex_sync/cli/__init__.py +0 -0
- jellyplex_sync/cli/sync.py +85 -0
- jellyplex_sync/jellyfin.py +197 -0
- jellyplex_sync/library.py +73 -0
- jellyplex_sync/plex.py +124 -0
- jellyplex_sync/py.typed +0 -0
- jellyplex_sync/sync.py +396 -0
- jellyplex_sync/utils.py +24 -0
- jellyplex_sync-0.1.5.dist-info/METADATA +190 -0
- jellyplex_sync-0.1.5.dist-info/RECORD +13 -0
- jellyplex_sync-0.1.5.dist-info/WHEEL +4 -0
- jellyplex_sync-0.1.5.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .jellyfin import (
|
|
2
|
+
JellyfinLibrary,
|
|
3
|
+
)
|
|
4
|
+
from .library import (
|
|
5
|
+
ACCEPTED_VIDEO_SUFFIXES,
|
|
6
|
+
MediaLibrary,
|
|
7
|
+
MovieInfo,
|
|
8
|
+
VideoInfo,
|
|
9
|
+
)
|
|
10
|
+
from .plex import (
|
|
11
|
+
PlexLibrary,
|
|
12
|
+
)
|
|
13
|
+
from .sync import (
|
|
14
|
+
sync,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ACCEPTED_VIDEO_SUFFIXES",
|
|
19
|
+
"JellyfinLibrary",
|
|
20
|
+
"MediaLibrary",
|
|
21
|
+
"MovieInfo",
|
|
22
|
+
"PlexLibrary",
|
|
23
|
+
"VideoInfo",
|
|
24
|
+
"sync",
|
|
25
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import jellyplex_sync as jp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="Create a Plex compatible media library from a Jellyfin library."
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"source",
|
|
17
|
+
help="Jellyfin media library",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"target",
|
|
21
|
+
help="Plex media library",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--convert-to",
|
|
25
|
+
type=str,
|
|
26
|
+
choices=[jp.JellyfinLibrary.shortname(), jp.PlexLibrary.shortname(), "auto"],
|
|
27
|
+
default="auto",
|
|
28
|
+
help="Type of library to convert to ('auto' will try to determine source library type)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--dry-run",
|
|
32
|
+
action="store_true",
|
|
33
|
+
help="Show actions only, don't execute them",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--delete",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Remove stray folders from target library",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--create",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Create missing target library",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--verbose",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Show more information messages",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--debug",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Show debug messages",
|
|
54
|
+
)
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
|
|
57
|
+
logging.basicConfig(
|
|
58
|
+
level=logging.INFO,
|
|
59
|
+
stream=sys.stderr,
|
|
60
|
+
format="%(levelname)s: %(asctime)s -- %(message)s",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
result = 0
|
|
64
|
+
try:
|
|
65
|
+
result = jp.sync(
|
|
66
|
+
args.source,
|
|
67
|
+
args.target,
|
|
68
|
+
dry_run=args.dry_run,
|
|
69
|
+
delete=args.delete,
|
|
70
|
+
create=args.create,
|
|
71
|
+
verbose=args.verbose,
|
|
72
|
+
debug=args.debug,
|
|
73
|
+
convert_to=args.convert_to,
|
|
74
|
+
)
|
|
75
|
+
except KeyboardInterrupt:
|
|
76
|
+
logging.info("INTERRUPTED")
|
|
77
|
+
result = 10
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
logging.error("Exception: %s", exc)
|
|
80
|
+
result = 99
|
|
81
|
+
sys.exit(result)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .library import (
|
|
11
|
+
RESOLUTION_PATTERN,
|
|
12
|
+
MediaLibrary,
|
|
13
|
+
MovieInfo,
|
|
14
|
+
VideoInfo,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
JELLYFIN_ID_PATTERN = re.compile(r"\[(?P<provider_id>[a-zA-Z]+id-[^\]]+)\]")
|
|
21
|
+
JELLYFIN_MOVIE_PATTERNS = [
|
|
22
|
+
re.compile(
|
|
23
|
+
r"^(?P<title>.+?)\s+\((?P<year>\d{4})\)\s* - \s*\[(?P<provider_id>[a-zA-Z]+id-[^\]]+)\]"
|
|
24
|
+
),
|
|
25
|
+
re.compile(
|
|
26
|
+
r"^(?P<title>.+?)\s+\((?P<year>\d{4})\)\s+\[(?P<provider_id>[a-zA-Z]+id-[^\]]+)\]"
|
|
27
|
+
),
|
|
28
|
+
re.compile(r"^(?P<title>.+?)\s+\((?P<year>\d{4})\)$"),
|
|
29
|
+
re.compile(r"^(?P<title>.+?)\s+\[(?P<provider_id>[a-zA-Z]+id-[^\]]+)\]"),
|
|
30
|
+
re.compile(r"^(?P<title>.+?)$"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# FIXME: It's not only a parser for variant strings anymore ...
|
|
35
|
+
class VariantParser(ABC):
|
|
36
|
+
def __init__(self, library: MediaLibrary):
|
|
37
|
+
self.library = library
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def parse(self, variant: str, video: VideoInfo) -> VideoInfo: ...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def video_name(self, movie_name: str, video: VideoInfo) -> str: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SimpleVariantParser(VariantParser):
|
|
47
|
+
def parse(self, variant: str, video: VideoInfo) -> VideoInfo:
|
|
48
|
+
return VideoInfo(
|
|
49
|
+
extension=video.extension,
|
|
50
|
+
edition=variant.strip(),
|
|
51
|
+
resolution=video.resolution,
|
|
52
|
+
tags=video.tags,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _tags_to_variant(self, video: VideoInfo) -> list[str]:
|
|
56
|
+
if video.resolution:
|
|
57
|
+
return [video.resolution]
|
|
58
|
+
for tag in video.tags or []:
|
|
59
|
+
if tag.upper() in ("DVD", "4k", "BD"):
|
|
60
|
+
return [tag.upper()]
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
def video_name(self, movie_name: str, video: VideoInfo) -> str:
|
|
64
|
+
parts = [movie_name]
|
|
65
|
+
variant_parts = self._tags_to_variant(video)
|
|
66
|
+
if video.edition:
|
|
67
|
+
variant_parts.append(video.edition)
|
|
68
|
+
if variant_parts:
|
|
69
|
+
parts.append(f"- {' '.join(variant_parts)}")
|
|
70
|
+
return f"{' '.join(parts)}{video.extension}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ResParser:
|
|
75
|
+
pattern: re.Pattern
|
|
76
|
+
mapping: Callable[[re.Match[str]], list[str | None]] | list[str | None]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SninerVariantParser(SimpleVariantParser):
|
|
80
|
+
RES_MAP: list[ResParser] = [
|
|
81
|
+
ResParser(
|
|
82
|
+
re.compile(r"4k([\.\-]([\w\d]+))?$"),
|
|
83
|
+
lambda m: ["2160p", m.group(2)] if m.group(1) else ["2160p"],
|
|
84
|
+
),
|
|
85
|
+
ResParser(
|
|
86
|
+
re.compile(r"BD([\.\-]([\w\d]+))?$"),
|
|
87
|
+
lambda m: ["1080p", m.group(2)] if m.group(1) else ["1080p"],
|
|
88
|
+
),
|
|
89
|
+
ResParser(
|
|
90
|
+
re.compile(r"DVD([\.\-]([\w\d]+))?$"),
|
|
91
|
+
lambda m: [None, "DVD", m.group(2)] if m.group(1) else [None, "DVD"],
|
|
92
|
+
),
|
|
93
|
+
ResParser(RESOLUTION_PATTERN, lambda m: [m.group(0)]),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
def _match_resolution(self, word: str) -> tuple[str | None, set[str]]:
|
|
97
|
+
tags: list[str | None] = []
|
|
98
|
+
for mapper in self.RES_MAP:
|
|
99
|
+
match = mapper.pattern.match(word)
|
|
100
|
+
if match:
|
|
101
|
+
tags = mapper.mapping(match) if callable(mapper.mapping) else mapper.mapping
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
return tags[0] if tags else None, set(t for t in tags[1:] if t)
|
|
105
|
+
|
|
106
|
+
def parse(self, variant: str, video: VideoInfo) -> VideoInfo:
|
|
107
|
+
edition: str | None = None
|
|
108
|
+
|
|
109
|
+
variant_parts = variant.split(" ")
|
|
110
|
+
|
|
111
|
+
res, tags = self._match_resolution(variant_parts[0])
|
|
112
|
+
if res or tags:
|
|
113
|
+
edition = " ".join(variant_parts[1:])
|
|
114
|
+
elif len(variant_parts) > 1:
|
|
115
|
+
res, tags = self._match_resolution(variant_parts[-1])
|
|
116
|
+
edition = " ".join(variant_parts[:-1]) if res or tags else variant
|
|
117
|
+
else:
|
|
118
|
+
edition = variant
|
|
119
|
+
|
|
120
|
+
tags = (video.tags or set()).union(tags)
|
|
121
|
+
|
|
122
|
+
return VideoInfo(
|
|
123
|
+
extension=video.extension,
|
|
124
|
+
edition=edition if edition else None,
|
|
125
|
+
resolution=res,
|
|
126
|
+
tags=tags if tags else None,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _tags_to_variant(self, video: VideoInfo) -> list[str]:
|
|
130
|
+
variant = super()._tags_to_variant(video)
|
|
131
|
+
if variant:
|
|
132
|
+
m = re.match(r"(\d{3,4})[pi]$", variant[0], flags=re.IGNORECASE)
|
|
133
|
+
if m:
|
|
134
|
+
res = m.group(1)
|
|
135
|
+
if res == "1080":
|
|
136
|
+
return ["BD"]
|
|
137
|
+
elif res == "2160":
|
|
138
|
+
return ["4k"]
|
|
139
|
+
elif res in ("480", "576"):
|
|
140
|
+
return ["DVD"]
|
|
141
|
+
return variant
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class JellyfinLibrary(MediaLibrary):
|
|
145
|
+
def __init__(
|
|
146
|
+
self, base_dir: pathlib.Path, *, variant_parser: type[VariantParser] | None = None
|
|
147
|
+
):
|
|
148
|
+
super().__init__(base_dir)
|
|
149
|
+
self.variant_parser = (
|
|
150
|
+
variant_parser(self) if variant_parser else SninerVariantParser(self)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def shortname(cls) -> str:
|
|
155
|
+
return "jellyfin"
|
|
156
|
+
|
|
157
|
+
def parse_movie_path(self, path: pathlib.Path) -> MovieInfo | None:
|
|
158
|
+
name = path.name
|
|
159
|
+
for regex in JELLYFIN_MOVIE_PATTERNS:
|
|
160
|
+
match = regex.match(name)
|
|
161
|
+
if match:
|
|
162
|
+
title = match.group("title").strip()
|
|
163
|
+
year = match.group("year") if "year" in match.groupdict() else None
|
|
164
|
+
provider_id = (
|
|
165
|
+
match.group("provider_id") if "provider_id" in match.groupdict() else None
|
|
166
|
+
)
|
|
167
|
+
provider = movie_id = None
|
|
168
|
+
if provider_id:
|
|
169
|
+
provider, movie_id = provider_id.split("-", 1)
|
|
170
|
+
provider = provider.rstrip("id")
|
|
171
|
+
return MovieInfo(title=title, year=year, provider=provider, movie_id=movie_id)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
def movie_name(self, movie: MovieInfo) -> str:
|
|
175
|
+
parts = [movie.title]
|
|
176
|
+
if movie.year:
|
|
177
|
+
parts.append(f"({movie.year})")
|
|
178
|
+
if movie.provider and movie.movie_id:
|
|
179
|
+
parts.append(f"[{movie.provider}id-{movie.movie_id}]")
|
|
180
|
+
return " ".join(parts)
|
|
181
|
+
|
|
182
|
+
def video_name(self, movie: MovieInfo, video: VideoInfo) -> str:
|
|
183
|
+
return self.variant_parser.video_name(self.movie_name(movie), video)
|
|
184
|
+
|
|
185
|
+
def parse_video_path(self, path: pathlib.Path) -> VideoInfo | None:
|
|
186
|
+
base_name = path.stem
|
|
187
|
+
video = VideoInfo(extension=path.suffix)
|
|
188
|
+
parts = base_name.split(" - ") # <spc><dash><spc> is required by Jellyfin for variants
|
|
189
|
+
if len(parts) > 1:
|
|
190
|
+
# Do no take the media id for an edition
|
|
191
|
+
if JELLYFIN_ID_PATTERN.match(parts[-1]):
|
|
192
|
+
return video
|
|
193
|
+
else:
|
|
194
|
+
# The variant is the substring after the final ' – ' in the filename.
|
|
195
|
+
variant = parts[-1].strip().lstrip("[").rstrip("]")
|
|
196
|
+
return self.variant_parser.parse(variant, video)
|
|
197
|
+
return video
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ACCEPTED_VIDEO_SUFFIXES = {".mkv", ".m4v"}
|
|
14
|
+
RESOLUTION_PATTERN = re.compile(r"\d{3,4}[pi]$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MovieInfo:
|
|
19
|
+
"""Metadata for the whole movie"""
|
|
20
|
+
|
|
21
|
+
title: str
|
|
22
|
+
year: str | None = None
|
|
23
|
+
provider: str | None = None
|
|
24
|
+
movie_id: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class VideoInfo:
|
|
29
|
+
"""Metadata for a single video file"""
|
|
30
|
+
|
|
31
|
+
extension: str
|
|
32
|
+
edition: str | None = None
|
|
33
|
+
resolution: str | None = None
|
|
34
|
+
tags: set[str] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MediaLibrary(ABC):
|
|
38
|
+
def __init__(self, base_dir: pathlib.Path):
|
|
39
|
+
self.base_dir = base_dir.resolve()
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def shortname(cls) -> str: ...
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def movie_name(self, movie: MovieInfo) -> str: ...
|
|
47
|
+
|
|
48
|
+
def movie_path(self, movie: MovieInfo) -> pathlib.Path:
|
|
49
|
+
return self.base_dir / self.movie_name(movie)
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def video_name(self, movie: MovieInfo, video: VideoInfo) -> str: ...
|
|
53
|
+
|
|
54
|
+
def video_path(self, movie: MovieInfo, video: VideoInfo) -> pathlib.Path:
|
|
55
|
+
return self.movie_path(movie) / self.video_name(movie, video)
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def parse_movie_path(self, path: pathlib.Path) -> MovieInfo | None: ...
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def parse_video_path(self, path: pathlib.Path) -> VideoInfo | None: ...
|
|
62
|
+
|
|
63
|
+
def scan(self) -> Generator[tuple[pathlib.Path, MovieInfo], None, None]:
|
|
64
|
+
for entry in self.base_dir.glob("*"):
|
|
65
|
+
if not entry.is_dir():
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
movie = self.parse_movie_path(entry)
|
|
69
|
+
if not movie:
|
|
70
|
+
log.warning("Ignoring folder with unparsable name: %s", entry.name)
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
yield entry, movie
|
jellyplex_sync/plex.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
|
|
8
|
+
from .library import (
|
|
9
|
+
RESOLUTION_PATTERN,
|
|
10
|
+
MediaLibrary,
|
|
11
|
+
MovieInfo,
|
|
12
|
+
VideoInfo,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PLEX_MOVIE_PATTERN = re.compile(r"^(?P<title>.+?)\s+\((?P<year>\d{4})\)")
|
|
19
|
+
PLEX_META_BLOCK_PATTERN = re.compile(r"(\{([A-Za-z]+)-([^}]+)\})")
|
|
20
|
+
PLEX_META_INFO_PATTERN = re.compile(r"(\[([^]]+)\])")
|
|
21
|
+
PLEX_METADATA_PROVIDER = {"imdb", "tmdb", "tvdb"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlexLibrary(MediaLibrary):
|
|
25
|
+
@classmethod
|
|
26
|
+
def shortname(cls) -> str:
|
|
27
|
+
return "plex"
|
|
28
|
+
|
|
29
|
+
def movie_name(self, movie: MovieInfo) -> str:
|
|
30
|
+
parts = [movie.title]
|
|
31
|
+
if movie.year:
|
|
32
|
+
parts.append(f"({movie.year})")
|
|
33
|
+
if movie.provider and movie.movie_id:
|
|
34
|
+
parts.append(f"{{{movie.provider}-{movie.movie_id}}}")
|
|
35
|
+
return " ".join(parts)
|
|
36
|
+
|
|
37
|
+
def video_name(self, movie: MovieInfo, video: VideoInfo) -> str:
|
|
38
|
+
parts = [self.movie_name(movie)]
|
|
39
|
+
if video.edition:
|
|
40
|
+
parts.append(f"{{edition-{video.edition}}}")
|
|
41
|
+
if video.tags:
|
|
42
|
+
tags = [f"[{t}]" for t in video.tags]
|
|
43
|
+
parts.append("".join(tags))
|
|
44
|
+
if video.resolution:
|
|
45
|
+
parts.append(f"[{video.resolution}]")
|
|
46
|
+
return f"{' '.join(parts)}{video.extension}"
|
|
47
|
+
|
|
48
|
+
def _parse_meta_blocks(self, name: str) -> Generator[tuple[str, str, str], None, None]:
|
|
49
|
+
# Find all '{KEY-VALUE}' instances
|
|
50
|
+
for blk, key, val in PLEX_META_BLOCK_PATTERN.findall(name):
|
|
51
|
+
yield key, val, blk
|
|
52
|
+
|
|
53
|
+
def _parse_info_blocks(self, name: str) -> Generator[tuple[str, str], None, None]:
|
|
54
|
+
# Find all '[METADATA]' instances
|
|
55
|
+
for blk, info in PLEX_META_INFO_PATTERN.findall(name):
|
|
56
|
+
yield info, blk
|
|
57
|
+
|
|
58
|
+
def parse_movie_path(self, path: pathlib.Path) -> MovieInfo | None:
|
|
59
|
+
name = path.name
|
|
60
|
+
|
|
61
|
+
# Find metadata provider and movie id
|
|
62
|
+
leftover = name
|
|
63
|
+
provider = movie_id = None
|
|
64
|
+
for key, val, blk in self._parse_meta_blocks(name):
|
|
65
|
+
p = key.lower()
|
|
66
|
+
if p in PLEX_METADATA_PROVIDER:
|
|
67
|
+
provider = p.strip()
|
|
68
|
+
movie_id = val.strip()
|
|
69
|
+
leftover = leftover.replace(blk, "")
|
|
70
|
+
|
|
71
|
+
# Remove additional metadata
|
|
72
|
+
for _info, blk in self._parse_info_blocks(leftover):
|
|
73
|
+
leftover = leftover.replace(blk, "")
|
|
74
|
+
|
|
75
|
+
# Cleanup remaining text
|
|
76
|
+
leftover = re.sub(r"\s+", " ", leftover)
|
|
77
|
+
leftover = leftover.strip()
|
|
78
|
+
|
|
79
|
+
# Parse movie title and year
|
|
80
|
+
match = PLEX_MOVIE_PATTERN.match(leftover)
|
|
81
|
+
if match:
|
|
82
|
+
title = match.group("title").strip()
|
|
83
|
+
year = match.group("year") if "year" in match.groupdict() else None
|
|
84
|
+
else:
|
|
85
|
+
title = leftover
|
|
86
|
+
year = None
|
|
87
|
+
|
|
88
|
+
if title:
|
|
89
|
+
return MovieInfo(title=title, year=year, provider=provider, movie_id=movie_id)
|
|
90
|
+
else:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def parse_video_path(self, path: pathlib.Path) -> VideoInfo | None:
|
|
94
|
+
name = path.stem
|
|
95
|
+
leftover = name
|
|
96
|
+
|
|
97
|
+
# Find edition
|
|
98
|
+
edition: str | None = None
|
|
99
|
+
for key, val, blk in self._parse_meta_blocks(name):
|
|
100
|
+
if key.lower() == "edition":
|
|
101
|
+
edition = val
|
|
102
|
+
leftover = leftover.replace(blk, "")
|
|
103
|
+
|
|
104
|
+
# Find additional metadata
|
|
105
|
+
tags: set[str] = set()
|
|
106
|
+
resolution: str | None = None
|
|
107
|
+
for info, blk in self._parse_info_blocks(leftover):
|
|
108
|
+
tag = info.strip()
|
|
109
|
+
if RESOLUTION_PATTERN.match(tag):
|
|
110
|
+
resolution = tag
|
|
111
|
+
else:
|
|
112
|
+
tags.add(tag)
|
|
113
|
+
leftover = leftover.replace(blk, "")
|
|
114
|
+
|
|
115
|
+
# Cleanup remaining text
|
|
116
|
+
leftover = re.sub(r"\s+", " ", leftover)
|
|
117
|
+
leftover = leftover.strip()
|
|
118
|
+
|
|
119
|
+
return VideoInfo(
|
|
120
|
+
edition=edition,
|
|
121
|
+
extension=path.suffix,
|
|
122
|
+
resolution=resolution,
|
|
123
|
+
tags=tags if tags else None,
|
|
124
|
+
)
|
jellyplex_sync/py.typed
ADDED
|
File without changes
|
jellyplex_sync/sync.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import pathlib
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from . import utils
|
|
10
|
+
from .jellyfin import (
|
|
11
|
+
JellyfinLibrary,
|
|
12
|
+
)
|
|
13
|
+
from .library import (
|
|
14
|
+
ACCEPTED_VIDEO_SUFFIXES,
|
|
15
|
+
MediaLibrary,
|
|
16
|
+
MovieInfo,
|
|
17
|
+
VideoInfo,
|
|
18
|
+
)
|
|
19
|
+
from .plex import (
|
|
20
|
+
PlexLibrary,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LibraryStats:
|
|
28
|
+
movies_total: int = 0
|
|
29
|
+
movies_processed: int = 0
|
|
30
|
+
items_removed: int = 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def scan_media_library(
|
|
34
|
+
source: MediaLibrary,
|
|
35
|
+
target: MediaLibrary,
|
|
36
|
+
*,
|
|
37
|
+
dry_run: bool = False,
|
|
38
|
+
delete: bool = False,
|
|
39
|
+
stats: LibraryStats | None = None,
|
|
40
|
+
) -> Generator[tuple[pathlib.Path, pathlib.Path, MovieInfo], None, None]:
|
|
41
|
+
"""Iterate over the source library and determine all movie folders.
|
|
42
|
+
Yields a tuple for each movie folder:
|
|
43
|
+
(source: pathlib.Path, destination: pathlib.Path, movie: MovieInfo)
|
|
44
|
+
"""
|
|
45
|
+
if source is target or source.base_dir == target.base_dir:
|
|
46
|
+
raise ValueError("Can not transfer library into itself")
|
|
47
|
+
|
|
48
|
+
stats = stats or LibraryStats()
|
|
49
|
+
movies_to_sync: dict[str, tuple[pathlib.Path, MovieInfo] | None] = {}
|
|
50
|
+
conflicting_source_dirs: dict[str, list[str]] = {}
|
|
51
|
+
|
|
52
|
+
# Inspect source libary for movie folders to sync
|
|
53
|
+
for entry, movie in source.scan():
|
|
54
|
+
target_name = target.movie_name(movie)
|
|
55
|
+
if target_name in movies_to_sync:
|
|
56
|
+
if target_name not in conflicting_source_dirs:
|
|
57
|
+
item = movies_to_sync[target_name]
|
|
58
|
+
conflicting_source_dirs[target_name] = [item[0].name] if item else []
|
|
59
|
+
conflicting_source_dirs[target_name].append(entry.name)
|
|
60
|
+
movies_to_sync[target_name] = None
|
|
61
|
+
else:
|
|
62
|
+
movies_to_sync[target_name] = (entry, movie)
|
|
63
|
+
stats.movies_total += 1
|
|
64
|
+
|
|
65
|
+
# If there are any conflicts we bail out now
|
|
66
|
+
if conflicting_source_dirs:
|
|
67
|
+
for dst, src in conflicting_source_dirs.items():
|
|
68
|
+
quoted = [f"'{s}'" for s in src]
|
|
69
|
+
log.error(f"Conflicting folders: {', '.join(quoted)} → '{dst}'")
|
|
70
|
+
log.info("You have to solve the conflicts first to proceed")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# Yield items for sync
|
|
74
|
+
for target_name, item in movies_to_sync.items():
|
|
75
|
+
if not item:
|
|
76
|
+
continue
|
|
77
|
+
stats.movies_processed += 1
|
|
78
|
+
yield item[0], target.base_dir / target_name, item[1]
|
|
79
|
+
|
|
80
|
+
# Remove stray items in target library
|
|
81
|
+
for entry in target.base_dir.iterdir():
|
|
82
|
+
if entry.name not in movies_to_sync:
|
|
83
|
+
if delete:
|
|
84
|
+
if dry_run:
|
|
85
|
+
log.info("DELETE %s", entry)
|
|
86
|
+
else:
|
|
87
|
+
log.info("Removing stray item '%s' in target library", entry.name)
|
|
88
|
+
utils.remove(entry)
|
|
89
|
+
stats.items_removed += 1
|
|
90
|
+
else:
|
|
91
|
+
if not dry_run:
|
|
92
|
+
log.info("Stray item '%s' found", entry.name)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class AssetStats:
|
|
97
|
+
files_total: int = 0
|
|
98
|
+
files_linked: int = 0
|
|
99
|
+
items_removed: int = 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def process_assets_folder(
|
|
103
|
+
source_path: pathlib.Path,
|
|
104
|
+
target_path: pathlib.Path,
|
|
105
|
+
*,
|
|
106
|
+
dry_run: bool = False,
|
|
107
|
+
delete: bool = False,
|
|
108
|
+
verbose: bool = False,
|
|
109
|
+
stats: AssetStats | None = None,
|
|
110
|
+
) -> AssetStats:
|
|
111
|
+
if not source_path.is_dir():
|
|
112
|
+
raise ValueError(f"{source_path!s} is not a folder")
|
|
113
|
+
|
|
114
|
+
if not target_path.exists():
|
|
115
|
+
if dry_run:
|
|
116
|
+
log.info("MKDIR %s", target_path)
|
|
117
|
+
else:
|
|
118
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
stats = stats or AssetStats()
|
|
121
|
+
synced_items = {}
|
|
122
|
+
|
|
123
|
+
# Hardlink missing files and dive into subfolders
|
|
124
|
+
for entry in source_path.iterdir():
|
|
125
|
+
dest = target_path / entry.name
|
|
126
|
+
if entry.is_dir():
|
|
127
|
+
process_assets_folder(entry, dest, verbose=verbose, stats=stats, dry_run=dry_run)
|
|
128
|
+
elif entry.is_file():
|
|
129
|
+
if dest.exists():
|
|
130
|
+
if dest.samefile(entry):
|
|
131
|
+
if verbose:
|
|
132
|
+
log.debug("Target file '%s' already exists, skipping", entry.name)
|
|
133
|
+
else:
|
|
134
|
+
if dry_run:
|
|
135
|
+
log.info("RELINK %s", entry)
|
|
136
|
+
else:
|
|
137
|
+
dest.unlink()
|
|
138
|
+
dest.hardlink_to(entry)
|
|
139
|
+
stats.files_linked += 1
|
|
140
|
+
else:
|
|
141
|
+
if dry_run:
|
|
142
|
+
log.info("LINK %s", dest)
|
|
143
|
+
else:
|
|
144
|
+
dest.hardlink_to(entry)
|
|
145
|
+
stats.files_linked += 1
|
|
146
|
+
stats.files_total += 1
|
|
147
|
+
synced_items[entry.name] = dest
|
|
148
|
+
|
|
149
|
+
if delete and target_path.is_dir():
|
|
150
|
+
# Remove stray items
|
|
151
|
+
for entry in target_path.iterdir():
|
|
152
|
+
if entry.name in synced_items:
|
|
153
|
+
continue
|
|
154
|
+
log.info("Removing stray item '%s' in target folder", entry.name)
|
|
155
|
+
if dry_run:
|
|
156
|
+
log.info("DELETE %s", entry.name)
|
|
157
|
+
else:
|
|
158
|
+
utils.remove(entry)
|
|
159
|
+
stats.items_removed += 1
|
|
160
|
+
|
|
161
|
+
return stats
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class MovieStats:
|
|
166
|
+
videos_total: int = 0
|
|
167
|
+
videos_linked: int = 0
|
|
168
|
+
items_removed: int = 0
|
|
169
|
+
asset_items_total: int = 0
|
|
170
|
+
asset_items_linked: int = 0
|
|
171
|
+
asset_items_removed: int = 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def process_movie(
|
|
175
|
+
source: MediaLibrary,
|
|
176
|
+
target: MediaLibrary,
|
|
177
|
+
source_path: pathlib.Path,
|
|
178
|
+
movie: MovieInfo,
|
|
179
|
+
*,
|
|
180
|
+
dry_run: bool = False,
|
|
181
|
+
delete: bool = False,
|
|
182
|
+
verbose: bool = False,
|
|
183
|
+
) -> MovieStats:
|
|
184
|
+
target_path = target.movie_path(movie)
|
|
185
|
+
|
|
186
|
+
if verbose:
|
|
187
|
+
log.info("Processing '%s' → '%s'", source_path.name, target_path.name)
|
|
188
|
+
|
|
189
|
+
stats = MovieStats()
|
|
190
|
+
|
|
191
|
+
videos_to_sync: dict[str, tuple[pathlib.Path, pathlib.Path]] = {}
|
|
192
|
+
assets_to_sync: dict[str, tuple[pathlib.Path, pathlib.Path]] = {}
|
|
193
|
+
|
|
194
|
+
# Scan for video files and assets
|
|
195
|
+
for entry in source_path.glob("*"):
|
|
196
|
+
if entry.is_file() and entry.suffix.lower() in ACCEPTED_VIDEO_SUFFIXES:
|
|
197
|
+
video = source.parse_video_path(entry)
|
|
198
|
+
video_path = target.video_path(
|
|
199
|
+
movie,
|
|
200
|
+
video or VideoInfo(extension=entry.suffix.lower()),
|
|
201
|
+
)
|
|
202
|
+
video_name = video_path.name
|
|
203
|
+
if video_name in videos_to_sync:
|
|
204
|
+
log.error("Conflicting video file '%s'. Aborting.", entry.name)
|
|
205
|
+
return MovieStats()
|
|
206
|
+
videos_to_sync[video_name] = (entry, video_path)
|
|
207
|
+
stats.videos_total += 1
|
|
208
|
+
elif entry.is_dir():
|
|
209
|
+
dir_name = entry.name
|
|
210
|
+
# TODO: Just a quick fix for selecting and manipulating directories
|
|
211
|
+
if dir_name.startswith("."):
|
|
212
|
+
log.debug("Ignoring asset folder '%s'", dir_name)
|
|
213
|
+
continue
|
|
214
|
+
assets_to_sync[dir_name] = (entry, target_path / dir_name)
|
|
215
|
+
|
|
216
|
+
if not target_path.exists():
|
|
217
|
+
if dry_run:
|
|
218
|
+
log.info("MKDIR %s", target_path)
|
|
219
|
+
else:
|
|
220
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
221
|
+
|
|
222
|
+
# Hardlink missing video files
|
|
223
|
+
for _video_name, item in videos_to_sync.items():
|
|
224
|
+
if item[1].exists():
|
|
225
|
+
if item[1].samefile(item[0]):
|
|
226
|
+
if verbose:
|
|
227
|
+
log.info("Target video file '%s' already exists", item[1].name)
|
|
228
|
+
continue
|
|
229
|
+
else:
|
|
230
|
+
log.info("Replacing video file '%s' → '%s'", item[0].name, item[1].name)
|
|
231
|
+
if dry_run:
|
|
232
|
+
log.info("DELETE %s", item[1])
|
|
233
|
+
else:
|
|
234
|
+
item[1].unlink()
|
|
235
|
+
if dry_run:
|
|
236
|
+
log.info("LINK %s", item[1])
|
|
237
|
+
else:
|
|
238
|
+
log.info("Linking video file '%s' → '%s'", item[0].name, item[1].name)
|
|
239
|
+
item[1].hardlink_to(item[0])
|
|
240
|
+
stats.videos_linked += 1
|
|
241
|
+
|
|
242
|
+
if delete and target_path.is_dir():
|
|
243
|
+
# Remove stray items
|
|
244
|
+
for entry in target_path.iterdir():
|
|
245
|
+
if entry.name in videos_to_sync or entry.name in assets_to_sync:
|
|
246
|
+
continue
|
|
247
|
+
if dry_run:
|
|
248
|
+
log.info("DELETE %s", entry)
|
|
249
|
+
else:
|
|
250
|
+
log.info(
|
|
251
|
+
"Removing stray item '%s' in movie folder '%s'",
|
|
252
|
+
entry.name,
|
|
253
|
+
target_path.relative_to(target.base_dir),
|
|
254
|
+
)
|
|
255
|
+
utils.remove(entry)
|
|
256
|
+
stats.items_removed += 1
|
|
257
|
+
|
|
258
|
+
# Sync assets folders
|
|
259
|
+
for _, item in assets_to_sync.items():
|
|
260
|
+
s = process_assets_folder(
|
|
261
|
+
item[0],
|
|
262
|
+
item[1],
|
|
263
|
+
delete=delete,
|
|
264
|
+
verbose=verbose,
|
|
265
|
+
dry_run=dry_run,
|
|
266
|
+
)
|
|
267
|
+
stats.asset_items_total += s.files_total
|
|
268
|
+
stats.asset_items_linked += s.files_linked
|
|
269
|
+
stats.asset_items_removed += s.items_removed
|
|
270
|
+
|
|
271
|
+
return stats
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def determine_library_type(path: pathlib.Path) -> type[MediaLibrary] | None:
|
|
275
|
+
plex_hints: int = 0
|
|
276
|
+
jellyfin_hints: int = 0
|
|
277
|
+
for entry in path.rglob("*"):
|
|
278
|
+
if entry.suffix.lower() not in ACCEPTED_VIDEO_SUFFIXES:
|
|
279
|
+
continue
|
|
280
|
+
fname = entry.stem
|
|
281
|
+
# Check for provider id
|
|
282
|
+
if re.search(r"\[[a-z]+id-[^\]]+\]", fname, flags=re.IGNORECASE):
|
|
283
|
+
return JellyfinLibrary
|
|
284
|
+
if re.search(r"\{[a-z]+-[^\}]+\}", fname, flags=re.IGNORECASE):
|
|
285
|
+
return PlexLibrary
|
|
286
|
+
# Check for Plex edition
|
|
287
|
+
if re.search(r"\{edition-[^\}]+\}", fname, flags=re.IGNORECASE):
|
|
288
|
+
return PlexLibrary
|
|
289
|
+
# Check for hints
|
|
290
|
+
variant = fname.split(" - ")
|
|
291
|
+
if len(variant) > 1 and re.search(r"\(\d{4}\)", variant[-1]) is None:
|
|
292
|
+
jellyfin_hints += 1
|
|
293
|
+
if re.search(r"\[\d{3,4}[pi]\]", fname, flags=re.IGNORECASE):
|
|
294
|
+
plex_hints += 1
|
|
295
|
+
if re.search(r"\[[a-z0-9\.\,]+\]", fname, flags=re.IGNORECASE):
|
|
296
|
+
plex_hints += 1
|
|
297
|
+
if plex_hints > jellyfin_hints:
|
|
298
|
+
return PlexLibrary
|
|
299
|
+
elif jellyfin_hints > plex_hints:
|
|
300
|
+
return JellyfinLibrary
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def sync(
|
|
305
|
+
source: str,
|
|
306
|
+
target: str,
|
|
307
|
+
*,
|
|
308
|
+
dry_run: bool = False,
|
|
309
|
+
delete: bool = False,
|
|
310
|
+
create: bool = False,
|
|
311
|
+
verbose: bool = False,
|
|
312
|
+
debug: bool = False,
|
|
313
|
+
convert_to: str | None = None,
|
|
314
|
+
) -> int:
|
|
315
|
+
if debug:
|
|
316
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
317
|
+
|
|
318
|
+
source_path = pathlib.Path(source)
|
|
319
|
+
target_path = pathlib.Path(target)
|
|
320
|
+
|
|
321
|
+
if not convert_to or convert_to == "auto":
|
|
322
|
+
source_type = determine_library_type(source_path)
|
|
323
|
+
if not source_type:
|
|
324
|
+
log.error(
|
|
325
|
+
"Unable to determine source library type, please provide --convert-to option"
|
|
326
|
+
)
|
|
327
|
+
return 1
|
|
328
|
+
target_type = PlexLibrary if source_type == JellyfinLibrary else JellyfinLibrary
|
|
329
|
+
elif convert_to in (JellyfinLibrary.shortname(), PlexLibrary.shortname()):
|
|
330
|
+
target_type = PlexLibrary if convert_to == PlexLibrary.shortname() else JellyfinLibrary
|
|
331
|
+
source_type = PlexLibrary if target_type == JellyfinLibrary else JellyfinLibrary
|
|
332
|
+
else:
|
|
333
|
+
raise ValueError("Unknown value for parameter 'convert_to'")
|
|
334
|
+
|
|
335
|
+
source_lib = source_type(source_path)
|
|
336
|
+
target_lib = target_type(target_path)
|
|
337
|
+
|
|
338
|
+
if dry_run:
|
|
339
|
+
log.info("SOURCE %s", source_lib.base_dir)
|
|
340
|
+
log.info("TARGET %s", target_lib.base_dir)
|
|
341
|
+
log.info(
|
|
342
|
+
"CONVERTING %s TO %s",
|
|
343
|
+
source_lib.shortname().capitalize(),
|
|
344
|
+
target_lib.shortname().capitalize(),
|
|
345
|
+
)
|
|
346
|
+
else:
|
|
347
|
+
log.info(
|
|
348
|
+
"Syncing '%s' (%s) to '%s' (%s)",
|
|
349
|
+
source_lib.base_dir,
|
|
350
|
+
source_lib.shortname().capitalize(),
|
|
351
|
+
target_lib.base_dir,
|
|
352
|
+
target_lib.shortname().capitalize(),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if not source_lib.base_dir.is_dir():
|
|
356
|
+
log.error("Source directory '%s' does not exist", source_lib.base_dir)
|
|
357
|
+
return 1
|
|
358
|
+
|
|
359
|
+
if not target_lib.base_dir.is_dir():
|
|
360
|
+
if create:
|
|
361
|
+
target_lib.base_dir.mkdir(parents=True)
|
|
362
|
+
else:
|
|
363
|
+
log.error("Target directory '%s' does not exist", target_lib.base_dir)
|
|
364
|
+
return 1
|
|
365
|
+
|
|
366
|
+
stat_movies: int = 0
|
|
367
|
+
stat_items_linked: int = 0
|
|
368
|
+
stat_items_removed: int = 0
|
|
369
|
+
lib_stats = LibraryStats()
|
|
370
|
+
|
|
371
|
+
for src, _, movie in scan_media_library(
|
|
372
|
+
source_lib, target_lib, delete=delete, dry_run=dry_run, stats=lib_stats
|
|
373
|
+
):
|
|
374
|
+
s = process_movie(
|
|
375
|
+
source_lib,
|
|
376
|
+
target_lib,
|
|
377
|
+
src,
|
|
378
|
+
movie,
|
|
379
|
+
delete=delete,
|
|
380
|
+
verbose=verbose,
|
|
381
|
+
dry_run=dry_run,
|
|
382
|
+
)
|
|
383
|
+
stat_movies += 1
|
|
384
|
+
stat_items_linked += s.asset_items_linked + s.videos_linked
|
|
385
|
+
stat_items_removed += s.asset_items_removed + s.items_removed
|
|
386
|
+
|
|
387
|
+
stat_items_removed += lib_stats.items_removed
|
|
388
|
+
|
|
389
|
+
summary = (
|
|
390
|
+
f"Summary: {stat_movies} movies found, "
|
|
391
|
+
f"{stat_items_linked} files updated, "
|
|
392
|
+
f"{stat_items_removed} files removed."
|
|
393
|
+
)
|
|
394
|
+
logging.info(summary)
|
|
395
|
+
|
|
396
|
+
return 0
|
jellyplex_sync/utils.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import pathlib
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def remove(item: pathlib.Path) -> None:
|
|
12
|
+
if item.is_file() or item.is_symlink():
|
|
13
|
+
item.unlink()
|
|
14
|
+
elif item.is_dir():
|
|
15
|
+
shutil.rmtree(item)
|
|
16
|
+
else:
|
|
17
|
+
log.warning("Will not remove '%s'", item)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def common_path(p1: pathlib.Path, p2: pathlib.Path) -> pathlib.Path | None:
|
|
21
|
+
try:
|
|
22
|
+
return pathlib.Path(os.path.commonpath([p1.resolve(), p2.resolve()]))
|
|
23
|
+
except ValueError:
|
|
24
|
+
return None
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: jellyplex-sync
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: Convert your media library between Jellyfin and Plex formats by creating a hard-linked mirror
|
|
5
|
+
Author: Stefan Schönberger
|
|
6
|
+
Author-email: Stefan Schönberger <stefan@sniner.dev>
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Bidirectional Movie Library Sync for Plex and Jellyfin
|
|
16
|
+
|
|
17
|
+
Can't decide between Jellyfin and Plex? This tool might help. It synchronizes your **movie library** between Jellyfin and Plex formats in **both directions** — without duplicating any files. Instead, it uses **hardlinks** to mirror your collection efficiently, saving storage while keeping both libraries in sync.
|
|
18
|
+
|
|
19
|
+
> **Warning:** This script will **overwrite the entire target directory**. Do not store or edit anything manually in the target library path. The source library is treated as the **only source of truth**, and any unmatched content in the target folder may be deleted without warning.
|
|
20
|
+
|
|
21
|
+
> **Note:** This tool is only useful if your media library is well-maintained and each movie resides in its own folder.
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
|
|
25
|
+
The script scans the source library, parses each movie folder for metadata (title, year, optional provider ID), and reproduces the same directory structure in the target location. Rather than copying video files, it creates hard links to avoid extra storage usage. Asset folders (e.g., `extras`, `subtitles`) are also mirrored. With `--delete`, any files or folders in the target that are no longer present in the source will be removed.
|
|
26
|
+
|
|
27
|
+
> ⚠️ **Important:** This script is designed exclusively for **movie libraries**. It does **not** support TV shows or miniseries. However, this is usually not a limitation in practice: for shows, Jellyfin and Plex use very similar directory structures, so you can typically point both apps to the same library without issues.
|
|
28
|
+
|
|
29
|
+
> ⚠️ **Unraid:** This script is not compatible with Unraid User Scripts. If you do not want to use the container image, there is an older release in branch `unraid_user_scripts`. Switch to this branch and use the single-file script - but please don't forget to install the Python Plugin in Unraid first.
|
|
30
|
+
|
|
31
|
+
## Docker Image
|
|
32
|
+
|
|
33
|
+
If you want to build the docker image locally:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd .../jellyplex-sync
|
|
37
|
+
docker build -t jellyplex .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
To run the docker container with the demo library in the project folder:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
docker run --rm -it -v .:/mnt jellyplex /mnt/DEMO_PLEX_LIBRARY/Movies /mnt/DEMO_PLEX_LIBRARY/Jellyfin
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Originally, this script was designed for use in Unraid as a standalone file. That version is still available in the `unraid_user_scripts` branch. On Unraid, the recommended way to run the script is via the Docker image. However, if you prefer to install the Python package locally (i.e. not on Unraid), the following examples show how you can use it as a CLI tool.
|
|
49
|
+
|
|
50
|
+
### Docker usage
|
|
51
|
+
|
|
52
|
+
To use the published container image without installing anything locally:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
docker run --rm -it -v /your/media:/mnt ghcr.io/sniner/jellyplex-sync:latest /mnt/source /mnt/target
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Example using the demo library included in the repo:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
docker run --rm -it -v .:/mnt ghcr.io/sniner/jellyplex-sync:latest /mnt/DEMO_PLEX_LIBRARY/Movies /mnt/DEMO_PLEX_LIBRARY/Jellyfin
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> Note: Make sure to adjust the volume mount (`-v`) so that both source and target paths are accessible inside the container. They must also reside within the same bind mount, otherwise hard links between source and target will not work.
|
|
65
|
+
|
|
66
|
+
### Media server integration
|
|
67
|
+
|
|
68
|
+
If you're using Unraid, you can add the included `jellyplex-sync.sh` script to the User Scripts plugin as a new custom script. This helper script pulls the latest container image (`ghcr.io/sniner/jellyplex-sync:latest`), removes any outdated images, and then runs the main sync operation.
|
|
69
|
+
|
|
70
|
+
At the very bottom of the script, you'll find the actual command that runs the container. Make sure to adjust the source and target paths to match your own media library structure.
|
|
71
|
+
|
|
72
|
+
> ⚠️ Important: The script runs in `--dry-run` mode by default. This means it won't make any changes yet — it will only show what would happen. Once you're confident everything is working as expected, you can remove the `--dry-run` flag to perform real changes.
|
|
73
|
+
|
|
74
|
+
Although tailored for Unraid, this script can also be used on other NAS systems or Linux servers — simply schedule it as a cronjob to automate regular syncs or run it manually on demand. Docker must be installed for the script to work, as it relies on the containerized version of the tool.
|
|
75
|
+
|
|
76
|
+
### Python CLI usage
|
|
77
|
+
|
|
78
|
+
If you install the Python package locally, you can run the tool as follows:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
jellyplex-sync [OPTIONS] /path/to/jellyfin/library /path/to/plex/library
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Options
|
|
85
|
+
|
|
86
|
+
- `--create`
|
|
87
|
+
Create the target directory if it does not exist.
|
|
88
|
+
|
|
89
|
+
- `--delete`
|
|
90
|
+
Remove movie folders and stray files in the target that are not present in the source.
|
|
91
|
+
|
|
92
|
+
- `--verbose`
|
|
93
|
+
Show informational messages about each operation.
|
|
94
|
+
|
|
95
|
+
- `--debug`
|
|
96
|
+
Enable debug-level logging for detailed parsing and linking steps.
|
|
97
|
+
|
|
98
|
+
- `--dry-run`
|
|
99
|
+
Show what would be done, without performing any actual changes. No files will be created, deleted, or linked.
|
|
100
|
+
|
|
101
|
+
- `--convert-to=...`
|
|
102
|
+
Choose between `jellyfin`, `plex` or `auto` (which is the default): `jellyfin` assumes the source library is in Plex format and creates a Jellyfin-compatible mirror. `plex` does the opposite. And `auto` inspects the source library and selects the appropriate conversion automatically.
|
|
103
|
+
|
|
104
|
+
## Examples
|
|
105
|
+
|
|
106
|
+
Mirror a Jellyfin library into an empty Plex structure:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
jellyplex-sync --create ~/Media/Jellyfin ~/Media/Plex
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Mirror and remove anything in the Plex folder that no longer exists in Jellyfin:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
jellyplex-sync --delete ~/Media/Jellyfin ~/Media/Plex
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Verbose output with full debug information:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
jellyplex-sync --verbose --debug --delete --create ~/Media/Jellyfin ~/Media/Plex
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Behavior
|
|
125
|
+
|
|
126
|
+
* **Hard links**: Video files are linked, not copied. This preserves disk space and ensures both libraries reflect the same physical files.
|
|
127
|
+
|
|
128
|
+
* **Asset folders**: Subdirectories (e.g., `other`, `interviews`) are processed recursively with the same hard-link logic. NB: rename `extras` folder to `other` in your Jellyfin library, because Plex does not recognize `extras`.
|
|
129
|
+
|
|
130
|
+
* **Stray items**: When `--delete` is used, any unexpected files or folders in the target library will be removed.
|
|
131
|
+
|
|
132
|
+
## Jellyfin movie library outline
|
|
133
|
+
|
|
134
|
+
This is the expected folder structure in your Jellyfin movie library. The script relies on it being consistent:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
Movies
|
|
138
|
+
├── A Bridge Too Far (1977) [imdbid-tt0075784]
|
|
139
|
+
│ ├── A Bridge Too Far (1977) [imdbid-tt0075784].mkv
|
|
140
|
+
│ └── trailers
|
|
141
|
+
│ └── A Bridge Too Far.mkv
|
|
142
|
+
└── Das Boot (1981) [imdbid-tt0082096]
|
|
143
|
+
├── Das Boot (1981) [imdbid-tt0082096] - Director's Cut.mkv
|
|
144
|
+
├── Das Boot (1981) [imdbid-tt0082096] - Theatrical Cut.mkv
|
|
145
|
+
└── other
|
|
146
|
+
├── Production Photos.mkv
|
|
147
|
+
└── Making of.mkv
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Each movie must reside in its own folder, with optional subfolders for extras. Different editions (e.g., Director's Cut, Theatrical Cut) must be named accordingly.
|
|
151
|
+
|
|
152
|
+
### Special filename handling
|
|
153
|
+
|
|
154
|
+
Jellyfin doesn't distinguish between editions (e.g., Director's Cut) and versions (e.g., 1080p vs. 4K). To work around this, I appended tags like "DVD", "BD", or "4K" to filenames in my personal library, ensuring the highest quality appears first and is selected by default in Jellyfin. Plex, on the other hand, supports editions natively and handles different versions via naming patterns and its internal version management. These specific tags are converted into Plex versions, while all other suffixes are treated as editions.
|
|
155
|
+
|
|
156
|
+
This naming convention is something I came up with for my personal library — it's not part of any official Jellyfin standard. If your setup uses a different scheme, you may want to adjust the parsing behavior by switching to a different VariantParser, such as the simpler SimpleVariantParser.
|
|
157
|
+
|
|
158
|
+
## Plex movie library outline
|
|
159
|
+
|
|
160
|
+
Plex follows a more structured naming convention than Jellyfin. While Jellyfin typically appends edition or variant information using a ` - ` (space-hyphen-space) pattern, Plex supports additional metadata inside **curly braces** for editions and **square brackets** for versions or other details.
|
|
161
|
+
|
|
162
|
+
Unlike Jellyfin, Plex’s naming system allows you to embed extra tags such as release source (`[BluRay]`), quality (`[4K]`), or codec (`[HEVC]`) directly in the filename. These tags are ignored by the default Plex scanners during media recognition, but remain visible in the interface — which makes them useful for organizing your collection without affecting playback or matching.
|
|
163
|
+
|
|
164
|
+
> Note: This behavior applies to Plex's default scanner. If you use custom scanners or agents, they may treat these tags differently.
|
|
165
|
+
|
|
166
|
+
I originally started with a Jellyfin-style library and converted it to be Plex-compatible. Over time, I came to prefer Plex's more expressive naming conventions and switched my personal collection to follow the Plex format. I now use Jellyfin mainly as a fallback for long-term archival and offline use.
|
|
167
|
+
|
|
168
|
+
This is the expected folder structure in Plex format (with some demo tags):
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
Movies
|
|
172
|
+
├── A Bridge Too Far (1977) {imdb-tt0075784}
|
|
173
|
+
│ ├── A Bridge Too Far (1977) {imdb-tt0075784}.mkv
|
|
174
|
+
│ └── trailers
|
|
175
|
+
│ └── A Bridge Too Far.mkv
|
|
176
|
+
└── Das Boot (1981) {imdb-tt0082096}
|
|
177
|
+
├── Das Boot (1981) {imdb-tt0082096} {edition-Director's Cut} [1080p].mkv
|
|
178
|
+
├── Das Boot (1981) {imdb-tt0082096} {edition-Theatrical Cut} [1080p][remux].mkv
|
|
179
|
+
└── other
|
|
180
|
+
├── Production Photos.mkv
|
|
181
|
+
└── Making of.mkv
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
This project is licensed under the [BSD 2-Clause License](./LICENSE).
|
|
187
|
+
|
|
188
|
+
## Disclaimer
|
|
189
|
+
|
|
190
|
+
This is a private project written for personal use. It doesn't cover all use cases or environments. Use at your own risk. Contributions or forks are welcome if you want to adapt it to your own setup.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
jellyplex_sync/__init__.py,sha256=QhKzlkwLoMbGrYGaI6s8-glJ0EMJE0mf7grJX3_70tg,374
|
|
2
|
+
jellyplex_sync/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
jellyplex_sync/cli/sync.py,sha256=W3xJ9m5STLMvVfJmHLFkxPHd6mG8BGlwsOj0-SSIGmE,2102
|
|
4
|
+
jellyplex_sync/jellyfin.py,sha256=xbH2EQAOFqcdte45D1xvoGGcO98EeyXO31nOnUYFX1Y,6822
|
|
5
|
+
jellyplex_sync/library.py,sha256=AVh-XHOfnRKBnyVm0ELxaelraM7yxdQg352QP8Einwk,1901
|
|
6
|
+
jellyplex_sync/plex.py,sha256=9HXiJA7jUp2nSqwpS3mxl_BTqc7CFoLJD1uhgnqKv00,3996
|
|
7
|
+
jellyplex_sync/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
jellyplex_sync/sync.py,sha256=bwAqB-t5-zPZICewn6z9Fz7clkf7JY8HX1HZkljAyM0,12911
|
|
9
|
+
jellyplex_sync/utils.py,sha256=KFOJIKzC-d89QLy6h6qlyjpJ9q40CYjVEUQnMjfaKtc,553
|
|
10
|
+
jellyplex_sync-0.1.5.dist-info/WHEEL,sha256=i9aSRDivn5iP9LaR1BLQX2GNAuriQWPsFwbbWygTX2k,81
|
|
11
|
+
jellyplex_sync-0.1.5.dist-info/entry_points.txt,sha256=it5BeVJB-vjkxcgsY_voLpACQh37mksrOS_5jpQuOvo,65
|
|
12
|
+
jellyplex_sync-0.1.5.dist-info/METADATA,sha256=TXjUGzGuF3oNXOx4ovJl4StaU-tWLU8150QLILgbLFQ,10433
|
|
13
|
+
jellyplex_sync-0.1.5.dist-info/RECORD,,
|