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

33 

34 

35class YoutubeMusicDlCommand(Command): 

36 """ 

37 Class that encapsulates behaviour of the youtube-music-dl command 

38 """ 

39 

40 @classmethod 

41 def name(cls) -> str: 

42 """ 

43 :return: The command name 

44 """ 

45 return "youtube-music-dl" 

46 

47 @classmethod 

48 def help(cls) -> str: 

49 """ 

50 :return: The help message for the command 

51 """ 

52 return "Downloads music from youtube" 

53 

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

71 

72 def execute(self): 

73 """ 

74 Executes the commands 

75 :return: None 

76 """ 

77 directory = self.args.artist_directory 

78 

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 

90 

91 tmp_dir = "/tmp/toktokkie-ytmusicdl" 

92 makedirs(tmp_dir, delete_before=True) 

93 

94 downloaded = self.download_from_youtube(tmp_dir) 

95 self.create_albums(metadata, downloaded) 

96 metadata.rename(noconfirm=True) 

97 metadata.apply_tags() 

98 

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 } 

126 

127 youtube_dl_args = [mp3_args] 

128 if not self.args.only_audio: 

129 youtube_dl_args.append(mp4_args) 

130 

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

140 

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

148 

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 

154 

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] 

158 

159 entry = { 

160 "id": video_id, 

161 "files": files, 

162 "name": mp3_name 

163 } 

164 downloaded.append(entry) 

165 

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 ) 

172 

173 return downloaded 

174 

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: 

188 

189 if self.args.album_name in existing: 

190 self.logger.warning( 

191 f"Album {self.args.album_name} already exists" 

192 ) 

193 return 

194 

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

199 

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) 

210 

211 names = [] 

212 for entry in info: 

213 assert isinstance(entry["name"], str) 

214 names.append(entry["name"]) 

215 

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 ) 

250 

251 music.write() 

252 

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) 

271 

272 assert len(names) == 1 

273 ordered.append(names.pop(0)) 

274 

275 return ordered