37 Commits

Author SHA1 Message Date
Ritiek Malhotra
9cac8998f2 Merge pull request #641 from ritiek/release-v1.2.3
Bump version for v1.2.3 release
2019-12-20 03:12:47 +05:30
Ritiek Malhotra
af4ccea206 Bump version for v1.2.3 release
Also removed "Beta status" from the classifier list in setup.py
2019-12-20 03:09:16 +05:30
Ritiek Malhotra
12b98c55cc Merge pull request #638 from ritiek/fix-crash
Patch all Pafy versions till v0.5.5
2019-12-20 03:04:34 +05:30
Ritiek Malhotra
16f240d4e6 Add a changelog entry
For the commit ca1ab51.
2019-12-20 02:59:05 +05:30
Ritiek Malhotra
ca1ab5118c Patch all Pafy versions till v0.5.5
For some reason, the newer release v0.5.5 of Pafy still does not
contain the new methods that were supposed to be a part of the release.
With this commit, we change to also apply patches on v0.5.5.

Addresses #633, #631.
2019-12-17 12:58:51 +05:30
Ritiek Malhotra
03a8b50ab4 Merge pull request #568 from kadaliao/feat/keep-trackid-as-songname
feat: add file-format key to use track id as saved filename
2019-09-07 19:43:00 +05:30
Linus Groh
ff47523478 Merge branch 'master' into feat/keep-trackid-as-songname 2019-09-07 11:49:10 +01:00
Kada Liao
1348c138c9 docs: add changlog 2019-09-07 18:46:11 +08:00
Ritiek Malhotra
3b5adeb1b9 Merge pull request #600 from cclauss/patch-1
Travis CI: Remove sudo and dist lines
2019-08-25 11:04:41 +05:30
Ritiek Malhotra
1b4d4c747c Merge pull request #597 from arthurlutz/patch-1
[spotdl] generate_m3u only takes track_file as argument
2019-08-25 11:03:01 +05:30
Christian Clauss
bfba7fd6e6 Travis CI: Remove sudo and dist lines
Sudo is deprecated in Travis and Xenial is the current default distro
2019-08-25 03:19:59 +02:00
Arthur Lutz
e4658825f7 [CHANGES] fixed changelog 2019-08-24 08:50:30 +02:00
Arthur Lutz
5242285637 [spotdl] generate_m3u only takes track_file as argument
Fixes #559
2019-08-23 12:34:29 +02:00
Ritiek Malhotra
cfbf97c028 Merge pull request #594 from Dsujan/#592_add_leading_zeros
Added leading zeros in track_number.Fixed issue #592
2019-08-01 22:27:19 +05:30
py-coder
0202c65110 Added leading zeros in track_number.Fixed issue #592 2019-08-01 10:17:24 +05:45
Ritiek Malhotra
d45655a2b7 Merge pull request #591 from ritiek/fix-docker-build
Fix missing packages with Docker build
2019-07-28 14:53:17 +05:30
Ritiek Malhotra
80bbf80090 Fix missing packages with Docker build 2019-07-28 14:41:03 +05:30
Kada Liao
94e29e7515 add key track_id for file-format parameter 2019-07-27 18:38:50 +08:00
Ritiek Malhotra
17600592a8 Merge pull request #585 from ritiek/refactor
Scrape lyrics from Genius and lyrics refactor
2019-07-25 12:05:25 +05:30
Ritiek Malhotra
34ea3ea91b Mention about Genius lyric provider 2019-07-25 11:41:07 +05:30
Ritiek Malhotra
647a2089e0 Merge pull request #587 from ritiek/missing-changelog
Add changelog entry for #580
2019-07-24 13:33:52 +05:30
Ritiek Malhotra
568ddc52ab Automatically retry randomly failed Travis jobs 2019-07-24 11:50:10 +05:30
Ritiek Malhotra
d9d92e5723 Add changelog entry for #580 2019-07-24 11:42:06 +05:30
Ritiek Malhotra
4f6cae9f80 Update CHANGES.md 2019-07-24 11:29:37 +05:30
Ritiek Malhotra
5bcacf01da Fallback to LyricWikia if lyrics not found on Genius 2019-07-24 10:56:04 +05:30
Ritiek Malhotra
54a1564596 Merge pull request #580 from NightMachinary/master
Added --no-remove-original-file. Fixed a bug with ffmpeg accessing stdin.
2019-07-23 16:05:15 +05:30
Fereidoon Mehri
597828866b Added --no-remove-original-file. Fixed bug with ffmpeg accessing stdin.
Fixed tests
2019-07-23 14:43:02 +04:30
Ritiek Malhotra
5134459554 Maybe stop calling pytest as module works? 2019-07-22 16:10:10 +05:30
Ritiek Malhotra
08566e02b5 Update command to run tests 2019-07-22 15:58:54 +05:30
Ritiek Malhotra
0d846cdcce Scrape lyrics from Genius and lyrics refactor 2019-07-22 15:55:05 +05:30
Ritiek Malhotra
341af5bce9 Merge pull request #584 from ritiek/fix-tests
Fix tests
2019-07-22 11:07:40 +05:30
Ritiek Malhotra
69522331df Fix tests 2019-07-20 21:49:25 +05:30
Ritiek Malhotra
5ca4317944 Merge pull request #558 from ritiek/pafy-prefer-secure-by-default
Pafy prefer secure HTTPS by default
2019-06-05 23:36:11 +05:30
Ritiek Malhotra
f4cd70b603 Bump to v1.2.2 2019-06-03 14:18:31 +05:30
Ritiek Malhotra
b6c5c88550 Fix tests for now and rephrase comments for clarity 2019-06-03 14:15:35 +05:30
Ritiek Malhotra
9f1f361dcb Add docs on what this is about 2019-06-03 14:15:23 +05:30
Ritiek Malhotra
fd74adb42f Prefer secure HTTPS by default 2019-06-03 14:04:41 +05:30
29 changed files with 383 additions and 107 deletions

View File

@@ -1,6 +1,4 @@
dist: xenial
language: python
sudo: required
python:
- "3.4"
- "3.5"
@@ -40,7 +38,7 @@ install:
- tinydownload 07426048687547254773 -o ~/bin/ffmpeg
- chmod 755 ~/bin/ffmpeg
- xdg-user-dirs-update
script: python -m pytest test --cov=.
script: travis_retry pytest --cov=.
after_success:
- pip install codecov
- codecov

View File

@@ -5,14 +5,23 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.2.3] - 2019-12-20
### Added
-
- Added `--no-remove-original-file` ([@NightMachinary](https://github.com/NightMachinary)) (#580)
- Added leading Zeros in `track_number` for correct sorting ([@Dsujan](https://github.com/Dsujan)) (#592)
- Added `track_id` key for `--file-format` parameter ([@kadaliao](https://github.com/kadaliao)) (#568)
### Fixed
-
- Some tracks randomly fail to download with Pafy v0.5.5 ([@ritiek](https://github.com/ritiek)) (#638)
- Generate list error --write-m3u ([@arthurlutz](https://github.com/arthurlutz)) (#559)
### Changed
-
- Fetch lyrics from Genius and fallback to LyricWikia if not found ([@ritiek](https://github.com/ritiek)) (#585)
## [1.2.2] - 2019-06-03
### Fixed
- Patch bug in Pafy to prefer secure HTTPS ([@ritiek](https://github.com/ritiek)) (#558)
## [1.2.1] - 2019-04-28
### Fixed

View File

@@ -24,7 +24,7 @@ don't feel bad. Open an issue any way!
unless mentioned otherwise.
- Code should be formatted using [black](https://github.com/ambv/black). Don't worry if you forgot or don't know how to do this, the codebase will be black-formatted with each release.
- All tests are placed in the [test directory](https://github.com/ritiek/spotify-downloader/tree/master/test). We use [pytest](https://github.com/pytest-dev/pytest)
to run the test suite: `$ python3 -m pytest test`.
to run the test suite: `$ pytest`.
If you don't have pytest, you can install it with `$ pip3 install pytest`.
- Add a note about the changes, your GitHub username and a reference to the PR to the `Unreleased` section of the [`CHANGES.md`](CHANGES.md) file (see existing releases for examples), add the appropriate section ("Added", "Changed", "Fixed" etc.) if necessary. You don't have to increment version numbers. See https://keepachangelog.com/en/1.0.0/ for more information.
- If you are planning to work on something big, let us know through an issue. So we can discuss more about it.

View File

@@ -11,7 +11,7 @@
- Can also download a song by entering its artist and song name (in case if you don't have the Spotify's HTTP link for some song).
- Automatically applies metadata to the downloaded song which includes:
- `Title`, `Artist`, `Album`, `Album art`, `Lyrics` (if found on [lyrics wikia](http://lyrics.wikia.com)), `Album artist`, `Genre`, `Track number`, `Disc number`, `Release date`, and more...
- `Title`, `Artist`, `Album`, `Album art`, `Lyrics` (if found either on [Genius](https://genius.com/) or [LyricsWikia](http://lyrics.wikia.com)), `Album artist`, `Genre`, `Track number`, `Disc number`, `Release date`, and more...
- Works straight out of the box and does not require you to generate or mess with your API keys (already included).
@@ -74,7 +74,7 @@ Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more info.
## Running Tests
```console
$ python3 -m pytest test
$ pytest
```
Obviously this requires the `pytest` module to be installed.

View File

@@ -10,7 +10,7 @@ setup(
name="spotdl",
# Tests are included automatically:
# https://docs.python.org/3.6/distutils/sourcedist.html#specifying-the-files-to-distribute
packages=["spotdl"],
packages=["spotdl", "spotdl.lyrics", "spotdl.lyrics.providers"],
version=spotdl.__version__,
install_requires=[
"pathlib >= 1.0.1",
@@ -46,7 +46,6 @@ setup(
"metadata",
],
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",

View File

@@ -1 +1 @@
__version__ = "1.2.1"
__version__ = "1.2.3"

View File

@@ -16,7 +16,14 @@ https://trac.ffmpeg.org/wiki/Encode/AAC
"""
def song(input_song, output_song, folder, avconv=False, trim_silence=False):
def song(
input_song,
output_song,
folder,
avconv=False,
trim_silence=False,
delete_original=True,
):
""" Do the audio format conversion. """
if avconv and trim_silence:
raise ValueError("avconv does not support trim_silence")
@@ -28,7 +35,9 @@ def song(input_song, output_song, folder, avconv=False, trim_silence=False):
else:
return 0
convert = Converter(input_song, output_song, folder, delete_original=True)
convert = Converter(
input_song, output_song, folder, delete_original=delete_original
)
if avconv:
exit_code, command = convert.with_avconv()
else:
@@ -97,7 +106,9 @@ class Converter:
return code, command
def with_ffmpeg(self, trim_silence=False):
ffmpeg_pre = "ffmpeg -y "
ffmpeg_pre = (
"ffmpeg -y -nostdin "
) # -nostdin is necessary for spotdl to be able to run in the backgroung.
if not log.level == 10:
ffmpeg_pre += "-hide_banner -nostats -v panic "

View File

@@ -96,6 +96,7 @@ class Downloader:
self.raw_song = raw_song
self.number = number
self.content, self.meta_tags = youtube_tools.match_video_and_metadata(raw_song)
self.total_songs = int(self.meta_tags["total_tracks"])
def download_single(self):
""" Logic behind downloading a song. """
@@ -133,6 +134,7 @@ class Downloader:
const.args.folder,
avconv=const.args.avconv,
trim_silence=const.args.trim_silence,
delete_original=not const.args.no_remove_original,
)
except FileNotFoundError:
encoder = "avconv" if const.args.avconv else "ffmpeg"
@@ -157,7 +159,10 @@ class Downloader:
def refine_songname(self, songname):
if self.meta_tags is not None:
refined_songname = internals.format_string(
const.args.file_format, self.meta_tags, slugification=True
const.args.file_format,
self.meta_tags,
slugification=True,
total_songs=self.total_songs,
)
log.debug(
'Refining songname from "{0}" to "{1}"'.format(

View File

@@ -15,6 +15,7 @@ _LOG_LEVELS_STR = ["INFO", "WARNING", "ERROR", "DEBUG"]
default_conf = {
"spotify-downloader": {
"no-remove-original": False,
"manual": False,
"no-metadata": False,
"no-fallback-metadata": False,
@@ -36,7 +37,7 @@ default_conf = {
"write-successful": None,
"log-level": "INFO",
"spotify_client_id": "4fe3fecfe5334023a1472516cc99d805",
"spotify_client_secret": "0f02b7c483c04257984695007a4a8d5c"
"spotify_client_secret": "0f02b7c483c04257984695007a4a8d5c",
}
}
@@ -139,6 +140,13 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
help="choose the track to download manually from a list of matching tracks",
action="store_true",
)
parser.add_argument(
"-nr",
"--no-remove-original",
default=config["no-remove-original"],
help="do not remove the original file after conversion",
action="store_true",
)
parser.add_argument(
"-nm",
"--no-metadata",
@@ -272,13 +280,13 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
"-sci",
"--spotify-client-id",
default=config["spotify_client_id"],
help=argparse.SUPPRESS
help=argparse.SUPPRESS,
)
parser.add_argument(
"-scs",
"--spotify-client-secret",
default=config["spotify_client_secret"],
help=argparse.SUPPRESS
help=argparse.SUPPRESS,
)
parser.add_argument(
"-c", "--config", default=None, help="path to custom config.yml file"
@@ -312,11 +320,12 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
if parsed.avconv and parsed.trim_silence:
parser.error("--trim-silence can only be used with FFmpeg")
if parsed.write_to and not (parsed.playlist \
or parsed.album \
or parsed.all_albums \
or parsed.username):
parser.error("--write-to can only be used with --playlist, --album, --all-albums, or --username")
if parsed.write_to and not (
parsed.playlist or parsed.album or parsed.all_albums or parsed.username
):
parser.error(
"--write-to can only be used with --playlist, --album, --all-albums, or --username"
)
parsed.log_level = log_leveller(parsed.log_level)

View File

@@ -1,8 +1,10 @@
from logzero import logger as log
import os
import sys
import math
import urllib.request
from spotdl import const
try:
@@ -30,6 +32,7 @@ formats = {
9: "track_number",
10: "total_tracks",
11: "isrc",
12: "track_id",
}
@@ -73,7 +76,9 @@ def is_youtube(raw_song):
return status
def format_string(string_format, tags, slugification=False, force_spaces=False):
def format_string(
string_format, tags, slugification=False, force_spaces=False, total_songs=0
):
""" Generate a string of the format '[artist] - [song]' for the given spotify song. """
format_tags = dict(formats)
format_tags[0] = tags["name"]
@@ -88,14 +93,22 @@ def format_string(string_format, tags, slugification=False, force_spaces=False):
format_tags[9] = tags["track_number"]
format_tags[10] = tags["total_tracks"]
format_tags[11] = tags["external_ids"]["isrc"]
format_tags[12] = tags["id"]
format_tags_sanitized = {
k: sanitize_title(str(v), ok="'-_()[]{}") if slugification else str(v)
for k, v in format_tags.items()
}
# calculating total digits presnet in total_songs to prepare a zfill.
total_digits = 0 if total_songs == 0 else int(math.log10(total_songs)) + 1
for x in formats:
format_tag = "{" + formats[x] + "}"
# Making consistent track number by prepending zero
# on it according to number of digits in total songs
if format_tag == "{track_number}":
format_tags_sanitized[x] = format_tags_sanitized[x].zfill(total_digits)
string_format = string_format.replace(format_tag, format_tags_sanitized[x])
if const.args.no_spaces and not force_spaces:
@@ -258,7 +271,7 @@ def remove_duplicates(tracks):
def content_available(url):
try:
response = urllib.request.urlopen(url)
except HTTPError:
except urllib.request.HTTPError:
return False
else:
return response.getcode() < 300

View File

@@ -0,0 +1 @@
from spotdl.lyrics.lyric_base import LyricBase

View File

@@ -0,0 +1,5 @@
class LyricsNotFound(Exception):
__module__ = Exception.__module__
def __init__(self, message=None):
super(LyricsNotFound, self).__init__(message)

View File

@@ -0,0 +1,14 @@
import lyricwikia
from abc import ABC
from abc import abstractmethod
class LyricBase(ABC):
@abstractmethod
def __init__(self, artist, song):
pass
@abstractmethod
def get_lyrics(self, linesep="\n", timeout=None):
pass

View File

@@ -0,0 +1,4 @@
from spotdl.lyrics.providers.genius import Genius
from spotdl.lyrics.providers.lyricwikia_wrapper import LyricWikia
LyricClasses = (Genius, LyricWikia)

View File

@@ -0,0 +1,47 @@
from bs4 import BeautifulSoup
import urllib.request
from spotdl.lyrics.lyric_base import LyricBase
from spotdl.lyrics.exceptions import LyricsNotFound
BASE_URL = "https://genius.com"
class Genius(LyricBase):
def __init__(self, artist, song):
self.artist = artist
self.song = song
self.base_url = BASE_URL
def _guess_lyric_url(self):
query = "/{} {} lyrics".format(self.artist, self.song)
query = query.replace(" ", "-")
encoded_query = urllib.request.quote(query)
lyric_url = self.base_url + encoded_query
return lyric_url
def _fetch_page(self, url, timeout=None):
request = urllib.request.Request(url)
request.add_header("User-Agent", "urllib")
try:
response = urllib.request.urlopen(request, timeout=timeout)
except urllib.request.HTTPError:
raise LyricsNotFound(
"Could not find lyrics for {} - {} at URL: {}".format(
self.artist, self.song, url
)
)
else:
return response.read()
def _get_lyrics_text(self, html):
soup = BeautifulSoup(html, "html.parser")
lyrics_paragraph = soup.find("p")
lyrics = lyrics_paragraph.get_text()
return lyrics
def get_lyrics(self, linesep="\n", timeout=None):
url = self._guess_lyric_url()
html_page = self._fetch_page(url, timeout=timeout)
lyrics = self._get_lyrics_text(html_page)
return lyrics.replace("\n", linesep)

View File

@@ -0,0 +1,18 @@
import lyricwikia
from spotdl.lyrics.lyric_base import LyricBase
from spotdl.lyrics.exceptions import LyricsNotFound
class LyricWikia(LyricBase):
def __init__(self, artist, song):
self.artist = artist
self.song = song
def get_lyrics(self, linesep="\n", timeout=None):
try:
lyrics = lyricwikia.get_lyrics(self.artist, self.song, linesep, timeout)
except lyricwikia.LyricsNotFound as e:
raise LyricsNotFound(e.args[0])
else:
return lyrics

View File

@@ -0,0 +1,37 @@
from spotdl.lyrics import LyricBase
from spotdl.lyrics import exceptions
from spotdl.lyrics.providers import Genius
import urllib.request
import pytest
class TestGenius:
def test_subclass(self):
assert issubclass(Genius, LyricBase)
@pytest.fixture(scope="module")
def track(self):
return Genius("artist", "song")
def test_base_url(self, track):
assert track.base_url == "https://genius.com"
def test_get_lyrics(self, track, monkeypatch):
def mocked_urlopen(url, timeout=None):
class DummyHTTPResponse:
def read(self):
return "<p>amazing lyrics!</p>"
return DummyHTTPResponse()
monkeypatch.setattr("urllib.request.urlopen", mocked_urlopen)
assert track.get_lyrics() == "amazing lyrics!"
def test_lyrics_not_found_error(self, track, monkeypatch):
def mocked_urlopen(url, timeout=None):
raise urllib.request.HTTPError("", "", "", "", "")
monkeypatch.setattr("urllib.request.urlopen", mocked_urlopen)
with pytest.raises(exceptions.LyricsNotFound):
track.get_lyrics()

View File

@@ -0,0 +1,35 @@
import lyricwikia
from spotdl.lyrics import LyricBase
from spotdl.lyrics import exceptions
from spotdl.lyrics.providers import LyricWikia
import pytest
class TestLyricWikia:
def test_subclass(self):
assert issubclass(LyricWikia, LyricBase)
def test_get_lyrics(self, monkeypatch):
# `LyricWikia` class uses the 3rd party method `lyricwikia.get_lyrics`
# internally and there is no need to test a 3rd party library as they
# have their own implementation of tests.
monkeypatch.setattr(
"lyricwikia.get_lyrics", lambda a, b, c, d: "awesome lyrics!"
)
track = LyricWikia("Lyricwikia", "Lyricwikia")
assert track.get_lyrics() == "awesome lyrics!"
def test_lyrics_not_found_error(self, monkeypatch):
def lyricwikia_lyrics_not_found(msg):
raise lyricwikia.LyricsNotFound(msg)
# Wrap `lyricwikia.LyricsNotFound` with `exceptions.LyricsNotFound` error.
monkeypatch.setattr(
"lyricwikia.get_lyrics",
lambda a, b, c, d: lyricwikia_lyrics_not_found("Nope, no lyrics."),
)
track = LyricWikia("Lyricwikia", "Lyricwikia")
with pytest.raises(exceptions.LyricsNotFound):
track.get_lyrics()

View File

@@ -80,7 +80,9 @@ class EmbedMetadata:
audiofile["TYER"] = TYER(encoding=3, text=meta_tags["year"])
if meta_tags["publisher"]:
audiofile["TPUB"] = TPUB(encoding=3, text=meta_tags["publisher"])
audiofile["COMM"] = COMM(encoding=3, text=meta_tags["external_urls"][self.provider])
audiofile["COMM"] = COMM(
encoding=3, text=meta_tags["external_urls"][self.provider]
)
if meta_tags["lyrics"]:
audiofile["USLT"] = USLT(
encoding=3, desc=u"Lyrics", text=meta_tags["lyrics"]

View File

@@ -11,35 +11,54 @@ def _getbestthumb(self):
part_url = "https://i.ytimg.com/vi/%s/" % self.videoid
# Thumbnail resolution sorted in descending order
thumbs = ("maxresdefault.jpg",
"sddefault.jpg",
"hqdefault.jpg",
"mqdefault.jpg",
"default.jpg")
thumbs = (
"maxresdefault.jpg",
"sddefault.jpg",
"hqdefault.jpg",
"mqdefault.jpg",
"default.jpg",
)
for thumb in thumbs:
url = part_url + thumb
if self._content_available(url):
return url
def _process_streams(self):
for format_index in range(len(self._ydl_info['formats'])):
for format_index in range(len(self._ydl_info["formats"])):
try:
self._ydl_info['formats'][format_index]['url'] = self._ydl_info['formats'][format_index]['fragment_base_url']
self._ydl_info["formats"][format_index]["url"] = self._ydl_info["formats"][
format_index
]["fragment_base_url"]
except KeyError:
pass
return backend_youtube_dl.YtdlPafy._old_process_streams(self)
@classmethod
def _content_available(cls, url):
return internals.content_available(url)
class PatchPafy:
"""
These patches have not been released by pafy on PyPI yet but
are useful to us.
"""
def patch_getbestthumb(self):
# https://github.com/mps-youtube/pafy/pull/211
pafy.backend_shared.BasePafy._bestthumb = None
pafy.backend_shared.BasePafy._content_available = _content_available
pafy.backend_shared.BasePafy.getbestthumb = _getbestthumb
def patch_process_streams(self):
backend_youtube_dl.YtdlPafy._old_process_streams = backend_youtube_dl.YtdlPafy._process_streams
# https://github.com/mps-youtube/pafy/pull/230
backend_youtube_dl.YtdlPafy._old_process_streams = (
backend_youtube_dl.YtdlPafy._process_streams
)
backend_youtube_dl.YtdlPafy._process_streams = _process_streams
def patch_insecure_streams(self):
# https://github.com/mps-youtube/pafy/pull/235
pafy.g.def_ydl_opts["prefer_insecure"] = False

View File

@@ -28,8 +28,9 @@ def match_args():
track_dl.download_single()
elif const.args.list:
if const.args.write_m3u:
youtube_tools.generate_m3u(track_file=const.args.list,
text_file=const.args.write_to)
youtube_tools.generate_m3u(
track_file=const.args.list
)
else:
list_dl = downloader.ListDownloader(
tracks_file=const.args.list,
@@ -38,17 +39,21 @@ def match_args():
)
list_dl.download_list()
elif const.args.playlist:
spotify_tools.write_playlist(playlist_url=const.args.playlist,
text_file=const.args.write_to)
spotify_tools.write_playlist(
playlist_url=const.args.playlist, text_file=const.args.write_to
)
elif const.args.album:
spotify_tools.write_album(album_url=const.args.album,
text_file=const.args.write_to)
spotify_tools.write_album(
album_url=const.args.album, text_file=const.args.write_to
)
elif const.args.all_albums:
spotify_tools.write_all_albums_from_artist(artist_url=const.args.all_albums,
text_file=const.args.write_to)
spotify_tools.write_all_albums_from_artist(
artist_url=const.args.all_albums, text_file=const.args.write_to
)
elif const.args.username:
spotify_tools.write_user_playlist(username=const.args.username,
text_file=const.args.write_to)
spotify_tools.write_user_playlist(
username=const.args.username, text_file=const.args.write_to
)
def main():

View File

@@ -1,6 +1,5 @@
import spotipy
import spotipy.oauth2 as oauth2
import lyricwikia
from slugify import slugify
from titlecase import titlecase
@@ -12,6 +11,8 @@ import functools
from spotdl import const
from spotdl import internals
from spotdl.lyrics.providers import LyricClasses
from spotdl.lyrics.exceptions import LyricsNotFound
spotify = None
@@ -36,6 +37,7 @@ def must_be_authorized(func, spotify=spotify):
token = generate_token()
spotify = spotipy.Spotify(auth=token)
return func(*args, **kwargs)
return wrapper
@@ -74,13 +76,16 @@ def generate_metadata(raw_song):
meta_tags[u"total_tracks"] = album["tracks"]["total"]
log.debug("Fetching lyrics")
meta_tags["lyrics"] = None
try:
meta_tags["lyrics"] = lyricwikia.get_lyrics(
meta_tags["artists"][0]["name"], meta_tags["name"]
)
except lyricwikia.LyricsNotFound:
meta_tags["lyrics"] = None
for LyricClass in LyricClasses:
track = LyricClass(meta_tags["artists"][0]["name"], meta_tags["name"])
try:
meta_tags["lyrics"] = track.get_lyrics()
except LyricsNotFound:
continue
else:
break
# Some sugar
meta_tags["year"], *_ = meta_tags["release_date"].split("-")

View File

@@ -16,11 +16,13 @@ pafy.g.opener.addheaders.append(("Range", "bytes=0-"))
# Implement unreleased methods on Pafy object
# More info: https://github.com/mps-youtube/pafy/pull/211
if pafy.__version__ <= "0.5.4":
if pafy.__version__ <= "0.5.5":
from spotdl import patcher
pafy_patcher = patcher.PatchPafy()
pafy_patcher.patch_getbestthumb()
pafy_patcher.patch_process_streams()
pafy_patcher.patch_insecure_streams()
def set_api_key():
@@ -51,10 +53,13 @@ def match_video_and_metadata(track):
""" Get and match track data from YouTube and Spotify. """
meta_tags = None
def fallback_metadata(meta_tags):
fallback_metadata_info = "Track not found on Spotify, falling back on YouTube metadata"
skip_fallback_metadata_warning = "Fallback condition not met, shall not embed metadata"
fallback_metadata_info = (
"Track not found on Spotify, falling back on YouTube metadata"
)
skip_fallback_metadata_warning = (
"Fallback condition not met, shall not embed metadata"
)
if meta_tags is None:
if const.args.no_fallback_metadata:
log.warning(skip_fallback_metadata_warning)
@@ -63,7 +68,6 @@ def match_video_and_metadata(track):
meta_tags = generate_metadata(content)
return meta_tags
if internals.is_youtube(track):
log.debug("Input song is a YouTube URL")
content = go_pafy(track, meta_tags=None)
@@ -94,25 +98,29 @@ def match_video_and_metadata(track):
def generate_metadata(content):
""" Fetch a song's metadata from YouTube. """
meta_tags = {"spotify_metadata": False,
"name": content.title,
"artists": [{"name": content.author}],
"duration": content.length,
"external_urls": {"youtube": content.watchv_url},
"album": {"images" : [{"url": content.getbestthumb()}],
"artists": [{"name": None}],"name": None},
"year": content.published.split("-")[0],
"release_date": content.published.split(" ")[0],
"type": "track",
"disc_number": 1,
"track_number": 1,
"total_tracks": 1,
"publisher": None,
"external_ids": {"isrc": None},
"lyrics": None,
"copyright": None,
"genre": None,
}
meta_tags = {
"spotify_metadata": False,
"name": content.title,
"artists": [{"name": content.author}],
"duration": content.length,
"external_urls": {"youtube": content.watchv_url},
"album": {
"images": [{"url": content.getbestthumb()}],
"artists": [{"name": None}],
"name": None,
},
"year": content.published.split("-")[0],
"release_date": content.published.split(" ")[0],
"type": "track",
"disc_number": 1,
"track_number": 1,
"total_tracks": 1,
"publisher": None,
"external_ids": {"isrc": None},
"lyrics": None,
"copyright": None,
"genre": None,
}
return meta_tags

View File

@@ -20,7 +20,7 @@ def load_defaults():
# so that we get same results even if YouTube changes the list/order of videos on their page.
GIST_URL = "https://gist.githubusercontent.com/ritiek/e731338e9810e31c2f00f13c249a45f5/raw/c11a27f3b5d11a8d082976f1cdd237bd605ec2c2/search_results.html"
def monkeypatch_youtube_search_page(*args, **kwargs):
fake_urlopen = urllib.request.urlopen(GIST_URL)
return fake_urlopen

View File

@@ -101,22 +101,34 @@ class TestDownload:
def test_m4a(self, monkeypatch, filename_fixture):
expect_download = True
monkeypatch.setattr("pafy.backend_shared.BaseStream.download", self.blank_audio_generator)
monkeypatch.setattr("pafy.backend_youtube_dl.YtdlStream.download", self.blank_audio_generator)
download = youtube_tools.download_song(filename_fixture + ".m4a", pytest.content_fixture)
monkeypatch.setattr(
"pafy.backend_shared.BaseStream.download", self.blank_audio_generator
)
monkeypatch.setattr(
"pafy.backend_youtube_dl.YtdlStream.download", self.blank_audio_generator
)
download = youtube_tools.download_song(
filename_fixture + ".m4a", pytest.content_fixture
)
assert download == expect_download
def test_webm(self, monkeypatch, filename_fixture):
expect_download = True
monkeypatch.setattr("pafy.backend_shared.BaseStream.download", self.blank_audio_generator)
monkeypatch.setattr("pafy.backend_youtube_dl.YtdlStream.download", self.blank_audio_generator)
download = youtube_tools.download_song(filename_fixture + ".webm", pytest.content_fixture)
monkeypatch.setattr(
"pafy.backend_shared.BaseStream.download", self.blank_audio_generator
)
monkeypatch.setattr(
"pafy.backend_youtube_dl.YtdlStream.download", self.blank_audio_generator
)
download = youtube_tools.download_song(
filename_fixture + ".webm", pytest.content_fixture
)
assert download == expect_download
class TestFFmpeg:
def test_convert_from_webm_to_mp3(self, filename_fixture, monkeypatch):
expect_command = "ffmpeg -y -hide_banner -nostats -v panic -i {0}.webm -codec:a libmp3lame -ar 44100 -b:a 192k -vn {0}.mp3".format(
expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.webm -codec:a libmp3lame -ar 44100 -b:a 192k -vn {0}.mp3".format(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
@@ -126,7 +138,7 @@ class TestFFmpeg:
assert " ".join(command) == expect_command
def test_convert_from_webm_to_m4a(self, filename_fixture, monkeypatch):
expect_command = "ffmpeg -y -hide_banner -nostats -v panic -i {0}.webm -cutoff 20000 -codec:a aac -ar 44100 -b:a 192k -vn {0}.m4a".format(
expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.webm -cutoff 20000 -codec:a aac -ar 44100 -b:a 192k -vn {0}.m4a".format(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
@@ -136,7 +148,7 @@ class TestFFmpeg:
assert " ".join(command) == expect_command
def test_convert_from_m4a_to_mp3(self, filename_fixture, monkeypatch):
expect_command = "ffmpeg -y -hide_banner -nostats -v panic -i {0}.m4a -codec:v copy -codec:a libmp3lame -ar 44100 -b:a 192k -vn {0}.mp3".format(
expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.m4a -codec:v copy -codec:a libmp3lame -ar 44100 -b:a 192k -vn {0}.mp3".format(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
@@ -146,7 +158,7 @@ class TestFFmpeg:
assert " ".join(command) == expect_command
def test_convert_from_m4a_to_webm(self, filename_fixture, monkeypatch):
expect_command = "ffmpeg -y -hide_banner -nostats -v panic -i {0}.m4a -codec:a libopus -vbr on -b:a 192k -vn {0}.webm".format(
expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.m4a -codec:a libopus -vbr on -b:a 192k -vn {0}.webm".format(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
@@ -156,7 +168,7 @@ class TestFFmpeg:
assert " ".join(command) == expect_command
def test_convert_from_m4a_to_flac(self, filename_fixture, monkeypatch):
expect_command = "ffmpeg -y -hide_banner -nostats -v panic -i {0}.m4a -codec:a flac -ar 44100 -b:a 192k -vn {0}.flac".format(
expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.m4a -codec:a flac -ar 44100 -b:a 192k -vn {0}.flac".format(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
@@ -166,7 +178,7 @@ class TestFFmpeg:
assert " ".join(command) == expect_command
def test_correct_container_for_m4a(self, filename_fixture, monkeypatch):
expect_command = "ffmpeg -y -hide_banner -nostats -v panic -i {0}.m4a.temp -acodec copy -b:a 192k -vn {0}.m4a".format(
expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.m4a.temp -acodec copy -b:a 192k -vn {0}.m4a".format(
os.path.join(const.args.folder, filename_fixture)
)
_, command = convert.song(

View File

@@ -6,6 +6,7 @@ import pytest
pafy_patcher = patcher.PatchPafy()
pafy_patcher.patch_getbestthumb()
class TestPafyContentAvailable:
pass
@@ -23,13 +24,12 @@ class TestMethodCalls:
def test_pafy_getbestthumb(self, content_fixture):
thumbnail = patcher._getbestthumb(content_fixture)
assert thumbnail == "https://i.ytimg.com/vi/3nQNiWdeH2Q/maxresdefault.jpg"
assert thumbnail == "https://i.ytimg.com/vi/3nQNiWdeH2Q/hqdefault.jpg"
def test_pafy_getbestthumb_without_ytdl(self, content_fixture):
content_fixture._ydl_info["thumbnails"][0]["url"] = None
thumbnail = patcher._getbestthumb(content_fixture)
assert thumbnail == "https://i.ytimg.com/vi/3nQNiWdeH2Q/maxresdefault.jpg"
assert thumbnail == "https://i.ytimg.com/vi/3nQNiWdeH2Q/sddefault.jpg"
def test_pafy_content_available(self):
TestPafyContentAvailable._content_available = patcher._content_available

View File

@@ -115,7 +115,7 @@ def test_write_playlist(tmpdir):
assert tracks == expect_tracks
# XXX: Mock this test off if it fails in future
# XXX: Monkeypatch these tests if they fail in future
class TestFetchAlbum:
@pytest.fixture(scope="module")
def album_fixture(self):
@@ -131,7 +131,7 @@ class TestFetchAlbum:
assert album_fixture["tracks"]["total"] == 15
# XXX: Mock this test off if it fails in future
# XXX: Monkeypatch these tests if they fail in future
class TestFetchAlbumsFromArtist:
@pytest.fixture(scope="module")
def albums_from_artist_fixture(self):
@@ -141,7 +141,7 @@ class TestFetchAlbumsFromArtist:
return albums
def test_len(self, albums_from_artist_fixture):
assert len(albums_from_artist_fixture) == 52
assert len(albums_from_artist_fixture) == 54
def test_zeroth_album_name(self, albums_from_artist_fixture):
assert albums_from_artist_fixture[0]["name"] == "Revolution Radio"

View File

@@ -104,19 +104,19 @@ def content_fixture(metadata_fixture):
MATCH_METADATA_NO_FALLBACK_TEST_TABLE = [
("https://open.spotify.com/track/5nWduGwBGBn1PSqYTJUDbS", True),
("http://youtube.com/watch?v=3nQNiWdeH2Q", None),
("Linux Talk | Working with Drives and Filesystems", None)
("Linux Talk | Working with Drives and Filesystems", None),
]
MATCH_METADATA_FALLBACK_TEST_TABLE = [
("https://open.spotify.com/track/5nWduGwBGBn1PSqYTJUDbS", True),
("http://youtube.com/watch?v=3nQNiWdeH2Q", False),
("Linux Talk | Working with Drives and Filesystems", False)
("Linux Talk | Working with Drives and Filesystems", False),
]
MATCH_METADATA_NO_METADATA_TEST_TABLE = [
("https://open.spotify.com/track/5nWduGwBGBn1PSqYTJUDbS", None),
("http://youtube.com/watch?v=3nQNiWdeH2Q", None),
("Linux Talk | Working with Drives and Filesystems", None)
("Linux Talk | Working with Drives and Filesystems", None),
]
@@ -128,21 +128,37 @@ class TestMetadataOrigin:
else:
assert metadata["spotify_metadata"] == metadata_type
@pytest.mark.parametrize("track, metadata_type", MATCH_METADATA_NO_FALLBACK_TEST_TABLE)
def test_match_metadata_with_no_fallback(self, track, metadata_type, content_fixture, monkeypatch):
monkeypatch.setattr(youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture)
@pytest.mark.parametrize(
"track, metadata_type", MATCH_METADATA_NO_FALLBACK_TEST_TABLE
)
def test_match_metadata_with_no_fallback(
self, track, metadata_type, content_fixture, monkeypatch
):
monkeypatch.setattr(
youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture
)
const.args.no_fallback_metadata = True
self.match_metadata(track, metadata_type)
@pytest.mark.parametrize("track, metadata_type", MATCH_METADATA_FALLBACK_TEST_TABLE)
def test_match_metadata_with_fallback(self, track, metadata_type, content_fixture, monkeypatch):
monkeypatch.setattr(youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture)
def test_match_metadata_with_fallback(
self, track, metadata_type, content_fixture, monkeypatch
):
monkeypatch.setattr(
youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture
)
const.args.no_fallback_metadata = False
self.match_metadata(track, metadata_type)
@pytest.mark.parametrize("track, metadata_type", MATCH_METADATA_NO_METADATA_TEST_TABLE)
def test_match_metadata_with_no_metadata(self, track, metadata_type, content_fixture, monkeypatch):
monkeypatch.setattr(youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture)
@pytest.mark.parametrize(
"track, metadata_type", MATCH_METADATA_NO_METADATA_TEST_TABLE
)
def test_match_metadata_with_no_metadata(
self, track, metadata_type, content_fixture, monkeypatch
):
monkeypatch.setattr(
youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture
)
const.args.no_metadata = True
self.match_metadata(track, metadata_type)
@@ -185,7 +201,11 @@ def test_check_exists(metadata_fixture, filename_fixture, tmpdir):
def test_generate_m3u(tmpdir, monkeypatch):
monkeypatch.setattr(youtube_tools.GenerateYouTubeURL, "_fetch_response", loader.monkeypatch_youtube_search_page)
monkeypatch.setattr(
youtube_tools.GenerateYouTubeURL,
"_fetch_response",
loader.monkeypatch_youtube_search_page,
)
expect_m3u = (
"#EXTM3U\n\n"
"#EXTINF:208,Janji - Heroes Tonight (feat. Johnning) [NCS Release]\n"