Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""LICENSE
2Copyright 2015 Hermann Krumrey <hermann@krumreyh.com>
4This file is part of toktokkie.
6toktokkie is free software: you can redistribute it and/or modify
7it under the terms of the GNU General Public License as published by
8the Free Software Foundation, either version 3 of the License, or
9(at your option) any later version.
11toktokkie is distributed in the hope that it will be useful,
12but WITHOUT ANY WARRANTY; without even the implied warranty of
13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14GNU General Public License for more details.
16You should have received a copy of the GNU General Public License
17along with toktokkie. If not, see <http://www.gnu.org/licenses/>.
18LICENSE"""
20import os
21import re
22from enum import Enum
23from typing import List, Dict, Any, Optional, cast, Set
24from puffotter.prompt import prompt
25from toktokkie.utils.update.Updater import Updater
26from toktokkie.enums import MediaType
27from toktokkie.metadata.tv.Tv import Tv
28from toktokkie.metadata.tv.components.TvSeason import TvSeason
29from toktokkie.metadata.base.components.RenameOperation import RenameOperation
30from toktokkie.metadata.base.Metadata import Metadata
31from toktokkie.exceptions import InvalidUpdateInstructions
34class DownloadInstructions:
35 """
36 The instructions for a download
37 """
39 def __init__(self, search_result: Any, directory: str, filename: str):
40 """
41 Initializes the download instructions
42 :param search_result: The search result object
43 :param directory: The directory in which to download to
44 :param filename: The filename to which to download to
45 """
46 self.search_result = search_result
47 self.directory = directory
48 self.filename = filename
51class Resolution(Enum):
52 """
53 Enum that models the different resolution options
54 """
55 X1080p = "1080p"
56 X720p = "720p"
57 X480p = "480p"
60# noinspection PyAbstractClass
61class TvUpdater(Updater):
62 """
63 Class that handles the configuration and execution of a generic tv updater
64 """
66 @classmethod
67 def applicable_media_types(cls) -> List[MediaType]:
68 """
69 :return: A list of media type with which the updater can be used with
70 """
71 return [MediaType.TV_SERIES]
73 @classmethod
74 def search_engine_names(cls) -> Set[str]:
75 """
76 :return: The names of applicable search engines
77 """
78 raise NotImplementedError()
80 @classmethod
81 def predefined_patterns(cls) -> Dict[str, str]:
82 """
83 :return: Predefined search patterns for this updater
84 """
85 raise NotImplementedError()
87 @property
88 def search_engine(self) -> Any:
89 """
90 :return: The search engine to use
91 """
92 raise NotImplementedError()
94 def download(self, download_instructions: List[DownloadInstructions]):
95 """
96 Performs a download
97 :param download_instructions: The download instrcutions
98 :return: None
99 """
100 raise NotImplementedError()
102 @classmethod
103 def json_schema(cls) -> Optional[Dict[str, Any]]:
104 """
105 :return: Optional JSON schema for a configuration file
106 """
107 search_engine_pattern = "^({})$".format(
108 "|".join(cls.search_engine_names())
109 )
110 resolution_pattern = \
111 "^({})$".format("|".join([x.value for x in Resolution]))
113 return {
114 "type": "object",
115 "properties": {
116 "season": {"type": "string"},
117 "search_name": {"type": "string"},
118 "search_engine": {
119 "type": "string",
120 "pattern": search_engine_pattern
121 },
122 "resolution": {
123 "type": "string",
124 "pattern": resolution_pattern
125 },
126 "episode_offset": {"type": "number"},
127 "search_pattern": {"type": "string"}
128 },
129 "required": [
130 "season",
131 "search_name",
132 "search_engine",
133 "resolution",
134 "search_pattern"
135 ],
136 "additionalProperties": False
137 }
139 @property
140 def season(self) -> TvSeason:
141 """
142 :return: The season to update
143 """
144 season_name = self.config["season"]
145 metadata = cast(Tv, self.metadata)
146 for season in metadata.seasons:
147 if season.name == season_name:
148 return season
149 raise InvalidUpdateInstructions(
150 "Invalid Season {}".format(season_name)
151 )
153 @property
154 def search_name(self) -> str:
155 """
156 :return: The name of the series for searching purposes
157 """
158 return self.config["search_name"]
160 @property
161 def resolution(self) -> Resolution:
162 """
163 :return: The resolution in which to update the series
164 """
165 return Resolution(self.config["resolution"])
167 @property
168 def p_resolution(self) -> str:
169 """
170 :return: The resolution in P-notation (1080p)
171 """
172 return self.resolution.value
174 @property
175 def x_resolution(self) -> str:
176 """
177 :return: The resolution in X-notation (1920x1080)
178 """
179 if self.resolution == Resolution.X1080p:
180 return "1920x1080"
181 elif self.resolution == Resolution.X720p:
182 return "1280x720"
183 else: # self.resolution == Resolution.X480p
184 return "720x480"
186 @property
187 def episode_offset(self) -> int:
188 """
189 :return: The amount of episodes offset from 1 when updating
190 """
191 return int(self.config["episode_offset"])
193 @property
194 def search_pattern(self) -> str:
195 """
196 :return: The search pattern to use
197 """
198 pattern = self.config["search_pattern"]
199 return self.predefined_patterns().get(pattern, pattern)
201 @classmethod
202 def _prompt(cls, metadata: Metadata) -> Optional[Dict[str, Any]]:
203 """
204 Prompts the user for information to create a config file
205 :param metadata: The metadata of the media for which to create an
206 updater config file
207 :return: The configuration JSON data
208 """
209 metadata = cast(Tv, metadata)
210 print(f"Generating {cls.name()} "
211 f"Update instructions for {metadata.name}")
213 normal_seasons = [
214 x.name for x in metadata.seasons if x.name.startswith("Season ")
215 ]
217 default_season = None # type: Optional[str]
218 if len(normal_seasons) > 0:
219 default_season = max(normal_seasons)
221 json_data = {
222 "season": prompt("Season", default=default_season),
223 "search_name": prompt("Search Name", default=metadata.name),
224 "search_engine": prompt(
225 "Search Engine",
226 choices=cls.search_engine_names()
227 ),
228 "resolution": prompt(
229 "Resolution",
230 default="1080p",
231 choices={"1080p", "720p", "480p"}
232 ),
233 "episode_offset": prompt(
234 "Episode Offset", default=0, _type=int
235 )
236 }
238 print("-" * 80)
239 print("Valid variables for search patterns:")
241 for variable in [
242 "@{NAME}",
243 "@{RES-P}",
244 "@{RES-X}",
245 "@{HASH}",
246 "@{EPI-1}",
247 "@{EPI-2}",
248 "@{EPI-3}",
249 "@{ANY}"
250 ]:
251 print(variable)
253 print("-" * 80)
254 print("Predefined patterns:")
256 for pattern_name, pattern in cls.predefined_patterns().items():
257 print(f"{pattern_name} ({pattern})")
259 print("-" * 80)
260 json_data["search_pattern"] = prompt("Search Pattern")
262 return json_data
264 def perform_search(self, search_term: str, search_regex: str) -> List[Any]:
265 """
266 Performs a search using the selected search engine
267 :param search_term: The term to search for
268 :param search_regex: The expected regex
269 :return: The search results
270 """
271 search_results = self.search_engine.search(search_term)
273 search_results = list(filter(
274 lambda x: re.match(re.compile(search_regex), x.filename),
275 search_results
276 ))
277 return search_results
279 def update(self):
280 """
281 Executes the XDCC Update procedure
282 :return: None
283 """
284 self._update_episode_names()
286 start_episode = 1 + len(os.listdir(self.season.path))
287 start_episode += self.episode_offset
289 episode_count = start_episode
290 download_instructions = []
292 while True:
293 search_term = self._generate_search_term(episode_count, False)
294 search_regex = self._generate_search_term(episode_count, True)
295 search_results = self.perform_search(search_term, search_regex)
297 if len(search_results) > 0:
298 result = search_results[0]
300 try:
301 ext = "." + result.filename.rsplit(".")[1]
302 except IndexError:
303 ext = ""
305 episode_number = episode_count - self.episode_offset
306 episode_name = "{} - S{}E{} - Episode {}{}".format(
307 self.metadata.name,
308 str(self.season.season_number).zfill(2),
309 str(episode_number).zfill(2),
310 episode_number,
311 ext
312 )
313 episode_name = RenameOperation.sanitize(
314 self.season.path, episode_name
315 )
317 download_instructions.append(DownloadInstructions(
318 result, self.season.path, episode_name
319 ))
320 episode_count += 1
322 else:
323 break
325 self.download(download_instructions)
326 self._update_episode_names()
328 def validate(self):
329 """
330 Checks if the configuration is valid
331 :return: None
332 """
333 super().validate()
335 # Check is done in season property definition
336 self.logger.debug("Loading season {}".format(self.season))
338 def _update_episode_names(self):
339 """
340 Renames the episodes in the season directory that's being updated
341 :return: None
342 """
343 for operation in self.metadata.create_rename_operations():
344 operation_dir = os.path.basename(os.path.dirname(operation.source))
345 if operation_dir == self.season.name:
346 operation.rename()
348 def _generate_search_term(self, episode: int, regex: bool) -> str:
349 """
350 Generates a search term/search term regex for a specified episode
351 :param episode: The episode for which to generate the search term
352 :param regex: Whether or not to generate a regex.
353 :return: The generated search term/regex
354 """
355 pattern = self.search_pattern
356 pattern = pattern.replace("@{NAME}", self.search_name)
357 pattern = pattern.replace("@{RES-P}", self.p_resolution)
358 pattern = pattern.replace("@{RES-X}", self.x_resolution)
360 if regex:
361 pattern = pattern.replace("[", "\\[")
362 pattern = pattern.replace("]", "\\]")
363 pattern = pattern.replace("(", "\\(")
364 pattern = pattern.replace(")", "\\)")
365 pattern = pattern.replace("@{HASH}", "[a-zA-Z0-9]+")
366 pattern = pattern.replace(
367 "@{EPI-1}", str(episode).zfill(1) + "(v[0-9]+)?"
368 )
369 pattern = pattern.replace(
370 "@{EPI-2}", str(episode).zfill(2) + "(v[0-9]+)?"
371 )
372 pattern = pattern.replace(
373 "@{EPI-3}", str(episode).zfill(3) + "(v[0-9]+)?"
374 )
375 pattern = pattern.replace("@{ANY}", ".*?")
377 else:
378 pattern = pattern.replace("@{EPI-1}", str(episode).zfill(1))
379 pattern = pattern.replace("@{EPI-2}", str(episode).zfill(2))
380 pattern = pattern.replace("@{EPI-3}", str(episode).zfill(3))
381 pattern = pattern.replace("[@{HASH}]", "")
382 pattern = pattern.replace("@{HASH}", "")
383 pattern = pattern.replace("@{ANY}", "")
385 return pattern