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 shutil
22import argparse
23from typing import List, Dict, Union
24from youtube_dl.YoutubeDL import YoutubeDL
25from colorama import Fore, Style
26from puffotter.prompt import prompt
27from puffotter.os import makedirs, listdir, get_ext
28from toktokkie.commands.Command import Command
29from toktokkie.exceptions import MissingMetadata, InvalidMetadata
30from toktokkie.enums import IdType
31from toktokkie.metadata.music.Music import Music
32from toktokkie.metadata.music.components.MusicAlbum import MusicAlbum
35class YoutubeMusicDlCommand(Command):
36 """
37 Class that encapsulates behaviour of the youtube-music-dl command
38 """
40 @classmethod
41 def name(cls) -> str:
42 """
43 :return: The command name
44 """
45 return "youtube-music-dl"
47 @classmethod
48 def help(cls) -> str:
49 """
50 :return: The help message for the command
51 """
52 return "Downloads music from youtube"
54 @classmethod
55 def prepare_parser(cls, parser: argparse.ArgumentParser):
56 """
57 Prepares an argumentparser for this command
58 :param parser: The parser to prepare
59 :return: None
60 """
61 parser.add_argument("artist_directory",
62 help="The directory of the artist")
63 parser.add_argument("year", type=int, help="The year of the music")
64 parser.add_argument("genre", help="The genre of the music")
65 parser.add_argument("youtube_urls", nargs="+",
66 help="The youtube video/playlist URLs")
67 parser.add_argument("--album-name", help="Specifies an album name for"
68 "the downloaded playlist")
69 parser.add_argument("--only-audio", action="store_true",
70 help="If specified, discards video files")
72 def execute(self):
73 """
74 Executes the commands
75 :return: None
76 """
77 directory = self.args.artist_directory
79 # Create directory if it does not exist
80 try:
81 metadata = Music(directory)
82 except MissingMetadata:
83 makedirs(directory)
84 self.logger.warning("Missing metadata")
85 metadata = Music.from_prompt(directory)
86 metadata.write()
87 except InvalidMetadata:
88 self.logger.warning(f"Invalid music metadata for {directory}")
89 return
91 tmp_dir = "/tmp/toktokkie-ytmusicdl"
92 makedirs(tmp_dir, delete_before=True)
94 downloaded = self.download_from_youtube(tmp_dir)
95 self.create_albums(metadata, downloaded)
96 metadata.rename(noconfirm=True)
97 metadata.apply_tags()
99 def download_from_youtube(self, target_dir: str) \
100 -> List[Dict[str, Union[str, Dict[str, str]]]]:
101 """
102 Downloads all youtube URLs from the command line arguments into a
103 directory. Both mp4 and mp3 files will be downloaded if not specified
104 otherwise by command line arguments.
105 Playlists will be resolved to their own video IDs and downloaded
106 seperately
107 :param target_dir: The directory in which to store the downloaded files
108 :return: The downloaded songs in the following form:
109 [{"files": {extension: path}}, "id": "ID"}, ...]
110 """
111 mp3_args = {
112 "format": "bestaudio/best",
113 "postprocessors": [{
114 "key": "FFmpegExtractAudio",
115 "preferredcodec": "mp3",
116 "preferredquality": "192",
117 }],
118 "outtmpl": f"{target_dir}/%(title)s.%(ext)s",
119 "ignoreerrors": True
120 }
121 mp4_args = {
122 "format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4",
123 "outtmpl": f"{target_dir}/%(title)s-video.%(ext)s",
124 "ignoreerrors": True
125 }
127 youtube_dl_args = [mp3_args]
128 if not self.args.only_audio:
129 youtube_dl_args.append(mp4_args)
131 video_ids = []
132 with YoutubeDL() as yt_info:
133 for youtube_url in self.args.youtube_urls:
134 url_info = yt_info.extract_info(youtube_url, download=False)
135 if url_info.get("_type") == "playlist":
136 for entry in url_info["entries"]:
137 video_ids.append(entry["id"])
138 else:
139 video_ids.append(url_info["id"])
141 directory_content = []
142 downloaded = []
143 for video_id in video_ids:
144 video_url = f"https://www.youtube.com/watch?v={video_id}"
145 for args in youtube_dl_args:
146 with YoutubeDL(args) as youtube_dl:
147 youtube_dl.download([video_url])
149 new_content = sorted([
150 x[1] for x in listdir(target_dir)
151 if x[1] not in directory_content
152 ])
153 directory_content += new_content
155 files = {get_ext(x): x for x in new_content}
156 mp3_file = os.path.basename(files["mp3"])
157 mp3_name = mp3_file.rsplit(".mp3", 1)[0]
159 entry = {
160 "id": video_id,
161 "files": files,
162 "name": mp3_name
163 }
164 downloaded.append(entry)
166 for entry in downloaded:
167 entry["name"] = prompt(
168 f"Enter song name for "
169 f"{Fore.LIGHTYELLOW_EX}{entry['name']}{Style.RESET_ALL}",
170 required=True
171 )
173 return downloaded
175 def create_albums(
176 self,
177 music: Music,
178 info: List[Dict[str, Union[str, Dict[str, str]]]]
179 ):
180 """
181 Creates album objects based on downloaded info
182 :param music: The music metadata
183 :param info: The info from youtube download
184 :return: None
185 """
186 existing = [x.name for x in music.albums]
187 if self.args.album_name is not None:
189 if self.args.album_name in existing:
190 self.logger.warning(
191 f"Album {self.args.album_name} already exists"
192 )
193 return
195 ids: Dict[IdType, List[str]] = {IdType.YOUTUBE_VIDEO: []}
196 for entry in info:
197 assert isinstance(entry["id"], str)
198 ids[IdType.YOUTUBE_VIDEO].append(entry["id"])
200 album = MusicAlbum(
201 music.directory_path,
202 music.ids,
203 ids,
204 self.args.album_name,
205 self.args.genre,
206 self.args.year
207 )
208 makedirs(album.path)
209 music.add_album(album)
211 names = []
212 for entry in info:
213 assert isinstance(entry["name"], str)
214 names.append(entry["name"])
216 order = self.prompt_song_order(names)
217 for entry in info:
218 assert isinstance(entry["name"], str)
219 assert isinstance(entry["files"], dict)
220 index = str(order.index(entry["name"]) + 1).zfill(2)
221 for ext, path in entry["files"].items():
222 shutil.move(path, os.path.join(
223 album.path,
224 f"{index} - {entry['name']}.{ext}"
225 ))
226 else:
227 for entry in info:
228 assert isinstance(entry["id"], str)
229 assert isinstance(entry["name"], str)
230 assert isinstance(entry["files"], dict)
231 album = MusicAlbum(
232 music.directory_path,
233 music.ids,
234 {IdType.YOUTUBE_VIDEO: [entry["id"]]},
235 entry["name"],
236 self.args.genre,
237 self.args.year
238 )
239 if album.name in existing:
240 self.logger.warning(f"Album {album.name} already exists")
241 continue
242 else:
243 makedirs(album.path)
244 music.add_album(album)
245 for ext, path in entry["files"].items():
246 shutil.move(
247 path,
248 os.path.join(album.path, f"{entry['name']}.{ext}")
249 )
251 music.write()
253 @staticmethod
254 def prompt_song_order(names: List[str]) -> List[str]:
255 """
256 Prompts the user for the song order in an album
257 :param names: The names of the songs
258 :return: List of ordered song names
259 """
260 ordered = []
261 while len(names) > 1:
262 for i, song in enumerate(names):
263 print(f"{i + 1} - {song}")
264 index = prompt(
265 "Next song in order: ",
266 _type=int,
267 choices={str(x) for x in range(1, len(names) + 1)}
268 ) - 1
269 selected = names.pop(index)
270 ordered.append(selected)
272 assert len(names) == 1
273 ordered.append(names.pop(0))
275 return ordered