Hide keyboard shortcuts

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> 

3 

4This file is part of toktokkie. 

5 

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. 

10 

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. 

15 

16You should have received a copy of the GNU General Public License 

17along with toktokkie. If not, see <http://www.gnu.org/licenses/>. 

18LICENSE""" 

19 

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 

32 

33 

34class DownloadInstructions: 

35 """ 

36 The instructions for a download 

37 """ 

38 

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 

49 

50 

51class Resolution(Enum): 

52 """ 

53 Enum that models the different resolution options 

54 """ 

55 X1080p = "1080p" 

56 X720p = "720p" 

57 X480p = "480p" 

58 

59 

60# noinspection PyAbstractClass 

61class TvUpdater(Updater): 

62 """ 

63 Class that handles the configuration and execution of a generic tv updater 

64 """ 

65 

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] 

72 

73 @classmethod 

74 def search_engine_names(cls) -> Set[str]: 

75 """ 

76 :return: The names of applicable search engines 

77 """ 

78 raise NotImplementedError() 

79 

80 @classmethod 

81 def predefined_patterns(cls) -> Dict[str, str]: 

82 """ 

83 :return: Predefined search patterns for this updater 

84 """ 

85 raise NotImplementedError() 

86 

87 @property 

88 def search_engine(self) -> Any: 

89 """ 

90 :return: The search engine to use 

91 """ 

92 raise NotImplementedError() 

93 

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() 

101 

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])) 

112 

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 } 

138 

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 ) 

152 

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"] 

159 

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"]) 

166 

167 @property 

168 def p_resolution(self) -> str: 

169 """ 

170 :return: The resolution in P-notation (1080p) 

171 """ 

172 return self.resolution.value 

173 

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" 

185 

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"]) 

192 

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) 

200 

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}") 

212 

213 normal_seasons = [ 

214 x.name for x in metadata.seasons if x.name.startswith("Season ") 

215 ] 

216 

217 default_season = None # type: Optional[str] 

218 if len(normal_seasons) > 0: 

219 default_season = max(normal_seasons) 

220 

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 } 

237 

238 print("-" * 80) 

239 print("Valid variables for search patterns:") 

240 

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) 

252 

253 print("-" * 80) 

254 print("Predefined patterns:") 

255 

256 for pattern_name, pattern in cls.predefined_patterns().items(): 

257 print(f"{pattern_name} ({pattern})") 

258 

259 print("-" * 80) 

260 json_data["search_pattern"] = prompt("Search Pattern") 

261 

262 return json_data 

263 

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) 

272 

273 search_results = list(filter( 

274 lambda x: re.match(re.compile(search_regex), x.filename), 

275 search_results 

276 )) 

277 return search_results 

278 

279 def update(self): 

280 """ 

281 Executes the XDCC Update procedure 

282 :return: None 

283 """ 

284 self._update_episode_names() 

285 

286 start_episode = 1 + len(os.listdir(self.season.path)) 

287 start_episode += self.episode_offset 

288 

289 episode_count = start_episode 

290 download_instructions = [] 

291 

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) 

296 

297 if len(search_results) > 0: 

298 result = search_results[0] 

299 

300 try: 

301 ext = "." + result.filename.rsplit(".")[1] 

302 except IndexError: 

303 ext = "" 

304 

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 ) 

316 

317 download_instructions.append(DownloadInstructions( 

318 result, self.season.path, episode_name 

319 )) 

320 episode_count += 1 

321 

322 else: 

323 break 

324 

325 self.download(download_instructions) 

326 self._update_episode_names() 

327 

328 def validate(self): 

329 """ 

330 Checks if the configuration is valid 

331 :return: None 

332 """ 

333 super().validate() 

334 

335 # Check is done in season property definition 

336 self.logger.debug("Loading season {}".format(self.season)) 

337 

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() 

347 

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) 

359 

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}", ".*?") 

376 

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}", "") 

384 

385 return pattern