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.
@@ -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
+ )
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
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.15
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ jellyplex-sync = jellyplex_sync.cli.sync:main
3
+