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 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
39class AnimeThemeDlCommand(Command):
40 """
41 Class that encapsulates behaviour of the anitheme-dl command
42 """
44 @classmethod
45 def name(cls) -> str:
46 """
47 :return: The command name
48 """
49 return "anitheme-dl"
51 @classmethod
52 def help(cls) -> str:
53 """
54 :return: The help message for the command
55 """
56 return "Downloads anime theme songs"
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")
73 def execute(self):
74 """
75 Executes the commands
76 :return: None
77 """
78 makedirs(self.args.out)
80 series_names = self.load_titles(self.args.year, self.args.season)
81 selected_series = self.prompt_selection(series_names)
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)
95 for song in selected_songs:
96 song.download_webm()
97 song.convert_to_mp3()
99 self.logger.info("Generating Artist/Album Structure")
100 self.generate_artist_album_structure(selected_songs)
102 self.post_commands()
104 self.logger.info("Done")
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")
116 for category in [ops_dir, eds_dir]:
117 dirs = list(map(lambda x: x[1], listdir(category)))
119 rename_ns = argparse.Namespace()
120 rename_ns.__dict__["directories"] = dirs
121 rename_ns.__dict__["noconfirm"] = True
122 RenameCommand(rename_ns).execute()
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()
134 album_art_ns = argparse.Namespace()
135 album_art_ns.__dict__["directories"] = dirs
136 AlbumArtFetchCommand(album_art_ns).execute()
138 music_tag_ns = argparse.Namespace()
139 music_tag_ns.__dict__["directories"] = dirs
140 MusicTagCommand(music_tag_ns).execute()
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)
160 soup = BeautifulSoup(response, "html.parser")
161 listings = soup.find("div", {"class": "md wiki"})
163 entries = listings.find_all("h3")
164 entries = list(map(lambda x: x.text, entries))
166 position = {"Winter": 1, "Spring": 2, "Summer": 3, "Fall": 4}
167 segments = self.segmentize(entries)
169 this_segment = segments[-position[season]]
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
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 """
188 segments = [] # type: List[List[str]]
189 current_segment = [] # type: List[str]
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)
200 return segments
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]]
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"]
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
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
235 while True:
237 selection = input(
238 "Please select the series for which to download songs: "
239 ).strip()
241 if selection == "":
242 print("Invalid Selection")
243 continue
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
252 with open(selection_file, "w") as f:
253 config["selection"] = parts
254 f.write(json.dumps(config))
256 return parts
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]]
271 use_old = False
272 excludes = [] # type: List[str]
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")
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
288 if not use_old:
289 for i, song in enumerate(selected_songs):
290 print("[{}]: {}".format(i + 1, song))
292 while True:
294 selection = \
295 input("Please select the songs to exclude: ").strip()
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
311 with open(excludes_file, "w") as f:
312 config["excludes"] = excludes
313 f.write(json.dumps(config))
315 new_selection = []
316 for song in selected_songs:
317 if song.filename not in excludes:
318 new_selection.append(song)
320 return new_selection
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
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))
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)
360 makedirs(oped_dir)
362 artists = {} # type: Dict[str, List[AniTheme]]
364 for song in songs:
365 if song.artist in artists:
366 artists[song.artist].append(song)
367 else:
368 artists[song.artist] = [song]
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 = []
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
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")
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)
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)
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()