def songobj_from_spotify_url(spotifyURL: str, output_format: str = None): # check if URL is a playlist, user, artist or album, if yes raise an Exception, # else procede # Get the Song Metadata print(f"Gathering Spotify Metadata for: {spotifyURL}") rawTrackMeta, rawArtistMeta, rawAlbumMeta = metadataProvider.from_url(spotifyURL) songName = rawTrackMeta["name"] albumName = rawTrackMeta["album"]["name"] contributingArtists = [] for artist in rawTrackMeta["artists"]: contributingArtists.append(artist["name"]) duration = round(rawTrackMeta["duration_ms"] / 1000, ndigits=3) convertedFileName = SongObj.create_file_name( songName, [artist["name"] for artist in rawTrackMeta["artists"]] ) if output_format is None: output_format = "mp3" convertedFilePath = Path(".", f"{convertedFileName}.{output_format}") displayName = ", ".join(contributingArtists) + " - " + songName # if a song is already downloaded skip it if convertedFilePath.is_file(): print(f'Skipping "{convertedFileName}" as it\'s already downloaded') return None # Get the song's downloadable audio link print(f'Searching YouTube for "{displayName}"', end="\r") youtubeLink = audioProvider.search_and_get_best_match( songName, contributingArtists, albumName, duration ) if youtubeLink is None: # raise Exception("Could not match any of the results on YouTube") print("Could not match any of the results on YouTube. Skipping") return None else: print(" " * (len(displayName) + 25), end="\r") print(f'Found YouTube URL for "{displayName}" : {youtubeLink}') # (try to) Get lyrics from Genius try: lyrics = metadataProvider.get_song_lyrics(songName, contributingArtists) except (AttributeError, IndexError): lyrics = "" return SongObj(rawTrackMeta, rawAlbumMeta, rawArtistMeta, youtubeLink, lyrics)
def load_tracking_file(self, trackingFilePath: str) -> None: """ `str` `trackingFilePath` : path to a .spotdlTrackingFile RETURNS `~` reads songsObj's from disk and prepares to track their download """ # Attempt to read .spotdlTrackingFile, raise exception if file can't be read trackingFile = Path(trackingFilePath) if not trackingFile.is_file(): raise FileNotFoundError( f"no such tracking file found: {trackingFilePath}") with trackingFile.open("rb") as file_handle: songDataDumps = eval(file_handle.read().decode()) # Save path to .spotdlTrackingFile self.saveFile = trackingFile # convert song data dumps to songObj's # ! see, songObj.get_data_dump and songObj.from_dump for more details for dump in songDataDumps: self.songObjList.append(SongObj.from_dump(dump))
def search_for_song(query: str) -> SongObj: ''' `str` `query` : what you'd type into spotify's search box Queries Spotify for a song and returns the best match ''' # get a spotify client spotifyClient = get_spotify_client() # get possible matches from spotify result = spotifyClient.search(query, type='track') # return first result link or if no matches are found, raise and Exception if len(result['tracks']['items']) == 0: raise Exception('No song matches found on Spotify') else: for songResult in result['tracks']['items']: songUrl = 'http://open.spotify.com/track/' + songResult['id'] song = SongObj.from_url(songUrl) if song.get_youtube_link() != None: return song raise Exception('Could not match any of the results on YouTube')
def get_album_tracks(albumUrl: str) -> List[SongObj]: ''' `str` `albumUrl` : Spotify Url of the album whose tracks are to be retrieved returns a `list<songObj>` containing Url's of each track in the given album ''' spotifyClient = get_spotify_client() albumTracks = [] trackResponse = spotifyClient.album_tracks(albumUrl) # while loop acts like do-while while True: for track in trackResponse['items']: song = SongObj.from_url('https://open.spotify.com/track/' + track['id']) if song.get_youtube_link() != None: albumTracks.append(song) # check if more tracks are to be passed if trackResponse['next']: trackResponse = spotifyClient.album_tracks(albumUrl, offset=len(albumTracks)) else: break return albumTracks
def create_song_obj(name="test song", artist="test artist") -> SongObj: artists = [{"name": artist}] raw_track_meta = { "name": name, "album": { "name": "test album", "artists": artists, "release_date": "2021", "images": [{ "url": "https://i.ytimg.com/vi_webp/iqKdEhx-dD4/hqdefault.webp" }], }, "artists": artists, "track_number": "1", "genres": ["test genre"], } raw_album_meta = {"genres": ["test genre"]} raw_artist_meta = {"genres": ["test artist genre"]} return SongObj( raw_track_meta, raw_album_meta, raw_artist_meta, "https://www.youtube.com/watch?v=Th_C95UMegc", )
def load_tracking_file(self, trackingFilePath: str) -> None: ''' `str` `trackingFilePath` : path to a .spotdlTrackingFile RETURNS `~` reads songsObj's from disk and prepares to track their download ''' # Attempt to read .spotdlTrackingFile, raise exception if file can't be read try: file = open(trackingFilePath, 'rb') songDataDumps = eval(file.read().decode()) file.close() except FileNotFoundError: raise Exception('no such tracking file found: %s' % trackingFilePath) # Save path to .spotdlTrackingFile self.saveFile = trackingFilePath # convert song data dumps to songObj's #! see, songObj.get_data_dump and songObj.from_dump for more details for dump in songDataDumps: self.songObjList.append(SongObj.from_dump(dump))
def get_playlist_tracks(playlistUrl: str) -> List[SongObj]: ''' `str` `playlistUrl` : Spotify Url of the album whose tracks are to be retrieved returns a `list<songObj>` containing Url's of each track in the given playlist ''' spotifyClient = SpotifyClient() playlistTracks = [] playlistResponse = spotifyClient.playlist_tracks(playlistUrl) # while loop to mimic do-while while True: for songEntry in playlistResponse['items']: if songEntry['track'] is None or songEntry['track']['id'] is None: continue song = SongObj.from_url('https://open.spotify.com/track/' + songEntry['track']['id']) if song.get_youtube_link() is not None: playlistTracks.append(song) # check if more tracks are to be passed if playlistResponse['next']: playlistResponse = spotifyClient.playlist_tracks( playlistUrl, offset=playlistResponse['offset'] + playlistResponse['limit']) else: break return playlistTracks
def get_artist_tracks(artistUrl: str) -> List[SongObj]: ''' `str` `albumUrl` : Spotify Url of the artist whose tracks are to be retrieved returns a `list<songObj>` containing Url's of each track in the artist profile ''' spotifyClient = SpotifyClient() artistTracks = [] offset = 0 artistResponse = spotifyClient.artist_albums(artistUrl) # while loop acts like do-while while True: for album in artistResponse['items']: # get albums and singles if not (album['album_group'] == 'appears_on' and album['album_type'] in ['album', 'compilation']): artistTracks.extend(get_album_tracks(album['id'])) # get features from other artists albums elif album['album_group'] == 'appears_on' and album[ 'album_type'] == 'album': trackResponse = spotifyClient.album_tracks(album['uri']) albumTracks = [] # while loop acts like do-while while True: for track in trackResponse['items']: for artist in track['artists']: if artist['id'] == artistResponse['href'].split( '/')[-2]: song = SongObj.from_url( 'https://open.spotify.com/track/' + track['id']) if song.get_youtube_link() is not None: albumTracks.append(song) # check if more tracks are to be passed if trackResponse['next']: trackResponse = spotifyClient.album_tracks( album['uri'], offset=len(albumTracks)) else: break artistTracks.extend(albumTracks) offset += len(artistResponse['items']) # check if more albums are to be passed if artistResponse['next']: artistResponse = spotifyClient.artist_albums(artistUrl, offset=offset) else: break return artistTracks
def console_entry_point(): ''' This is where all the console processing magic happens. Its super simple, rudimentary even but, it's dead simple & it works. ''' arguments = parse_arguments() SpotifyClient.init(client_id='4fe3fecfe5334023a1472516cc99d805', client_secret='0f02b7c483c04257984695007a4a8d5c') if arguments.path: if not os.path.isdir(arguments.path): sys.exit("The output directory doesn't exist.") print(f"Will download to: {os.path.abspath(arguments.path)}") os.chdir(arguments.path) with DownloadManager() as downloader: for request in arguments.url: if 'open.spotify.com' in request and 'track' in request: print('Fetching Song...') song = SongObj.from_url(request) if song.get_youtube_link() is not None: downloader.download_single_song(song) else: print( 'Skipping %s (%s) as no match could be found on youtube' % (song.get_song_name(), request)) elif 'open.spotify.com' in request and 'album' in request: print('Fetching Album...') songObjList = get_album_tracks(request) downloader.download_multiple_songs(songObjList) elif 'open.spotify.com' in request and 'playlist' in request: print('Fetching Playlist...') songObjList = get_playlist_tracks(request) downloader.download_multiple_songs(songObjList) elif 'open.spotify.com' in request and 'artist' in request: print('Fetching artist...') artistObjList = get_artist_tracks(request) downloader.download_multiple_songs(artistObjList) elif request.endswith('.spotdlTrackingFile'): print('Preparing to resume download...') downloader.resume_download_from_tracking_file(request) else: print('Searching for song "%s"...' % request) try: song = search_for_song(request) downloader.download_single_song(song) except Exception as e: print(e)
def from_dump(dataDump: dict): rawTrackMeta = dataDump["rawTrackMeta"] rawAlbumMeta = dataDump["rawAlbumMeta"] rawArtistMeta = dataDump["rawAlbumMeta"] youtubeLink = dataDump["youtubeLink"] lyrics = dataDump["lyrics"] return SongObj(rawTrackMeta, rawAlbumMeta, rawArtistMeta, youtubeLink, lyrics)
def console_entry_point(): ''' This is where all the console processing magic happens. Its super simple, rudimentary even but, it's dead simple & it works. ''' if '--help' in sys.argv or '-h' in sys.argv or len(sys.argv) == 1: print(help_notice) #! We use 'return None' as a convenient exit/break from the function return None spotifyClient.initialize( clientId='4fe3fecfe5334023a1472516cc99d805', clientSecret='0f02b7c483c04257984695007a4a8d5c' ) downloader = DownloadManager() for request in sys.argv[1:]: if 'open.spotify.com' in request and 'track' in request: print('Fetching Song...') song = SongObj.from_url(request) if song.get_youtube_link() != None: downloader.download_single_song(song) else: print('Skipping %s (%s) as no match could be found on youtube' % ( song.get_song_name(), request )) elif 'open.spotify.com' in request and 'album' in request: print('Fetching Album...') songObjList = get_album_tracks(request) downloader.download_multiple_songs(songObjList) elif 'open.spotify.com' in request and 'playlist' in request: print('Fetching Playlist...') songObjList = get_playlist_tracks(request) downloader.download_multiple_songs(songObjList) elif request.endswith('.spotdlTrackingFile'): print('Preparing to resume download...') downloader.resume_download_from_tracking_file(request) else: print('Searching for song "%s"...' % request) try: song = search_for_song(request) downloader.download_single_song(song) except Exception: print('No song named "%s" could be found on spotify' % request) downloader.close()
def dlmusic(): Dir=Dirfield.get() url=musfield.get() temp=SongObj.from_url(url) currdir=os.getcwd() os.chdir(Dir) download=DownloadManager() download.download_single_song(songObj=temp) os.remove('./Temp') os.chdir(currdir) return
def get_song_obj_from_url(songEntry, playlistResponse, spotifyClient, playlistUrl): playlistTracks = [] song = SongObj.from_url('https://open.spotify.com/track/' + songEntry['track']['id']) print('Got song info: %s' % (song.get_song_name())) if song.get_youtube_link() != None: playlistTracks.append(song) # check if more tracks are to be passed if playlistResponse['next']: playlistResponse = spotifyClient.playlist_tracks( playlistUrl, offset=len(playlistTracks)) return playlistTracks
def process_request(downloader, request): if 'open.spotify.com' in request and 'track' in request: print('Fetching Song...') song = SongObj.from_url(request) if song.get_youtube_link() is not None: downloader.download_single_song(song) else: print('Skipping %s (%s) as no match could be found on youtube' % (song.get_song_name(), request)) elif 'open.spotify.com' in request and 'album' in request: print('Fetching Album...') songObjList = get_album_tracks(request) downloader.download_multiple_songs(songObjList) elif 'open.spotify.com' in request and 'playlist' in request: print('Fetching Playlist...') songObjList = get_playlist_tracks(request) downloader.download_multiple_songs(songObjList) elif 'open.spotify.com' in request and 'artist' in request: print('Fetching artist...') artistObjList = get_artist_tracks(request) downloader.download_multiple_songs(artistObjList) elif request.endswith('.spotdlTrackingFile'): print('Preparing to resume download...') downloader.resume_download_from_tracking_file(request) else: print('Searching for song "%s"...' % request) try: song = search_for_song(request) downloader.download_single_song(song) except Exception as e: print(e)
async def download_song(self, songObj: SongObj) -> None: ''' `songObj` `songObj` : song to be downloaded RETURNS `~` Downloads, Converts, Normalizes song & embeds metadata as ID3 tags. ''' #! all YouTube downloads are to .\Temp; they are then converted and put into .\ and #! finally followed up with ID3 metadata tags #! we explicitly use the os.path.join function here to ensure download is #! platform agnostic # Create a .\Temp folder if not present tempFolder = Path('.', 'Temp') if not tempFolder.exists(): tempFolder.mkdir() # build file name of converted file artistStr = '' #! we eliminate contributing artist names that are also in the song name, else we #! would end up with things like 'Jetta, Mastubs - I'd love to change the world #! (Mastubs REMIX).mp3' which is kinda an odd file name. for artist in songObj.get_contributing_artists(): if artist.lower() not in songObj.get_song_name().lower(): artistStr += artist + ', ' #! the ...[:-2] is to avoid the last ', ' appended to artistStr convertedFileName = artistStr[:-2] + ' - ' + songObj.get_song_name() #! this is windows specific (disallowed chars) for disallowedChar in ['/', '?', '\\', '*', '|', '<', '>']: if disallowedChar in convertedFileName: convertedFileName = convertedFileName.replace( disallowedChar, '') #! double quotes (") and semi-colons (:) are also disallowed characters but we would #! like to retain their equivalents, so they aren't removed in the prior loop convertedFileName = convertedFileName.replace( '"', "'").replace(':', '-') convertedFilePath = Path(".", f"{convertedFileName}.opus") # if a song is already downloaded skip it if convertedFilePath.is_file(): if self.displayManager: self.displayManager.notify_download_skip() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) #! None is the default return value of all functions, we just explicitly define #! it here as a continent way to avoid executing the rest of the function. return None # download Audio from YouTube if self.displayManager: youtubeHandler = YouTube( url=songObj.get_youtube_link(), on_progress_callback=self.displayManager.pytube_progress_hook ) else: youtubeHandler = YouTube(songObj.get_youtube_link()) trackAudioStream = youtubeHandler.streams.filter( only_audio=True).order_by('bitrate').last() if not trackAudioStream: print(f"Unable to get audio stream for \"{songObj.get_song_name()}\" " f"by \"{songObj.get_contributing_artists()[0]}\" " f"from video \"{songObj.get_youtube_link()}\"") return None downloadedFilePathString = await self._download_from_youtube(convertedFileName, tempFolder, trackAudioStream) if downloadedFilePathString is None: return None downloadedFilePath = Path(downloadedFilePathString) # Encapsulate downloaded file to OGG container without re-processing command = 'ffmpeg -v quiet -y -i "%s" -acodec copy "%s"' #! bash/ffmpeg on Unix systems need to have excape char (\) for special characters: \$ #! alternatively the quotes could be reversed (single <-> double) in the command then #! the windows special characters needs escaping (^): ^\ ^& ^| ^> ^< ^^ if sys.platform == 'win32': formattedCommand = command % ( str(downloadedFilePath), str(convertedFilePath) ) else: formattedCommand = command % ( str(downloadedFilePath).replace('$', '\$'), str(convertedFilePath).replace('$', '\$') ) process = await asyncio.subprocess.create_subprocess_shell(formattedCommand) _ = await process.communicate() #! Wait till converted file is actually created while True: if convertedFilePath.is_file(): break if self.displayManager: self.displayManager.notify_conversion_completion() self.set_vorbis_data(convertedFilePath, songObj) # Do the necessary cleanup if self.displayManager: self.displayManager.notify_download_completion() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) # delete the unnecessary YouTube download File if downloadedFilePath and downloadedFilePath.is_file(): downloadedFilePath.unlink()
async def download_song(self, songObj: SongObj) -> None: ''' `songObj` `songObj` : song to be downloaded RETURNS `~` Downloads, Converts, Normalizes song & embeds metadata as ID3 tags. ''' #! all YouTube downloads are to .\Temp; they are then converted and put into .\ and #! finally followed up with ID3 metadata tags #! we explicitly use the os.path.join function here to ensure download is #! platform agnostic # Create a .\Temp folder if not present tempFolder = Path('.', 'Temp') if not tempFolder.exists(): tempFolder.mkdir() # build file name of converted file artistStr = '' #! we eliminate contributing artist names that are also in the song name, else we #! would end up with things like 'Jetta, Mastubs - I'd love to change the world #! (Mastubs REMIX).mp3' which is kinda an odd file name. for artist in songObj.get_contributing_artists(): if artist.lower() not in songObj.get_song_name().lower(): artistStr += artist + ', ' #! the ...[:-2] is to avoid the last ', ' appended to artistStr convertedFileName = artistStr[:-2] + ' - ' + songObj.get_song_name() #! this is windows specific (disallowed chars) for disallowedChar in ['/', '?', '\\', '*', '|', '<', '>']: if disallowedChar in convertedFileName: convertedFileName = convertedFileName.replace( disallowedChar, '') #! double quotes (") and semi-colons (:) are also disallowed characters but we would #! like to retain their equivalents, so they aren't removed in the prior loop convertedFileName = convertedFileName.replace( '"', "'").replace(': ', ' - ') convertedFilePath = Path(".", f"{convertedFileName}.opus") # if a song is already downloaded skip it if convertedFilePath.is_file(): if self.displayManager: self.displayManager.notify_download_skip() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) #! None is the default return value of all functions, we just explicitly define #! it here as a continent way to avoid executing the rest of the function. return None # download Audio from YouTube if self.displayManager: youtubeHandler = YouTube( url=songObj.get_youtube_link(), on_progress_callback=self.displayManager.pytube_progress_hook ) else: youtubeHandler = YouTube(songObj.get_youtube_link()) trackAudioStream = youtubeHandler.streams.filter( only_audio=True).order_by('bitrate').last() if not trackAudioStream: print(f"Unable to get audio stream for \"{songObj.get_song_name()}\" " f"by \"{songObj.get_contributing_artists()[0]}\" " f"from video \"{songObj.get_youtube_link()}\"") return None downloadedFilePathString = await self._download_from_youtube(convertedFileName, tempFolder, trackAudioStream) if downloadedFilePathString is None: return None downloadedFilePath = Path(downloadedFilePathString) # convert downloaded file command = 'ffmpeg -v quiet -y -i "%s" -acodec libopus -b:a 80K -vbr on -compression_level 10 "%s"' #! bash/ffmpeg on Unix systems need to have excape char (\) for special characters: \$ #! alternatively the quotes could be reversed (single <-> double) in the command then #! the windows special characters needs escaping (^): ^\ ^& ^| ^> ^< ^^ if sys.platform == 'win32': formattedCommand = command % ( str(downloadedFilePath), str(convertedFilePath) ) else: formattedCommand = command % ( str(downloadedFilePath).replace('$', '\$'), str(convertedFilePath).replace('$', '\$') ) process = await asyncio.subprocess.create_subprocess_shell(formattedCommand) _ = await process.communicate() #! Wait till converted file is actually created while True: if convertedFilePath.is_file(): break if self.displayManager: self.displayManager.notify_conversion_completion() # embed song details audioFile = OggOpus(convertedFilePath) #! Get rid of all existing tags (if any exist) audioFile.delete() #! song name audioFile['title'] = songObj.get_song_name() audioFile['titlesort'] = songObj.get_song_name() #! track number audioFile['tracknumber'] = str(songObj.get_track_number()) #! genres audioFile['genre'] = songObj.get_genres() #! all involved artists audioFile['artist'] = songObj.get_contributing_artists() #! album name audioFile['album'] = songObj.get_album_name() #! album artist (all of 'em) audioFile['albumartist'] = songObj.get_album_artists() #! album release date (to what ever precision available) audioFile['date'] = songObj.get_album_release() audioFile['originaldate'] = songObj.get_album_release() audioFile.save() #! setting the album art audioFile = OggOpus(convertedFilePath) rawAlbumArt = urlopen(songObj.get_album_cover_url()).read() picture = Picture() picture.data = rawAlbumArt picture.type = 3 picture.desc = u"Cover" picture.mime = u"image/jpeg" picture_data = picture.write() encoded_data = base64.b64encode(picture_data) vcomment_value = encoded_data.decode("ascii") audioFile["metadata_block_picture"] = [vcomment_value] audioFile.save() # Do the necessary cleanup if self.displayManager: self.displayManager.notify_download_completion() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) # delete the unnecessary YouTube download File if downloadedFilePath and downloadedFilePath.is_file(): downloadedFilePath.unlink()
async def download_song(self, songObj: SongObj) -> None: """ `songObj` `songObj` : song to be downloaded RETURNS `~` Downloads, Converts, Normalizes song & embeds metadata as ID3 tags. """ dispayProgressTracker = self.displayManager.new_progress_tracker( songObj) # ! since most errors are expected to happen within this function, we wrap in # ! exception catcher to prevent blocking on multiple downloads try: # ! all YouTube downloads are to .\Temp; they are then converted and put into .\ and # ! finally followed up with ID3 metadata tags # ! we explicitly use the os.path.join function here to ensure download is # ! platform agnostic # Create a .\Temp folder if not present tempFolder = Path(".", "Temp") if not tempFolder.exists(): tempFolder.mkdir() convertedFileName = songObj.get_file_name() convertedFilePath = Path( ".", f"{convertedFileName}.{self.arguments['format']}") # if a song is already downloaded skip it if convertedFilePath.is_file(): if self.displayManager: dispayProgressTracker.notify_download_skip() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) # ! None is the default return value of all functions, we just explicitly define # ! it here as a continent way to avoid executing the rest of the function. return None # download Audio from YouTube if dispayProgressTracker: youtubeHandler = YouTube( url=songObj.get_youtube_link(), on_progress_callback=dispayProgressTracker. pytube_progress_hook, ) else: youtubeHandler = YouTube(songObj.get_youtube_link()) trackAudioStream = (youtubeHandler.streams.filter( only_audio=True).order_by("bitrate").last()) if not trackAudioStream: print( f'Unable to get audio stream for "{songObj.get_song_name()}" ' f'by "{songObj.get_contributing_artists()[0]}" ' f'from video "{songObj.get_youtube_link()}"') return None downloadedFilePathString = await self._perform_audio_download_async( convertedFileName, tempFolder, trackAudioStream) if downloadedFilePathString is None: return None if dispayProgressTracker: dispayProgressTracker.notify_youtube_download_completion() downloadedFilePath = Path(downloadedFilePathString) ffmpeg_success = await ffmpeg.convert( downloaded_file_path=downloadedFilePath, converted_file_path=convertedFilePath, output_format=self.arguments["format"], ffmpeg_path=self.arguments["ffmpeg_path"], ) if dispayProgressTracker: dispayProgressTracker.notify_conversion_completion() if ffmpeg_success is False: # delete the file that wasn't successfully converted convertedFilePath.unlink() else: # if a file was successfully downloaded, tag it set_id3_data(convertedFilePath, songObj, self.arguments["format"]) # Do the necessary cleanup if dispayProgressTracker: dispayProgressTracker.notify_download_completion() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) # delete the unnecessary YouTube download File if downloadedFilePath and downloadedFilePath.is_file(): downloadedFilePath.unlink() except Exception as e: tb = traceback.format_exc() if dispayProgressTracker: dispayProgressTracker.notify_error(e, tb) else: raise e
def console_entry_point(): """ This is where all the console processing magic happens. Its super simple, rudimentary even but, it's dead simple & it works. """ if "--help" in cliArgs or "-h" in cliArgs: print(help_notice) #! We use 'return None' as a convenient exit/break from the function return None initialize( clientId="4fe3fecfe5334023a1472516cc99d805", clientSecret="0f02b7c483c04257984695007a4a8d5c", ) downloader = DownloadManager() for request in cliArgs[1:]: if "?" in request: # strip unnecessary data for both url and uri # e.g https://open.spotify.com/track/4Q34FP1AT7GEl9oLgNtiWj?context=spotify%3Aplaylist%3A37i9dQZF1DXcBWIGoYBM5M&si=DlMAsJ5pSD6tdUSn2XqB0g # becomes https://open.spotify.com/track/4Q34FP1AT7GEl9oLgNtiWj # e.g spotify:track:4Q34FP1AT7GEl9oLgNtiWj?context=spotify%3Aplaylist%3A37i9dQZF1DXcBWIGoYBM5M # becomes spotify:track:4Q34FP1AT7GEl9oLgNtiWj request = request[:request.find("?")] if "open.spotify.com" in request: # it's a url if "track" in request: print("Fetching Song...") song = SongObj.from_url(request) if song.get_youtube_link() != None: downloader.download_single_song(song) else: print( "Skipping %s (%s) as no match could be found on youtube" % (song.get_song_name(), request)) elif "album" in request: print("Fetching Album...") songObjList = get_album_tracks(request) downloader.download_multiple_songs(songObjList) elif "playlist" in request: print("Fetching Playlist...") songObjList = get_playlist_tracks(request) downloader.download_multiple_songs(songObjList) elif "spotify:" in request: # it's a URI with format Spotify:...:ID if "track:" in request: print("Fetching Song...") # yes, passing a URI to this function still works coz it relies on another # spotipy function that simply extracts the ID, ideally u can just pass the ID # and the track downloads song = SongObj.from_url(request) if song.get_youtube_link() != None: downloader.download_single_song(song) else: print( f"Skipping {song.get_song_name()} ({request}) as no match could be found on youtube" ) elif "album:" in request: print("Fetching Album...") songObjList = get_album_tracks(request) downloader.download_multiple_songs(songObjList) elif "playlist:" in request: print("Fetching Playlist...") songObjList = get_playlist_tracks(request) downloader.download_multiple_songs(songObjList) elif request.endswith(".spotdlTrackingFile"): print("Preparing to resume download...") downloader.resume_download_from_tracking_file(request) else: print('Searching for song "%s"...' % request) try: song = search_for_song(request) downloader.download_single_song(song) except Exception: print('No song named "%s" could be found on spotify' % request) downloader.close()
async def download_song(self, songObj: SongObj) -> None: ''' `songObj` `songObj` : song to be downloaded RETURNS `~` Downloads, Converts, Normalizes song & embeds metadata as ID3 tags. ''' #! all YouTube downloads are to .\Temp; they are then converted and put into .\ and #! finally followed up with ID3 metadata tags #! we explicitly use the os.path.join function here to ensure download is #! platform agnostic # Create a .\Temp folder if not present tempFolder = Path('.', 'Temp') if not tempFolder.exists(): tempFolder.mkdir() # build file name of converted file artistStr = '' #! we eliminate contributing artist names that are also in the song name, else we #! would end up with things like 'Jetta, Mastubs - I'd love to change the world #! (Mastubs REMIX).mp3' which is kinda an odd file name. for artist in songObj.get_contributing_artists(): if artist.lower() not in songObj.get_song_name().lower(): artistStr += artist + ', ' #! the ...[:-2] is to avoid the last ', ' appended to artistStr convertedFileName = artistStr[:-2] + ' - ' + songObj.get_song_name() #! this is windows specific (disallowed chars) for disallowedChar in ['/', '?', '\\', '*', '|', '<', '>']: if disallowedChar in convertedFileName: convertedFileName = convertedFileName.replace( disallowedChar, '') #! double quotes (") and semi-colons (:) are also disallowed characters but we would #! like to retain their equivalents, so they aren't removed in the prior loop convertedFileName = convertedFileName.replace('"', "'").replace( ': ', ' - ') convertedFilePath = Path(".", f"{convertedFileName}.mp3") # if a song is already downloaded skip it if convertedFilePath.is_file(): if self.displayManager: self.displayManager.notify_download_skip() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) #! None is the default return value of all functions, we just explicitly define #! it here as a continent way to avoid executing the rest of the function. return None # download Audio from YouTube if self.displayManager: youtubeHandler = YouTube( url=songObj.get_youtube_link(), on_progress_callback=self.displayManager.pytube_progress_hook) else: youtubeHandler = YouTube(songObj.get_youtube_link()) trackAudioStream = youtubeHandler.streams.filter( only_audio=True).order_by('bitrate').last() if not trackAudioStream: print( f"Unable to get audio stream for \"{songObj.get_song_name()}\" " f"by \"{songObj.get_contributing_artists()[0]}\" " f"from video \"{songObj.get_youtube_link()}\"") return None downloadedFilePathString = await self._download_from_youtube( convertedFileName, tempFolder, trackAudioStream) if downloadedFilePathString is None: return None downloadedFilePath = Path(downloadedFilePathString) # convert downloaded file to MP3 with normalization #! -af loudnorm=I=-7:LRA applies EBR 128 loudness normalization algorithm with #! intergrated loudness target (I) set to -17, using values lower than -15 #! causes 'pumping' i.e. rhythmic variation in loudness that should not #! exist -loud parts exaggerate, soft parts left alone. #! #! dynaudnorm applies dynamic non-linear RMS based normalization, this is what #! actually normalized the audio. The loudnorm filter just makes the apparent #! loudness constant #! #! apad=pad_dur=2 adds 2 seconds of silence toward the end of the track, this is #! done because the loudnorm filter clips/cuts/deletes the last 1-2 seconds on #! occasion especially if the song is EDM-like, so we add a few extra seconds to #! combat that. #! #! -acodec libmp3lame sets the encoded to 'libmp3lame' which is far better #! than the default 'mp3_mf', '-abr true' automatically determines and passes the #! audio encoding bitrate to the filters and encoder. This ensures that the #! sampled length of songs matches the actual length (i.e. a 5 min song won't display #! as 47 seconds long in your music player, yeah that was an issue earlier.) command = 'ffmpeg -v quiet -y -i "%s" -acodec libmp3lame -abr true ' \ f'-b:a {trackAudioStream.bitrate} ' \ '-af "apad=pad_dur=2, dynaudnorm, loudnorm=I=-17" "%s"' #! bash/ffmpeg on Unix systems need to have excape char (\) for special characters: \$ #! alternatively the quotes could be reversed (single <-> double) in the command then #! the windows special characters needs escaping (^): ^\ ^& ^| ^> ^< ^^ if sys.platform == 'win32': formattedCommand = command % (str(downloadedFilePath), str(convertedFilePath)) else: formattedCommand = command % (str(downloadedFilePath).replace( '$', '\$'), str(convertedFilePath).replace('$', '\$')) process = await asyncio.subprocess.create_subprocess_shell( formattedCommand) _ = await process.communicate() #! Wait till converted file is actually created while True: if convertedFilePath.is_file(): break if self.displayManager: self.displayManager.notify_conversion_completion() # embed song details #! we save tags as both ID3 v2.3 and v2.4 #! The simple ID3 tags audioFile = EasyID3(convertedFilePath) #! Get rid of all existing ID3 tags (if any exist) audioFile.delete() #! song name audioFile['title'] = songObj.get_song_name() audioFile['titlesort'] = songObj.get_song_name() #! track number audioFile['tracknumber'] = str(songObj.get_track_number()) #! genres (pretty pointless if you ask me) #! we only apply the first available genre as ID3 v2.3 doesn't support multiple #! genres and ~80% of the world PC's run Windows - an OS with no ID3 v2.4 support genres = songObj.get_genres() if len(genres) > 0: audioFile['genre'] = genres[0] #! all involved artists audioFile['artist'] = songObj.get_contributing_artists() #! album name audioFile['album'] = songObj.get_album_name() #! album artist (all of 'em) audioFile['albumartist'] = songObj.get_album_artists() #! album release date (to what ever precision available) audioFile['date'] = songObj.get_album_release() audioFile['originaldate'] = songObj.get_album_release() #! save as both ID3 v2.3 & v2.4 as v2.3 isn't fully features and #! windows doesn't support v2.4 until later versions of Win10 audioFile.save(v2_version=3) #! setting the album art audioFile = ID3(convertedFilePath) rawAlbumArt = urlopen(songObj.get_album_cover_url()).read() audioFile['APIC'] = AlbumCover(encoding=3, mime='image/jpeg', type=3, desc='Cover', data=rawAlbumArt) audioFile.save(v2_version=3) # Do the necessary cleanup if self.displayManager: self.displayManager.notify_download_completion() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) # delete the unnecessary YouTube download File if downloadedFilePath and downloadedFilePath.is_file(): downloadedFilePath.unlink()
def console_entry_point(): ''' This is where all the console processing magic happens. Its super simple, rudimentary even but, it's dead simple & it works. ''' arguments = parse_arguments() if ffmpeg.has_correct_version(arguments.ignore_ffmpeg_version) is False: sys.exit(1) SpotifyClient.init(client_id='5f573c9620494bae87890c0f08a60293', client_secret='212476d9b0f3472eaa762d90b19b0ba8') if arguments.path: if not os.path.isdir(arguments.path): sys.exit("The output directory doesn't exist.") print(f"Will download to: {os.path.abspath(arguments.path)}") os.chdir(arguments.path) with DownloadManager() as downloader: for request in arguments.url: if 'open.spotify.com' in request and 'track' in request: print('Fetching Song...') song = SongObj.from_url(request) if song.get_youtube_link() is not None: downloader.download_single_song(song) else: print( 'Skipping %s (%s) as no match could be found on youtube' % (song.get_song_name(), request)) elif 'open.spotify.com' in request and 'album' in request: print('Fetching Album...') songObjList = get_album_tracks(request) downloader.download_multiple_songs(songObjList) elif 'open.spotify.com' in request and 'playlist' in request: print('Fetching Playlist...') songObjList = get_playlist_tracks(request) downloader.download_multiple_songs(songObjList) elif 'open.spotify.com' in request and 'artist' in request: print('Fetching artist...') artistObjList = get_artist_tracks(request) downloader.download_multiple_songs(artistObjList) elif request.endswith('.spotdlTrackingFile'): print('Preparing to resume download...') downloader.resume_download_from_tracking_file(request) else: print('Searching for song "%s"...' % request) try: song = search_for_song(request) downloader.download_single_song(song) except Exception as e: print(e)
def console_entry_point(): ''' This is where all the console processing magic happens. Its super simple, rudimentary even but, it's dead simple & it works. ''' arguments = parse_arguments() spotifyClient.initialize(clientId='03eb56e5ab2843e98507b3a6a0359a56', clientSecret='4e6600fae80845ef8dab67ccaaecee4d') if arguments.path: if not os.path.isdir(arguments.path): sys.exit("The output directory doesn't exist.") print(f"Will download to: {os.path.abspath(arguments.path)}") os.chdir(arguments.path) downloader = DownloadManager() for request in arguments.url: if 'open.spotify.com' in request and 'track' in request: print('Fetching Song...') song = SongObj.from_url(request) if song.get_youtube_link() is not None: downloader.download_single_song(song) else: print( 'Skipping %s (%s) as no match could be found on youtube' % (song.get_song_name(), request)) elif 'open.spotify.com' in request and 'album' in request: print('Fetching Album...') songObjList = get_album_tracks(request) downloader.download_multiple_songs(songObjList) elif 'open.spotify.com' in request and 'playlist' in request: print('Fetching Playlist...') songObjList = get_playlist_tracks(request) downloader.download_multiple_songs(songObjList) elif 'open.spotify.com' in request and 'artist' in request: print('Fetching artist...') artistObjList = get_artist_tracks(request) downloader.download_multiple_songs(artistObjList) elif request.endswith('.spotdlTrackingFile'): print('Preparing to resume download...') downloader.resume_download_from_tracking_file(request) else: print('Searching for song "%s"...' % request) try: song = search_for_song(request) downloader.download_single_song(song) except Exception: print('No song named "%s" could be found on spotify' % request) downloader.close()
def console_entry_point(): ''' This is where all the console processing magic happens. Its super simple, rudimentary even but, it's dead simple & it works. ''' if '--help' in cliArgs or '-h' in cliArgs: print(help_notice) #! We use 'return None' as a convenient exit/break from the function return None if '--quiet' in cliArgs: #! removing --quiet so it doesnt mess up with the download cliArgs.remove('--quiet') #! make stdout & stderr silent sys.stdout = quiet() sys.stderr = quiet() initialize(clientId='4fe3fecfe5334023a1472516cc99d805', clientSecret='0f02b7c483c04257984695007a4a8d5c') downloader = DownloadManager() for request in cliArgs[1:]: if ('open.spotify.com' in request and 'track' in request) or 'spotify:track:' in request: print('Fetching Song...') song = SongObj.from_url(request) if song.get_youtube_link() != None: downloader.download_single_song(song) else: print( 'Skipping %s (%s) as no match could be found on youtube' % (song.get_song_name(), request)) elif ('open.spotify.com' in request and 'album' in request) or 'spotify:album:' in request: print('Fetching Album...') songObjList = get_album_tracks(request) downloader.download_multiple_songs(songObjList) elif ('open.spotify.com' in request and 'playlist' in request) or 'spotify:playlist:' in request: print('Fetching Playlist...') songObjList = get_playlist_tracks(request) downloader.download_multiple_songs(songObjList) elif request.endswith('.txt'): print('Fetching songs from %s...' % request) songObjList = [] with open(request, 'r') as songFile: for songLink in songFile.readlines(): song = SongObj.from_url(songLink) songObjList.append(song) downloader.download_multiple_songs(songObjList) elif request.endswith('.spotdlTrackingFile'): print('Preparing to resume download...') downloader.resume_download_from_tracking_file(request) else: print('Searching for song "%s"...' % request) try: song = search_for_song(request) downloader.download_single_song(song) except Exception: print('No song named "%s" could be found on spotify' % request) downloader.close()
from spotdl.search.songObj import SongObj from spotdl.search.spotifyClient import initialize from spotdl.lyrics.genius import Genius initialize( clientId="4fe3fecfe5334023a1472516cc99d805", clientSecret="0f02b7c483c04257984695007a4a8d5c", ) # g = Genius() # print(g.from_query("A$ap Rocky", "j",lyric_fail=True)) s = SongObj.from_url( 'spotify:track:02kDW379Yfd5PzW5A6vuGt?context=spotify%3Aplaylist%3A37i9dQZF1DXcBWIGoYBM5M' ) print(s.get_lyrics()) # print(Genius.from_query("Pop Smoke", "For The Night"))
async def download_song(self, songObj: SongObj) -> None: ''' `songObj` `songObj` : song to be downloaded RETURNS `~` Downloads, Converts, Normalizes song & embeds metadata as ID3 tags. ''' dispayProgressTracker = self.displayManager.new_progress_tracker( songObj) # ! since most errors are expected to happen within this function, we wrap in # ! exception catcher to prevent blocking on multiple downloads try: # ! all YouTube downloads are to .\Temp; they are then converted and put into .\ and # ! finally followed up with ID3 metadata tags # ! we explicitly use the os.path.join function here to ensure download is # ! platform agnostic # Create a .\Temp folder if not present tempFolder = Path('.', 'Temp') if not tempFolder.exists(): tempFolder.mkdir() # build file name of converted file artistStr = '' # ! we eliminate contributing artist names that are also in the song name, else we # ! would end up with things like 'Jetta, Mastubs - I'd love to change the world # ! (Mastubs REMIX).mp3' which is kinda an odd file name. for artist in songObj.get_contributing_artists(): if artist.lower() not in songObj.get_song_name().lower(): artistStr += artist + ', ' # make sure that main artist is included in artistStr even if they # are in the song name, for example # Lil Baby - Never Recover (Lil Baby & Gunna, Drake).mp3 if songObj.get_contributing_artists()[0].lower( ) not in artistStr.lower(): artistStr = songObj.get_contributing_artists( )[0] + ', ' + artistStr # ! the ...[:-2] is to avoid the last ', ' appended to artistStr convertedFileName = artistStr[:-2] + \ ' - ' + songObj.get_song_name() # ! this is windows specific (disallowed chars) for disallowedChar in ['/', '?', '\\', '*', '|', '<', '>']: if disallowedChar in convertedFileName: convertedFileName = convertedFileName.replace( disallowedChar, '') # ! double quotes (") and semi-colons (:) are also disallowed characters but we would # ! like to retain their equivalents, so they aren't removed in the prior loop convertedFileName = convertedFileName.replace('"', "'").replace( ':', '-') convertedFilePath = Path(".", f"{convertedFileName}.mp3") # if a song is already downloaded skip it if convertedFilePath.is_file(): if self.displayManager: dispayProgressTracker.notify_download_skip() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) # ! None is the default return value of all functions, we just explicitly define # ! it here as a continent way to avoid executing the rest of the function. return None # download Audio from YouTube if dispayProgressTracker: youtubeHandler = YouTube( url=songObj.get_youtube_link(), on_progress_callback=dispayProgressTracker. pytube_progress_hook) else: youtubeHandler = YouTube(songObj.get_youtube_link()) trackAudioStream = youtubeHandler.streams.filter( only_audio=True).order_by('bitrate').last() if not trackAudioStream: print( f"Unable to get audio stream for \"{songObj.get_song_name()}\" " f"by \"{songObj.get_contributing_artists()[0]}\" " f"from video \"{songObj.get_youtube_link()}\"") return None downloadedFilePathString = await self._download_from_youtube( convertedFileName, tempFolder, trackAudioStream) if downloadedFilePathString is None: return None if dispayProgressTracker: dispayProgressTracker.notify_youtube_download_completion() downloadedFilePath = Path(downloadedFilePathString) ffmpeg_success = await ffmpeg.convert( trackAudioStream=trackAudioStream, downloadedFilePath=downloadedFilePath, convertedFilePath=convertedFilePath, ffmpegPath=self.ffmpeg_path) if dispayProgressTracker: dispayProgressTracker.notify_conversion_completion() if ffmpeg_success is False: # delete the file that wasn't successfully converted convertedFilePath.unlink() else: # if a file was successfully downloaded, tag it self.set_id3_data(convertedFilePath, songObj) # Do the necessary cleanup if dispayProgressTracker: dispayProgressTracker.notify_download_completion() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) # delete the unnecessary YouTube download File if downloadedFilePath and downloadedFilePath.is_file(): downloadedFilePath.unlink() except Exception as e: tb = traceback.format_exc() if dispayProgressTracker: dispayProgressTracker.notify_error(e, tb) else: raise e
def download_song( songObj: SongObj, displayManager: DisplayManager = None, downloadTracker: DownloadTracker = None, ) -> None: """ `songObj` `songObj` : song to be downloaded `AutoProxy` `displayManager` : autoproxy reference to a `DisplayManager` `AutoProxy` `downloadTracker`: autoproxy reference to a `DownloadTracker` RETURNS `~` Downloads, Converts, Normalizes song & embeds metadata as ID3 tags. """ #! all YouTube downloads are to .\Temp; they are then converted and put into .\ and #! finally followed up with ID3 metadata tags #! we explicitly use the os.path.join function here to ensure download is #! platform agnostic # Create a .\Temp folder if not present tempFolder = join(".", "Temp") if not exists(tempFolder): mkdir(tempFolder) # build file name of converted file artistStr = "" #! we eliminate contributing artist names that are also in the song name, else we #! would end up with things like 'Jetta, Mastubs - I'd love to change the world #! (Mastubs REMIX).mp3' which is kinda an odd file name. for artist in songObj.get_contributing_artists(): if artist.lower() not in songObj.get_song_name().lower(): artistStr += artist + ", " #! the ...[:-2] is to avoid the last ', ' appended to artistStr convertedFileName = artistStr[:-2] + " - " + songObj.get_song_name() #! this is windows specific (disallowed chars) for disallowedChar in ["/", "?", "\\", "*", "|", "<", ">"]: if disallowedChar in convertedFileName: convertedFileName = convertedFileName.replace(disallowedChar, "") #! double quotes (") and semi-colons (:) are also disallowed characters but we would #! like to retain their equivalents, so they aren't removed in the prior loop convertedFileName = convertedFileName.replace('"', "'").replace(": ", " - ") convertedFilePath = join(".", convertedFileName) + ".mp3" # if a song is already downloaded skip it if exists(convertedFilePath): if displayManager: displayManager.notify_download_skip() if downloadTracker: downloadTracker.notify_download_completion(songObj) #! None is the default return value of all functions, we just explicitly define #! it here as a continent way to avoid executing the rest of the function. return None Lyrics = songObj.get_lyrics() # download Audio from YouTube if displayManager: youtubeHandler = YouTube( url=songObj.get_youtube_link(), on_progress_callback=displayManager.pytube_progress_hook, ) else: youtubeHandler = YouTube(songObj.get_youtube_link()) trackAudioStream = youtubeHandler.streams.get_audio_only() #! The actual download, if there is any error, it'll be here, try: #! pyTube will save the song in .\Temp\$songName.mp4, it doesn't save as '.mp3' downloadedFilePath = trackAudioStream.download( output_path=tempFolder, filename=convertedFileName, skip_existing=False) except: #! This is equivalent to a failed download, we do nothing, the song remains on #! downloadTrackers download queue and all is well... #! #! None is again used as a convenient exit remove(join(tempFolder, convertedFileName) + ".mp4") return None # convert downloaded file to MP3 with normalization #! -af loudnorm=I=-7:LRA applies EBR 128 loudness normalization algorithm with #! intergrated loudness target (I) set to -17, using values lower than -15 #! causes 'pumping' i.e. rhythmic variation in loudness that should not #! exist -loud parts exaggerate, soft parts left alone. #! #! dynaudnorm applies dynamic non-linear RMS based normalization, this is what #! actually normalized the audio. The loudnorm filter just makes the apparent #! loudness constant #! #! apad=pad_dur=2 adds 2 seconds of silence toward the end of the track, this is #! done because the loudnorm filter clips/cuts/deletes the last 1-2 seconds on #! occasion especially if the song is EDM-like, so we add a few extra seconds to #! combat that. #! #! -acodec libmp3lame sets the encoded to 'libmp3lame' which is far better #! than the default 'mp3_mf', '-abr true' automatically determines and passes the #! audio encoding bitrate to the filters and encoder. This ensures that the #! sampled length of songs matches the actual length (i.e. a 5 min song won't display #! as 47 seconds long in your music player, yeah that was an issue earlier.) command = 'ffmpeg -v quiet -hwaccel_output_format cuda -y -i "%s" -acodec libmp3lame -abr true "%s"' formattedCommand = command % (downloadedFilePath, convertedFilePath) # run_in_shell(formattedCommand) return_code = None return_code = call(formattedCommand) if return_code != 0: raise ("Error occurred during conversion, ffmpeg issue probably") if displayManager: displayManager.notify_conversion_completion() # embed song details #! we save tags as both ID3 v2.3 and v2.4 #! The simple ID3 tags audioFile = EasyID3(convertedFilePath) #! Get rid of all existing ID3 tags (if any exist) audioFile.delete() #! song name audioFile["title"] = songObj.get_song_name() audioFile["titlesort"] = songObj.get_song_name() #! track number audioFile["tracknumber"] = str(songObj.get_track_number()) #! genres (pretty pointless if you ask me) #! we only apply the first available genre as ID3 v2.3 doesn't support multiple #! genres and ~80% of the world PC's run Windows - an OS with no ID3 v2.4 support genres = songObj.get_genres() if len(genres) > 0: audioFile["genre"] = genres[0] #! all involved artists audioFile["artist"] = songObj.get_contributing_artists() #! album name audioFile["album"] = songObj.get_album_name() #! album artist (all of 'em) audioFile["albumartist"] = songObj.get_album_artists() #! album release date (to what ever precision available) audioFile["date"] = songObj.get_album_release() audioFile["originaldate"] = songObj.get_album_release() #! save as both ID3 v2.3 & v2.4 as v2.3 isn't fully features and #! windows doesn't support v2.4 until later versions of Win10 audioFile.save(v2_version=3) #! setting the album art audioFile = ID3(convertedFilePath) rawAlbumArt = get(songObj.get_album_cover_url()).content audioFile["APIC"] = AlbumCover(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=rawAlbumArt) audioFile["USLT"] = USLT(encoding=3, desc=u"Lyrics", text=Lyrics) audioFile.save(v2_version=3) # Do the necessary cleanup if displayManager: displayManager.notify_download_completion() if downloadTracker: downloadTracker.notify_download_completion(songObj) # delete the unnecessary YouTube download File remove(downloadedFilePath)
async def download_song(self, songObj: SongObj) -> None: ''' `songObj` `songObj` : song to be downloaded RETURNS `~` Downloads, Converts, Normalizes song & embeds metadata as ID3 tags. ''' # ! all YouTube downloads are to .\Temp; they are then converted and put into .\ and # ! finally followed up with ID3 metadata tags # ! we explicitly use the os.path.join function here to ensure download is # ! platform agnostic # Create a .\Temp folder if not present tempFolder = Path('.', 'Temp') if not tempFolder.exists(): tempFolder.mkdir() # build file name of converted file artistStr = '' # ! we eliminate contributing artist names that are also in the song name, else we # ! would end up with things like 'Jetta, Mastubs - I'd love to change the world # ! (Mastubs REMIX).mp3' which is kinda an odd file name. for artist in songObj.get_contributing_artists(): if artist.lower() not in songObj.get_song_name().lower(): artistStr += artist + ', ' # make sure that main artist is included in artistStr even if they # are in the song name, for example # Lil Baby - Never Recover (Lil Baby & Gunna, Drake).mp3 if songObj.get_contributing_artists()[0].lower() not in artistStr.lower(): artistStr = songObj.get_contributing_artists()[0] + ', ' + artistStr # ! the ...[:-2] is to avoid the last ', ' appended to artistStr convertedFileName = artistStr[:-2] + ' - ' + songObj.get_song_name() # ! this is windows specific (disallowed chars) for disallowedChar in ['/', '?', '\\', '*', '|', '<', '>']: if disallowedChar in convertedFileName: convertedFileName = convertedFileName.replace( disallowedChar, '') # ! double quotes (") and semi-colons (:) are also disallowed characters but we would # ! like to retain their equivalents, so they aren't removed in the prior loop convertedFileName = convertedFileName.replace( '"', "'").replace(':', '-') # Shorten the filename if it's too long # 250 + .mp3 = 254 (max length == 254) convertedFileName = convertedFileName[:250] convertedFilePath = Path(".", f"{convertedFileName}.mp3") # if a song is already downloaded skip it if convertedFilePath.is_file(): if self.displayManager: self.displayManager.notify_download_skip() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) # ! None is the default return value of all functions, we just explicitly define # ! it here as a continent way to avoid executing the rest of the function. return None # download Audio from YouTube if self.displayManager: youtubeHandler = YouTube( url=songObj.get_youtube_link(), on_progress_callback=self.displayManager.pytube_progress_hook ) else: youtubeHandler = YouTube(songObj.get_youtube_link()) trackAudioStream = youtubeHandler.streams.filter( only_audio=True).order_by('bitrate').last() if not trackAudioStream: print(f"Unable to get audio stream for \"{songObj.get_song_name()}\" " f"by \"{songObj.get_contributing_artists()[0]}\" " f"from video \"{songObj.get_youtube_link()}\"") return None downloadedFilePathString = await self._download_from_youtube(convertedFileName, tempFolder, trackAudioStream) if downloadedFilePathString is None: return None downloadedFilePath = Path(downloadedFilePathString) # convert downloaded file to MP3 with normalization # ! -af loudnorm=I=-7:LRA applies EBR 128 loudness normalization algorithm with # ! intergrated loudness target (I) set to -17, using values lower than -15 # ! causes 'pumping' i.e. rhythmic variation in loudness that should not # ! exist -loud parts exaggerate, soft parts left alone. # ! # ! dynaudnorm applies dynamic non-linear RMS based normalization, this is what # ! actually normalized the audio. The loudnorm filter just makes the apparent # ! loudness constant # ! # ! apad=pad_dur=2 adds 2 seconds of silence toward the end of the track, this is # ! done because the loudnorm filter clips/cuts/deletes the last 1-2 seconds on # ! occasion especially if the song is EDM-like, so we add a few extra seconds to # ! combat that. # ! # ! -acodec libmp3lame sets the encoded to 'libmp3lame' which is far better # ! than the default 'mp3_mf', '-abr true' automatically determines and passes the # ! audio encoding bitrate to the filters and encoder. This ensures that the # ! sampled length of songs matches the actual length (i.e. a 5 min song won't display # ! as 47 seconds long in your music player, yeah that was an issue earlier.) command = 'ffmpeg -v quiet -y -i "%s" -acodec libmp3lame -abr true ' \ f'-b:a {trackAudioStream.bitrate} ' \ '-af "apad=pad_dur=2, dynaudnorm, loudnorm=I=-17" "%s"' # ! bash/ffmpeg on Unix systems need to have excape char (\) for special characters: \$ # ! alternatively the quotes could be reversed (single <-> double) in the command then # ! the windows special characters needs escaping (^): ^\ ^& ^| ^> ^< ^^ if sys.platform == 'win32': formattedCommand = command % ( str(downloadedFilePath), str(convertedFilePath) ) else: formattedCommand = command % ( str(downloadedFilePath).replace('$', r'\$'), str(convertedFilePath).replace('$', r'\$') ) process = await asyncio.subprocess.create_subprocess_shell(formattedCommand) _ = await process.communicate() # ! Wait till converted file is actually created while True: if convertedFilePath.is_file(): break if self.displayManager: self.displayManager.notify_conversion_completion() self.set_id3_data(convertedFilePath, songObj) # Do the necessary cleanup if self.displayManager: self.displayManager.notify_download_completion() if self.downloadTracker: self.downloadTracker.notify_download_completion(songObj) # delete the unnecessary YouTube download File if downloadedFilePath and downloadedFilePath.is_file(): downloadedFilePath.unlink()
def download_song(songObj: SongObj, displayManager: DisplayManager = None, downloadTracker: DownloadTracker = None) -> None: ''' `songObj` `songObj` : song to be downloaded `AutoProxy` `displayManager` : autoproxy reference to a `DisplayManager` `AutoProxy` `downloadTracker`: autoproxy reference to a `DownloadTracker` RETURNS `~` Downloads, Converts, Normalizes song & embeds metadata as ID3 tags. ''' #! all YouTube downloads are to .\Temp; they are then converted and put into .\ and #! finally followed up with ID3 metadata tags #! we explicitly use the os.path.join function here to ensure download is #! platform agnostic # Create a .\Temp folder if not present tempFolder = join('.', 'Temp') if not exists(tempFolder): mkdir(tempFolder) # build file name of converted file artistStr = '' #! we eliminate contributing artist names that are also in the song name, else we #! would end up with things like 'Jetta, Mastubs - I'd love to change the world #! (Mastubs REMIX).mp3' which is kinda an odd file name. for artist in songObj.get_contributing_artists(): if artist.lower() not in songObj.get_song_name().lower(): artistStr += artist + ', ' #! the ...[:-2] is to avoid the last ', ' appended to artistStr convertedFileName = artistStr[:-2] + ' - ' + songObj.get_song_name() #! this is windows specific (disallowed chars) for disallowedChar in ['/', '?', '\\', '*', '|', '<', '>']: if disallowedChar in convertedFileName: convertedFileName = convertedFileName.replace(disallowedChar, '') #! double quotes (") and semi-colons (:) are also disallowed characters but we would #! like to retain their equivalents, so they aren't removed in the prior loop convertedFileName = convertedFileName.replace('"', "'").replace(': ', ' - ') #! if a songObj's playlistIndex is not None then we prepend it to keep the playlist/album order playlistIndex = songObj.get_playlist_index() if playlistIndex is not None: convertedFileName = '{:04d}'.format( playlistIndex) + ' - ' + convertedFileName convertedFilePath = join('.', convertedFileName) + '.mp3' # if a song is already downloaded skip it if exists(convertedFilePath): if displayManager: displayManager.notify_download_skip() if downloadTracker: downloadTracker.notify_download_completion(songObj) #! None is the default return value of all functions, we just explicitly define #! it here as a continent way to avoid executing the rest of the function. return None # download Audio from YouTube if displayManager: youtubeHandler = YouTube( url=songObj.get_youtube_link(), on_progress_callback=displayManager.pytube_progress_hook) else: youtubeHandler = YouTube(songObj.get_youtube_link()) trackAudioStream = youtubeHandler.streams.get_audio_only() #! The actual download, if there is any error, it'll be here, try: #! pyTube will save the song in .\Temp\$songName.mp4, it doesn't save as '.mp3' downloadedFilePath = trackAudioStream.download( output_path=tempFolder, filename=convertedFileName, skip_existing=False) except: #! This is equivalent to a failed download, we do nothing, the song remains on #! downloadTrackers download queue and all is well... #! #! None is again used as a convenient exit remove(join(tempFolder, convertedFileName) + '.mp4') return None # convert downloaded file to MP3 with normalization #! -af loudnorm=I=-7:LRA applies EBR 128 loudness normalization algorithm with #! intergrated loudness target (I) set to -17, using values lower than -15 #! causes 'pumping' i.e. rhythmic variation in loudness that should not #! exist -loud parts exaggerate, soft parts left alone. #! #! apad=pad_dur=2 adds 2 seconds of silence toward the end of the track, this is #! done because the loudnorm filter clips/cuts/deletes the last 1-2 seconds on #! occasion especially if the song is EDM-like, so we add a few extra seconds to #! combat that. #! #! -acodec libmp3lame sets the encoded to 'libmp3lame' which is far better #! than the default 'mp3_mf', '-abr true' automatically determines and passes the #! audio encoding bitrate to the filters and encoder. This ensures that the #! sampled length of songs matches the actual length (i.e. a 5 min song won't display #! as 47 seconds long in your music player, yeah that was an issue earlier.) command = 'ffmpeg -v quiet -y -i "%s" -acodec libmp3lame -abr true -af loudnorm=I=-17 "%s"' formattedCommand = command % (downloadedFilePath, convertedFilePath) run_in_shell(formattedCommand) #! Wait till converted file is actually created while True: if exists(convertedFilePath): break if displayManager: displayManager.notify_conversion_completion() # embed song details #! we save tags as both ID3 v2.3 and v2.4 #! The simple ID3 tags audioFile = EasyID3(convertedFilePath) #! Get rid of all existing ID3 tags (if any exist) audioFile.delete() #! song name audioFile['title'] = songObj.get_song_name() audioFile['titlesort'] = songObj.get_song_name() #! track number audioFile['tracknumber'] = str(songObj.get_track_number()) #! genres (pretty pointless if you ask me) #! we only apply the first available genre as ID3 v2.3 doesn't support multiple #! genres and ~80% of the world PC's run Windows - an OS with no ID3 v2.4 support genres = songObj.get_genres() if len(genres) > 0: audioFile['genre'] = genres[0] #! all involved artists audioFile['artist'] = songObj.get_contributing_artists() #! album name audioFile['album'] = songObj.get_album_name() #! album artist (all of 'em) audioFile['albumartist'] = songObj.get_album_artists() #! album release date (to what ever precision available) audioFile['date'] = songObj.get_album_release() audioFile['originaldate'] = songObj.get_album_release() #! spotify link: in case you wanna re-download your whole offline library, #! you can just read the links from the tags and redownload the songs. audioFile['website'] = songObj.get_spotify_link() #! save as both ID3 v2.3 & v2.4 as v2.3 isn't fully features and #! windows doesn't support v2.4 until later versions of Win10 audioFile.save(v2_version=3) #! setting the album art audioFile = ID3(convertedFilePath) rawAlbumArt = urlopen(songObj.get_album_cover_url()).read() audioFile['APIC'] = AlbumCover(encoding=3, mime='image/jpeg', type=3, desc='Cover', data=rawAlbumArt) #! adding lyrics try: lyrics = songObj.get_song_lyrics() USLTOutput = USLT(encoding=3, lang=u'eng', desc=u'desc', text=lyrics) audioFile["USLT::'eng'"] = USLTOutput except: pass audioFile.save(v2_version=3) # Do the necessary cleanup if displayManager: displayManager.notify_download_completion() if downloadTracker: downloadTracker.notify_download_completion(songObj) # delete the unnecessary YouTube download File remove(downloadedFilePath)