From ffbbf59757c40752f98c983f15cd9783f20fa06b Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 22 Jun 2024 11:29:14 -0700 Subject: [PATCH] Add script to create dummy media files (#1432) --- tools/plex-dummyfiles.py | 344 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 tools/plex-dummyfiles.py diff --git a/tools/plex-dummyfiles.py b/tools/plex-dummyfiles.py new file mode 100644 index 000000000..11cb25eb3 --- /dev/null +++ b/tools/plex-dummyfiles.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Plex-DummyFiles creates dummy files for testing with the proper +Plex folder and file naming structure. +""" + +import argparse +import os +import re +import shutil +from pathlib import Path +from typing import Any, List, Optional, Tuple, Union + + +BASE_DIR_PATH = Path(__file__).parents[1].absolute() +STUB_VIDEO_PATH = BASE_DIR_PATH / "tests" / "data" / "video_stub.mp4" + + +class DummyFiles: + def __init__(self, **kwargs: Any): + self.dummy_file: Path = kwargs['file'] + self.root_folder: Path = kwargs['root'] + self.title: str = kwargs['title'] + self.year: int = kwargs['year'] + self.tmdb: Optional[int] = kwargs['tmdb'] + self.tvdb: Optional[int] = kwargs['tvdb'] + self.imdb: Optional[str] = kwargs['imdb'] + self.dry_run: bool = kwargs['dry_run'] + self.clean: bool = kwargs['clean'] + + @property + def external_id(self) -> Optional[str]: + """Return the external ID of the media.""" + if self.tmdb: + return f"tmdb-{self.tmdb}" + if self.tvdb: + return f"tvdb-{self.tvdb}" + if self.imdb: + return f"imdb-{self.imdb}" + return None + + def create_folder(self, folder: Path, parent: Optional[Path] = None, level: int = 0) -> None: + """Create a folder with the path.""" + print(f"{'│ ' * level}├─ {folder}{os.sep}") + + if parent: + folder = parent / folder + folder = self.root_folder / folder + + if not self.dry_run: + if self.clean and folder.exists(): + shutil.rmtree(folder) + # No check for illegal characters in folder name + folder.mkdir(parents=True, exist_ok=True) + + def create_files(self, files: List[Path], parent: Optional[Path] = None, level: int = 1) -> None: + """Create a list of files with the given paths.""" + for file in files: + print(f"{'│ ' * level}├─ {file}") + + if parent: + file = parent / file + file = self.root_folder / file + + if not self.dry_run: + # No check for illegal characters in file name + # Will overwrite files if they exist + shutil.copy(self.dummy_file, file) + + +class DummyMovie(DummyFiles): + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + versions = kwargs['versions'] or [["", 1]] + self.edition: Optional[str] = kwargs['edition'] + self.versions: List[str] = [v[0] for v in versions] + self.parts: List[int] = [v[1] for v in versions] + self.movie_folder: Path = self.create_movie_folder() + self.create_movie_files() + + def create_movie_folder(self) -> Path: + """Create the movie folder with the proper naming structure.""" + folder = f"{self.title} ({self.year})" + + if self.edition: + folder = f"{folder} {{edition-{self.edition}}}" + if self.external_id: + folder = f"{folder} {{{self.external_id}}}" + + movie_folder = Path(folder) + self.create_folder(movie_folder) + return movie_folder + + def create_movie_files(self) -> None: + """Create the list of movie files with the proper naming structure.""" + title = f"{self.title} ({self.year})" + + _movie_parts: List[List[str]] = [] + movie_files: List[Path] = [] + + for version in self.versions: + if version: + _movie_parts.append([title, f"- {version}"]) + else: + _movie_parts.append([title]) + + if self.edition: + for _movie_part in _movie_parts: + _movie_part.append(f"{{edition-{self.edition}}}") + + if self.external_id: + for _movie_part in _movie_parts: + _movie_part.append(f"{{{self.external_id}}}") + + for _movie_part, parts in zip(_movie_parts, self.parts): + if parts > 1: + for part in range(1, parts + 1): + _movie_file = f"{' '.join(_movie_part)} - pt{part}{self.dummy_file.suffix}" + movie_files.append(Path(_movie_file)) + else: + _movie_file = f"{' '.join(_movie_part)}{self.dummy_file.suffix}" + movie_files.append(Path(_movie_file)) + + self.create_files(movie_files, parent=self.movie_folder) + + +class DummyShow(DummyFiles): + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) + self.seasons: List[List[int]] = kwargs['seasons'] + self.episodes: List[List[Union[int, List[int], Tuple[int, int]]]] = kwargs['episodes'] + self.show_folder: Path = self.create_show_folder() + self.create_episode_files() + + def create_show_folder(self) -> Path: + """Create the show folder with the proper naming structure.""" + folder = f"{self.title} ({self.year})" + + if self.external_id: + folder = f"{folder} {{{self.external_id}}}" + + show_folder = Path(folder) + self.create_folder(show_folder) + return show_folder + + def create_episode_files(self) -> None: + """Create the list of season folders and episode files with the proper naming structure.""" + for seasons, episodes in zip(self.seasons, self.episodes): + for season in seasons: + season_folder = Path(f"Season {season:02}") + + self.create_folder(season_folder, parent=self.show_folder, level=1) + + episode_files: List[Path] = [] + + for episode in episodes: + if isinstance(episode, tuple): + _episode_file = ( + f"{self.title} ({self.year})" + f" - S{season:02}E{episode[0]:02}-E{episode[1]:02}{self.dummy_file.suffix}" + ) + episode_files.append(Path(_episode_file)) + elif isinstance(episode, list) and episode[1] > 1: + for part in range(1, episode[1] + 1): + _episode_file = ( + f"{self.title} ({self.year})" + f" - S{season:02}E{episode[0]:02} - pt{part}{self.dummy_file.suffix}" + ) + episode_files.append(Path(_episode_file)) + else: + _episode_file = f"{self.title} ({self.year}) - S{season:02}E{episode:02}{self.dummy_file.suffix}" + episode_files.append(Path(_episode_file)) + + self.create_files(episode_files, parent=self.show_folder / season_folder, level=2) + + +def validate_folder_path(folder: str) -> Path: + folder_path = Path(folder) + if not folder_path.exists(): + raise argparse.ArgumentTypeError(f"Folder does not exist: {folder_path}") + if not folder_path.is_dir(): + raise argparse.ArgumentTypeError(f"Path is not a folder: {folder_path}") + return folder_path + + +def validate_file_path(file: str) -> Path: + file_path = Path(file) + if not file_path.exists(): + raise argparse.ArgumentTypeError(f"File does not exist: {file_path}") + if not file_path.is_file(): + raise argparse.ArgumentTypeError(f"Path is not a file: {file_path}") + return file_path + + +def validate_imdb_id(imdb_id: str) -> str: + if re.match(r"tt\d{7,8}", imdb_id): + return imdb_id + raise argparse.ArgumentTypeError(f"Invalid IMDB ID: {imdb_id}") + + +def validate_versions( + version_str: str, + sep_parts: str = "|", +) -> List[Union[str, int]]: + version_parts = version_str.split(sep_parts) + if len(version_parts) == 1: + return [version_parts[0], 1] + return [version_parts[0], int(version_parts[1])] + + +def validate_number_ranges( + num_str: str, + sep: str = ",", + sep_range: str = "-", + sep_stack: str = "+", + sep_parts: str = "|", +) -> List[Union[int, List[int], Tuple[int, int]]]: + parsed: List[Union[int, List[int], Tuple[int, int]]] = [] + for part in num_str.split(sep): + if sep_range in part: + r1, r2 = [int(i) for i in part.split(sep_range)] + parsed.extend(list(range(r1, r2 + 1))) + elif sep_stack in part: + s1, s2 = [int(i) for i in part.split(sep_stack)] + parsed.append((s1, s2)) + elif sep_parts in part: + ep, pt = [int(i) for i in part.split(sep_parts)] + parsed.append([ep, pt]) + else: + parsed.append(int(part)) + return parsed + + +if __name__ == "__main__": # noqa: C901 + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "media_type", + help="Type of media to create", + choices=["movie", "show"], + ) + parser.add_argument( + "-r", + "--root", + help="Root media folder to create the dummy folders and files", + type=validate_folder_path, + required=True + ) + parser.add_argument( + "-t", + "--title", + help="Title of the media", + required=True, + ) + parser.add_argument( + "-y", + "--year", + help="Year of the media", + type=int, + required=True, + ) + + movie_group = parser.add_argument_group("Movie Options") + movie_group.add_argument( + "-ed", + "--edition", + help="Edition title" + ) + movie_group.add_argument( + "-vs", + "--versions", + help="Versions and parts to create (| for parts)", + action="append", + type=validate_versions, + ) + + show_group = parser.add_argument_group("TV Show Options") + show_group.add_argument( + "-sn", + "--seasons", + help="Seasons to create (- for range)", + action="append", + type=validate_number_ranges, + ) + show_group.add_argument( + "-ep", + "--episodes", + help="Episodes to create (- for range, + for stacked, | for parts)", + action="append", + type=validate_number_ranges, + ) + + id_group = parser.add_mutually_exclusive_group() + id_group.add_argument( + "--tmdb", + help="TMDB ID of the media", + type=int, + ) + id_group.add_argument( + "--tvdb", + help="TVDB ID of the media", + type=int, + ) + id_group.add_argument( + "--imdb", + help="IMDB ID of the media", + type=validate_imdb_id, + ) + + parser.add_argument( + "-f", + "--file", + help="Path to the dummy video file", + type=validate_file_path, + default=STUB_VIDEO_PATH, + ) + parser.add_argument( + "--dry-run", + help="Print the folder and file structure without creating the files", + action="store_true", + ) + parser.add_argument( + "--clean", + help="Remove the old files before creating new dummy files", + action="store_true", + ) + + opts, _ = parser.parse_known_args() + + if opts.dry_run: + print("Dry Run: No files will be created") + + print(f"{opts.root}{os.sep}") + + if opts.media_type == "movie": + DummyMovie(**vars(opts)) + elif opts.media_type == "show": + if not opts.seasons or not opts.episodes: + parser.error("Both --seasons and --episodes are required for TV shows") + if len(opts.seasons) != len(opts.episodes): + parser.error("Number of seasons and episodes arguments must match") + if any(not isinstance(season, int) for season_groups in opts.seasons for season in season_groups): + parser.error("Seasons must be a list of integers or integer ranges") + DummyShow(**vars(opts))