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 json 

22import shutil 

23import argparse 

24from typing import Dict, List 

25from bs4 import BeautifulSoup 

26from toktokkie.commands.Command import Command 

27from toktokkie.metadata.music.Music import Music 

28from puffotter.os import makedirs, listdir 

29from puffotter.requests import aggressive_request 

30from toktokkie.metadata.music.components.MusicAlbum import MusicAlbum 

31from toktokkie.metadata.music.components.MusicThemeSong import MusicThemeSong 

32from toktokkie.commands.RenameCommand import RenameCommand 

33from toktokkie.commands.PlaylistCreateCommand import PlaylistCreateCommand 

34from toktokkie.commands.AlbumArtFetchCommand import AlbumArtFetchCommand 

35from toktokkie.commands.MusicTagCommand import MusicTagCommand 

36from toktokkie.utils.anithemes.AniTheme import AniTheme 

37 

38 

39class AnimeThemeDlCommand(Command): 

40 """ 

41 Class that encapsulates behaviour of the anitheme-dl command 

42 """ 

43 

44 @classmethod 

45 def name(cls) -> str: 

46 """ 

47 :return: The command name 

48 """ 

49 return "anitheme-dl" 

50 

51 @classmethod 

52 def help(cls) -> str: 

53 """ 

54 :return: The help message for the command 

55 """ 

56 return "Downloads anime theme songs" 

57 

58 @classmethod 

59 def prepare_parser(cls, parser: argparse.ArgumentParser): 

60 """ 

61 Prepares an argumentparser for this command 

62 :param parser: The parser to prepare 

63 :return: None 

64 """ 

65 parser.add_argument("year", type=int, 

66 help="The year for which to download songs") 

67 parser.add_argument("season", type=str, 

68 choices={"Spring", "Winter", "Summer", "Fall"}, 

69 help="The season for which to download songs.") 

70 parser.add_argument("--out", "-o", default="dl", 

71 help="The destination directory") 

72 

73 def execute(self): 

74 """ 

75 Executes the commands 

76 :return: None 

77 """ 

78 makedirs(self.args.out) 

79 

80 series_names = self.load_titles(self.args.year, self.args.season) 

81 selected_series = self.prompt_selection(series_names) 

82 

83 self.logger.info("Loading data...") 

84 selected_songs = AniTheme.load_reddit_anithemes_wiki_info( 

85 self.args.year, 

86 self.args.season, 

87 selected_series 

88 ) 

89 selected_songs = list(filter( 

90 lambda x: not x.alternate_version, 

91 selected_songs 

92 )) 

93 selected_songs = self.handle_excludes(selected_songs) 

94 

95 for song in selected_songs: 

96 song.download_webm() 

97 song.convert_to_mp3() 

98 

99 self.logger.info("Generating Artist/Album Structure") 

100 self.generate_artist_album_structure(selected_songs) 

101 

102 self.post_commands() 

103 

104 self.logger.info("Done") 

105 

106 def post_commands(self): 

107 """ 

108 Commands executed after the core anitheme-dl functionality has 

109 been completed 

110 :return: None 

111 """ 

112 structure_dir = self.args.out 

113 ops_dir = os.path.join(structure_dir, "OP") 

114 eds_dir = os.path.join(structure_dir, "ED") 

115 

116 for category in [ops_dir, eds_dir]: 

117 dirs = list(map(lambda x: x[1], listdir(category))) 

118 

119 rename_ns = argparse.Namespace() 

120 rename_ns.__dict__["directories"] = dirs 

121 rename_ns.__dict__["noconfirm"] = True 

122 RenameCommand(rename_ns).execute() 

123 

124 playlist_ns = argparse.Namespace() 

125 playlist_ns.__dict__["directories"] = dirs 

126 playlist_ns.__dict__["playlist_file"] = os.path.join( 

127 structure_dir, 

128 "{} playlist.m3u".format(os.path.basename(category)) 

129 ) 

130 playlist_ns.__dict__["format"] = "m3u" 

131 playlist_ns.__dict__["prefix"] = None 

132 PlaylistCreateCommand(playlist_ns).execute() 

133 

134 album_art_ns = argparse.Namespace() 

135 album_art_ns.__dict__["directories"] = dirs 

136 AlbumArtFetchCommand(album_art_ns).execute() 

137 

138 music_tag_ns = argparse.Namespace() 

139 music_tag_ns.__dict__["directories"] = dirs 

140 MusicTagCommand(music_tag_ns).execute() 

141 

142 def load_titles( 

143 self, 

144 year: int, 

145 season: str, 

146 include_previous_season: bool = True 

147 ) -> List[str]: 

148 """ 

149 Loads a list of titles which can then be selected by the user 

150 :param year: The year for which to fetch titles 

151 :param season: The season for which to fetch titles 

152 :param include_previous_season: Whether to include the previous season 

153 :return: The list of titles 

154 """ 

155 print("Loading titles...") 

156 url = "https://old.reddit.com/r/AnimeThemes/wiki/" \ 

157 "{}#wiki_{}_{}_season".format(year, year, season) 

158 response = aggressive_request(url) 

159 

160 soup = BeautifulSoup(response, "html.parser") 

161 listings = soup.find("div", {"class": "md wiki"}) 

162 

163 entries = listings.find_all("h3") 

164 entries = list(map(lambda x: x.text, entries)) 

165 

166 position = {"Winter": 1, "Spring": 2, "Summer": 3, "Fall": 4} 

167 segments = self.segmentize(entries) 

168 

169 this_segment = segments[-position[season]] 

170 

171 if include_previous_season: 

172 if season == "Winter": 

173 additional_segment = self.load_titles(year - 1, "Fall", False) 

174 else: 

175 additional_segment = segments[-position[season] + 1] 

176 return additional_segment + this_segment 

177 else: 

178 return this_segment 

179 

180 @staticmethod 

181 def segmentize(titles: List[str]) -> List[List[str]]: 

182 """ 

183 Segments a list of titles into segments (seasons) 

184 :param titles: The titles to segmentize 

185 :return: The segments 

186 """ 

187 

188 segments = [] # type: List[List[str]] 

189 current_segment = [] # type: List[str] 

190 

191 for i, title in enumerate(titles): 

192 if i > 0 \ 

193 and titles[i - 1].upper() > title.upper() \ 

194 and titles[i - 1][0].lower() != title[0].lower(): 

195 segments.append(current_segment) 

196 current_segment = [] 

197 current_segment.append(title) 

198 segments.append(current_segment) 

199 

200 return segments 

201 

202 def prompt_selection(self, shows: List[str]) -> List[str]: 

203 """ 

204 Prompts the user for a selection of series for which to download songs 

205 :param shows: All series that are up for selection 

206 :return: A list of series names that were selected 

207 """ 

208 config = {} # type: Dict[str, List[str]] 

209 

210 selection_file = os.path.join(self.args.out, "config.json") 

211 if os.path.isfile(selection_file): 

212 with open(selection_file, "r") as f: 

213 config = json.loads(f.read()) 

214 old_selection = config["selection"] 

215 

216 while True: 

217 resp = input("Use previous selection? {} (y|n)" 

218 .format(old_selection)) 

219 if resp.lower() in ["y", "n"]: 

220 if resp.lower() == "y": 

221 return old_selection 

222 else: 

223 break 

224 else: 

225 continue 

226 

227 segments = self.segmentize(shows) 

228 counter = 0 

229 for segment in segments: 

230 print("-" * 80) 

231 for show in segment: 

232 print("[{}]: {}".format(counter + 1, show)) 

233 counter += 1 

234 

235 while True: 

236 

237 selection = input( 

238 "Please select the series for which to download songs: " 

239 ).strip() 

240 

241 if selection == "": 

242 print("Invalid Selection") 

243 continue 

244 

245 try: 

246 parts = selection.strip().split(",") 

247 parts = list(map(lambda x: shows[int(x) - 1], parts)) 

248 except (ValueError, IndexError): 

249 print("Invalid Selection") 

250 continue 

251 

252 with open(selection_file, "w") as f: 

253 config["selection"] = parts 

254 f.write(json.dumps(config)) 

255 

256 return parts 

257 

258 def handle_excludes( 

259 self, 

260 selected_songs: List[AniTheme] 

261 ) -> List[AniTheme]: 

262 """ 

263 Allows the user to exclude certain songs from being downloaded 

264 Deletes any files that may already exist for excluded songs 

265 :param selected_songs: All currently selected songs 

266 :return: The selected songs minus any excluded songs 

267 """ 

268 excludes_file = os.path.join(self.args.out, "config.json") 

269 config = {} # type: Dict[str, List[str]] 

270 

271 use_old = False 

272 excludes = [] # type: List[str] 

273 

274 if os.path.isfile(excludes_file): 

275 with open(excludes_file, "r") as f: 

276 config = json.loads(f.read()) 

277 old_selection = config.get("excludes") 

278 

279 while old_selection is not None: 

280 resp = input("Use previous exclusion? {} (y|n)" 

281 .format(old_selection)) 

282 if resp.lower() in ["y", "n"]: 

283 if resp.lower() == "y": 

284 excludes = old_selection 

285 use_old = True 

286 break 

287 

288 if not use_old: 

289 for i, song in enumerate(selected_songs): 

290 print("[{}]: {}".format(i + 1, song)) 

291 

292 while True: 

293 

294 selection = \ 

295 input("Please select the songs to exclude: ").strip() 

296 

297 if selection == "": 

298 excludes = [] 

299 break 

300 try: 

301 parts = selection.strip().split(",") 

302 excludes = list(map( 

303 lambda x: selected_songs[int(x) - 1].filename, 

304 parts 

305 )) 

306 except (ValueError, IndexError): 

307 print("Invalid Selection") 

308 continue 

309 break 

310 

311 with open(excludes_file, "w") as f: 

312 config["excludes"] = excludes 

313 f.write(json.dumps(config)) 

314 

315 new_selection = [] 

316 for song in selected_songs: 

317 if song.filename not in excludes: 

318 new_selection.append(song) 

319 

320 return new_selection 

321 

322 @staticmethod 

323 def resolve_selected_songs( 

324 selected_series: List[str], 

325 data: Dict[str, List[Dict[str, str]]] 

326 ) -> List[Dict[str, str]]: 

327 """ 

328 Retrieves a list of all songs that are included in a 

329 selection of series 

330 :param selected_series: The selection of series 

331 :param data: The song data from reddit 

332 :return: The list of selected songs 

333 """ 

334 selected_songs = [] # type: List[Dict[str, str]] 

335 for series in selected_series: 

336 selected_songs += data[series] 

337 return selected_songs 

338 

339 def generate_artist_album_structure( 

340 self, 

341 selected_songs: List[AniTheme] 

342 ): 

343 """ 

344 Generates a folder structure for OPs and EDs following the schema: 

345 Artist 

346 - Album 

347 - Song 

348 Songs are copied from the mp3 directory. 

349 :param selected_songs: The song data 

350 :return: None 

351 """ 

352 ops = list(filter(lambda x: "OP" in x.theme_type, selected_songs)) 

353 eds = list(filter(lambda x: "ED" in x.theme_type, selected_songs)) 

354 

355 for oped_type, songs in [("OP", ops), ("ED", eds)]: 

356 oped_dir = os.path.join(self.args.out, oped_type) 

357 if os.path.isdir(oped_dir): 

358 shutil.rmtree(oped_dir) 

359 

360 makedirs(oped_dir) 

361 

362 artists = {} # type: Dict[str, List[AniTheme]] 

363 

364 for song in songs: 

365 if song.artist in artists: 

366 artists[song.artist].append(song) 

367 else: 

368 artists[song.artist] = [song] 

369 

370 for artist, artist_songs in artists.items(): 

371 artist_dir = os.path.join(oped_dir, artist) 

372 makedirs(artist_dir) 

373 albums_metadata = [] 

374 theme_songs_metadata = [] 

375 

376 for song in artist_songs: 

377 mp3_file = song.temp_mp3_file 

378 webm_file = song.temp_webm_file 

379 album = song.song_name 

380 title = song.filename 

381 

382 album_dir = os.path.join(artist_dir, album) 

383 song_path = os.path.join(album_dir, title + ".mp3") 

384 vid_path = os.path.join(album_dir, title + "-video.webm") 

385 

386 makedirs(album_dir) 

387 if not os.path.isfile(song_path): 

388 shutil.copyfile(mp3_file, song_path) 

389 if not os.path.isfile(vid_path): 

390 shutil.copyfile(webm_file, vid_path) 

391 

392 album_obj = MusicAlbum.from_json(artist_dir, {}, { 

393 "name": song.song_name, 

394 "ids": {}, 

395 "genre": "Anime", 

396 "year": int(self.args.year) 

397 }) 

398 albums_metadata.append(album_obj) 

399 theme_songs_metadata.append(MusicThemeSong.from_json( 

400 album_obj, 

401 { 

402 "name": song.song_name, 

403 "series_ids": { 

404 "myanimelist": [str(song.mal_id)], 

405 "anilist": [str(song.anilist_id)] 

406 }, 

407 "theme_type": oped_type.lower() 

408 } 

409 )) 

410 metadir = os.path.join(artist_dir, ".meta") 

411 icondir = os.path.join(metadir, "icons") 

412 makedirs(metadir) 

413 makedirs(icondir) 

414 

415 metadata = { 

416 "type": "music", 

417 "tags": [], 

418 "ids": {"musicbrainz_artist": ["0"]}, 

419 "albums": [x.json for x in albums_metadata], 

420 "theme_songs": [x.json for x in theme_songs_metadata] 

421 } 

422 Music(artist_dir, json_data=metadata).write()