|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# -*- coding: utf-8 -*- |
| 3 | +""" |
| 4 | +Plex-DummyFiles creates dummy files for testing with the proper |
| 5 | +Plex folder and file naming structure. |
| 6 | +""" |
| 7 | + |
| 8 | +import argparse |
| 9 | +import os |
| 10 | +import re |
| 11 | +import shutil |
| 12 | +from pathlib import Path |
| 13 | +from typing import Any, List, Optional, Tuple, Union |
| 14 | + |
| 15 | + |
| 16 | +BASE_DIR_PATH = Path(__file__).parents[1].absolute() |
| 17 | +STUB_VIDEO_PATH = BASE_DIR_PATH / "tests" / "data" / "video_stub.mp4" |
| 18 | + |
| 19 | + |
| 20 | +class DummyFiles: |
| 21 | + def __init__(self, **kwargs: Any): |
| 22 | + self.dummy_file: Path = kwargs['file'] |
| 23 | + self.root_folder: Path = kwargs['root'] |
| 24 | + self.title: str = kwargs['title'] |
| 25 | + self.year: int = kwargs['year'] |
| 26 | + self.tmdb: Optional[int] = kwargs['tmdb'] |
| 27 | + self.tvdb: Optional[int] = kwargs['tvdb'] |
| 28 | + self.imdb: Optional[str] = kwargs['imdb'] |
| 29 | + self.dry_run: bool = kwargs['dry_run'] |
| 30 | + self.clean: bool = kwargs['clean'] |
| 31 | + |
| 32 | + @property |
| 33 | + def external_id(self) -> Optional[str]: |
| 34 | + """Return the external ID of the media.""" |
| 35 | + if self.tmdb: |
| 36 | + return f"tmdb-{self.tmdb}" |
| 37 | + if self.tvdb: |
| 38 | + return f"tvdb-{self.tvdb}" |
| 39 | + if self.imdb: |
| 40 | + return f"imdb-{self.imdb}" |
| 41 | + return None |
| 42 | + |
| 43 | + def create_folder(self, folder: Path, parent: Optional[Path] = None, level: int = 0) -> None: |
| 44 | + """Create a folder with the path.""" |
| 45 | + print(f"{'│ ' * level}├─ {folder}{os.sep}") |
| 46 | + |
| 47 | + if parent: |
| 48 | + folder = parent / folder |
| 49 | + folder = self.root_folder / folder |
| 50 | + |
| 51 | + if not self.dry_run: |
| 52 | + if self.clean and folder.exists(): |
| 53 | + shutil.rmtree(folder) |
| 54 | + # No check for illegal characters in folder name |
| 55 | + folder.mkdir(parents=True, exist_ok=True) |
| 56 | + |
| 57 | + def create_files(self, files: List[Path], parent: Optional[Path] = None, level: int = 1) -> None: |
| 58 | + """Create a list of files with the given paths.""" |
| 59 | + for file in files: |
| 60 | + print(f"{'│ ' * level}├─ {file}") |
| 61 | + |
| 62 | + if parent: |
| 63 | + file = parent / file |
| 64 | + file = self.root_folder / file |
| 65 | + |
| 66 | + if not self.dry_run: |
| 67 | + # No check for illegal characters in file name |
| 68 | + # Will overwrite files if they exist |
| 69 | + shutil.copy(self.dummy_file, file) |
| 70 | + |
| 71 | + |
| 72 | +class DummyMovie(DummyFiles): |
| 73 | + def __init__(self, **kwargs: Any): |
| 74 | + super().__init__(**kwargs) |
| 75 | + versions = kwargs['versions'] or [["", 1]] |
| 76 | + self.edition: Optional[str] = kwargs['edition'] |
| 77 | + self.versions: List[str] = [v[0] for v in versions] |
| 78 | + self.parts: List[int] = [v[1] for v in versions] |
| 79 | + self.movie_folder: Path = self.create_movie_folder() |
| 80 | + self.create_movie_files() |
| 81 | + |
| 82 | + def create_movie_folder(self) -> Path: |
| 83 | + """Create the movie folder with the proper naming structure.""" |
| 84 | + folder = f"{self.title} ({self.year})" |
| 85 | + |
| 86 | + if self.edition: |
| 87 | + folder = f"{folder} {{edition-{self.edition}}}" |
| 88 | + if self.external_id: |
| 89 | + folder = f"{folder} {{{self.external_id}}}" |
| 90 | + |
| 91 | + movie_folder = Path(folder) |
| 92 | + self.create_folder(movie_folder) |
| 93 | + return movie_folder |
| 94 | + |
| 95 | + def create_movie_files(self) -> None: |
| 96 | + """Create the list of movie files with the proper naming structure.""" |
| 97 | + title = f"{self.title} ({self.year})" |
| 98 | + |
| 99 | + _movie_parts: List[List[str]] = [] |
| 100 | + movie_files: List[Path] = [] |
| 101 | + |
| 102 | + for version in self.versions: |
| 103 | + if version: |
| 104 | + _movie_parts.append([title, f"- {version}"]) |
| 105 | + else: |
| 106 | + _movie_parts.append([title]) |
| 107 | + |
| 108 | + if self.edition: |
| 109 | + for _movie_part in _movie_parts: |
| 110 | + _movie_part.append(f"{{edition-{self.edition}}}") |
| 111 | + |
| 112 | + if self.external_id: |
| 113 | + for _movie_part in _movie_parts: |
| 114 | + _movie_part.append(f"{{{self.external_id}}}") |
| 115 | + |
| 116 | + for _movie_part, parts in zip(_movie_parts, self.parts): |
| 117 | + if parts > 1: |
| 118 | + for part in range(1, parts + 1): |
| 119 | + _movie_file = f"{' '.join(_movie_part)} - pt{part}{self.dummy_file.suffix}" |
| 120 | + movie_files.append(Path(_movie_file)) |
| 121 | + else: |
| 122 | + _movie_file = f"{' '.join(_movie_part)}{self.dummy_file.suffix}" |
| 123 | + movie_files.append(Path(_movie_file)) |
| 124 | + |
| 125 | + self.create_files(movie_files, parent=self.movie_folder) |
| 126 | + |
| 127 | + |
| 128 | +class DummyShow(DummyFiles): |
| 129 | + def __init__(self, **kwargs: Any): |
| 130 | + super().__init__(**kwargs) |
| 131 | + self.seasons: List[List[int]] = kwargs['seasons'] |
| 132 | + self.episodes: List[List[Union[int, List[int], Tuple[int, int]]]] = kwargs['episodes'] |
| 133 | + self.show_folder: Path = self.create_show_folder() |
| 134 | + self.create_episode_files() |
| 135 | + |
| 136 | + def create_show_folder(self) -> Path: |
| 137 | + """Create the show folder with the proper naming structure.""" |
| 138 | + folder = f"{self.title} ({self.year})" |
| 139 | + |
| 140 | + if self.external_id: |
| 141 | + folder = f"{folder} {{{self.external_id}}}" |
| 142 | + |
| 143 | + show_folder = Path(folder) |
| 144 | + self.create_folder(show_folder) |
| 145 | + return show_folder |
| 146 | + |
| 147 | + def create_episode_files(self) -> None: |
| 148 | + """Create the list of season folders and episode files with the proper naming structure.""" |
| 149 | + for seasons, episodes in zip(self.seasons, self.episodes): |
| 150 | + for season in seasons: |
| 151 | + season_folder = Path(f"Season {season:02}") |
| 152 | + |
| 153 | + self.create_folder(season_folder, parent=self.show_folder, level=1) |
| 154 | + |
| 155 | + episode_files: List[Path] = [] |
| 156 | + |
| 157 | + for episode in episodes: |
| 158 | + if isinstance(episode, tuple): |
| 159 | + _episode_file = ( |
| 160 | + f"{self.title} ({self.year})" |
| 161 | + f" - S{season:02}E{episode[0]:02}-E{episode[1]:02}{self.dummy_file.suffix}" |
| 162 | + ) |
| 163 | + episode_files.append(Path(_episode_file)) |
| 164 | + elif isinstance(episode, list) and episode[1] > 1: |
| 165 | + for part in range(1, episode[1] + 1): |
| 166 | + _episode_file = ( |
| 167 | + f"{self.title} ({self.year})" |
| 168 | + f" - S{season:02}E{episode[0]:02} - pt{part}{self.dummy_file.suffix}" |
| 169 | + ) |
| 170 | + episode_files.append(Path(_episode_file)) |
| 171 | + else: |
| 172 | + _episode_file = f"{self.title} ({self.year}) - S{season:02}E{episode:02}{self.dummy_file.suffix}" |
| 173 | + episode_files.append(Path(_episode_file)) |
| 174 | + |
| 175 | + self.create_files(episode_files, parent=self.show_folder / season_folder, level=2) |
| 176 | + |
| 177 | + |
| 178 | +def validate_folder_path(folder: str) -> Path: |
| 179 | + folder_path = Path(folder) |
| 180 | + if not folder_path.exists(): |
| 181 | + raise argparse.ArgumentTypeError(f"Folder does not exist: {folder_path}") |
| 182 | + if not folder_path.is_dir(): |
| 183 | + raise argparse.ArgumentTypeError(f"Path is not a folder: {folder_path}") |
| 184 | + return folder_path |
| 185 | + |
| 186 | + |
| 187 | +def validate_file_path(file: str) -> Path: |
| 188 | + file_path = Path(file) |
| 189 | + if not file_path.exists(): |
| 190 | + raise argparse.ArgumentTypeError(f"File does not exist: {file_path}") |
| 191 | + if not file_path.is_file(): |
| 192 | + raise argparse.ArgumentTypeError(f"Path is not a file: {file_path}") |
| 193 | + return file_path |
| 194 | + |
| 195 | + |
| 196 | +def validate_imdb_id(imdb_id: str) -> str: |
| 197 | + if re.match(r"tt\d{7,8}", imdb_id): |
| 198 | + return imdb_id |
| 199 | + raise argparse.ArgumentTypeError(f"Invalid IMDB ID: {imdb_id}") |
| 200 | + |
| 201 | + |
| 202 | +def validate_versions( |
| 203 | + version_str: str, |
| 204 | + sep_parts: str = "|", |
| 205 | +) -> List[Union[str, int]]: |
| 206 | + version_parts = version_str.split(sep_parts) |
| 207 | + if len(version_parts) == 1: |
| 208 | + return [version_parts[0], 1] |
| 209 | + return [version_parts[0], int(version_parts[1])] |
| 210 | + |
| 211 | + |
| 212 | +def validate_number_ranges( |
| 213 | + num_str: str, |
| 214 | + sep: str = ",", |
| 215 | + sep_range: str = "-", |
| 216 | + sep_stack: str = "+", |
| 217 | + sep_parts: str = "|", |
| 218 | +) -> List[Union[int, List[int], Tuple[int, int]]]: |
| 219 | + parsed: List[Union[int, List[int], Tuple[int, int]]] = [] |
| 220 | + for part in num_str.split(sep): |
| 221 | + if sep_range in part: |
| 222 | + r1, r2 = [int(i) for i in part.split(sep_range)] |
| 223 | + parsed.extend(list(range(r1, r2 + 1))) |
| 224 | + elif sep_stack in part: |
| 225 | + s1, s2 = [int(i) for i in part.split(sep_stack)] |
| 226 | + parsed.append((s1, s2)) |
| 227 | + elif sep_parts in part: |
| 228 | + ep, pt = [int(i) for i in part.split(sep_parts)] |
| 229 | + parsed.append([ep, pt]) |
| 230 | + else: |
| 231 | + parsed.append(int(part)) |
| 232 | + return parsed |
| 233 | + |
| 234 | + |
| 235 | +if __name__ == "__main__": # noqa: C901 |
| 236 | + parser = argparse.ArgumentParser(description=__doc__) |
| 237 | + parser.add_argument( |
| 238 | + "media_type", |
| 239 | + help="Type of media to create", |
| 240 | + choices=["movie", "show"], |
| 241 | + ) |
| 242 | + parser.add_argument( |
| 243 | + "-r", |
| 244 | + "--root", |
| 245 | + help="Root media folder to create the dummy folders and files", |
| 246 | + type=validate_folder_path, |
| 247 | + required=True |
| 248 | + ) |
| 249 | + parser.add_argument( |
| 250 | + "-t", |
| 251 | + "--title", |
| 252 | + help="Title of the media", |
| 253 | + required=True, |
| 254 | + ) |
| 255 | + parser.add_argument( |
| 256 | + "-y", |
| 257 | + "--year", |
| 258 | + help="Year of the media", |
| 259 | + type=int, |
| 260 | + required=True, |
| 261 | + ) |
| 262 | + |
| 263 | + movie_group = parser.add_argument_group("Movie Options") |
| 264 | + movie_group.add_argument( |
| 265 | + "-ed", |
| 266 | + "--edition", |
| 267 | + help="Edition title" |
| 268 | + ) |
| 269 | + movie_group.add_argument( |
| 270 | + "-vs", |
| 271 | + "--versions", |
| 272 | + help="Versions and parts to create (| for parts)", |
| 273 | + action="append", |
| 274 | + type=validate_versions, |
| 275 | + ) |
| 276 | + |
| 277 | + show_group = parser.add_argument_group("TV Show Options") |
| 278 | + show_group.add_argument( |
| 279 | + "-sn", |
| 280 | + "--seasons", |
| 281 | + help="Seasons to create (- for range)", |
| 282 | + action="append", |
| 283 | + type=validate_number_ranges, |
| 284 | + ) |
| 285 | + show_group.add_argument( |
| 286 | + "-ep", |
| 287 | + "--episodes", |
| 288 | + help="Episodes to create (- for range, + for stacked, | for parts)", |
| 289 | + action="append", |
| 290 | + type=validate_number_ranges, |
| 291 | + ) |
| 292 | + |
| 293 | + id_group = parser.add_mutually_exclusive_group() |
| 294 | + id_group.add_argument( |
| 295 | + "--tmdb", |
| 296 | + help="TMDB ID of the media", |
| 297 | + type=int, |
| 298 | + ) |
| 299 | + id_group.add_argument( |
| 300 | + "--tvdb", |
| 301 | + help="TVDB ID of the media", |
| 302 | + type=int, |
| 303 | + ) |
| 304 | + id_group.add_argument( |
| 305 | + "--imdb", |
| 306 | + help="IMDB ID of the media", |
| 307 | + type=validate_imdb_id, |
| 308 | + ) |
| 309 | + |
| 310 | + parser.add_argument( |
| 311 | + "-f", |
| 312 | + "--file", |
| 313 | + help="Path to the dummy video file", |
| 314 | + type=validate_file_path, |
| 315 | + default=STUB_VIDEO_PATH, |
| 316 | + ) |
| 317 | + parser.add_argument( |
| 318 | + "--dry-run", |
| 319 | + help="Print the folder and file structure without creating the files", |
| 320 | + action="store_true", |
| 321 | + ) |
| 322 | + parser.add_argument( |
| 323 | + "--clean", |
| 324 | + help="Remove the old files before creating new dummy files", |
| 325 | + action="store_true", |
| 326 | + ) |
| 327 | + |
| 328 | + opts, _ = parser.parse_known_args() |
| 329 | + |
| 330 | + if opts.dry_run: |
| 331 | + print("Dry Run: No files will be created") |
| 332 | + |
| 333 | + print(f"{opts.root}{os.sep}") |
| 334 | + |
| 335 | + if opts.media_type == "movie": |
| 336 | + DummyMovie(**vars(opts)) |
| 337 | + elif opts.media_type == "show": |
| 338 | + if not opts.seasons or not opts.episodes: |
| 339 | + parser.error("Both --seasons and --episodes are required for TV shows") |
| 340 | + if len(opts.seasons) != len(opts.episodes): |
| 341 | + parser.error("Number of seasons and episodes arguments must match") |
| 342 | + if any(not isinstance(season, int) for season_groups in opts.seasons for season in season_groups): |
| 343 | + parser.error("Seasons must be a list of integers or integer ranges") |
| 344 | + DummyShow(**vars(opts)) |
0 commit comments