is not found or 154 | # if
has a class (like
), then we got an invalid 155 | # response. 156 | # Retry in such a case. 157 | invalid_response = paragraph is None or paragraph.get("class") is not None 158 | to_retry = retries > 0 and invalid_response 159 | if to_retry: 160 | logger.debug( 161 | "Retrying since Genius returned invalid response for search " 162 | "results. Retries left: {retries}.".format(retries=retries) 163 | ) 164 | return self.from_url(url, linesep=linesep, retries=retries-1, timeout=timeout) 165 | 166 | if invalid_response: 167 | raise LyricsNotFoundError( 168 | 'Genius returned invalid response for the search URL "{}".'.format(url) 169 | ) 170 | lyrics = self._get_lyrics_text(paragraph) 171 | return lyrics.replace("\n", linesep) 172 | 173 | -------------------------------------------------------------------------------- /spotdl/lyrics/providers/lyricwikia_wrapper.py: -------------------------------------------------------------------------------- 1 | import lyricwikia 2 | 3 | from spotdl.lyrics.lyric_base import LyricBase 4 | from spotdl.lyrics.exceptions import LyricsNotFoundError 5 | 6 | 7 | class LyricWikia(LyricBase): 8 | """ 9 | Fetch lyrics from LyricWikia. 10 | 11 | Examples 12 | -------- 13 | + Fetching lyrics for *"Tobu - Cruel"*: 14 | 15 | >>> from spotdl.lyrics.providers import LyricWikia 16 | >>> genius = LyricWikia() 17 | >>> lyrics = genius.from_artist_and_track("Tobu", "Cruel") 18 | >>> print(lyrics) 19 | """ 20 | 21 | def from_artist_and_track(self, artist, track, linesep="\n", timeout=None): 22 | """ 23 | Returns the lyric string for the given artist and track. 24 | """ 25 | try: 26 | lyrics = lyricwikia.get_lyrics(artist, track, linesep, timeout) 27 | except lyricwikia.LyricsNotFound as e: 28 | raise LyricsNotFoundError(e.args[0]) 29 | return lyrics 30 | 31 | -------------------------------------------------------------------------------- /spotdl/lyrics/providers/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/lyrics/providers/tests/__init__.py -------------------------------------------------------------------------------- /spotdl/lyrics/providers/tests/test_genius.py: -------------------------------------------------------------------------------- 1 | from spotdl.lyrics import LyricBase 2 | from spotdl.lyrics import exceptions 3 | from spotdl.lyrics.providers import Genius 4 | 5 | import urllib.request 6 | import json 7 | import pytest 8 | 9 | class TestGenius: 10 | def test_subclass(self): 11 | assert issubclass(Genius, LyricBase) 12 | 13 | @pytest.fixture(scope="module") 14 | def expect_lyrics_count(self): 15 | # This is the number of characters in lyrics found 16 | # for the track in `lyric_url` fixture below 17 | return 1845 18 | 19 | @pytest.fixture(scope="module") 20 | def genius(self): 21 | return Genius() 22 | 23 | def test_base_url(self, genius): 24 | assert genius.base_url == "https://genius.com" 25 | 26 | @pytest.fixture(scope="module") 27 | def artist(self): 28 | return "selena gomez" 29 | 30 | @pytest.fixture(scope="module") 31 | def track(self): 32 | return "wolves" 33 | 34 | @pytest.fixture(scope="module") 35 | def query(self, artist, track): 36 | return "{} {}".format(artist, track) 37 | 38 | @pytest.fixture(scope="module") 39 | def guess_url(self, query): 40 | return "https://genius.com/selena-gomez-wolves-lyrics" 41 | 42 | @pytest.fixture(scope="module") 43 | def lyric_url(self): 44 | return "https://genius.com/Selena-gomez-and-marshmello-wolves-lyrics" 45 | 46 | def test_guess_lyric_url_from_artist_and_track(self, genius, artist, track, guess_url): 47 | url = genius._guess_lyric_url_from_artist_and_track(artist, track) 48 | assert url == guess_url 49 | 50 | class MockHTTPResponse: 51 | expect_lyrics = "" 52 | 53 | def __init__(self, request, timeout=None): 54 | search_results_url = "https://genius.com/api/search/multi?per_page=1&q=selena%2Bgomez%2Bwolves" 55 | if request._full_url == search_results_url: 56 | read_method = lambda: json.dumps({ 57 | "response": {"sections": [{"hits": [{"result": { 58 | "path": "/Selena-gomez-and-marshmello-wolves-lyrics" 59 | } }] }] } 60 | }) 61 | else: 62 | read_method = lambda: "
" + self.expect_lyrics + "
" 63 | 64 | self.read = read_method 65 | 66 | @pytest.mark.network 67 | def test_best_matching_lyric_url_from_query(self, genius, query, lyric_url): 68 | url = genius.best_matching_lyric_url_from_query(query) 69 | assert url == lyric_url 70 | 71 | def test_mock_best_matching_lyric_url_from_query(self, genius, query, lyric_url, monkeypatch): 72 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 73 | self.test_best_matching_lyric_url_from_query(genius, query, lyric_url) 74 | 75 | @pytest.mark.network 76 | def test_from_url(self, genius, lyric_url, expect_lyrics_count): 77 | lyrics = genius.from_url(lyric_url) 78 | assert len(lyrics) == expect_lyrics_count 79 | 80 | def test_mock_from_url(self, genius, lyric_url, expect_lyrics_count, monkeypatch): 81 | self.MockHTTPResponse.expect_lyrics = "a" * expect_lyrics_count 82 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 83 | self.test_from_url(genius, lyric_url, expect_lyrics_count) 84 | 85 | @pytest.mark.network 86 | def test_from_artist_and_track(self, genius, artist, track, expect_lyrics_count): 87 | lyrics = genius.from_artist_and_track(artist, track) 88 | assert len(lyrics) == expect_lyrics_count 89 | 90 | def test_mock_from_artist_and_track(self, genius, artist, track, expect_lyrics_count, monkeypatch): 91 | self.MockHTTPResponse.expect_lyrics = "a" * expect_lyrics_count 92 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 93 | self.test_from_artist_and_track(genius, artist, track, expect_lyrics_count) 94 | 95 | @pytest.mark.network 96 | def test_from_query(self, genius, query, expect_lyrics_count): 97 | lyrics = genius.from_query(query) 98 | assert len(lyrics) == expect_lyrics_count 99 | 100 | def test_mock_from_query(self, genius, query, expect_lyrics_count, monkeypatch): 101 | self.MockHTTPResponse.expect_lyrics = "a" * expect_lyrics_count 102 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 103 | self.test_from_query(genius, query, expect_lyrics_count) 104 | 105 | @pytest.mark.network 106 | def test_lyrics_not_found_error(self, genius): 107 | with pytest.raises(exceptions.LyricsNotFoundError): 108 | genius.from_artist_and_track(self, "nonexistent_artist", "nonexistent_track") 109 | 110 | def test_mock_lyrics_not_found_error(self, genius, monkeypatch): 111 | def mock_urlopen(url, timeout=None): 112 | raise urllib.request.HTTPError("", "", "", "", "") 113 | 114 | monkeypatch.setattr("urllib.request.urlopen", mock_urlopen) 115 | self.test_lyrics_not_found_error(genius) 116 | 117 | -------------------------------------------------------------------------------- /spotdl/lyrics/providers/tests/test_lyricwikia_wrapper.py: -------------------------------------------------------------------------------- 1 | import lyricwikia 2 | 3 | from spotdl.lyrics import LyricBase 4 | from spotdl.lyrics import exceptions 5 | from spotdl.lyrics.providers import LyricWikia 6 | 7 | import pytest 8 | 9 | 10 | class TestLyricWikia: 11 | def test_subclass(self): 12 | assert issubclass(LyricWikia, LyricBase) 13 | 14 | def test_from_artist_and_track(self, monkeypatch): 15 | # `LyricWikia` class uses the 3rd party method `lyricwikia.get_lyrics` 16 | # internally and there is no need to test a 3rd party library as they 17 | # have their own implementation of tests. 18 | monkeypatch.setattr( 19 | "lyricwikia.get_lyrics", lambda a, b, c, d: "awesome lyrics!" 20 | ) 21 | artist, track = "selena gomez", "wolves" 22 | lyrics = LyricWikia().from_artist_and_track(artist, track) 23 | assert lyrics == "awesome lyrics!" 24 | 25 | def test_lyrics_not_found_error(self, monkeypatch): 26 | def lyricwikia_lyrics_not_found(msg): 27 | raise lyricwikia.LyricsNotFound(msg) 28 | 29 | # Wrap `lyricwikia.LyricsNotFoundError` with `exceptions.LyricsNotFoundError` error. 30 | monkeypatch.setattr( 31 | "lyricwikia.get_lyrics", 32 | lambda a, b, c, d: lyricwikia_lyrics_not_found("Nope, no lyrics."), 33 | ) 34 | artist, track = "nonexistent_artist", "nonexistent_track" 35 | with pytest.raises(exceptions.LyricsNotFoundError): 36 | LyricWikia().from_artist_and_track(artist, track) 37 | -------------------------------------------------------------------------------- /spotdl/lyrics/tests/test_lyric_base.py: -------------------------------------------------------------------------------- 1 | from spotdl.lyrics import LyricBase 2 | 3 | import pytest 4 | 5 | 6 | class TestAbstractBaseClass: 7 | def test_lyricbase(self): 8 | assert LyricBase() 9 | 10 | def test_inherit_abstract_base_class_encoderbase(self): 11 | class LyricKid(LyricBase): 12 | def from_query(self, query): 13 | raise NotImplementedError 14 | 15 | def from_artist_and_track(self, artist, track): 16 | pass 17 | 18 | def from_url(self, url): 19 | raise NotImplementedError 20 | 21 | LyricKid() 22 | -------------------------------------------------------------------------------- /spotdl/lyrics/tests/test_lyrics_exceptions.py: -------------------------------------------------------------------------------- 1 | from spotdl.lyrics.exceptions import LyricsNotFoundError 2 | 3 | def test_lyrics_not_found_subclass(): 4 | assert issubclass(LyricsNotFoundError, Exception) 5 | 6 | -------------------------------------------------------------------------------- /spotdl/metadata/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.provider_base import ProviderBase 2 | from spotdl.metadata.provider_base import StreamsBase 3 | 4 | from spotdl.metadata.exceptions import BadMediaFileError 5 | from spotdl.metadata.exceptions import MetadataNotFoundError 6 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 7 | from spotdl.metadata.exceptions import YouTubeMetadataNotFoundError 8 | 9 | from spotdl.metadata.embedder_base import EmbedderBase 10 | 11 | from spotdl.metadata.formatter import format_string 12 | 13 | -------------------------------------------------------------------------------- /spotdl/metadata/embedder_base.py: -------------------------------------------------------------------------------- 1 | import mutagen 2 | import os 3 | 4 | from abc import ABC 5 | from abc import abstractmethod 6 | 7 | import urllib.request 8 | 9 | from spotdl.metadata import BadMediaFileError 10 | 11 | class EmbedderBase(ABC): 12 | """ 13 | The subclass must define the supported media file encoding 14 | formats here using a static variable - such as: 15 | 16 | >>> supported_formats = ("mp3", "m4a", "flac", "ogg", "opus") 17 | """ 18 | supported_formats = () 19 | 20 | @abstractmethod 21 | def __init__(self): 22 | """ 23 | For every supported format, there must be a corresponding 24 | method that applies metadata on this format. 25 | 26 | Such as if mp3 is supported, there must exist a method named 27 | `as_mp3` on this class that applies metadata on mp3 files. 28 | """ 29 | # self.targets = { fmt: eval(str("self.as_" + fmt)) 30 | # for fmt in self.supported_formats } 31 | # 32 | # TODO: The above code seems to fail for some reason 33 | # I do not know. 34 | self.targets = {} 35 | for fmt in self.supported_formats: 36 | # FIXME: Calling `eval` is dangerous here! 37 | self.targets[fmt] = eval("self.as_" + fmt) 38 | 39 | def get_encoding(self, path): 40 | """ 41 | This method must determine the encoding for a local 42 | audio file. Such as "mp3", "wav", "m4a", etc. 43 | """ 44 | _, extension = os.path.splitext(path) 45 | # Ignore the initial dot from file extension 46 | return extension[1:] 47 | 48 | def apply_metadata(self, path, metadata, cached_albumart=None, encoding=None): 49 | """ 50 | This method must automatically detect the media encoding 51 | format from file path and embed the corresponding metadata 52 | on the given file by calling an appropriate submethod. 53 | """ 54 | if cached_albumart is None: 55 | cached_albumart = urllib.request.urlopen( 56 | metadata["album"]["images"][0]["url"], 57 | ).read() 58 | if encoding is None: 59 | encoding = self.get_encoding(path) 60 | if encoding not in self.supported_formats: 61 | raise BadMediaFileError( 62 | 'The input format ("{}") is not supported.'.format( 63 | encoding, 64 | )) 65 | embed_on_given_format = self.targets[encoding] 66 | try: 67 | embed_on_given_format(path, metadata, cached_albumart=cached_albumart) 68 | except (mutagen.id3.error, mutagen.flac.error, mutagen.oggopus.error): 69 | raise BadMediaFileError( 70 | 'Cannot apply metadata as "{}" is badly encoded as ' 71 | '"{}".'.format(path, encoding) 72 | ) 73 | 74 | def as_mp3(self, path, metadata, cached_albumart=None): 75 | """ 76 | Method for mp3 support. This method might be defined in 77 | a subclass. 78 | 79 | Other methods for additional supported formats must also 80 | be declared here. 81 | """ 82 | raise NotImplementedError 83 | 84 | def as_m4a(self, path, metadata, cached_albumart=None): 85 | """ 86 | Method for m4a support. This method might be defined in 87 | a subclass. 88 | 89 | Other methods for additional supported formats must also 90 | be declared here. 91 | """ 92 | raise NotImplementedError 93 | 94 | def as_flac(self, path, metadata, cached_albumart=None): 95 | """ 96 | Method for flac support. This method might be defined in 97 | a subclass. 98 | 99 | Other methods for additional supported formats must also 100 | be declared here. 101 | """ 102 | raise NotImplementedError 103 | 104 | def as_ogg(self, path, metadata, cached_albumart=None): 105 | """ 106 | Method for ogg support. This method might be defined in 107 | a subclass. 108 | 109 | Other methods for additional supported formats must also 110 | be declared here. 111 | """ 112 | raise NotImplementedError 113 | 114 | def as_opus(self, path, metadata, cached_albumart=None): 115 | """ 116 | Method for opus support. This method might be defined in 117 | a subclass. 118 | 119 | Other methods for additional supported formats must also 120 | be declared here. 121 | """ 122 | raise NotImplementedError 123 | -------------------------------------------------------------------------------- /spotdl/metadata/embedders/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.embedders.default_embedder import EmbedderDefault 2 | 3 | -------------------------------------------------------------------------------- /spotdl/metadata/embedders/default_embedder.py: -------------------------------------------------------------------------------- 1 | from mutagen.easyid3 import EasyID3 2 | from mutagen.id3 import ID3, TORY, TYER, TPUB, APIC, USLT, COMM 3 | from mutagen.mp4 import MP4, MP4Cover 4 | from mutagen.flac import Picture, FLAC 5 | from mutagen.oggvorbis import OggVorbis 6 | from mutagen.oggopus import OggOpus 7 | 8 | import urllib.request 9 | import base64 10 | 11 | from spotdl.metadata import EmbedderBase 12 | from spotdl.metadata import BadMediaFileError 13 | 14 | import logging 15 | logger = logging.getLogger(__name__) 16 | 17 | # Apple has specific tags - see mutagen docs - 18 | # http://mutagen.readthedocs.io/en/latest/api/mp4.html 19 | M4A_TAG_PRESET = { 20 | "album": "\xa9alb", 21 | "artist": "\xa9ART", 22 | "date": "\xa9day", 23 | "title": "\xa9nam", 24 | "year": "\xa9day", 25 | "originaldate": "purd", 26 | "comment": "\xa9cmt", 27 | "group": "\xa9grp", 28 | "writer": "\xa9wrt", 29 | "genre": "\xa9gen", 30 | "tracknumber": "trkn", 31 | "albumartist": "aART", 32 | "discnumber": "disk", 33 | "cpil": "cpil", 34 | "albumart": "covr", 35 | "copyright": "cprt", 36 | "tempo": "tmpo", 37 | "lyrics": "\xa9lyr", 38 | "comment": "\xa9cmt", 39 | "explicit": "rtng", 40 | } 41 | 42 | TAG_PRESET = {} 43 | for key in M4A_TAG_PRESET.keys(): 44 | TAG_PRESET[key] = key 45 | 46 | 47 | class EmbedderDefault(EmbedderBase): 48 | """ 49 | A class for applying metadata on media files. 50 | 51 | Examples 52 | -------- 53 | - Applying metadata on an already downloaded MP3 file: 54 | 55 | >>> from spotdl.metadata_search import MetadataSearch 56 | >>> provider = MetadataSearch("ncs spectre") 57 | >>> metadata = provider.on_youtube() 58 | >>> from spotdl.metadata.embedders import EmbedderDefault 59 | >>> embedder = EmbedderDefault() 60 | >>> embedder.as_mp3("media.mp3", metadata) 61 | """ 62 | supported_formats = ("mp3", "m4a", "flac", "ogg", "opus") 63 | 64 | def __init__(self): 65 | super().__init__() 66 | self._m4a_tag_preset = M4A_TAG_PRESET 67 | self._tag_preset = TAG_PRESET 68 | # self.provider = "spotify" if metadata["spotify_metadata"] else "youtube" 69 | def as_mp3(self, path, metadata, cached_albumart=None): 70 | """ 71 | Apply metadata on MP3 media files. 72 | 73 | Parameters 74 | ---------- 75 | path: `str` 76 | Path to the media file. 77 | 78 | metadata: `dict` 79 | Metadata (standardized) to apply to the media file. 80 | 81 | cached_albumart: `bool` 82 | An albumart image binary. If passed, the albumart URL 83 | present in the ``metadata`` won't be downloaded or used. 84 | """ 85 | logger.debug('Writing MP3 metadata to "{path}".'.format(path=path)) 86 | # EasyID3 is fun to use ;) 87 | # For supported easyid3 tags: 88 | # https://github.com/quodlibet/mutagen/blob/master/mutagen/easyid3.py 89 | # Check out somewhere at end of above linked file 90 | audiofile = EasyID3(path) 91 | self._embed_basic_metadata(audiofile, metadata, "mp3", preset=TAG_PRESET) 92 | audiofile["media"] = metadata["type"] 93 | audiofile["author"] = metadata["artists"][0]["name"] 94 | audiofile["lyricist"] = metadata["artists"][0]["name"] 95 | audiofile["arranger"] = metadata["artists"][0]["name"] 96 | audiofile["performer"] = metadata["artists"][0]["name"] 97 | provider = metadata["provider"] 98 | audiofile["website"] = metadata["external_urls"][provider] 99 | audiofile["length"] = str(metadata["duration"]) 100 | if metadata["publisher"]: 101 | audiofile["encodedby"] = metadata["publisher"] 102 | if metadata["external_ids"]["isrc"]: 103 | audiofile["isrc"] = metadata["external_ids"]["isrc"] 104 | audiofile.save(v2_version=3) 105 | 106 | # For supported id3 tags: 107 | # https://github.com/quodlibet/mutagen/blob/master/mutagen/id3/_frames.py 108 | # Each class in the linked source file represents an id3 tag 109 | audiofile = ID3(path) 110 | if metadata["year"]: 111 | audiofile["TORY"] = TORY(encoding=3, text=metadata["year"]) 112 | audiofile["TYER"] = TYER(encoding=3, text=metadata["year"]) 113 | if metadata["publisher"]: 114 | audiofile["TPUB"] = TPUB(encoding=3, text=metadata["publisher"]) 115 | provider = metadata["provider"] 116 | audiofile["COMM"] = COMM( 117 | encoding=3, text=metadata["external_urls"][provider] 118 | ) 119 | if metadata["lyrics"]: 120 | audiofile["USLT"] = USLT( 121 | encoding=3, desc=u"Lyrics", text=metadata["lyrics"] 122 | ) 123 | if cached_albumart is None: 124 | cached_albumart = urllib.request.urlopen( 125 | metadata["album"]["images"][0]["url"] 126 | ).read() 127 | try: 128 | audiofile["APIC"] = APIC( 129 | encoding=3, 130 | mime="image/jpeg", 131 | type=3, 132 | desc=u"Cover", 133 | data=cached_albumart, 134 | ) 135 | except IndexError: 136 | pass 137 | 138 | audiofile.save(v2_version=3) 139 | 140 | def as_m4a(self, path, metadata, cached_albumart=None): 141 | """ 142 | Apply metadata on FLAC media files. 143 | 144 | Parameters 145 | ---------- 146 | path: `str` 147 | Path to the media file. 148 | 149 | metadata: `dict` 150 | Metadata (standardized) to apply to the media file. 151 | 152 | cached_albumart: `bool` 153 | An albumart image binary. If passed, the albumart URL 154 | present in the ``metadata`` won't be downloaded or used. 155 | """ 156 | 157 | logger.debug('Writing M4A metadata to "{path}".'.format(path=path)) 158 | # For supported m4a tags: 159 | # https://github.com/quodlibet/mutagen/blob/master/mutagen/mp4/__init__.py 160 | # Look for the class named `MP4Tags` in the linked source file 161 | audiofile = MP4(path) 162 | self._embed_basic_metadata(audiofile, metadata, "m4a", preset=M4A_TAG_PRESET) 163 | if metadata["year"]: 164 | audiofile[M4A_TAG_PRESET["year"]] = metadata["year"] 165 | provider = metadata["provider"] 166 | audiofile[M4A_TAG_PRESET["comment"]] = metadata["external_urls"][provider] 167 | if metadata["lyrics"]: 168 | audiofile[M4A_TAG_PRESET["lyrics"]] = metadata["lyrics"] 169 | # Explicit values: Dirty: 4, Clean: 2, None: 0 170 | audiofile[M4A_TAG_PRESET["explicit"]] = (4,) if metadata["explicit"] else (2,) 171 | try: 172 | if cached_albumart is None: 173 | cached_albumart = urllib.request.urlopen( 174 | metadata["album"]["images"][0]["url"] 175 | ).read() 176 | audiofile[M4A_TAG_PRESET["albumart"]] = [ 177 | MP4Cover(cached_albumart, imageformat=MP4Cover.FORMAT_JPEG) 178 | ] 179 | except IndexError: 180 | pass 181 | 182 | audiofile.save() 183 | 184 | def as_flac(self, path, metadata, cached_albumart=None): 185 | """ 186 | Apply metadata on MP3 media files. 187 | 188 | Parameters 189 | ---------- 190 | path: `str` 191 | Path to the media file. 192 | 193 | metadata: `dict` 194 | Metadata (standardized) to apply to the media file. 195 | 196 | cached_albumart: `bool` 197 | An albumart image binary. If passed, the albumart URL 198 | present in the ``metadata`` won't be downloaded or used. 199 | """ 200 | 201 | logger.debug('Writing FLAC metadata to "{path}".'.format(path=path)) 202 | # For supported flac tags: 203 | # https://github.com/quodlibet/mutagen/blob/master/mutagen/mp4/__init__.py 204 | # Look for the class named `MP4Tags` in the linked source file 205 | audiofile = FLAC(path) 206 | 207 | self._embed_basic_metadata(audiofile, metadata, "flac") 208 | self._embed_ogg_metadata(audiofile, metadata) 209 | self._embed_mbp_picture(audiofile, "metadata", cached_albumart, "flac") 210 | 211 | audiofile.save() 212 | 213 | def as_ogg(self, path, metadata, cached_albumart=None): 214 | logger.debug('Writing OGG Vorbis metadata to "{path}".'.format(path=path)) 215 | audiofile = OggVorbis(path) 216 | 217 | self._embed_basic_metadata(audiofile, metadata, "ogg") 218 | self._embed_ogg_metadata(audiofile, metadata) 219 | self._embed_mbp_picture(audiofile, metadata, cached_albumart, "ogg") 220 | 221 | audiofile.save() 222 | 223 | def as_opus(self, path, metadata, cached_albumart=None): 224 | logger.debug('Writing Opus metadata to "{path}".'.format(path=path)) 225 | audiofile = OggOpus(path) 226 | 227 | self._embed_basic_metadata(audiofile, metadata, "opus") 228 | self._embed_ogg_metadata(audiofile, metadata) 229 | self._embed_mbp_picture(audiofile, metadata, cached_albumart, "opus") 230 | 231 | audiofile.save() 232 | 233 | def _embed_ogg_metadata(self, audiofile, metadata): 234 | if metadata["year"]: 235 | audiofile["year"] = metadata["year"] 236 | provider = metadata["provider"] 237 | audiofile["comment"] = metadata["external_urls"][provider] 238 | if metadata["lyrics"]: 239 | audiofile["lyrics"] = metadata["lyrics"] 240 | 241 | def _embed_mbp_picture(self, audiofile, metadata, cached_albumart, encoding): 242 | image = Picture() 243 | image.type = 3 244 | image.desc = "Cover" 245 | image.mime = "image/jpeg" 246 | if cached_albumart is None: 247 | cached_albumart = urllib.request.urlopen( 248 | metadata["album"]["images"][0]["url"] 249 | ).read() 250 | image.data = cached_albumart 251 | 252 | if encoding == "flac": 253 | audiofile.add_picture(image) 254 | elif encoding == "ogg" or encoding == "opus": 255 | # From the Mutagen docs (https://mutagen.readthedocs.io/en/latest/user/vcomment.html) 256 | image_data = image.write() 257 | encoded_data = base64.b64encode(image_data) 258 | vcomment_value = encoded_data.decode("ascii") 259 | audiofile["metadata_block_picture"] = [vcomment_value] 260 | 261 | def _embed_basic_metadata(self, audiofile, metadata, encoding, preset=TAG_PRESET): 262 | audiofile[preset["artist"]] = metadata["artists"][0]["name"] 263 | if metadata["album"]["artists"][0]["name"]: 264 | audiofile[preset["albumartist"]] = metadata["album"]["artists"][0]["name"] 265 | if metadata["album"]["name"]: 266 | audiofile[preset["album"]] = metadata["album"]["name"] 267 | audiofile[preset["title"]] = metadata["name"] 268 | if metadata["release_date"]: 269 | audiofile[preset["date"]] = metadata["release_date"] 270 | audiofile[preset["originaldate"]] = metadata["release_date"] 271 | if metadata["genre"]: 272 | audiofile[preset["genre"]] = metadata["genre"] 273 | if metadata["copyright"]: 274 | audiofile[preset["copyright"]] = metadata["copyright"] 275 | if encoding == "flac" or encoding == "ogg" or encoding == "opus": 276 | audiofile[preset["discnumber"]] = str(metadata["disc_number"]) 277 | else: 278 | audiofile[preset["discnumber"]] = [(metadata["disc_number"], 0)] 279 | zfilled_track_number = str(metadata["track_number"]).zfill(len(str(metadata["total_tracks"]))) 280 | if encoding == "flac" or encoding == "ogg" or encoding == "opus": 281 | audiofile[preset["tracknumber"]] = zfilled_track_number 282 | else: 283 | if preset["tracknumber"] == TAG_PRESET["tracknumber"]: 284 | audiofile[preset["tracknumber"]] = "{}/{}".format( 285 | zfilled_track_number, metadata["total_tracks"] 286 | ) 287 | else: 288 | audiofile[preset["tracknumber"]] = [ 289 | (metadata["track_number"], metadata["total_tracks"]) 290 | ] 291 | 292 | -------------------------------------------------------------------------------- /spotdl/metadata/embedders/tests/test_default_embedder.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.embedders import EmbedderDefault 2 | 3 | import pytest 4 | 5 | @pytest.mark.xfail 6 | def test_embedder(): 7 | # Do not forget to Write tests for this! 8 | raise NotImplementedError 9 | 10 | -------------------------------------------------------------------------------- /spotdl/metadata/exceptions.py: -------------------------------------------------------------------------------- 1 | class BadMediaFileError(Exception): 2 | __module__ = Exception.__module__ 3 | 4 | def __init__(self, message=None): 5 | super().__init__(message) 6 | 7 | 8 | class MetadataNotFoundError(Exception): 9 | __module__ = Exception.__module__ 10 | 11 | def __init__(self, message=None): 12 | super().__init__(message) 13 | 14 | 15 | class SpotifyMetadataNotFoundError(MetadataNotFoundError): 16 | __module__ = Exception.__module__ 17 | 18 | def __init__(self, message=None): 19 | super().__init__(message) 20 | 21 | 22 | class YouTubeMetadataNotFoundError(MetadataNotFoundError): 23 | __module__ = Exception.__module__ 24 | 25 | def __init__(self, message=None): 26 | super().__init__(message) 27 | 28 | -------------------------------------------------------------------------------- /spotdl/metadata/formatter.py: -------------------------------------------------------------------------------- 1 | def format_string(string, metadata, output_extension="", sanitizer=lambda s: s): 2 | """ 3 | Replaces any special tags contained in the string with their 4 | metadata values. 5 | 6 | Parameters 7 | ---------- 8 | string: `str` 9 | A string containing any special tags. 10 | 11 | metadata: `dict` 12 | Metadata in standardized form. 13 | 14 | output_extension: `str` 15 | This is used to replace the special tag *"{output-ext}"* (if any) 16 | in the ``string`` passed. 17 | 18 | sanitizer: `function` 19 | This sanitizer function is called on every metadata value 20 | before replacing it with its special tag. 21 | 22 | Returns 23 | ------- 24 | string: `str` 25 | A string with all special tags replaced with their 26 | corresponding metadata values. 27 | 28 | Examples 29 | -------- 30 | + Formatting the string *"{artist} - {track-name}"* with metadata 31 | from YouTube: 32 | 33 | >>> from spotdl.metadata_search import MetadataSearch 34 | >>> searcher = MetadataSearch("ncs spectre") 35 | >>> metadata = searcher.on_youtube() 36 | >>> from spotdl.metadata import format_string 37 | >>> string = format_string("{artist} - {track-name}", metadata) 38 | >>> string 39 | 'NoCopyrightSounds - Alan Walker - Spectre [NCS Release]' 40 | """ 41 | 42 | formats = { 43 | "{track-name}" : metadata["name"], 44 | "{artist}" : metadata["artists"][0]["name"], 45 | "{album}" : metadata["album"]["name"], 46 | "{album-artist}" : metadata["artists"][0]["name"], 47 | "{genre}" : metadata["genre"], 48 | "{disc-number}" : metadata["disc_number"], 49 | "{duration}" : metadata["duration"], 50 | "{year}" : metadata["year"], 51 | "{original-date}": metadata["release_date"], 52 | "{track-number}" : str(metadata["track_number"]).zfill(len(str(metadata["total_tracks"]))), 53 | "{total-tracks}" : metadata["total_tracks"], 54 | "{isrc}" : metadata["external_ids"]["isrc"], 55 | "{track-id}" : metadata.get("id", ""), 56 | "{output-ext}" : output_extension, 57 | } 58 | 59 | for key, value in formats.items(): 60 | string = string.replace(key, sanitizer(str(value))) 61 | 62 | return string 63 | 64 | -------------------------------------------------------------------------------- /spotdl/metadata/provider_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from abc import abstractmethod 3 | from collections.abc import Sequence 4 | 5 | class StreamsBase(Sequence): 6 | @abstractmethod 7 | def __init__(self, streams): 8 | """ 9 | This method must parse audio streams into a list of 10 | dictionaries with the keys: 11 | "bitrate", "download_url", "encoding", "filesize". 12 | 13 | The list should typically be sorted in descending order 14 | based on the audio stream's bitrate. 15 | 16 | This sorted list must be assigned to ``self.streams``. 17 | """ 18 | 19 | self.streams = streams 20 | 21 | def __repr__(self): 22 | return "Streams({})".format(self.streams) 23 | 24 | def __len__(self): 25 | return len(self.streams) 26 | 27 | def __getitem__(self, index): 28 | return self.streams[index] 29 | 30 | def __eq__(self, instance): 31 | return self.streams == instance.streams 32 | 33 | def getbest(self): 34 | """ 35 | Returns the audio stream with the highest bitrate. 36 | """ 37 | 38 | return self.streams[0] 39 | 40 | def getworst(self): 41 | """ 42 | Returns the audio stream with the lowest bitrate. 43 | """ 44 | 45 | return self.streams[-1] 46 | 47 | 48 | class ProviderBase(ABC): 49 | def set_credentials(self, client_id, client_secret): 50 | """ 51 | This method may or not be used depending on whether the 52 | metadata provider requires authentication or not. 53 | """ 54 | 55 | raise NotImplementedError 56 | 57 | @abstractmethod 58 | def from_url(self, url): 59 | """ 60 | Fetches metadata for the given URL. 61 | 62 | Parameters 63 | ---------- 64 | url: `str` 65 | Media URL. 66 | 67 | Returns 68 | ------- 69 | metadata: `dict` 70 | A *dict* of standardized metadata. 71 | """ 72 | 73 | pass 74 | 75 | def from_query(self, query): 76 | """ 77 | Fetches metadata for the given search query. 78 | 79 | Parameters 80 | ---------- 81 | query: `str` 82 | Search query. 83 | 84 | Returns 85 | ------- 86 | metadata: `dict` 87 | A *dict* of standardized metadata. 88 | """ 89 | 90 | raise NotImplementedError 91 | 92 | @abstractmethod 93 | def _metadata_to_standard_form(self, metadata): 94 | """ 95 | Transforms the metadata into a format consistent with all other 96 | metadata providers, for easy utilization. 97 | """ 98 | 99 | pass 100 | 101 | -------------------------------------------------------------------------------- /spotdl/metadata/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.providers.spotify import ProviderSpotify 2 | from spotdl.metadata.providers.youtube import ProviderYouTube 3 | from spotdl.metadata.providers.youtube import YouTubeSearch 4 | 5 | -------------------------------------------------------------------------------- /spotdl/metadata/providers/spotify.py: -------------------------------------------------------------------------------- 1 | import spotipy 2 | import spotipy.oauth2 as oauth2 3 | 4 | from spotdl.metadata import ProviderBase 5 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 6 | 7 | from spotdl.authorize.services import AuthorizeSpotify 8 | from spotdl.authorize import SpotifyAuthorizationError 9 | import spotdl.util 10 | 11 | import logging 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class ProviderSpotify(ProviderBase): 16 | """ 17 | Fetch metadata using Spotify API in standardized form. 18 | 19 | Parameters 20 | ---------- 21 | spotify: :class:`spotdl.authorize.services.AuthorizeSpotify`, :class:`spotipy.Spotify`, ``None`` 22 | An authorized instance to make API calls to Spotify endpoints. 23 | 24 | If ``None``, it will attempt to reference an already created 25 | :class:`spotdl.authorize.services.AuthorizeSpotify` instance 26 | or you can set your own *Client ID* and *Client Secret* 27 | by calling :func:`ProviderSpotify.set_credentials` later on. 28 | 29 | Examples 30 | -------- 31 | - Fetching a track's metadata using Spotify URI: 32 | 33 | >>> from spotdl.authorize.services import AuthorizeSpotify 34 | # It is necessary to authorize Spotify API otherwise API 35 | # calls won't pass through Spotify. That means we won't 36 | # be able to fetch metadata from Spotify. 37 | >>> AuthorizeSpotify( 38 | ... client_id="your_spotify_client_id", 39 | ... client_secret="your_spotify_client_secret", 40 | ... ) 41 | >>> 42 | >>> from spotdl.metadata.providers import ProviderSpotify 43 | >>> provider = ProviderSpotify() 44 | >>> metadata = provider.from_url( 45 | ... "https://open.spotify.com/track/0aTiUssEOy0Mt69bsavj6K" 46 | ... ) 47 | >>> metadata["name"] 48 | 'Descending' 49 | """ 50 | 51 | def __init__(self, spotify=None): 52 | if spotify is None: 53 | try: 54 | spotify = AuthorizeSpotify() 55 | except SpotifyAuthorizationError: 56 | pass 57 | self.spotify = spotify 58 | 59 | def set_credentials(self, client_id, client_secret): 60 | """ 61 | Set your own credentials to authorize with Spotify API. 62 | This is useful if you initially didn't authorize API calls 63 | while creating an instance of :class:`ProviderSpotify`. 64 | """ 65 | token = self._generate_token(client_id, client_secret) 66 | self.spotify = spotipy.Spotify(auth=token) 67 | 68 | def assert_credentials(self): 69 | if self.spotify is None: 70 | raise SpotifyAuthorizationError( 71 | "You must first setup an AuthorizeSpotify instance, or pass " 72 | "in client_id and client_secret to the set_credentials method." 73 | ) 74 | 75 | def from_url(self, url): 76 | self.assert_credentials() 77 | logger.debug('Fetching Spotify metadata for "{url}".'.format(url=url)) 78 | metadata = self.spotify.track(url) 79 | return self._metadata_to_standard_form(metadata) 80 | 81 | def from_query(self, query): 82 | self.assert_credentials() 83 | tracks = self.search(query)["tracks"]["items"] 84 | if not tracks: 85 | raise SpotifyMetadataNotFoundError( 86 | 'Spotify returned no tracks for the search query "{}".'.format( 87 | query, 88 | ) 89 | ) 90 | return self._metadata_to_standard_form(tracks[0]) 91 | 92 | def search(self, query): 93 | self.assert_credentials() 94 | return self.spotify.search(query) 95 | 96 | def _generate_token(self, client_id, client_secret): 97 | credentials = oauth2.SpotifyClientCredentials( 98 | client_secret=client_secret, 99 | ) 100 | token = credentials.get_access_token() 101 | return token 102 | 103 | def _metadata_to_standard_form(self, metadata): 104 | self.assert_credentials() 105 | artist = self.spotify.artist(metadata["artists"][0]["id"]) 106 | album = self.spotify.album(metadata["album"]["id"]) 107 | 108 | try: 109 | metadata[u"genre"] = spotdl.util.titlecase(artist["genres"][0]) 110 | except IndexError: 111 | metadata[u"genre"] = None 112 | try: 113 | metadata[u"copyright"] = album["copyrights"][0]["text"] 114 | except IndexError: 115 | metadata[u"copyright"] = None 116 | try: 117 | metadata[u"external_ids"][u"isrc"] 118 | except KeyError: 119 | metadata[u"external_ids"][u"isrc"] = None 120 | 121 | metadata[u"release_date"] = album["release_date"] 122 | metadata[u"publisher"] = album["label"] 123 | metadata[u"total_tracks"] = album["tracks"]["total"] 124 | 125 | # Some sugar 126 | metadata["year"], *_ = metadata["release_date"].split("-") 127 | metadata["duration"] = metadata["duration_ms"] / 1000.0 128 | metadata["provider"] = "spotify" 129 | 130 | # Remove unwanted parameters 131 | del metadata["duration_ms"] 132 | del metadata["available_markets"] 133 | del metadata["album"]["available_markets"] 134 | 135 | return metadata 136 | 137 | -------------------------------------------------------------------------------- /spotdl/metadata/providers/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/metadata/providers/tests/__init__.py -------------------------------------------------------------------------------- /spotdl/metadata/providers/tests/data/streams.dump: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/metadata/providers/tests/data/streams.dump -------------------------------------------------------------------------------- /spotdl/metadata/providers/tests/test_spotify.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata import ProviderBase 2 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 3 | from spotdl.metadata.providers import ProviderSpotify 4 | 5 | import pytest 6 | 7 | class TestProviderSpotify: 8 | def test_subclass(self): 9 | assert issubclass(ProviderSpotify, ProviderBase) 10 | 11 | @pytest.mark.xfail 12 | def test_spotify_stuff(self): 13 | raise NotImplementedError 14 | 15 | # def test_metadata_not_found_error(self): 16 | # provider = ProviderSpotify(spotify=spotify) 17 | # with pytest.raises(SpotifyMetadataNotFoundError): 18 | # provider.from_query("This track doesn't exist on Spotify.") 19 | 20 | -------------------------------------------------------------------------------- /spotdl/metadata/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/metadata/tests/__init__.py -------------------------------------------------------------------------------- /spotdl/metadata/tests/test_embedder_base.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata import EmbedderBase 2 | from spotdl.metadata import BadMediaFileError 3 | 4 | import pytest 5 | 6 | class EmbedderKid(EmbedderBase): 7 | def __init__(self): 8 | super().__init__() 9 | 10 | 11 | class TestEmbedderBaseABC: 12 | def test_error_base_class_embedderbase(self): 13 | with pytest.raises(TypeError): 14 | # This abstract base class must be inherited from 15 | # for instantiation 16 | EmbedderBase() 17 | 18 | def test_inherit_abstract_base_class_streamsbase(self): 19 | EmbedderKid() 20 | 21 | 22 | class TestMethods: 23 | @pytest.fixture(scope="module") 24 | def embedderkid(self): 25 | return EmbedderKid() 26 | 27 | def test_target_formats(self, embedderkid): 28 | assert embedderkid.supported_formats == () 29 | 30 | @pytest.mark.parametrize("path, expect_encoding", ( 31 | ("/a/b/c/file.mp3", "mp3"), 32 | ("music/pop/1.wav", "wav"), 33 | ("/a path/with spaces/track.m4a", "m4a"), 34 | )) 35 | def test_get_encoding(self, embedderkid, path, expect_encoding): 36 | assert embedderkid.get_encoding(path) == expect_encoding 37 | 38 | def test_apply_metadata_with_explicit_encoding(self, embedderkid): 39 | with pytest.raises(BadMediaFileError): 40 | embedderkid.apply_metadata("/path/to/music.mp3", {}, cached_albumart="imagedata", encoding="mp3") 41 | 42 | def test_apply_metadata_with_implicit_encoding(self, embedderkid): 43 | with pytest.raises(BadMediaFileError): 44 | embedderkid.apply_metadata("/path/to/music.wav", {}, cached_albumart="imagedata") 45 | 46 | class MockHTTPResponse: 47 | """ 48 | This mocks `urllib.request.urlopen` for custom response text. 49 | """ 50 | response_file = "" 51 | 52 | def __init__(self, url): 53 | pass 54 | 55 | def read(self): 56 | pass 57 | 58 | def test_apply_metadata_without_cached_image(self, embedderkid, monkeypatch): 59 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 60 | metadata = {"album": {"images": [{"url": "http://animageurl.com"},]}} 61 | with pytest.raises(BadMediaFileError): 62 | embedderkid.apply_metadata("/path/to/music.wav", metadata, cached_albumart=None) 63 | 64 | @pytest.mark.parametrize("fmt_method_suffix", ( 65 | "as_mp3", 66 | "as_m4a", 67 | "as_flac", 68 | "as_ogg", 69 | "as_opus", 70 | )) 71 | def test_embed_formats(self, fmt_method_suffix, embedderkid): 72 | method = eval("embedderkid." + fmt_method_suffix) 73 | with pytest.raises(NotImplementedError): 74 | method("/a/random/path", {}) 75 | 76 | -------------------------------------------------------------------------------- /spotdl/metadata/tests/test_metadata_exceptions.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.exceptions import MetadataNotFoundError 2 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 3 | from spotdl.metadata.exceptions import YouTubeMetadataNotFoundError 4 | 5 | 6 | class TestMetadataNotFoundSubclass: 7 | def test_metadata_not_found_subclass(self): 8 | assert issubclass(MetadataNotFoundError, Exception) 9 | 10 | def test_spotify_metadata_not_found(self): 11 | assert issubclass(SpotifyMetadataNotFoundError, MetadataNotFoundError) 12 | 13 | def test_youtube_metadata_not_found(self): 14 | assert issubclass(YouTubeMetadataNotFoundError, MetadataNotFoundError) 15 | 16 | -------------------------------------------------------------------------------- /spotdl/metadata/tests/test_provider_base.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata import ProviderBase 2 | from spotdl.metadata import StreamsBase 3 | 4 | import pytest 5 | 6 | class TestStreamsBaseABC: 7 | def test_error_abstract_base_class_streamsbase(self): 8 | with pytest.raises(TypeError): 9 | # This abstract base class must be inherited from 10 | # for instantiation 11 | StreamsBase() 12 | 13 | def test_inherit_abstract_base_class_streamsbase(self): 14 | class StreamsKid(StreamsBase): 15 | def __init__(self, streams): 16 | super().__init__(streams) 17 | 18 | streams = ("stream1", "stream2", "stream3") 19 | kid = StreamsKid(streams) 20 | assert kid.streams == streams 21 | 22 | 23 | class TestMethods: 24 | class StreamsKid(StreamsBase): 25 | def __init__(self, streams): 26 | super().__init__(streams) 27 | 28 | 29 | @pytest.fixture(scope="module") 30 | def streamskid(self): 31 | streams = ("stream1", "stream2", "stream3") 32 | streamskid = self.StreamsKid(streams) 33 | return streamskid 34 | 35 | def test_getbest(self, streamskid): 36 | best_stream = streamskid.getbest() 37 | assert best_stream == "stream1" 38 | 39 | def test_getworst(self, streamskid): 40 | worst_stream = streamskid.getworst() 41 | assert worst_stream == "stream3" 42 | 43 | 44 | class TestProviderBaseABC: 45 | def test_error_abstract_base_class_providerbase(self): 46 | with pytest.raises(TypeError): 47 | # This abstract base class must be inherited from 48 | # for instantiation 49 | ProviderBase() 50 | 51 | def test_inherit_abstract_base_class_providerbase(self): 52 | class ProviderKid(ProviderBase): 53 | def from_url(self, query): 54 | pass 55 | 56 | def _metadata_to_standard_form(self, metadata): 57 | pass 58 | 59 | ProviderKid() 60 | 61 | -------------------------------------------------------------------------------- /spotdl/metadata_search.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.providers import ProviderSpotify 2 | from spotdl.metadata.providers import ProviderYouTube 3 | from spotdl.lyrics.providers import Genius 4 | from spotdl.lyrics.exceptions import LyricsNotFoundError 5 | 6 | import spotdl.metadata 7 | import spotdl.util 8 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 9 | 10 | from spotdl.command_line.exceptions import NoYouTubeVideoFoundError 11 | from spotdl.command_line.exceptions import NoYouTubeVideoMatchError 12 | 13 | import sys 14 | import logging 15 | logger = logging.getLogger(__name__) 16 | 17 | PROVIDERS = { 18 | "spotify": ProviderSpotify, 19 | "youtube": ProviderYouTube, 20 | } 21 | 22 | 23 | def _prompt_for_youtube_search_result(videos): 24 | max_index_length = len(str(len(videos))) 25 | max_title_length = max(len(v["title"]) for v in videos) 26 | msg = "{index:>{max_index}}. Skip downloading this track".format( 27 | index=0, 28 | max_index=max_index_length, 29 | ) 30 | print(msg, file=sys.stderr) 31 | for index, video in enumerate(videos, 1): 32 | vid_details = "{index:>{max_index}}. {title:<{max_title}}\n{new_line_gap} {url} [{duration}]".format( 33 | index=index, 34 | max_index=max_index_length, 35 | title=video["title"], 36 | max_title=max_title_length, 37 | new_line_gap=" " * max_index_length, 38 | url=video["url"], 39 | duration=video["duration"], 40 | ) 41 | print(vid_details, file=sys.stderr) 42 | print("", file=sys.stderr) 43 | 44 | selection = spotdl.util.prompt_user_for_selection(range(1, len(videos)+1)) 45 | 46 | if selection is None: 47 | return None 48 | return videos[selection-1] 49 | 50 | 51 | class MetadataSearch: 52 | """ 53 | A dedicated class to perform metadata searches on various 54 | providers. 55 | 56 | Parameters 57 | ---------- 58 | track: `str` 59 | A Spotify URI, YouTube URL or a search query. 60 | 61 | lyrics: `bool` 62 | Whether or not to fetch lyrics. 63 | 64 | yt_search_format: `str` 65 | The search format for making YouTube searches (if needed). 66 | 67 | yt_manual: `bool` 68 | Whether or not to manually choose the YouTube video result. 69 | 70 | providers: `dict` 71 | Available metadata providers. 72 | 73 | Examples 74 | -------- 75 | + Fetch track's metadata from YouTube and Spotify: 76 | 77 | >>> from spotdl.authorize.services import AuthorizeSpotify 78 | # It is necessary to authorize Spotify API otherwise API 79 | # calls won't pass through Spotify. That means we won't 80 | # be able to fetch metadata from Spotify. 81 | >>> AuthorizeSpotify( 82 | ... client_id="your_spotify_client_id", 83 | ... client_secret="your_spotify_client_secret", 84 | ... ) 85 | >>> 86 | >>> from spotdl.metadata_search import MetadataSearch 87 | >>> searcher = MetadataSearch("ncs spectre") 88 | >>> metadata = searcher.on_youtube_and_spotify() 89 | >>> metadata["external_urls"]["youtube"] 90 | 'https://youtube.com/watch?v=AOeY-nDp7hI' 91 | >>> metadata["external_urls"]["spotify"] 92 | 'https://open.spotify.com/track/0K3m6DKdX9VKewdb3r5uiT' 93 | """ 94 | 95 | def __init__(self, track, lyrics=False, yt_search_format="{artist} - {track-name}", yt_manual=False, providers=PROVIDERS): 96 | self.track = track 97 | self.track_type = spotdl.util.track_type(track) 98 | self.lyrics = lyrics 99 | self.yt_search_format = yt_search_format 100 | self.yt_manual = yt_manual 101 | self.providers = {} 102 | for provider, parent in providers.items(): 103 | self.providers[provider] = parent() 104 | self.lyric_provider = Genius() 105 | 106 | def get_lyrics(self, query): 107 | """ 108 | Internally calls :func:`spotdl.lyrics.LyricBase.from_query` 109 | but will warn and return ``None`` no lyrics found. 110 | 111 | Parameters 112 | ---------- 113 | query: `str` 114 | The query to perform the search with. 115 | 116 | Returns 117 | ------- 118 | lyrics: `str`, `None` 119 | Depending on whether the lyrics were found or not. 120 | """ 121 | 122 | try: 123 | lyrics = self.lyric_provider.from_query(query) 124 | except LyricsNotFoundError as e: 125 | logger.warning(e.args[0]) 126 | lyrics = None 127 | return lyrics 128 | 129 | def _make_lyric_search_query(self, metadata): 130 | if self.track_type == "query": 131 | lyric_query = self.track 132 | else: 133 | lyric_search_format = "{artist} - {track-name}" 134 | lyric_query = spotdl.metadata.format_string( 135 | lyric_search_format, 136 | metadata 137 | ) 138 | return lyric_query 139 | 140 | def on_youtube_and_spotify(self): 141 | """ 142 | Performs the search on both YouTube and Spotify. 143 | 144 | Returns 145 | ------- 146 | metadata: `dict` 147 | Combined metadata in standardized form, with Spotify 148 | overriding any same YouTube metadata values. If ``lyrics`` 149 | was ``True`` in :class:`MetadataSearch`, call 150 | ``metadata["lyrics"].join()`` to access them. 151 | """ 152 | 153 | track_type_mapper = { 154 | "spotify": self._on_youtube_and_spotify_for_type_spotify, 155 | "youtube": self._on_youtube_and_spotify_for_type_youtube, 156 | "query": self._on_youtube_and_spotify_for_type_query, 157 | } 158 | caller = track_type_mapper[self.track_type] 159 | metadata = caller(self.track) 160 | 161 | if not self.lyrics: 162 | return metadata 163 | 164 | lyric_query = self._make_lyric_search_query(metadata) 165 | metadata["lyrics"] = spotdl.util.ThreadWithReturnValue( 166 | target=self.get_lyrics, 167 | args=(lyric_query,), 168 | ) 169 | 170 | return metadata 171 | 172 | def on_youtube(self): 173 | """ 174 | Performs the search on YouTube. 175 | 176 | Returns 177 | ------- 178 | metadata: `dict` 179 | Metadata in standardized form. If ``lyrics`` was ``True`` in 180 | :class:`MetadataSearch`, call ``metadata["lyrics"].join()`` 181 | to access them. 182 | """ 183 | 184 | track_type_mapper = { 185 | "spotify": self._on_youtube_for_type_spotify, 186 | "youtube": self._on_youtube_for_type_youtube, 187 | "query": self._on_youtube_for_type_query, 188 | } 189 | caller = track_type_mapper[self.track_type] 190 | metadata = caller(self.track) 191 | 192 | if not self.lyrics: 193 | return metadata 194 | 195 | lyric_query = self._make_lyric_search_query(metadata) 196 | metadata["lyrics"] = spotdl.util.ThreadWithReturnValue( 197 | target=self.get_lyrics, 198 | arguments=(lyric_query,), 199 | ) 200 | 201 | return metadata 202 | 203 | def on_spotify(self): 204 | """ 205 | Performs the search on Spotify. 206 | 207 | Returns 208 | ------- 209 | metadata: `dict` 210 | Metadata in standardized form. If ``lyrics`` was ``True`` in 211 | :class:`MetadataSearch`, call ``metadata["lyrics"].join()`` 212 | to access them. 213 | """ 214 | 215 | track_type_mapper = { 216 | "spotify": self._on_spotify_for_type_spotify, 217 | "youtube": self._on_spotify_for_type_youtube, 218 | "query": self._on_spotify_for_type_query, 219 | } 220 | caller = track_type_mapper[self.track_type] 221 | metadata = caller(self.track) 222 | 223 | if not self.lyrics: 224 | return metadata 225 | 226 | lyric_query = self._make_lyric_search_query(metadata) 227 | metadata["lyrics"] = spotdl.util.ThreadWithReturnValue( 228 | target=self.get_lyrics, 229 | arguments=(lyric_query,), 230 | ) 231 | 232 | return metadata 233 | 234 | def best_on_youtube_search(self): 235 | """ 236 | Performs a search on YouTube returning the most relevant video. 237 | 238 | Returns 239 | ------- 240 | video: `dict` 241 | Contains the keys: *title*, *url* and *duration*. 242 | """ 243 | 244 | track_type_mapper = { 245 | "spotify": self._best_on_youtube_search_for_type_spotify, 246 | "youtube": self._best_on_youtube_search_for_type_youtube, 247 | "query": self._best_on_youtube_search_for_type_query, 248 | } 249 | caller = track_type_mapper[self.track_type] 250 | video = caller(self.track) 251 | return video 252 | 253 | def _best_on_youtube_search_for_type_query(self, query): 254 | videos = self.providers["youtube"].search(query) 255 | if not videos: 256 | raise NoYouTubeVideoFoundError( 257 | 'YouTube returned no videos for the search query "{}".'.format(query) 258 | ) 259 | if self.yt_manual: 260 | video = _prompt_for_youtube_search_result(videos) 261 | else: 262 | video = videos.bestmatch() 263 | 264 | if video is None: 265 | raise NoYouTubeVideoMatchError( 266 | 'No matching videos found on YouTube for the search query "{}".'.format( 267 | query 268 | ) 269 | ) 270 | return video 271 | 272 | def _best_on_youtube_search_for_type_youtube(self, url): 273 | video = self._best_on_youtube_search_for_type_query(url) 274 | return video 275 | 276 | def _best_on_youtube_search_for_type_spotify(self, url): 277 | spotify_metadata = self._on_spotify_for_type_spotify(url) 278 | search_query = spotdl.metadata.format_string(self.yt_search_format, spotify_metadata) 279 | video = self._best_on_youtube_search_for_type_query(search_query) 280 | return video 281 | 282 | def _on_youtube_and_spotify_for_type_spotify(self, url): 283 | logger.debug("Extracting YouTube and Spotify metadata for input Spotify URI.") 284 | spotify_metadata = self._on_spotify_for_type_spotify(url) 285 | search_query = spotdl.metadata.format_string(self.yt_search_format, spotify_metadata) 286 | youtube_video = self._best_on_youtube_search_for_type_query(search_query) 287 | youtube_metadata = self.providers["youtube"].from_url(youtube_video["url"]) 288 | metadata = spotdl.util.merge_copy( 289 | youtube_metadata, 290 | spotify_metadata 291 | ) 292 | return metadata 293 | 294 | def _on_youtube_and_spotify_for_type_youtube(self, url): 295 | logger.debug("Extracting YouTube and Spotify metadata for input YouTube URL.") 296 | youtube_metadata = self._on_youtube_for_type_youtube(url) 297 | search_query = spotdl.metadata.format_string("{track-name}", youtube_metadata) 298 | spotify_metadata = self._on_spotify_for_type_query(search_query) 299 | metadata = spotdl.util.merge_copy( 300 | youtube_metadata, 301 | spotify_metadata 302 | ) 303 | return metadata 304 | 305 | def _on_youtube_and_spotify_for_type_query(self, query): 306 | logger.debug("Extracting YouTube and Spotify metadata for input track query.") 307 | # Make use of threads here to search on both YouTube & Spotify 308 | # at the same time. 309 | spotify_metadata = spotdl.util.ThreadWithReturnValue( 310 | target=self._on_spotify_for_type_query, 311 | args=(query,) 312 | ) 313 | spotify_metadata.start() 314 | youtube_metadata = self._on_youtube_for_type_query(query) 315 | metadata = spotdl.util.merge_copy( 316 | youtube_metadata, 317 | spotify_metadata.join() 318 | ) 319 | return metadata 320 | 321 | def _on_youtube_for_type_spotify(self, url): 322 | logger.debug("Extracting YouTube metadata for input Spotify URI.") 323 | youtube_video = self._best_on_youtube_search_for_type_spotify(url) 324 | youtube_metadata = self.providers["youtube"].from_url(youtube_video["url"]) 325 | return youtube_metadata 326 | 327 | def _on_youtube_for_type_youtube(self, url): 328 | logger.debug("Extracting YouTube metadata for input YouTube URL.") 329 | youtube_metadata = self.providers["youtube"].from_url(url) 330 | return youtube_metadata 331 | 332 | def _on_youtube_for_type_query(self, query): 333 | logger.debug("Extracting YouTube metadata for input track query.") 334 | youtube_video = self._best_on_youtube_search_for_type_query(query) 335 | youtube_metadata = self.providers["youtube"].from_url(youtube_video["url"]) 336 | return youtube_metadata 337 | 338 | def _on_spotify_for_type_youtube(self, url): 339 | logger.debug("Extracting Spotify metadata for input YouTube URL.") 340 | youtube_metadata = self.providers["youtube"].from_url(url) 341 | search_query = spotdl.metadata.format_string("{track-name}", youtube_metadata) 342 | spotify_metadata = self.providers["spotify"].from_query(search_query) 343 | return spotify_metadata 344 | 345 | def _on_spotify_for_type_spotify(self, url): 346 | logger.debug("Extracting Spotify metadata for input Spotify URI.") 347 | spotify_metadata = self.providers["spotify"].from_url(url) 348 | return spotify_metadata 349 | 350 | def _on_spotify_for_type_query(self, query): 351 | logger.debug("Extracting Spotify metadata for input track query.") 352 | try: 353 | spotify_metadata = self.providers["spotify"].from_query(query) 354 | except SpotifyMetadataNotFoundError as e: 355 | logger.warn(e.args[0]) 356 | spotify_metadata = {} 357 | return spotify_metadata 358 | 359 | -------------------------------------------------------------------------------- /spotdl/tests/test_config.py: -------------------------------------------------------------------------------- 1 | import spotdl.config 2 | 3 | import argparse 4 | import os 5 | import sys 6 | import yaml 7 | import pytest 8 | 9 | 10 | @pytest.mark.xfail 11 | @pytest.fixture(scope="module") 12 | def config_path(tmpdir_factory): 13 | config_path = os.path.join(str(tmpdir_factory.mktemp("config")), "config.yml") 14 | return config_path 15 | 16 | 17 | @pytest.mark.xfail 18 | @pytest.fixture(scope="module") 19 | def modified_config(): 20 | modified_config = dict(spotdl.config.DEFAULT_CONFIGURATION) 21 | return modified_config 22 | 23 | 24 | def test_dump_n_read_config(config_path): 25 | expect_config = spotdl.config.DEFAULT_CONFIGURATION 26 | spotdl.config.dump_config( 27 | config_path, 28 | config=expect_config, 29 | ) 30 | config = spotdl.config.read_config(config_path) 31 | assert config == expect_config 32 | 33 | 34 | class TestDefaultConfigFile: 35 | @pytest.mark.skipif(not sys.platform == "linux", reason="Linux only") 36 | def test_linux_default_config_file(self): 37 | expect_default_config_file = os.path.expanduser("~/.config/spotdl/config.yml") 38 | assert spotdl.config.DEFAULT_CONFIG_FILE == expect_default_config_file 39 | 40 | @pytest.mark.xfail 41 | @pytest.mark.skipif(not sys.platform == "darwin" and not sys.platform == "win32", 42 | reason="Windows only") 43 | def test_windows_default_config_file(self): 44 | raise NotImplementedError 45 | 46 | @pytest.mark.xfail 47 | @pytest.mark.skipif(not sys.platform == "darwin", 48 | reason="OS X only") 49 | def test_osx_default_config_file(self): 50 | raise NotImplementedError 51 | 52 | 53 | class TestConfig: 54 | @pytest.mark.xfail 55 | def test_custom_config_path(self, config_path, modified_config): 56 | parser = argparse.ArgumentParser() 57 | with open(config_path, "w") as config_file: 58 | yaml.dump(modified_config, config_file, default_flow_style=False) 59 | overridden_config = spotdl.config.override_config( 60 | config_path, parser, raw_args="" 61 | ) 62 | modified_values = [ 63 | str(value) 64 | for value in modified_config["spotify-downloader"].values() 65 | ] 66 | overridden_config.folder = os.path.realpath(overridden_config.folder) 67 | overridden_values = [ 68 | str(value) for value in overridden_config.__dict__.values() 69 | ] 70 | assert sorted(overridden_values) == sorted(modified_values) 71 | 72 | -------------------------------------------------------------------------------- /spotdl/tests/test_util.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import subprocess 4 | 5 | import spotdl.util 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture(scope="module") 11 | def directory_fixture(tmpdir_factory): 12 | dir_path = os.path.join(str(tmpdir_factory.mktemp("tmpdir")), "filter_this_directory") 13 | return dir_path 14 | 15 | 16 | @pytest.mark.parametrize("value", [ 17 | 5, 18 | "string", 19 | {"a": 1, "b": 2}, 20 | (10, 20, 30, "string"), 21 | [2, 4, "sample"] 22 | ]) 23 | def test_thread_with_return_value(value): 24 | returner = lambda x: x 25 | thread = spotdl.util.ThreadWithReturnValue( 26 | target=returner, 27 | args=(value,) 28 | ) 29 | thread.start() 30 | assert value == thread.join() 31 | 32 | 33 | @pytest.mark.parametrize("track, track_type", [ 34 | ("https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD", "spotify"), 35 | ("spotify:track:3SipFlNddvL0XNZRLXvdZD", "spotify"), 36 | ("3SipFlNddvL0XNZRLXvdZD", "spotify"), 37 | ("https://www.youtube.com/watch?v=oMiNsd176NM", "youtube"), 38 | ("oMiNsd176NM", "youtube"), 39 | ("kodaline - saving grace", "query"), 40 | ("or anything else", "query"), 41 | ]) 42 | def test_track_type(track, track_type): 43 | assert spotdl.util.track_type(track) == track_type 44 | 45 | 46 | @pytest.mark.parametrize("str_duration, sec_duration", [ 47 | ("0:23", 23), 48 | ("0:45", 45), 49 | ("2:19", 139), 50 | ("3:33", 213), 51 | ("7:38", 458), 52 | ("1:30:05", 5405), 53 | ]) 54 | def test_get_seconds_from_video_time(str_duration, sec_duration): 55 | secs = spotdl.util.get_sec(str_duration) 56 | assert secs == sec_duration 57 | 58 | 59 | @pytest.mark.parametrize("duplicates, expected", [ 60 | (("https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 61 | "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",), 62 | ( "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",),), 63 | 64 | (("https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 65 | "", 66 | "https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD",), 67 | ( "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 68 | "https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD",),), 69 | 70 | (("ncs fade", 71 | "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 72 | "", 73 | "ncs fade",), 74 | ("ncs fade", 75 | "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ"),), 76 | 77 | (("ncs spectre ", 78 | " https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 79 | ""), 80 | ( "ncs spectre", 81 | "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ"),), 82 | ]) 83 | def test_remove_duplicates(duplicates, expected): 84 | uniques = spotdl.util.remove_duplicates( 85 | duplicates, 86 | condition=lambda x: x, 87 | operation=str.strip, 88 | ) 89 | assert tuple(uniques) == expected 90 | 91 | -------------------------------------------------------------------------------- /spotdl/track.py: -------------------------------------------------------------------------------- 1 | import tqdm 2 | 3 | import urllib.request 4 | import subprocess 5 | import sys 6 | 7 | from spotdl.encode.encoders import EncoderFFmpeg 8 | from spotdl.metadata.embedders import EmbedderDefault 9 | from spotdl.metadata import BadMediaFileError 10 | 11 | import spotdl.util 12 | 13 | import logging 14 | logger = logging.getLogger(__name__) 15 | 16 | CHUNK_SIZE = 16 * 1024 17 | 18 | class Track: 19 | """ 20 | This class allows for various operations on provided track 21 | metadata. 22 | 23 | Parameters 24 | ---------- 25 | metadata: `dict` 26 | Track metadata in standardized form. 27 | 28 | cache_albumart: `bool` 29 | Whether or not to cache albumart data by making network request 30 | to the given URL. This caching is done as soon as a 31 | :class:`Track` object is created. 32 | 33 | Examples 34 | -------- 35 | + Downloading the audio track *"NCS - Spectre"* in opus format from 36 | YouTube while simultaneously encoding it to an mp3 format: 37 | 38 | >>> from spotdl.metadata_search import MetadataSearch 39 | >>> provider = MetadataSearch("ncs spectre") 40 | >>> metadata = provider.on_youtube() 41 | # The same metadata can also be retrived using `ProviderYouTube`: 42 | >>> # from spotdl.metadata.providers import ProviderYouTube 43 | >>> # provider = ProviderYouTube() 44 | >>> # metadata = provider.from_query("ncs spectre") 45 | # However, it is recommended to use `MetadataSearch` whenever 46 | # possible as it provides a higher level API. 47 | >>> 48 | >>> from spotdl.track import Track 49 | >>> track = Track(metadata) 50 | >>> stream = metadata["streams"].get( 51 | ... quality="best", 52 | ... preftype="opus", 53 | ... ) 54 | >>> 55 | >>> import spotdl.metadata 56 | >>> filename = spotdl.metadata.format_string( 57 | ... "{artist} - {track-name}.{output-ext}", 58 | ... metadata, 59 | ... output_extension="mp3", 60 | ... ) 61 | >>> 62 | >>> filename 63 | 'NoCopyrightSounds - Alan Walker - Spectre [NCS Release].mp3' 64 | >>> track.download_while_re_encoding(stream, filename) 65 | """ 66 | 67 | def __init__(self, metadata, cache_albumart=False): 68 | self.metadata = metadata 69 | self._chunksize = CHUNK_SIZE 70 | if cache_albumart: 71 | self._albumart_thread = self._cache_albumart() 72 | self._cache_albumart = cache_albumart 73 | 74 | def _cache_albumart(self): 75 | albumart_thread = spotdl.util.ThreadWithReturnValue( 76 | target=lambda url: urllib.request.urlopen(url).read(), 77 | args=(self.metadata["album"]["images"][0]["url"],) 78 | ) 79 | albumart_thread.start() 80 | return albumart_thread 81 | 82 | def _calculate_total_chunks(self, filesize): 83 | """ 84 | Determines the total number of chunks. 85 | 86 | Parameters 87 | ---------- 88 | filesize: `int` 89 | Total size of file in bytes. 90 | 91 | Returns 92 | ------- 93 | chunks: `int` 94 | Total number of chunks based on the file size and chunk 95 | size. 96 | """ 97 | 98 | chunks = (filesize // self._chunksize) + 1 99 | return chunks 100 | 101 | def _make_progress_bar(self, iterations): 102 | """ 103 | Creates a progress bar using :class:`tqdm`. 104 | 105 | Parameters 106 | ---------- 107 | iterations: `int` 108 | Number of iterations to be performed. 109 | 110 | Returns 111 | ------- 112 | progress_bar: :class:`tqdm.std.tqdm` 113 | An iterator object. 114 | """ 115 | 116 | progress_bar = tqdm.trange( 117 | iterations, 118 | unit_scale=(self._chunksize // 1024), 119 | unit="KiB", 120 | dynamic_ncols=True, 121 | bar_format='{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt}KiB ' 122 | '[{elapsed}<{remaining}, {rate_fmt}{postfix}]', 123 | ) 124 | return progress_bar 125 | 126 | def download_while_re_encoding(self, stream, target_path, target_encoding=None, 127 | encoder=EncoderFFmpeg(must_exist=False), show_progress=True): 128 | """ 129 | Downloads a stream while simuntaneously encoding it to a 130 | given target format. 131 | 132 | Parameters 133 | ---------- 134 | stream: `dict` 135 | A `dict` containing stream information in the keys: 136 | `encoding`, `filesize`, `connection`. 137 | 138 | target_path: `str` 139 | Path to file to write the target stream to. 140 | 141 | target_encoding: `str`, `None` 142 | Specify a target encoding. If ``None``, the target encoding 143 | is automatically determined from the ``target_path``. 144 | 145 | encoder: :class:`spotdl.encode.EncoderBase` object 146 | A :class:`spotdl.encode.EncoderBase` object to use for encoding. 147 | 148 | show_progress: `bool` 149 | Whether or not to display a progress bar. 150 | """ 151 | 152 | total_chunks = self._calculate_total_chunks(stream["filesize"]) 153 | process = encoder.re_encode_from_stdin( 154 | stream["encoding"], 155 | target_path, 156 | target_encoding=target_encoding 157 | ) 158 | response = stream["connection"] 159 | 160 | progress_bar = self._make_progress_bar(total_chunks) 161 | for _ in progress_bar: 162 | chunk = response.read(self._chunksize) 163 | process.stdin.write(chunk) 164 | 165 | process.stdin.close() 166 | process.wait() 167 | 168 | def download(self, stream, target_path, show_progress=True): 169 | """ 170 | Downloads a stream. 171 | 172 | Parameters 173 | ---------- 174 | stream: `dict` 175 | A `dict` containing stream information in the keys: 176 | `filesize`, `connection`. 177 | 178 | target_path: `str` 179 | Path to file to write the downloaded stream to. 180 | 181 | show_progress: `bool` 182 | Whether or not to display a progress bar. 183 | """ 184 | 185 | total_chunks = self._calculate_total_chunks(stream["filesize"]) 186 | progress_bar = self._make_progress_bar(total_chunks) 187 | response = stream["connection"] 188 | 189 | def writer(response, progress_bar, file_io): 190 | for _ in progress_bar: 191 | chunk = response.read(self._chunksize) 192 | file_io.write(chunk) 193 | 194 | write_to_stdout = target_path == "-" 195 | if write_to_stdout: 196 | file_io = sys.stdout.buffer 197 | writer(response, progress_bar, file_io) 198 | else: 199 | with open(target_path, "wb") as file_io: 200 | writer(response, progress_bar, file_io) 201 | 202 | def re_encode(self, input_path, target_path, target_encoding=None, 203 | encoder=EncoderFFmpeg(must_exist=False), show_progress=True): 204 | """ 205 | Encodes an already downloaded stream. 206 | 207 | Parameters 208 | ---------- 209 | input_path: `str` 210 | Path to input file. 211 | 212 | target_path: `str` 213 | Path to target file. 214 | 215 | target_encoding: `str` 216 | Encoding to encode the input file to. If ``None``, the 217 | target encoding is determined from ``target_path``. 218 | 219 | encoder: :class:`spotdl.encode.EncoderBase` object 220 | A :class:`spotdl.encode.EncoderBase` object to use for encoding. 221 | 222 | show_progress: `bool` 223 | Whether or not to display a progress bar. 224 | """ 225 | stream = self.metadata["streams"].getbest() 226 | total_chunks = self._calculate_total_chunks(stream["filesize"]) 227 | process = encoder.re_encode_from_stdin( 228 | stream["encoding"], 229 | target_path, 230 | target_encoding=target_encoding 231 | ) 232 | with open(input_path, "rb") as fin: 233 | for _ in tqdm.trange(total_chunks): 234 | chunk = fin.read(self._chunksize) 235 | process.stdin.write(chunk) 236 | 237 | process.stdin.close() 238 | process.wait() 239 | 240 | def apply_metadata(self, input_path, encoding=None, embedder=EmbedderDefault()): 241 | """ 242 | Applies metadata on the audio file. 243 | 244 | Parameters 245 | ---------- 246 | input_path: `str` 247 | Path to audio file to apply metadata to. 248 | 249 | encoding: `str` 250 | Encoding of the input audio file. If ``None``, the target 251 | encoding is determined from ``input_path``. 252 | 253 | embedder: :class:`spotdl.metadata.embedders.EmbedderDefault` 254 | An object of :class:`spotdl.metadata.embedders.EmbedderDefault` 255 | which depicts the metadata embedding strategy to use. 256 | """ 257 | if self._cache_albumart: 258 | albumart = self._albumart_thread.join() 259 | else: 260 | albumart = None 261 | 262 | try: 263 | embedder.apply_metadata( 264 | input_path, 265 | self.metadata, 266 | cached_albumart=albumart, 267 | encoding=encoding, 268 | ) 269 | except BadMediaFileError as e: 270 | msg = ("{} Such problems should be fixed " 271 | "with FFmpeg set as the encoder.").format(e.args[0]) 272 | logger.warning(msg) 273 | 274 | -------------------------------------------------------------------------------- /spotdl/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import math 4 | import urllib.request 5 | import threading 6 | 7 | import logging 8 | import coloredlogs 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | try: 13 | import winreg 14 | except ImportError: 15 | pass 16 | 17 | try: 18 | from slugify import SLUG_OK, slugify 19 | except ImportError: 20 | logger.error("Oops! `unicode-slugify` was not found.") 21 | logger.info("Please remove any other slugify library and install `unicode-slugify`") 22 | raise 23 | 24 | 25 | # This has been referred from 26 | # https://stackoverflow.com/a/6894023/6554943 27 | # It's because threaded functions do not return by default 28 | # Whereas this will return the value when `join` method 29 | # is called. 30 | class ThreadWithReturnValue(threading.Thread): 31 | def __init__(self, target=lambda: None, args=()): 32 | super().__init__(target=target, args=args) 33 | self._return = None 34 | 35 | def run(self): 36 | if self._target is not None: 37 | self._return = self._target( 38 | *self._args, 39 | **self._kwargs 40 | ) 41 | 42 | def join(self, *args, **kwargs): 43 | super().join(*args, **kwargs) 44 | return self._return 45 | 46 | 47 | def install_logger(level, to_disable=("chardet", "urllib3", "spotipy", "pytube")): 48 | for module in to_disable: 49 | logging.getLogger(module).setLevel(logging.CRITICAL) 50 | if level == logging.DEBUG: 51 | fmt = "%(levelname)s:%(name)s:%(lineno)d:\n%(message)s\n" 52 | else: 53 | fmt = "%(levelname)s: %(message)s" 54 | logging.basicConfig(format=fmt, level=level) 55 | coloredlogs.DEFAULT_FIELD_STYLES = { 56 | "levelname": {"bold": True, "color": "yellow"}, 57 | "name": {"color": "blue"}, 58 | "lineno": {"color": "magenta"}, 59 | } 60 | coloredlogs.install(level=level, fmt=fmt, logger=logger) 61 | 62 | 63 | def merge_copy(base, overrider): 64 | return merge(base.copy(), overrider) 65 | 66 | 67 | def merge(base, overrider): 68 | """ Override base dict with an overrider dict, recursively. """ 69 | for key, value in overrider.items(): 70 | if isinstance(value, dict): 71 | subitem = base.setdefault(key, {}) 72 | merge(subitem, value) 73 | else: 74 | base[key] = value 75 | 76 | return base 77 | 78 | 79 | def prompt_user_for_selection(items): 80 | """ Let the user input a choice. """ 81 | logger.info("Enter a number:") 82 | while True: 83 | try: 84 | the_chosen_one = int(input("> ")) 85 | if 1 <= the_chosen_one <= len(items): 86 | return items[the_chosen_one - 1] 87 | elif the_chosen_one == 0: 88 | return None 89 | else: 90 | logger.warning("Choose a valid number!") 91 | except ValueError: 92 | logger.warning("Choose a valid number!") 93 | 94 | 95 | def is_spotify(track): 96 | """ Check if the input song is a Spotify link. """ 97 | status = len(track) == 22 and track.replace(" ", "%20") == track 98 | status = status or track.find("spotify") > -1 99 | return status 100 | 101 | 102 | def is_youtube(track): 103 | """ Check if the input song is a YouTube link. """ 104 | status = len(track) == 11 and track.replace(" ", "%20") == track 105 | status = status and not track.lower() == track 106 | status = status or "youtube.com/watch?v=" in track 107 | return status 108 | 109 | 110 | def track_type(track): 111 | track_types = { 112 | "spotify": is_spotify, 113 | "youtube": is_youtube, 114 | } 115 | for provider, fn in track_types.items(): 116 | if fn(track): 117 | return provider 118 | return "query" 119 | 120 | 121 | def sanitize(string, ok="&-_()[]{}", spaces_to_underscores=False): 122 | """ Generate filename of the song to be downloaded. """ 123 | if spaces_to_underscores: 124 | string = string.replace(" ", "_") 125 | # replace slashes with "-" to avoid directory creation errors 126 | string = string.replace("/", "-").replace("\\", "-") 127 | # slugify removes any special characters 128 | string = slugify(string, ok=ok, lower=False, spaces=True) 129 | return string 130 | 131 | 132 | def get_sec(time_str): 133 | if ":" in time_str: 134 | splitter = ":" 135 | elif "." in time_str: 136 | splitter = "." 137 | else: 138 | raise ValueError( 139 | "No expected character found in {} to split" "time values.".format(time_str) 140 | ) 141 | v = time_str.split(splitter, 3) 142 | v.reverse() 143 | sec = 0 144 | if len(v) > 0: # seconds 145 | sec += int(v[0]) 146 | if len(v) > 1: # minutes 147 | sec += int(v[1]) * 60 148 | if len(v) > 2: # hours 149 | sec += int(v[2]) * 3600 150 | return sec 151 | 152 | 153 | def remove_duplicates(elements, condition=lambda _: True, operation=lambda x: x): 154 | """ 155 | Removes duplicates from a list whilst preserving order. 156 | 157 | We could directly call `set()` on the list but it changes 158 | the order of elements. 159 | """ 160 | 161 | local_set = set() 162 | local_set_add = local_set.add 163 | filtered_list = [] 164 | for x in elements: 165 | if condition(x) and not (x in local_set or local_set_add(x)): 166 | operated = operation(x) 167 | filtered_list.append(operated) 168 | local_set_add(operated) 169 | return filtered_list 170 | 171 | 172 | def titlecase(string): 173 | return " ".join(word.capitalize() for word in string.split()) 174 | 175 | 176 | def readlines_from_nonbinary_file(path): 177 | with open(path, "r") as fin: 178 | lines = fin.read().splitlines() 179 | return lines 180 | 181 | 182 | def writelines_to_nonbinary_file(path, lines): 183 | with open(path, "w") as fout: 184 | fout.writelines(map(lambda x: x + "\n", lines)) 185 | 186 | -------------------------------------------------------------------------------- /spotdl/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.2.2" 2 | 3 | --------------------------------------------------------------------------------