Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Support for downloads from text files and also added a lyrics provider. #859

Merged
12 commits merged into from
Oct 19, 2020
44 changes: 28 additions & 16 deletions spotdl/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
#! Eg.
#! python __main__.py https://open.spotify.com/playlist/37i9dQZF1DWXhcuQw7KIeM?si=xubKHEBESM27RqGkqoXzgQ 'old gods of asgard Control' https://open.spotify.com/album/2YMWspDGtbDgYULXvVQFM6?si=gF5dOQm8QUSo-NdZVsFjAQ https://open.spotify.com/track/08mG3Y1vljYA6bvDt4Wqkj?si=SxezdxmlTx-CaVoucHmrUA
#!
#! Well, yeah its a pretty long example but, in theory, it should work like a charm.
#! Well, yeah its a pretty long example but, in theory, it should work like a charm.
#!
#! A '.spotdlTrackingFile' is automatically created with the name of the first song in the playlist/album or
#! the name of the song supplied. We don't really re re re-query YTM and SPotify as all relevant details are
Expand Down Expand Up @@ -72,6 +72,7 @@
tracks for more speed
'''


def console_entry_point():
'''
This is where all the console processing magic happens.
Expand All @@ -85,10 +86,10 @@ def console_entry_point():
return None

initialize(
clientId='4fe3fecfe5334023a1472516cc99d805',
clientSecret='0f02b7c483c04257984695007a4a8d5c'
)
clientId = '4fe3fecfe5334023a1472516cc99d805',
clientSecret = '0f02b7c483c04257984695007a4a8d5c'
)

downloader = DownloadManager()

for request in cliArgs[1:]:
Expand All @@ -99,38 +100,49 @@ def console_entry_point():
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
))

print(f'Skipping {song.get_song_name()} ({request}) '
s1as3r marked this conversation as resolved.
Show resolved Hide resolved
'as no match could be found on youtube')

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('.txt'):
print(f'Fetching songs from {request}...')
songObjList = []

with open(request, 'r') as songFile:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CliArgs is a list, it's much more simpler to add the links to CliArgs - that way, the .txt file can have song, playlist and album links and still work.

From a maintenance standpoint, fewer lines of code is always better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, there's a catch, if the .txt file is the last argument, spotdl will just skip the songs. And it won't be able to take advantage of parallel downloads.

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)
print(f'Searching for song "{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)
print(f'No song named "{request}" could be found on spotify')

downloader.close()


if __name__ == '__main__':
freeze_support()

console_entry_point()
console_entry_point()
110 changes: 54 additions & 56 deletions spotdl/download/downloader.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#===============
#=== Imports ===
#===============
# ===============
# === Imports ===
# ===============

from spotdl.download.progressHandlers import ProgressRootProcess

Expand All @@ -12,6 +12,7 @@
from spotdl.patches.pyTube import YouTube

from mutagen.easyid3 import EasyID3, ID3
from mutagen.id3 import USLT
from mutagen.id3 import APIC as AlbumCover

from urllib.request import urlopen
Expand All @@ -23,17 +24,16 @@
from spotdl.download.progressHandlers import DisplayManager, DownloadTracker



#==========================
#=== Base functionality ===
#==========================
# ==========================
# === Base functionality ===
# ==========================

#! Technically, this should ideally be defined within the downloadManager class. But due
#! to the quirks of multiprocessing.Pool, that can't be done. Do not consider this as a
#! standalone function but rather as part of DownloadManager

def download_song(songObj: SongObj, displayManager: DisplayManager = None,
downloadTracker: DownloadTracker = None) -> None:
downloadTracker: DownloadTracker = None) -> None:
'''
`songObj` `songObj` : song to be downloaded

Expand All @@ -51,13 +51,13 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,

#! 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 = ''

Expand All @@ -67,35 +67,31 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,
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 ['/', '?', '\\', '*','|', '<', '>']:
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



# download Audio from YouTube
if displayManager:
Expand All @@ -105,16 +101,16 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,
)
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
output_path = tempFolder,
filename = convertedFileName,
skip_existing = False
)
except:
#! This is equivalent to a failed download, we do nothing, the song remains on
Expand All @@ -123,16 +119,14 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,
#! None is again used as a convenient exit
remove(join(tempFolder, convertedFileName) + '.mp4')
return None

s1as3r marked this conversation as resolved.
Show resolved Hide resolved


# 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
Expand All @@ -148,10 +142,10 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,
#! 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 "apad=pad_dur=2, dynaudnorm, loudnorm=I=-17" "%s"'
formattedCommand = command % (downloadedFilePath, convertedFilePath)
command = f'ffmpeg -v quiet -y -i "{downloadedFilePath}" -acodec libmp3lame' + \
f' -abr true -af "apad=pad_dur=2, dynaudnorm, loudnorm=I=-17" "{convertedFilePath}"'

run_in_shell(formattedCommand)
run_in_shell(command)

#! Wait till converted file is actually created
while True:
Expand All @@ -161,8 +155,6 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,
if displayManager:
displayManager.notify_conversion_completion()



# embed song details
#! we save tags as both ID3 v2.3 and v2.4

Expand All @@ -173,7 +165,7 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,
audioFile.delete()

#! song name
audioFile['title'] = songObj.get_song_name()
audioFile['title'] = songObj.get_song_name()
audioFile['titlesort'] = songObj.get_song_name()

#! track number
Expand All @@ -186,7 +178,7 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,

if len(genres) > 0:
audioFile['genre'] = genres[0]

#! all involved artists
audioFile['artist'] = songObj.get_contributing_artists()

Expand All @@ -202,7 +194,7 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,

#! 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)
audioFile.save(v2_version=3)

#! setting the album art
audioFile = ID3(convertedFilePath)
Expand All @@ -211,30 +203,36 @@ def download_song(songObj: SongObj, displayManager: DisplayManager = None,

audioFile['APIC'] = AlbumCover(
encoding = 3,
mime = 'image/jpeg',
type = 3,
desc = 'Cover',
data = rawAlbumArt
mime = 'image/jpeg',
type = 3,
desc = 'Cover',
data = rawAlbumArt
)

audioFile.save(v2_version = 3)

#! 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)


#===========================================================
#=== The Download Manager (the tyrannical boss lady/guy) ===
#===========================================================
# ===========================================================
# === The Download Manager (the tyrannical boss lady/guy) ===
# ===========================================================

class DownloadManager():
#! Big pool sizes on slow connections will lead to more incomplete downloads
Expand All @@ -254,8 +252,8 @@ def __init__(self):
self.displayManager.clear()

# initialize worker pool
self.workerPool = Pool( DownloadManager.poolSize )
self.workerPool = Pool(DownloadManager.poolSize)

def download_single_song(self, songObj: SongObj) -> None:
'''
`songObj` `song` : song to be downloaded
Expand All @@ -274,7 +272,7 @@ def download_single_song(self, songObj: SongObj) -> None:
download_song(songObj, self.displayManager, self.downloadTracker)

print()

def download_multiple_songs(self, songObjList: List[SongObj]) -> None:
'''
`list<songObj>` `songObjList` : list of songs to be downloaded
Expand All @@ -291,14 +289,14 @@ def download_multiple_songs(self, songObjList: List[SongObj]) -> None:
self.displayManager.set_song_count_to(len(songObjList))

self.workerPool.starmap(
func = download_song,
iterable = (
func=download_song,
iterable=(
(song, self.displayManager, self.downloadTracker)
for song in songObjList
for song in songObjList
)
)
print()

def resume_download_from_tracking_file(self, trackingFilePath: str) -> None:
'''
`str` `trackingFilePath` : path to a .spotdlTrackingFile
Expand All @@ -320,11 +318,11 @@ def resume_download_from_tracking_file(self, trackingFilePath: str) -> None:
func = download_song,
iterable = (
(song, self.displayManager, self.downloadTracker)
for song in songObjList
for song in songObjList
)
)
print()

def close(self) -> None:
'''
RETURNS `~`
Expand All @@ -336,4 +334,4 @@ def close(self) -> None:
self.rootProcess.shutdown()

self.workerPool.close()
self.workerPool.join()
self.workerPool.join()
Loading