Skip to content

Commit ffbbf59

Browse files
authored
Add script to create dummy media files (#1432)
1 parent 185696e commit ffbbf59

File tree

1 file changed

+344
-0
lines changed

1 file changed

+344
-0
lines changed

tools/plex-dummyfiles.py

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
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

Comments
 (0)