316 Commits

Author SHA1 Message Date
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
Ritiek Malhotra
b808265c38 Merge pull request #540 from ritiek/release-v1.2.1
Bump to v1.2.1
2019-04-28 17:09:33 +05:30
Ritiek Malhotra
21a1f1a150 Bump to v1.2.1 2019-04-28 17:05:44 +05:30
Ritiek Malhotra
951ae02e08 Merge pull request #539 from ritiek/patch-audiostream-urls
Patch bug in Pafy when fetching audiostreams with latest youtube-dl
2019-04-28 17:03:25 +05:30
Ritiek Malhotra
dfd48f75ce Update CHANGES.md 2019-04-28 16:46:30 +05:30
Ritiek Malhotra
bb385a3bfd Skip avconv tests as it is no longer provided in later distros 2019-04-28 15:31:43 +05:30
Ritiek Malhotra
a9477c7873 Fix tests 2019-04-28 15:26:18 +05:30
Ritiek Malhotra
c225e5821b Patch bug in Pafy when fetching audiostreams with latest youtube-dl 2019-04-28 15:09:42 +05:30
Ritiek Malhotra
d61309b0ce Merge pull request #522 from ritiek/hightlight-shell-code-blocks
Use "console" as language to highlight shell code blocks with
2019-03-17 10:02:33 +05:30
Ritiek Malhotra
5b2a073033 Merge pull request #519 from ritiek/remove-duplicate-debuglog-entry
Remove duplicate debuglog entry
2019-03-17 10:02:23 +05:30
Linus Groh
f17e5f58d8 Update README.md 2019-03-16 17:44:49 +00:00
Ritiek Malhotra
d3668f55bb Update CHANGES.md 2019-03-14 20:13:27 +05:30
Ritiek Malhotra
6ca136f039 Remove duplicate debuglog entry 2019-03-14 20:12:53 +05:30
Sumanjay
e2a136d885 Update CHANGES.md (#518)
* Update CHANGES.md

* Update CHANGES.md
2019-03-14 19:58:15 +05:30
Ritiek Malhotra
d10f3e9df0 Merge pull request #517 from cyberboysumanjay/master
Fix YAMLLoadWarning
2019-03-14 18:22:47 +05:30
Sumanjay
46eb2e3e32 Fix YAMLLoadWarning 2019-03-14 13:26:35 +05:30
Ritiek Malhotra
21fd63be6f Bump to v1.2.0 (#508)
* Bump to v1.2.0

* Add v1.2.0 release header
2019-03-01 00:40:15 -08:00
Ritiek Malhotra
703e228345 Write tracks to custom file with --write-to (#507)
* Write tracks to custom file

* Update CHANGES.md
2019-02-28 02:48:02 -08:00
Ritiek Malhotra
2825f6c593 Fix prompt when using '/' to create sub-directories (#503)
* Fix prompt when using '/' to create sub-directories

* Fix and update CHANGES.md
2019-02-27 10:45:05 -08:00
Ritiek Malhotra
ac7d42535f Replace class SpotifyAuthorize with @must_be_authorized (#506)
* @must_be_authorized decorator for functions in spotify_tools.py

* We don't need this

* Add tests

* Update CHANGELOG.md
2019-02-27 09:48:18 -08:00
Ritiek Malhotra
1767899a8a Merge pull request #502 from ritiek/spotify-creds
Spotify Credentials from file
2019-02-26 20:50:12 -08:00
Ritiek Malhotra
e9f046bea1 Rebase fixes 2019-02-26 23:41:37 +05:30
Manveer Basra
4fc23a84dc Refactored to use spotify_tools.SpotifyAuthorize class 2019-02-26 22:02:59 +05:30
Manveer Basra
c886ccf603 Refactored to pass tests 2019-02-26 21:24:54 +05:30
Manveer Basra
cf9b0690fd Set default client id/secret in handle.py to None 2019-02-26 21:23:09 +05:30
Manveer Basra
d215ce685d Exposed Spotify Client ID/Secret in config.yml 2019-02-26 21:22:08 +05:30
Manveer Basra
0492c711cc Refactored for consistency 2019-02-26 20:56:30 +05:30
Manveer Basra
42f33162ea --list flag accepts only text files using mimetypes 2019-02-26 20:56:03 +05:30
Ritiek Malhotra
4a051fee19 Merge pull request #457 from ritiek/youtube-metadata
Use YouTube as fallback for track metadata if not found on Spotify
2019-02-25 22:24:15 -08:00
Ritiek Malhotra
441c75ec64 Load defaults in test_spotify_tools.py 2019-02-26 10:33:05 +05:30
Ritiek Malhotra
72ae2bc0cd Update CHANGES.md 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
548a87e945 Re-add test for m3u files (removed accidently in #448) 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
ed1c068c36 Add tests 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
ec19491f4f Fix tests and monkeypatch Pafy.download method for version on GitHub 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
e56cd3caca Add option for not falling back on YouTube metadata 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
eb77880f9f Use YouTube as fallback for track metadata if not found on Spotify 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
ddb4b01897 Merge pull request #494 from ritiek/release-v1.1.2
Release changes for v1.1.2
2019-02-10 20:52:42 +05:30
Ritiek Malhotra
1d401d26c1 Bump to v1.1.2 2019-02-10 20:30:39 +05:30
Ritiek Malhotra
cfa9f78ce4 Mark section for v1.1.2 2019-02-10 20:28:52 +05:30
Ritiek Malhotra
01c6c11a1d Black format code 2019-02-10 20:26:22 +05:30
Ritiek Malhotra
eb1be87039 Merge pull request #493 from ritiek/fetch-all-album-types
Fetch all album types for an artist by default
2019-02-08 18:03:37 +05:30
Ritiek Malhotra
925521aa3b Fix tests for now 2019-02-04 20:18:01 +05:30
Ritiek Malhotra
1d2b43a5f9 Update CHANGES.md 2019-02-04 15:27:04 +05:30
Ritiek Malhotra
542201091d Fetch all artist albums by default 2019-02-04 15:24:37 +05:30
Ritiek Malhotra
a182fe5eb3 Use argparse special features to handle displaying version info (#486)
* Use argparse special features to handle displaying version info

* Remove version argument check from spotdl.py
2019-01-21 05:56:21 -08:00
Ritiek Malhotra
3dac0125a9 Merge pull request #477 from ritiek/missing-changelogs
Changelog entries for missed PRs
2019-01-14 21:24:16 -08:00
Ritiek Malhotra
fbf930fe43 Changelog entries for missed PRs 2019-01-15 10:53:07 +05:30
Ritiek Malhotra
b72eb773f3 Merge pull request #475 from ritiek/fix-m4a-when-encoder-not-found
Fix renaming files when encoder is not found
2019-01-14 19:55:24 -08:00
Ritiek Malhotra
8944dec8e0 Merge branch 'master' into fix-m4a-when-encoder-not-found 2019-01-14 19:48:33 -08:00
Ritiek Malhotra
76906cfdbc Merge pull request #476 from Silverfeelin/master
Use folder argument as base for album/playlist file exports.
2019-01-13 11:38:24 -08:00
Silverfeelin
a18f888e97 Update CHANGES.md 2019-01-13 20:25:05 +01:00
Silverfeelin
6c07267312 Use folder argument as base for album/playlist file exports. 2019-01-13 17:12:05 +01:00
Ritiek Malhotra
f078875f0e Update CHANGES.md 2019-01-13 18:32:48 +05:30
Ritiek Malhotra
31cd1c5856 Move 'encoder not found' warning to more appropriate place 2019-01-13 18:19:38 +05:30
Ritiek Malhotra
54d3336aa2 Fix renaming files when encoder is not present 2019-01-13 18:19:06 +05:30
Ritiek Malhotra
94500e31a3 Merge pull request #469 from tillhainbach/master
Use first artist from album object for album artist
2019-01-08 20:06:00 -08:00
tillhainbach
bf6e6fb0c5 first artist from album object for album artist 2019-01-08 22:53:22 +01:00
ifduyue
67ae7d5c4c Add missing import time (#465)
This fixes #464
2019-01-04 09:56:21 +00:00
Ritiek Malhotra
f4d8bd0c8c Update CHANGES.md (#461) 2019-01-02 19:34:58 +00:00
Ritiek Malhotra
b58c4775f2 Run black formatter on the whole codebase (#460) 2019-01-02 19:22:50 +00:00
Ritiek Malhotra
8c3c4c251b Merge branch 'release-v1.1.1' 2019-01-03 00:43:55 +05:30
Ritiek Malhotra
c6bc994658 Fix conflicts caused by merge of #459 2019-01-03 00:43:37 +05:30
Ritiek Malhotra
53dd292b55 Fix conversion conflicts when both input and output filenames are same (#459)
* Workaround conversion conflicts by appending '.temp' to input filename

* Fix tests

* Add a test and some minor changes

* Update CHANGES.md
2019-01-02 18:54:27 +00:00
Ritiek Malhotra
2ce0857f92 Update CHANGES.md 2018-12-29 15:30:40 +05:30
Ritiek Malhotra
0d0a85b761 Some updates for using spotdl as library 2018-12-29 14:56:00 +05:30
Ritiek Malhotra
9f09a13063 Specify encoding for README.md so unicode characters are dealt with correctly 2018-12-29 14:10:31 +05:30
Ritiek Malhotra
fbc04671d8 Use Black to format code 2018-12-29 14:09:34 +05:30
Ritiek Malhotra
a4493a1e5f Bump to v1.1.1 2018-12-29 14:08:58 +05:30
Amit Lawanghare
1cf421960c Issue with Spotify-url and --no-metadata #452 (#454)
* Applied a check on null result in case of no youtube search

* Allow fetch metadata from spotify upon searching spotify-url and no-metadata

* updated changes.md

* Updated CHANGES.md as per suggestion

* removed unnecessary bool hit

Co-Authored-By: Amit-L <amit.lawanghare@gmail.com>

* removed unnecessary bool hit, anti PEP 8

Co-Authored-By: Amit-L <amit.lawanghare@gmail.com>

* resolved conflicts

* Error shown no videos found

* Dont to show any manual option for no result
2018-12-29 14:05:24 +05:30
Ritiek Malhotra
51b01fc448 [WIP] Monkeypatch tests (#448)
* Parameterize test_internals.py

* Create test_spotify_tools.py

* Monkeypatch pafy.download

* Monkeypatch YouTube search page

* Replace globals with fixtures

* Add missing urllib import, re-ordering and rename test_with_metadata.py

* Avoid creating temp directory in current working directory during test

* Update CHANGES.md
2018-12-26 17:15:56 +05:30
Linus Groh
bfe958dadc Merge pull request #453 from ritiek/fix-incorrect-metadata-m4a
Fix .m4a containers
2018-12-25 15:20:58 +01:00
Ritiek Malhotra
018fb5d7f0 Update CHANGES.md 2018-12-25 19:48:06 +05:30
Ritiek Malhotra
9170ff22a7 Surround filename in quotes 2018-12-25 19:34:49 +05:30
Ritiek Malhotra
a0847f19b9 Fix .m4a containers 2018-12-25 19:32:49 +05:30
Linus Groh
9652ecac27 Merge pull request #444 from ritiek/update-faq-section
Remove question links from FAQ section
2018-12-18 13:15:04 +01:00
Ritiek Malhotra
1a16a55db1 Remove question links from FAQ section 2018-12-09 20:46:07 +05:30
Linus Groh
44f64530ef Merge pull request #442 from ritiek/download-multiple-tracks
Pass multiple tracks at once in --song argument
2018-12-04 18:01:41 +01:00
Ritiek Malhotra
8d7dc762de Add a changelog entry 2018-12-04 20:38:30 +05:30
Ritiek Malhotra
9e6d7cdc99 Pass multiple tracks at once in --song argument 2018-12-03 21:47:57 +05:30
Linus Groh
3df87ab763 Merge pull request #440 from ritiek/fix-missing-import
Import spotipy in downloader.py
2018-12-02 17:11:05 +01:00
Ritiek Malhotra
608c53f759 Fixed: Missing import spotipy 2018-12-02 12:22:02 +05:30
Ritiek Malhotra
1e34124de9 Import spotipy in downloader.py 2018-12-02 12:16:02 +05:30
Ritiek Malhotra
eae9316cee [WIP] Refactor spotdl.py; introduced classes (#410)
* Refactor spotdl.py; introduced classes

* introduce CheckExists class

* Move these classes to download.py

* Fix refresh_token

* Add a changelog entry
2018-11-25 17:07:56 +05:30
Linus Groh
8ced90cb39 Add Python 3.7 to Travis CI tests (#429)
* Add Python 3.7 to Travis CI tests

* Update Travis config to use Xenial
2018-11-18 19:48:33 +05:30
Linus Groh
f1d7d19a6c Merge pull request #432 from ritiek/compact-issue-template
Update issue template to mention latest version and similar issues as comments
2018-11-15 20:17:14 +01:00
Ritiek Malhotra
47ab429a05 Change latest version and similar issues to comments 2018-11-15 12:27:36 +05:30
Linus Groh
6f6d95b2f9 Change colon format emoji to Unicode character (#428) 2018-11-14 10:11:45 +05:30
Ritiek Malhotra
f0ab90719b Bump to v1.1.0 (#427) 2018-11-13 23:28:52 +05:30
Linus Groh
41a5758a63 Merge pull request #426 from ritiek/rere-fix-tests
Rere-fix tests
2018-11-13 17:21:49 +01:00
Ritiek Malhotra
c685fa2bfd Rere-fix tests 2018-11-13 21:27:21 +05:30
Linus Groh
b18a17c2a1 Update CONTRIBUTING.md (#425) 2018-11-13 20:50:33 +05:30
Linus Groh
a0d9667660 Update CHANGES.md for upcoming 1.1.0 release (#424) 2018-11-13 20:48:09 +05:30
Manveer Basra
20b5e44ed4 --list flag accepts only text files using mimetypes (#414)
* --list flag accepts only text files using mimetypes

* Refactored for consistency

* Workaround to make tests pass
2018-10-29 23:00:35 -07:00
Ritiek Malhotra
be4bb25c96 Filter unwanted text from Spotify URLs when extracting information (#394)
* Split unwanted URL part

* Convert get_splits() -> extract_spotify_id()

* Add tests for extract_spotify_id()

* Extract Spotify artist ID when fetching artist albums
2018-10-26 18:29:29 +05:30
Manveer Basra
94dc27a77b Refactored refresh token (#408)
* Outputs error details when track download fails from list file

* Refactored Spotipy token refreshing

* Reverted to old refreshing method

Kept refresh_token() in spotify_tools.py
2018-10-25 20:00:46 +05:30
Manveer Basra
680525ea3d Outputs error details when track download fails from list file (#406) 2018-10-24 22:06:39 +05:30
Manveer Basra
94f0b3e95d Doesn't search song on Spotify if "--no-metadata" passed (#404)
* Doesn't search song on Spotify if '--no-metadata' passed

* Doesn't search song on Spotify if '--no-metadata' passed

* Doesn't war user that 'no metadata found' if '--no-metadata' passed
2018-10-23 17:27:12 +05:30
Ritiek Malhotra
f65034f17e Create a custom user for tests (#405) 2018-10-22 23:03:06 +05:30
Manveer Basra
acff5fc8e2 Check and replace slashes with dashes to avoid directory creation error (#402)
* Added check for track titles containing slashes

* Revert white-space typos

* Added check for windows backslash

* Added check for non-string filename titles
2018-10-21 14:21:31 +05:30
Ritiek Malhotra
b12ca8c785 Add support for .m3u playlists (#401)
* Add support for .m3u playlists

* Run black code formatter on changes

* Stay consistent with Spotify test track
2018-10-20 16:19:14 +05:30
Manveer Basra
7d321d9616 Changed test track to one whose lyrics are found (#400)
* Changed test track to one whose lyrics are found

* Fixed incorrect values

* Update playlists test to reflect change in playlist
2018-10-19 20:25:50 +05:30
AlfredoSequeida
2b42f0b3a1 added the ability to get all artist's songs as suggested by #366 (#389)
* added the ability to get all artist's songs as suggested by #366

* added log to featch_all_albums_from_artist function and removed the use of a uri

* updated the functionality to get all albums with the ability to get singles

* updated main function with new write_all_albums_from_artist function to get all albums from an artist

* fixed typos

* updated test case for getting all albums from artist

* fixed typos
2018-10-10 23:53:10 -07:00
Linus Groh
e554b4252c Move black badge before gitter badge (#395) 2018-10-09 03:14:48 -07:00
Linus Groh
8eb16a6fe3 Merge pull request #379 from ritiek/comment-metadata
Embed comment metadata in .m4a
2018-10-09 11:28:58 +02:00
Ritiek Malhotra
519fe75eac Merge branch 'master' into comment-metadata 2018-10-09 01:30:06 -07:00
Linus Groh
13c83bd225 Introduce usage of black (code formatter) (#393) 2018-10-09 00:57:11 -07:00
Sam Redmond
71ee6ad5e2 Windows - 'My Music' folder won't be assumed to be on C drive (#387)
* Windows - 'My Music' folder won't be assumed to be on C drive

Windows has a nice registry check to get the absolute path of the 'My Music' folder. This helps because some people change their location of their music folder.

* Updated according to suggestions

Let me know if there are anymore improvements 👍

* Fixups
2018-10-09 00:25:45 -07:00
Ritiek Malhotra
a565d449ea Merge pull request #386 from ritiek/skip-file
Add command line options for skip and successful file
2018-10-07 15:32:06 -07:00
Linus Groh
525925de42 Break long line into multiple 2018-10-07 21:40:00 +02:00
Linus Groh
bef24eef7f Place newline before track URL when appending to track file 2018-10-05 00:00:23 +02:00
Linus Groh
3a52fe4de5 Add command line options for skip and successful file 2018-10-04 23:43:54 +02:00
Linus Groh
2725402ab3 Update tests (#384)
* Update tests

* Move comment regarding changing YT URLs to the appropriate assert
2018-10-02 15:43:21 +05:30
Linus Groh
6cb12722d0 Update setup.py (#383) 2018-10-02 13:00:27 +05:30
Linus Groh
9703bec5c8 Merge pull request #380 from ritiek/duplicates-in-file
Overwrite track file with unique tracks
2018-10-02 09:16:24 +02:00
Ritiek Malhotra
e076d11a19 Overwrite track file with unique tracks 2018-10-02 12:36:55 +05:30
Linus Groh
ac94cf4f3b Update dependencies (#382) 2018-10-02 12:21:39 +05:30
Linus Groh
667477a4be Merge pull request #369 from ritiek/remove-duplicates-preserve-order
Remove duplicates whilst preserving order
2018-10-01 17:19:04 +02:00
Ritiek Malhotra
f80c223025 Embed comment metadata in .m4a 2018-10-01 19:10:28 +05:30
Nodar Gogoberidze
e720cbcf93 Add check for publisher tag before adding publisher id3 tag to audio file (#377) 2018-09-30 14:59:15 +05:30
Ritiek Malhotra
1d54ffb63c Fix logging in spotdl.py (#364)
* Fix import typo (logging -> logger)

* Fix logging in handle.py
2018-09-30 12:53:28 +05:30
Arryon Tijsma
fc7d5abf16 Correctly embed metadata in .m4a 2018-09-26 21:59:28 +05:30
Linus Groh
fe8521127a Merge pull request #361 from sdhutchins/master
Improved README
2018-09-26 18:06:28 +02:00
Ritiek Malhotra
95139222d0 Remove duplicates while preserving order 2018-09-26 10:45:48 +05:30
Arryon Tijsma
32c2ace96c Fixes #272 by refactoring global log to conventional global... (#358)
* Fixes #272 by refactoring global log to conventional global as used by logzero

* Remove unnecessary logger from download_single, which was a test case

* Remove unnecessary logger from main(), which was a test case
2018-09-21 22:43:26 -07:00
Ritiek Malhotra
ba8f872d6d Be sure to check out the wiki! 2018-09-22 10:19:14 +05:30
Shaurita D. Hutchins
b6a40eb45d Changed pip install line to code block. 2018-09-21 11:13:14 -05:00
Shaurita D. Hutchins
c5bb9452b2 Update README.md 2018-09-20 11:45:09 -05:00
Shaurita D. Hutchins
f7928bc1b7 Updated README.
Added pip install information to README and made metadata bullet list smaller.
2018-09-20 11:43:51 -05:00
Dimitris Aggelou
803a677167 Addressing issue#338 (#357) 2018-09-16 06:10:07 -07:00
Linus Groh
9cd8fdbc2f Minor fixes for a working 1.0.0 release (#347) 2018-09-09 08:35:42 -07:00
Ritiek Malhotra
40d711b532 Update README examples and add PyPi badge (#346)
* Add PyPi badge

* python3 spotdl.py -> spotdl
2018-09-09 08:25:50 -07:00
Ritiek Malhotra
e0c8960906 Changes for v1.0.0 release (#345)
* Move spotdl.py inside spotdl directory

* Fix Dockerfile

* Re-upload FFmpeg binary

* Small fixes ;)
2018-09-09 08:00:01 -07:00
Vishnunarayan K I
a7e9009aa6 Merge pull request #335 from ritiek/default-config-path
Move default `config.yml` to .config folder
2018-08-19 22:34:32 +05:30
Ritiek Malhotra
dd81f80fda Use appdirs to figure out user config directory 2018-08-19 19:32:22 +05:30
Ritiek Malhotra
04824d2f20 Merge pull request #333 from ritiek/abs-path-music-dir
Use absolute path to download directory
2018-08-17 03:50:33 +05:30
Ritiek Malhotra
7b9fe73fb8 Use absolute path to download directory 2018-08-12 21:38:02 +05:30
Ritiek Malhotra
5b4efa05b6 Merge pull request #316 from Mello-Yello/master
Trim silence at the beginning of a song
2018-07-29 05:56:54 -07:00
Ritiek Malhotra
d6bffd7493 Merge branch 'master' into master 2018-07-28 23:43:55 -07:00
Ritiek Malhotra
a3b32547e0 Pass **config to parser.set_defaults() (#319) 2018-07-28 23:42:42 -07:00
Mello-Yello
e749f14828 Minor fixes 2018-07-28 13:25:03 +02:00
Mello-Yello
b0a945e2d2 Add the --trim-silence parameter 2018-07-28 11:01:49 +02:00
Linus Groh
ef1e471526 Directly link FAQs in README, minor changes (#315) 2018-07-27 07:31:43 -07:00
Mello-Yello
7674db7f71 Trim silence at the beginning of a song 2018-07-27 16:05:44 +02:00
Linus Groh
12c3b928ee Merge pull request #314 from ritiek/fix-song-exist-check
Fix song exist check
2018-07-26 22:03:02 +02:00
Linus Groh
149f38f393 Fix song exist check 2018-07-26 21:40:35 +02:00
Linus Groh
cf3ecd017d Merge pull request #311 from ritiek/fix-song-exist-check
Fix check for song with same name
2018-07-26 21:30:43 +02:00
Linus Groh
d54c9a530f Merge pull request #313 from ritiek/fix-tests
Fix tests
2018-07-26 21:27:14 +02:00
Linus Groh
3aff8d02c5 Fix tests 2018-07-26 21:09:59 +02:00
Linus Groh
862affb805 Fix check for song with same name 2018-07-26 19:45:23 +02:00
Ritiek Malhotra
2f49cc230a [WIP] Move detailed information to wiki pages (#308)
* Move detailed information to wiki pages

* Remove help usage and add an FAQ section

* Update README.md

* Update README.md

* Downloading playlists and albums
2018-07-26 09:32:33 -07:00
Ritiek Malhotra
55908e3097 Merge pull request #298 from ritiek/linusg-patch-1
Add note about Vagrant, fix headings in README
2018-07-15 19:27:56 +05:30
Ritiek Malhotra
5b0a67ca1a Merge pull request #305 from ritiek/linusg-patch-2
Update usage information in README.md
2018-07-15 19:24:19 +05:30
Ritiek Malhotra
8347a11553 Merge pull request #306 from ritiek/linusg-patch-3
Remove prompt from inline commands
2018-07-15 19:22:29 +05:30
Linus Groh
8b1844ea7e Remove prompt from inline commands 2018-07-15 15:08:44 +02:00
Linus Groh
1a49457167 Update usage information in README.md 2018-07-15 15:01:38 +02:00
Linus Groh
ae4edf2267 Merge pull request #297 from ritiek/remove-duplicates
Remove duplicates from track file
2018-07-04 16:25:07 +02:00
Ritiek Malhotra
eb96aa8c13 Handle '.' as a time value separator (#301) 2018-07-04 18:27:14 +05:30
Linus Groh
e3b06fc946 Add note about Vagrant, fix headings in README 2018-06-26 14:57:14 +02:00
ritiek
40ed9b494b Remove duplicates from track file 2018-06-26 10:12:23 +05:30
Ritiek Malhotra
0a3b7bd7d8 Pass correct arguments when retrying to fetch videotime (#277) 2018-05-26 13:37:14 +05:30
Ritiek Malhotra
9b181df77e Slugify filenames when metadata found on Spotify too (#265) 2018-05-20 15:18:45 +05:30
Vishnunarayan K I
c885c9eff0 Fix tracknumber in metadata embedding (#281) 2018-05-20 15:02:16 +05:30
Linus Groh
a1b4266d08 First run config info (#273)
* Show default options and config path when running for the first time

* Remove unnecessary global
2018-04-24 17:31:47 +05:30
Ritiek Malhotra
4eb4628128 Add Gitter badge (#274) 2018-04-24 17:30:51 +05:30
Ritiek Malhotra
dfcb07ed45 Change default music folder (#225)
* Get default music folder via xdg-user-dirs

* Add a test
2018-04-22 21:14:47 +05:30
Vishnunarayan K I
7e48ece898 Add python version check in setup.py (#268) 2018-04-21 19:22:26 +05:30
Ritiek Malhotra
745066a646 Update search query! 2018-04-18 23:13:42 +05:30
Ritiek Malhotra
b6dbc5c00a Show complete list of tracks if metadata not found in manual mode (#266)
* Show complete list of tracks if metadata not found in manual mode

* Add regression test
2018-04-18 19:31:29 +05:30
Linus Groh
e066d7c876 Remove pandoc from Travis dependencies 2018-04-17 17:18:45 +02:00
Linus Groh
8b58230438 Restore README.md without instructions to install from PyPI 2018-04-17 17:16:10 +02:00
Linus Groh
2d2f5aa40c Bump version 2018-04-17 14:17:13 +02:00
Linus Groh
2c487df118 Add -V/--version flag 2018-04-17 14:15:51 +02:00
Linus Groh
678068c4fe Fix setup.py entry point 2018-04-17 14:10:50 +02:00
Linus Groh
8b54dc5e88 Merge branch 'introduce-releases' 2018-04-17 13:21:23 +02:00
Linus Groh
b0d5895180 Bump version 2018-04-17 13:20:01 +02:00
Linus Groh
12d3da0f8d Fix PyPI Markdown, lower version to 0.9.0 2018-04-17 13:15:34 +02:00
Linus Groh
8d569aca3f Fix tests 2018-04-17 13:02:45 +02:00
Linus Groh
225aec5df7 Remove local config.yml backup 2018-04-17 12:58:12 +02:00
Linus Groh
4769878618 Merge branch 'master' into introduce-releases 2018-04-17 12:55:03 +02:00
ritiek
56e723dfd4 Code of Conduct 2018-04-12 20:38:41 +05:30
ritiek
1e1a74f7f4 Improve usage help 2018-04-08 16:17:15 +05:30
Ritiek Malhotra
fc226442fe Custom YouTube search string (#261)
* Custom YouTube search string

* Fix sorting issues on < Python 3.6
2018-04-08 15:56:44 +05:30
ritiek
4d18224bb7 Fix embedding of lyrics in mp3 2018-04-02 01:16:45 +05:30
ritiek
441ac62882 Missing sys! 2018-04-02 01:07:01 +05:30
Ritiek Malhotra
96ab547c5c Support FLAC output format (#259)
* Convert to .flac option

* Embed metadata to FLAC

* Update usage help

* Write tests
2018-04-02 00:47:31 +05:30
Linus Groh
7f7c3d6f58 Update youtube_tools.py 2018-03-10 12:27:16 +01:00
Ritiek Malhotra
a571ca2a38 Add BeautifulSoup4 as a dependency 2018-03-10 10:52:19 +05:30
Ritiek Malhotra
c5846d615e Update test for new API key 2018-03-09 20:57:19 +05:30
Ritiek Malhotra
2cc9a4a9d3 Update YouTube API key to not conflict with users before #250 2018-03-09 20:52:46 +05:30
Ritiek Malhotra
b968b5d206 Scrape YouTube by default and optionally use YouTube API to perform searches (#250)
* YouTube scraping

* Cleanup GenerateYouTubeURL class

* Some minor improvements

* Add test to fetch title with and without api key
2018-03-09 20:40:15 +05:30
Ritiek Malhotra
46f313777b Update music only URL 2018-03-09 16:10:59 +05:30
Vishnunarayan K I
4ad77de97f Filter out items other than videos in search (#249) 2018-03-09 13:17:46 +05:30
ritiek
f943080edb Partially fix download speed throttle 2018-02-25 03:34:59 +05:30
Ritiek Malhotra
c4bb047187 Test URL is now None 2018-02-24 15:03:42 +05:30
Ritiek Malhotra
666334dfd8 Install codecov after success 2018-02-24 14:41:49 +05:30
ritiek
c8db9ed5da Improve conversion 2018-02-13 18:03:50 +05:30
Linus Groh
3db04ce635 Merge pull request #233 from ns23/#226-locateCongigFile
Add option for a custom config file (#226)
2018-02-05 18:44:12 +01:00
Nitesh Sawant
a8f261edae Changes as per second PR review 2018-02-05 23:01:44 +05:30
Nitesh Sawant
0e3249646f Made Changes as per comments on PR
removed the unnecessary comma and space at the end!
 put the closing bracket on the previous line, as it's done with all the other parser.add_argument calls.
remove the pass - it's completely unneccesary.
2018-02-04 22:36:04 +05:30
Nitesh Sawant
8550abd06a Removed unnecessary imports 2018-02-04 16:07:39 +05:30
Nitesh Sawant
e1ffa92b9c wrote help description for
def override_config
2018-02-04 16:01:37 +05:30
Nitesh Sawant
6bd2a71666 Implemented passing config.yml as command line argument 2018-02-04 15:46:08 +05:30
Nitesh Sawant
5346cbf363 Merge remote-tracking branch 'upstream/master' 2018-02-02 21:33:01 +05:30
Linus Groh
38b5148623 Fix link in README.md 2018-02-02 16:24:20 +01:00
Linus Groh
819fbe0c87 Fix Travis builds 2018-02-02 16:11:06 +01:00
Linus Groh
9699dab99c Change command for GitHub releases installation 2018-02-02 16:09:30 +01:00
Linus Groh
7fa85af616 Fix setup.py by not importing __version__ 2018-02-02 16:05:59 +01:00
Linus Groh
56d24f03ae Fix tests 2018-02-02 15:31:47 +01:00
Linus Groh
76ef1ffcb2 Change alpha to beta 2018-02-02 15:21:29 +01:00
Linus Groh
177f72b532 Prepare for releases 2018-02-02 15:19:12 +01:00
Ritiek Malhotra
08ae7ae24a Use our token with pafy (#228) 2018-01-30 19:47:26 +05:30
Ritiek Malhotra
ffb4764074 Remove some debug code 2018-01-29 21:45:36 +05:30
Ritiek Malhotra
d77ad8f6e3 Show docker pulls shield 2018-01-29 21:28:58 +05:30
ritiek
54a9f33ba4 Add test for embedding metadata into .webm 2018-01-27 11:20:19 +05:30
Ritiek Malhotra
3e6b2d7702 Increase coverage (#218)
* Monkeypatch fetch user and use pytest.tempdir

* Cover spotify_tools.grab_album()

* Cover avconv conversion

* Cover grab_single()

* Reduce code repetition

* Move grab_playlist() to spotify_tools.py

* Move Spotify specific functions to spotify_tools.py

* Refactoring

* Return track list from write_tracks()

* Fix tests

* Cover more cases in generate_youtube_url()

* Test for unavailable audio streams

* Test for filename without spaces

* handle.py 100% coverage

* Improve config tests

* Speed up tests

* Install avconv and libfdk-aac

* Some cleaning

* FFmpeg with libfdk-aac, libopus

* Some refactoring

* Convert tmpdir to string

* Cover YouTube title when downloading from list

* Explicitly cover some internals.py functions
2018-01-26 20:44:37 +05:30
Ritiek Malhotra
d624bbb3d5 Specify optional param in enumerate() 2018-01-25 19:30:16 +05:30
Linus Groh
8fd28c81ae Improve spaces replacement with slugify 2018-01-24 17:36:20 +01:00
Linus Groh
4a940ab09f Fix spaces not being replaced by dashes when no metadata is found (#220) 2018-01-24 17:10:14 +01:00
Suhas Karanth
caba5a1c3b Fix already_tagged check for mp4 in metadata.py>compare (#219) 2018-01-24 21:03:00 +05:30
Linus Groh
972065b7c9 Some code refactoring 2018-01-24 16:24:19 +01:00
Nitesh Sawant
ea91ded9bc Merge remote-tracking branch 'upstream/master' 2018-01-23 23:07:55 +05:30
Nitesh Sawant
8dae25fb42 Tell the user to install unicode-slugify in case of ImportError #176 (#206) 2018-01-23 22:10:03 +05:30
Nitesh Sawant
988427d881 Fix merge conflict
Changed videotime_from_seconds function to fix merge conflict.
2018-01-23 22:03:39 +05:30
Linus Groh
9fe216135b Merge pull request #217 from vn-ki/master
Beautify videotime_from_seconds func
2018-01-22 23:47:20 +01:00
vn-ki
9d87424860 Beautify videotime_from_seconds func 2018-01-23 04:12:42 +05:30
Nitesh Sawant
9ed7347f03 Changes as per PR comments 2018-01-23 00:06:08 +05:30
Nitesh Sawant
7cccabc145 Changed code as per PR comments 2018-01-23 00:02:06 +05:30
Nitesh Sawant
1b2b3a0ea1 21 Jan update 2018-01-22 23:47:56 +05:30
Ritiek Malhotra
0cd85b59bd Code coverage integration (#216)
* Cleaner travis.yml?

* Do we really need wget

* pytest --cov

* Add codecov badge
2018-01-22 20:50:30 +05:30
Ritiek Malhotra
fcfebc55e6 Save file names using a custom format specifiers (#205)
* Use custom formats to generate file name

* Do not mess up search term

* Fix tests

* Fix conflicting names

* Fix subprocess call on file paths with spaces

* Create directories and keep spaces as defaults

* Fix config merge

* Remove underscores from default file format

* Remove debug info

* Show possible formats in usage help
2018-01-19 13:58:27 +05:30
Nitesh Sawant
3affdc830a Tell the user to install unicode-slugify in case of ImportError 2018-01-15 23:50:19 +05:30
ritiek
ed235610ad Move common code to loader.py 2018-01-15 14:20:12 +05:30
Miguel Piedrafita
1fe94c9896 Add a config option to preserve spaces in file names (#201)
* Add a config option to preserve spaces in file names

* Typo

* Fix replacing in path
2018-01-15 13:41:11 +05:30
Linus Groh
e283231f8e Merge pull request #202 from Aareon/patch-2
Use enumerate
2018-01-14 23:20:01 +01:00
Aareon Sullivan
bb76220e86 Update spotdl.py 2018-01-14 14:18:24 -08:00
Aareon Sullivan
beafd4e446 Use enumerate, remove random usages of exit codes
As per the documentation for `sys.exit` most codes besides 0 and 1 are underdeveloped and produce mostly undefined results. Nothing wrong with sticking to the safe route.
2018-01-14 13:54:00 -08:00
Ritiek Malhotra
66e16e4b33 Shell identifier 2018-01-13 17:46:07 +05:30
Ritiek Malhotra
9f35471f3a Read configuration from config.yml (#200)
* Read from config.yml

* Mention config.yml

* Require PyYAML >= 3.12
2018-01-13 16:59:44 +05:30
Ritiek Malhotra
48e323dca5 Update usage help 2018-01-12 22:02:45 +05:30
ritiek
f9d9c7d5fa Refactor tests 2018-01-12 21:56:58 +05:30
ritiek
621e1eb21e Some cleanup 2018-01-12 21:52:43 +05:30
Linus Groh
8c6cc1cc22 Fix typo, do minor improvements 2018-01-12 13:39:58 +01:00
Linus Groh
f7c4cbd50d Show no metadata warning before download 2018-01-12 13:34:46 +01:00
Ritiek Malhotra
a9ba5b71c8 Resolve variable names 2018-01-12 17:39:32 +05:30
ritiek
b226b8380a Resolve imports 2018-01-12 17:34:01 +05:30
ritiek
68bb24612b Minor fix 2018-01-12 17:20:56 +05:30
ritiek
beac83cf17 Merge branch 'refactor' 2018-01-12 17:09:27 +05:30
ritiek
da68ca3989 Fix conflicts 2018-01-12 17:09:13 +05:30
ritiek
8c76bd8ea3 Fix conflicts 2018-01-12 15:56:09 +05:30
ritiek
b2f3c43d0f Remove unused imports and fix token regeneration 2018-01-11 22:32:32 +05:30
ritiek
fce2a1abcd Fix tests and const.py to hold options 2018-01-11 21:50:40 +05:30
Ritiek Malhotra
58cfa121f3 Write lyrics to track metadata (#194)
* TODO: Don't throw traceback if no lyrics found

* Add more metadata fields

* Refactor debug logging

* Fix traceback when lyrics not found

* It already vomits metadata :3

* Bump lyricwikia >= 0.1.8
2018-01-11 02:13:23 +05:30
Nitesh Sawant
fb70ad32bb Download only songs whose metadata is found #193 (#197) 2018-01-11 01:51:49 +05:30
ritiek
77dab0665d Create youtube_tools.py 2018-01-10 21:20:35 +05:30
ritiek
a117064791 Refactor conversion and minor changes to metadata 2018-01-10 20:01:37 +05:30
ritiek
91fb0c3e50 Fix tests 2018-01-10 16:25:41 +05:30
ritiek
ea6d52999f Refactor metadata 2018-01-09 17:12:26 +05:30
ritiek
84fbb30412 Fix unused imports 2018-01-09 15:48:47 +05:30
ritiek
a6028e2155 Split spotify functions 2018-01-09 15:26:42 +05:30
Ritiek Malhotra
cb738ca731 Die typo! 2018-01-07 21:23:32 +05:30
Ritiek Malhotra
2c4e8ac728 Update Docker usage 2018-01-07 21:16:56 +05:30
Ritiek Malhotra
05e5135892 Link to CONTRIBUTING.md 2018-01-07 14:46:37 +05:30
Ritiek Malhotra
4d4b10c39f Create CONTRIBUTING.md 2018-01-07 14:44:53 +05:30
ritiek
3f17ec1f8a Flake encoder WARN 2018-01-06 19:08:11 +05:30
ritiek
ee340db2ea WARN if encoder not found 2018-01-06 19:01:47 +05:30
ritiek
90b08b3334 Remove BeautifulSoup dependency (as of #191) 2018-01-06 18:42:22 +05:30
Vishnunarayan K I
4d664956cd Switch to youtube API (#191)
* Switch to youtube API

* Fix test failures

* Minor fix
2018-01-06 18:28:36 +05:30
Ritiek Malhotra
57055eb65d Update usage 2018-01-06 13:08:23 +05:30
Ritiek Malhotra
090cdd1c59 Add --dry-run option (#190) 2018-01-06 13:02:07 +05:30
Linus Groh
e175608135 Change return code for KeyboardInterrupt 2018-01-04 13:13:15 +01:00
Linus Groh
0af4a13c2b Change shebang in spotdl.py 2018-01-04 13:07:02 +01:00
Linus Groh
1addf39bd2 Update copyright year in LICENSE 2018-01-04 12:59:17 +01:00
Ritiek Malhotra
3edfb9a5de Mention new --overwrite option 2017-12-31 12:47:00 +05:30
Victor Yap
01bb783724 Add --overwrite option (#182)
* Add overwrite option

* Fix tests

* address changes requested
2017-12-31 12:43:34 +05:30
Linus Groh
64548b6894 Fix link and language in README.md 2017-12-30 04:50:26 +01:00
Ritiek Malhotra
6076b83661 Update ISSUE_TEMPLATE.md 2017-12-23 17:58:34 +05:30
Ritiek Malhotra
f5587d69b5 Update docker instructions 2017-12-21 16:25:33 +05:30
Ritiek Malhotra
c75a243690 Improve formatting 2017-12-21 15:44:49 +05:30
Ritiek Malhotra
fddfeb43cd Add docker automated shield and other changes 2017-12-21 15:43:15 +05:30
Ritiek Malhotra
bbfc3e21c2 Update Dockerfile 2017-12-21 15:06:56 +05:30
Ritiek Malhotra
58f54c0041 Add docker build status 2017-12-21 14:13:38 +05:30
Ritiek Malhotra
cc018ea0a2 Update Dockerfile 2017-12-21 14:00:09 +05:30
Ritiek Malhotra
6242c2d7c0 Update Dockerfile 2017-12-21 13:46:27 +05:30
Ritiek Malhotra
93388418f0 Show filepath in debug mode 2017-12-21 13:24:36 +05:30
Ritiek Malhotra
0a27e1d523 Merge pull request #169 from bcardiff/docker
Add docker image
2017-12-16 11:01:42 +05:30
Brian J. Cardiff
5811403116 Add docker image 2017-12-15 13:12:00 -03:00
ritiek
fe11ade687 Log name if song already exists 2017-12-15 20:10:57 +05:30
Ritiek Malhotra
df513acc35 Add logging capability (#175)
* Refactoring and addition of logzero (#172)

* Refactored convert.py and added logging.

* Added logging and refactored.

* Added logzero to requirements.txt

* Added logging to metadata.py

* Created a log in misc.py. Updated slugify import.

* Some general improvement

* Improve message layout

* Improve test mechanism

* Implement debug level logging

* Fix some minor mistakes

* Make pytest happy

* Remove unimplemented --verbose option

* Update ISSUE_TEMPLATE.md

* Rename LICENSE

* Remove obvious from log.debug()

* Show track URL when writing to file (debug)
2017-12-15 19:57:57 +05:30
Ritiek Malhotra
8ea89f9d1c Import sys to deal with sys.exit() 2017-12-09 00:36:30 +05:30
Aareon Sullivan
77baa71d24 Minor tweaks (#154)
* Minor tweaks

Use at your own risk. Just thought I’d contribute at least a little bit

* Exit code 0

Exit code 0 is standard for notifying the system of a regular, non-error exit.

* No longer need unnecessary flag

* Fix `already_tagged` not defined

* <3 Zen of Python

* Silly me
2017-12-08 15:17:36 +05:30
ritiek
84b6ec601b Remove debug code 2017-12-05 22:29:20 +05:30
ritiek
9bee8d8787 Fix parameters to write_playlist() with -u option 2017-12-05 22:28:21 +05:30
Brian J. Cardiff
c8d71954b4 Fix typo in README (#168) 2017-12-03 11:53:06 +05:30
Ritiek Malhotra
6075a829e5 Return codes depicting success/failure (#164)
* Add error codes for fetch playlist failures

* Add return codes to README.md
2017-11-30 10:44:46 +05:30
ritiek
d39272d6a2 Avoid use of os.chdir() 2017-11-30 10:20:36 +05:30
fanlp3
6ac60000e6 fixes: #159 Refactored grab_playlist (#160)
* Refactored grab_playlist

grab_playlist was looping through the user's playlist until it finds the playlist.
In some cases a public playlist isn't in the user's playlists
Using a more direct approach, querying for the tracks of a playlist

* Remove old `def write_playlist()`

* Playlist URL download now works on private playlists
2017-11-30 09:49:38 +05:30
Ritiek Malhotra
7d7f4b75f7 Fix link in README 2017-11-25 19:52:01 +05:30
Ritiek Malhotra
24614795af Remove outdated usage image 2017-11-19 18:48:50 +05:30
ritiek
b4619d0621 Fix tests 2017-11-14 00:00:08 +05:30
ritiek
ade74e8272 Call generate_metadata() only once 2017-11-13 23:45:59 +05:30
ritiek
618476eca7 Update ISRC metadata 2017-11-13 21:33:29 +05:30
Linus Groh
b08213c8b0 Fix example usage description in README.md 2017-11-12 01:15:32 +01:00
Ritiek Malhotra
7f4cb749c8 Add note for collaborative playlists 2017-11-11 19:26:08 +05:30
ritiek
0deea6b384 Fix error when playlist not found 2017-11-11 19:17:10 +05:30
Ritiek Malhotra
fd130f8626 Error out if playlist not found 2017-11-11 19:13:37 +05:30
Ritiek Malhotra
b879f10401 Update all example commands to NCS music 2017-11-10 21:42:05 +05:30
Ritiek Malhotra
5468730ad3 Update contributing 2017-11-10 21:28:05 +05:30
Ritiek Malhotra
cfd392c6ce Fix access token regeneration 2017-10-28 01:07:50 +05:30
Ritiek Malhotra
2780ba405f Catch only IndexError 2017-10-27 14:37:26 +05:30
ritiek
2f0018adce Fix ValueError if publisher not found 2017-10-20 14:27:37 +05:30
Linus Groh
a3cfcfcd81 Add note about spaces in filenames in README.md 2017-10-17 22:59:07 +02:00
Valérian Galliat
237b4cca7e Add an option to grab an album (#142) 2017-10-08 19:43:55 +05:30
38 changed files with 3479 additions and 1151 deletions

117
.gitignore vendored
View File

@@ -1,6 +1,117 @@
*.pyc
__pycache__/
.cache/
# Spotdl generated files
*.m4a
*.mp3
config.yml
Music/
*.txt
*.m3u
.pytest_cache/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
.static_storage/
.media/
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# vscode
.vscode

View File

@@ -1,15 +1,46 @@
dist: trusty
dist: xenial
language: python
sudo: required
python:
- "3.4"
- "3.5"
- "3.6"
install:
- sudo apt-get -qq update
- sudo apt-get -y install autoconf automake build-essential libass-dev libfreetype6-dev libtheora-dev libtool libva-dev libvdpau-dev libvorbis-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev pkg-config texinfo wget zlib1g-dev
- sudo apt-get -y install yasm nasm libmp3lame-dev
- pip install -r requirements.txt
- "3.7"
before_install:
- pip install tinydownload
- tinydownload 05861434675432854607 -o ~/bin/ffmpeg
- pip install pytest-cov
addons:
apt:
packages:
- xdg-user-dirs
- automake
- autoconf
- build-essential
- libass-dev
- libfreetype6-dev
- libtheora-dev
- libtool
- libva-dev
- libvdpau-dev
- libvorbis-dev
- libxcb1-dev
- libxcb-shm0-dev
- libxcb-xfixes0-dev
- libfdk-aac-dev
- libopus-dev
- pkg-config
- texinfo
- zlib1g-dev
- yasm
- nasm
- libmp3lame-dev
- libav-tools
install:
- pip install -e .
- tinydownload 07426048687547254773 -o ~/bin/ffmpeg
- chmod 755 ~/bin/ffmpeg
script: python -m pytest test
- xdg-user-dirs-update
script: python -m pytest test --cov=.
after_success:
- pip install codecov
- codecov

109
CHANGES.md Normal file
View File

@@ -0,0 +1,109 @@
# Changelog
All notable changes to this project will be documented in this file.
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]
### Added
-
### Fixed
-
### Changed
-
## [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
- Patch bug in Pafy when fetching audiostreams with latest youtube-dl ([@ritiek](https://github.com/ritiek)) (#539)
### Changed
- Removed duplicate debug log entry from `internals.trim_song` ([@ritiek](https://github.com/ritiek)) (#519)
- Fix YAMLLoadWarning ([@cyberboysumanjay](https://github.com/cyberboysumanjay)) (#517)
## [1.2.0] - 2019-03-01
### Added
- `--write-to` parameter for setting custom file to write Spotify track URLs to ([@ritiek](https://github.com/ritiek)) (#507)
- Set custom Spotify Client ID and Client Secret via config.yml ([@ManveerBasra](https://github.com/ManveerBasra)) (#502)
- Use YouTube as fallback metadata if track not found on Spotify. Also added `--no-fallback-metadata`
to preserve old behaviour ([@ritiek](https://github.com/ritiek)) (#457)
### Fixed
- Fix already downloaded prompt when using "/" in `--file-format` to create sub-directories ([@ritiek](https://github.com/ritiek)) (#503)
- Fix writing playlist tracks to file ([@ritiek](https://github.com/ritiek)) (#506)
## [1.1.2] - 2019-02-10
### Changed
- Fetch all artist albums by default instead of only fetching the "album" type ([@ritiek](https://github.com/ritiek)) (#493)
- Option `-f` (`--folder`) is used when exporting text files using `-p` (`--playlist`) for playlists or `-b` (`--album`) for albums ([@Silverfeelin](https://github.com/Silverfeelin)) (#476)
- Use first artist from album object for album artist ([@tillhainbach](https://github.com/tillhainbach))
### Fixed
- Fix renaming files when encoder is not found ([@ritiek](https://github.com/ritiek)) (#475)
- Add missing `import time` ([@ifduyue](https://github.com/ifduyue)) (#465)
## [1.1.1] - 2019-01-03
### Added
- Output informative message in case of no result found in YouTube search ([@Amit-L](https://github.com/Amit-L)) (#452)
- Ability to pass multiple tracks with `-s` option ([@ritiek](https://github.com/ritiek)) (#442)
### Changed
- Allowed to fetch metadata from Spotify upon searching Spotify-URL and `--no-metadata` to gather YouTube custom-search fields ([@Amit-L](https://github.com/Amit-L)) (#452)
- Change FFmpeg to use the built-in encoder `aac` instead of 3rd party `libfdk-aac` which does not
ship with the apt package ([@ritiek](https://github.com/ritiek)) (#448)
- Monkeypatch ever-changing network-relying tests ([@ritiek](https://github.com/ritiek)) (#448)
- Correct `.m4a` container before writing metadata so metadata fields shows up properly in
media players (especially iTunes) ([@ritiek](https://github.com/ritiek) with thanks to [@Amit-L](https://github.com/Amit-L)!) (#453)
- Refactored core downloading module ([@ritiek](https://github.com/ritiek)) (#410)
### Fixed
- Workaround conversion conflicts when input and output filename are same ([@ritiek](https://github.com/ritiek)) (#459)
- Applied a check on result in case of search using Spotify-URL `--no-metadata` option ([@Amit-L](https://github.com/Amit-L)) (#452)
- Included a missing `import spotipy` in downloader.py ([@ritiek](https://github.com/ritiek)) (#440)
## [1.1.0] - 2018-11-13
### Added
- Output error details when track download fails from list file ([@ManveerBasra](https://github.com/ManveerBasra)) (#406)
- Add support for `.m3u` playlists ([@ritiek](https://github.com/ritiek)) (#401)
- Introduce usage of black (code formatter) ([@linusg](https://github.com/linusg)) (#393)
- Added command line option for getting all artist's songs ([@AlfredoSequeida](https://github.com/AlfredoSequeida)) (#389)
- Added command line options for skipping tracks file and successful downloads file and
place newline before track URL when appending to track file ([@linusg](https://github.com/linusg)) (#386)
- Overwrite track file with unique tracks ([@ritiek](https://github.com/ritiek)) (#380)
- Embed comment metadata in `.m4a` ([@ritiek](https://github.com/ritiek)) (#379)
- Added check for publisher tag before adding publisher id3 tag to audio file ([@gnodar01](https://github.com/gnodar01)) (#377)
### Changed
- `--list` flag accepts only text files using mimetypes ([@ManveerBasra](https://github.com/ManveerBasra)) (#414)
- Refactored Spotify token refresh ([@ManveerBasra](https://github.com/ManveerBasra)) (#408)
- Don't search song on Spotify if `--no-metadata` is passed ([@ManveerBasra](https://github.com/ManveerBasra)) (#404)
- Changed test track to one whose lyrics are found ([@ManveerBasra](https://github.com/ManveerBasra)) (#400)
- Windows - 'My Music' folder won't be assumed to be on C drive but looked up in Registry ([@SillySam](https://github.com/SillySam)) (#387)
- Updated `setup.py` (fix PyPI URL, add Python 3.7 modifier) ([@linusg](https://github.com/linusg)) (#383)
- Updated dependencies to their newest versions (as of 2018-10-02) ([@linusg](https://github.com/linusg)) (#382)
- Remove duplicates from track file while preserving order ([@ritiek](https://github.com/ritiek)) (#369)
- Moved a lot of content from `README.md` to the [repository's GitHub wiki](https://github.com/ritiek/spotify-downloader/wiki) ([@sdhutchins](https://github.com/sdhutchins), [@ritiek](https://github.com/ritiek)) (#361)
- Refactored internal use of logging ([@arryon](https://github.com/arryon)) (#358)
### Fixed
- Check and replace slashes with dashes to avoid directory creation error ([@ManveerBasra](https://github.com/ManveerBasra)) (#402)
- Filter unwanted text from Spotify URLs when extracting information ([@ritiek](https://github.com/ritiek)) (#394)
- Correctly embed metadata in `.m4a` ([@arryon](https://github.com/arryon)) (#372)
- Slugify will not ignore the `'` character (single quotation mark) anymore ([@jimangel2001](https://github.com/jimangel2001)) (#357)
## [1.0.0] - 2018-09-09
### Added
- Initial complete release, recommended way to install is now from PyPI
## 1.0.0-beta.1 - 2018-02-02
### Added
- Initial release, prepare for 1.0.0
[Unreleased]: https://github.com/ritiek/spotify-downloader/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/ritiek/spotify-downloader/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/ritiek/spotify-downloader/compare/v1.0.0-beta.1...v1.0.0

74
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,74 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project owner at ritiekmalhotra123@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org

38
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,38 @@
# Contributing
- Want to contribute to [spotify-downloader](https://github.com/ritiek/spotify-downloader)?
That's great. We are happy to have you!
- Here is a basic outline on opening issues and making PRs:
## Opening Issues
- Search for your problem in the
[issues section](https://github.com/ritiek/spotify-downloader/issues)
before opening a new ticket. It might be already answered and save both you and us time. :smile:
- Provide as much information as possible when opening your ticket, including any relevant examples (if any).
- If your issue is a *bug*, make sure you pass `--log-level=DEBUG` when invoking
`spotdl.py` and paste the output in your issue.
- If you think your question is naive or something and you can't find anything related,
don't feel bad. Open an issue any way!
## Making Pull Requests
- Look up for open issues and see if you can help out there.
- Easy issues for newcomers are usually labelled as
[good-first-issue](https://github.com/ritiek/spotify-downloader/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
- When making a PR, point it to the [master branch](https://github.com/ritiek/spotify-downloader/tree/master)
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`.
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.
- Lastly, please don't hesitate to ask if you have any questions!
Let us know (through an issue) if you are facing any trouble making a PR, we'd be glad to help you out!
## Related Resources
- There's also a web-based front-end to operate this tool, which under (major) construction
called [spotifube](https://github.com/linusg/spotifube).
Check it out if you'd like to contribute to it!

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.6-alpine
RUN apk add --no-cache \
ffmpeg
ADD spotdl/ /spotify-downloader/spotdl
ADD setup.py /spotify-downloader/setup.py
ADD README.md /spotify-downloader/README.md
WORKDIR /spotify-downloader
RUN pip install .
RUN mkdir /music
WORKDIR /music
ENTRYPOINT ["spotdl", "-f", "/music"]

View File

@@ -1,31 +1,25 @@
<!--
Please follow the guide below
- You will be asked some questions and requested to provide some information, please read them CAREFULLY and answer honestly
- Put an `x` into all the boxes [ ] relevant to your *issue* (like that [x])
- Before opening your ticket, make sure you either installed the latest release from PyPI
or installed directly from the master branch and have searched through existing issues
including closed ones.
-->
<!--
- Put an `x` into the box [ ] below (like [x]) depending on the purpose of your issue
- Use *Preview* tab to see how your issue will actually look like
-->
- [ ] Using latest version as provided on the [master branch](https://github.com/ritiek/spotify-downloader/tree/master)
- [ ] [Searched](https://github.com/ritiek/spotify-downloader/issues?utf8=%E2%9C%93&q=is%3Aissue) for similar issues including closed ones
#### What is the purpose of your *issue*?
- [ ] Script won't run
- [ ] Encountered bug
- [ ] Feature request
- [ ] Bug
- [ ] Feature Request
- [ ] Question
- [ ] Other
#### System information
- Your `python` version: `python 3.x`
- Your operating system: `Ubuntu 16.04`
### Description
<!-- Provide as much information possible with relevant examples and whatever you have tried below -->
<!-- Provide as much information possible and whatever you have tried below -->
<!-- Give your issue a relevant title and you are good to go -->
### Log
<!-- Run the script with `--log-level=DEBUG` and paste the output below-->

2
LICENSE.txt → LICENSE Executable file → Normal file
View File

@@ -1,5 +1,5 @@
The MIT License (MIT)
Copyright (c) 2016 Ritiek Malhotra
Copyright (c) 2018 Ritiek Malhotra
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

205
README.md Executable file → Normal file
View File

@@ -1,196 +1,91 @@
# Spotify-Downloader
[![PyPi](https://img.shields.io/pypi/v/spotdl.svg)](https://pypi.org/project/spotdl)
[![Build Status](https://travis-ci.org/ritiek/spotify-downloader.svg?branch=master)](https://travis-ci.org/ritiek/spotify-downloader)
[![Coverage Status](https://codecov.io/gh/ritiek/spotify-downloader/branch/master/graph/badge.svg)](https://codecov.io/gh/ritiek/spotify-downloader)
[![Docker Build Status](https://img.shields.io/docker/build/ritiek/spotify-downloader.svg)](https://hub.docker.com/r/ritiek/spotify-downloader)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black)
[![Gitter Chat](https://badges.gitter.im/ritiek/spotify-downloader/Lobby.svg)](https://gitter.im/spotify-downloader/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
- Downloads songs from YouTube in an MP3 format by using Spotify's HTTP link.
- Downloads songs from YouTube in an MP3 format by using Spotify's HTTP link.
- 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:
- Automatically fixes song's meta-tags which include:
- `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
- Album artist
- Genre
- Track number
- Disc number
- Release date
- And some more...
- Works straight out of the box and does not require you to generate or mess with your API keys (already included).
- Works straight out of the box and does not require to generate or mess with your API keys.
That's how your Music library will look like!
Below is how your music library will look!
<img src="http://i.imgur.com/Gpch7JI.png" width="290"><img src="http://i.imgur.com/5vhk3HY.png" width="290"><img src="http://i.imgur.com/RDTCCST.png" width="290">
## Reporting Issues
## Installation
- Search for your problem in the [issues section](https://github.com/Ritiek/spotify-downloader/issues?utf8=%E2%9C%93&q=) before opening a new ticket. It might be already answered and save us time. :smile:
❗️ **This tool works only with Python 3.**
- Provide as much information possible when opening your ticket.
Python 2 compatibility was dropped because of the way it deals with unicode (2020 is coming soon too).
If you still need to use Python 2 - check out the (outdated)
[python2](https://github.com/ritiek/spotify-downloader/tree/python2) branch.
## Installation & Usage
spotify-downloader works with all major distributions and even on low-powered devices such as a Raspberry Pi.
<img src="http://i.imgur.com/Dg8p9up.png" width="600">
- **This tool supports only Python 3**, Python 2 compatibility was dropped because of the way it deals with unicode. If you need to use Python 2 though, check out the (old) `python2` branch.
- Note: `play` and `lyrics` commands have been deprecated in the current brach since they were not of much use and created unnecessary clutter. You can still get them back by using `old` branch though.
### Debian, Ubuntu, Linux & Mac
```
cd
git clone https://github.com/ritiek/spotify-downloader
cd spotify-downloader
pip install -U -r requirements.txt
spotify-downloader can be installed via pip with:
```console
$ pip3 install spotdl
```
**Important:** if you have installed both Python 2 and 3, the `pip` command could invoke an installation for Python 2. To see which Python version `pip` refers to, try `pip -V`. If it turns out `pip` is your Python 2 pip, try `pip3 install -U -r requirements.txt` instead.
but be sure to check out the [Installation](https://github.com/ritiek/spotify-downloader/wiki/Installation) wiki
page for detailed OS-specific instructions to get it and other dependencies it relies on working on your system.
You'll also need to install FFmpeg for conversion (use `--avconv` if you'd like to use that instead):
## Usage
Linux: `sudo apt-get install ffmpeg`
For the most basic usage, downloading tracks is as easy as
Mac: `brew install ffmpeg --with-libmp3lame --with-libass --with-opus --with-fdk-aac`
If it does not install correctly, you may have to build it from source. For more info see https://trac.ffmpeg.org/wiki/CompilationGuide.
### Windows
Assuming you have Python 3 ([preferably v3.6 or above to stay away from Unicode errors](https://stackoverflow.com/questions/30539882/whats-the-deal-with-python-3-4-unicode-different-languages-and-windows)) already installed and in PATH.
- Download and extract the [zip file](https://github.com/ritiek/spotify-downloader/archive/master.zip) from master branch.
- Download FFmpeg for Windows from [here](http://ffmpeg.zeranoe.com/builds/). Copy `ffmpeg.exe` from `ffmpeg-xxx-winxx-static\bin\ffmpeg.exe` to PATH (usually C:\Windows\System32\) or just place it in the root directory extracted from the above step.
- Open `cmd` and type `pip install -U -r requirements.txt` to install dependencies. The same note about `pip` as for Debian, Ubuntu, Linux & Mac applies.
## Instructions for Downloading Songs
**Important:** as like with `pip`, there might be no `python3` command. This is most likely the case when you have only Python 3 but not 2 installed. In this case try the `python` command instead of `python3`, but make sure `python -V` gives you a `Python 3.x.x`!
- For all available options, run `python3 spotdl.py --help`.
```
usage: spotdl.py [-h] (-s SONG | -l LIST | -p PLAYLIST | -u USERNAME) [-m]
[-nm] [-a] [-f FOLDER] [-v] [-i INPUT_EXT] [-o OUTPUT_EXT]
Download and convert songs from Spotify, Youtube etc.
optional arguments:
-h, --help show this help message and exit
-s SONG, --song SONG download song by spotify link or name (default: None)
-l LIST, --list LIST download songs from a file (default: None)
-p PLAYLIST, --playlist PLAYLIST
load songs from playlist URL into <playlist_name>.txt
(default: None)
-u USERNAME, --username USERNAME
load songs from user's playlist into
<playlist_name>.txt (default: None)
-m, --manual choose the song to download manually (default: False)
-nm, --no-metadata do not embed metadata in songs (default: False)
-a, --avconv Use avconv for conversion otherwise set defaults to
ffmpeg (default: False)
-f FOLDER, --folder FOLDER
path to folder where files will be stored in (default:
Music/)
-v, --verbose show debug output (default: False)
-i INPUT_EXT, --input_ext INPUT_EXT
prefered input format .m4a or .webm (Opus) (default:
.m4a)
-o OUTPUT_EXT, --output_ext OUTPUT_EXT
prefered output extension .mp3 or .m4a (AAC) (default:
.mp3)
```console
$ spotdl --song https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ
$ spotdl --song "ncs - spectre"
```
#### Download by Name
For downloading playlist and albums, you need to first load all the tracks into text file and then pass
this text file to `--list` argument. Here is how you would do it for a playlist
For example
- We want to download Hello by Adele, simply run `python3 spotdl.py --song "adele hello"`.
- The script will automatically look for the best matching song and download it in the folder `Music/` placed in the root directory of the code base.
- It will now convert the song to an mp3 and try to fix meta-tags and album-art by looking up on Spotify.
#### Download by Spotify Link (Recommended)
For example
- We want to download the same song (i.e: Hello by Adele) but using Spotify Link this time that looks like `http://open.spotify.com/track/1MDoll6jK4rrk2BcFRP5i7`, you can copy it from your Spotify desktop or mobile app by right clicking or long tap on the song and copy HTTP link.
- Run `python3 spotdl.py --song http://open.spotify.com/track/1MDoll6jK4rrk2BcFRP5i7`, it should download Hello by Adele.
- Just like before, it will again convert the song to an mp3 but since we used a Spotify HTTP link, the script is guaranteed to fetch the correct meta-tags and album-art.
#### Download multiple songs at once
For example
- We want to download `Hello by Adele`, `The Nights by Avicci` and `21 Guns by Green Day` just using a single command.
Let's suppose, we have the Spotify link for only `Hello by Adele` and `21 Guns by Green Day`.
No problem!
- Just make a `list.txt` in the same folder as the script and add all the songs you want to download, in our case it is
(if you are on Windows, just edit `list.txt` - i.e `C:\Python36\spotify-downloader-master\list.txt`)
```
https://open.spotify.com/track/1MDoll6jK4rrk2BcFRP5i7
the nights avicci
http://open.spotify.com/track/64yrDBpcdwEdNY9loyEGbX
```console
$ spotdl --playlist https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD
INFO: Writing 62 tracks to ncs-releases.txt
$ spotdl --list ncs-releases.txt
```
- Now pass `--list=list.txt` to the script, i.e `python3 spotdl.py --list=list.txt` and it will start downloading songs mentioned in `list.txt`.
Run `spotdl --help` to get a list of all available options in spotify-downloader.
- You can stop downloading songs by hitting `ctrl+c`, the script will automatically resume from the song where you stopped it the next time you want to download the songs present in `list.txt`.
Check out the [Available options](https://github.com/ritiek/spotify-downloader/wiki/Available-options)
wiki page for the list of currently available options with their description.
- Songs that are already downloaded will be skipped and not be downloaded again.
The wiki page [Instructions for Downloading Songs](https://github.com/ritiek/spotify-downloader/wiki/Instructions-for-Downloading-Songs)
contains detailed information about different available ways to download tracks.
#### Download playlists
## FAQ
- You can copy the Spotify URL of the playlist and pass it in `--playlist` option.
All FAQs will be mentioned in our [FAQ wiki page](https://github.com/ritiek/spotify-downloader/wiki/FAQ).
For example
## Contributing
- `python3 spotdl.py --playlist https://open.spotify.com/user/camillazi/playlist/71MXqcSOKCxsLNtRvONkhF`
Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more info.
- The script will load all the tracks from the playlist into `<playlist_name>.txt`
## Running Tests
- Then you can simply run `python3 spotdl.py --list=<playlist_name>.txt` to download all the tracks.
#### Download playlists by username
- You can also load songs using Spotify username if you don't have the playlist URL. (Open profile in Spotify, click on the three little dots below name, "Share", "Copy to clipboard", paste last numbers into command-line: `https://open.spotify.com/user/0123456790`)
- Try running `python3 spotdl.py -u <your_username>`, it will show all your public playlists.
- Once you select the one you want to download, the script will load all the tracks from the playlist into `<playlist_name>.txt`.
- Run `python3 spotdl.py --list=<playlist_name>.txt` to download all the tracks.
#### Specify the target directory
If you don't want to download all the songs to the `Music/` folder relative to the `spotdl.py` script, you can use the `-f`/`--folder` option. E.g. `python3 spotdl.py -s "adele hello" -f "/home/user/Music/"`. This works with both relative and absolute paths.
## Running tests
```
python3 -m pytest test
```console
$ python3 -m pytest test
```
Obviously this requires the `pytest` module to be installed.
Obviously this requires the `pytest` module to be installed.
## Disclaimer
Downloading copyright songs may be illegal in your country. This tool is for educational purposes only and was created only to show how Spotify's API can be exploited to download music from YouTube. Please support the artists by buying their music.
Downloading copyright songs may be illegal in your country.
This tool is for educational purposes only and was created only to show
how Spotify's API can be exploited to download music from YouTube.
Please support the artists by buying their music.
## License
```The MIT License```
[![License](https://img.shields.io/github/license/ritiek/spotify-downloader.svg)](https://github.com/ritiek/spotify-downloader/blob/master/LICENSE)

View File

@@ -1 +0,0 @@

View File

@@ -1,68 +0,0 @@
import subprocess
import os
"""
What are the differences and similarities between ffmpeg, libav, and avconv?
https://stackoverflow.com/questions/9477115
ffmeg encoders high to lower quality
libopus > libvorbis >= libfdk_aac > aac > libmp3lame
libfdk_aac due to copyrights needs to be compiled by end user
on MacOS brew install ffmpeg --with-fdk-aac will do just that. Other OS?
https://trac.ffmpeg.org/wiki/Encode/AAC
"""
def song(input_song, output_song, folder, avconv=False, verbose=False):
"""Do the audio format conversion."""
if not input_song == output_song:
print('Converting {0} to {1}'.format(
input_song, output_song.split('.')[-1]))
if avconv:
exit_code = convert_with_avconv(input_song, output_song, folder, verbose)
else:
exit_code = convert_with_ffmpeg(input_song, output_song, folder, verbose)
return exit_code
return 0
def convert_with_avconv(input_song, output_song, folder, verbose):
"""Convert the audio file using avconv."""
if verbose:
level = 'debug'
else:
level = '0'
command = ['avconv',
'-loglevel', level,
'-i', os.path.join(folder, input_song),
'-ab', '192k',
os.path.join(folder, output_song)]
return subprocess.call(command)
def convert_with_ffmpeg(input_song, output_song, folder, verbose):
"""Convert the audio file using FFmpeg."""
ffmpeg_pre = 'ffmpeg -y '
if not verbose:
ffmpeg_pre += '-hide_banner -nostats -v panic '
input_ext = input_song.split('.')[-1]
output_ext = output_song.split('.')[-1]
if input_ext == 'm4a':
if output_ext == 'mp3':
ffmpeg_params = '-codec:v copy -codec:a libmp3lame -q:a 2 '
elif output_ext == 'webm':
ffmpeg_params = '-c:a libopus -vbr on -b:a 192k -vn '
elif input_ext == 'webm':
if output_ext == 'mp3':
ffmpeg_params = ' -ab 192k -ar 44100 -vn '
elif output_ext == 'm4a':
ffmpeg_params = '-cutoff 20000 -c:a libfdk_aac -b:a 192k -vn '
command = '{0}-i {1} {2}{3}'.format(
ffmpeg_pre, os.path.join(folder, input_song), ffmpeg_params, os.path.join(folder, output_song)).split(' ')
return subprocess.call(command)

View File

@@ -1,125 +0,0 @@
from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3, APIC
from mutagen.mp4 import MP4, MP4Cover
import urllib.request
def compare(music_file, metadata):
"""Check if the input music file title matches the expected title."""
already_tagged = False
try:
if music_file.endswith('.mp3'):
audiofile = EasyID3(music_file)
# fetch track title metadata
already_tagged = audiofile['title'][0] == metadata['name']
elif music_file.endswith('.m4a'):
tags = {'title': '\xa9nam'}
audiofile = MP4(music_file)
# fetch track title metadata
already_tagged = audiofile[tags['title']] == metadata['name']
except (KeyError, TypeError):
pass
return already_tagged
def embed(music_file, meta_tags):
"""Embed metadata."""
if meta_tags is None:
print('Could not find meta-tags')
return None
elif music_file.endswith('.m4a'):
print('Fixing meta-tags')
return embed_m4a(music_file, meta_tags)
elif music_file.endswith('.mp3'):
print('Fixing meta-tags')
return embed_mp3(music_file, meta_tags)
else:
print('Cannot embed meta-tags into given output extension')
return False
def embed_mp3(music_file, meta_tags):
"""Embed metadata to MP3 files."""
# EasyID3 is fun to use ;)
audiofile = EasyID3(music_file)
audiofile['artist'] = meta_tags['artists'][0]['name']
audiofile['albumartist'] = meta_tags['artists'][0]['name']
audiofile['album'] = meta_tags['album']['name']
audiofile['title'] = meta_tags['name']
audiofile['tracknumber'] = [meta_tags['track_number'],
meta_tags['total_tracks']]
audiofile['discnumber'] = [meta_tags['disc_number'], 0]
audiofile['date'] = meta_tags['release_date']
audiofile['originaldate'] = meta_tags['release_date']
audiofile['media'] = meta_tags['type']
audiofile['author'] = meta_tags['artists'][0]['name']
audiofile['lyricist'] = meta_tags['artists'][0]['name']
audiofile['arranger'] = meta_tags['artists'][0]['name']
audiofile['performer'] = meta_tags['artists'][0]['name']
audiofile['encodedby'] = meta_tags['publisher']
audiofile['website'] = meta_tags['external_urls']['spotify']
audiofile['length'] = str(meta_tags['duration_ms'] / 1000)
if meta_tags['genre']:
audiofile['genre'] = meta_tags['genre']
if meta_tags['copyright']:
audiofile['copyright'] = meta_tags['copyright']
if meta_tags['isrc']:
audiofile['isrc'] = meta_tags['external_ids']['isrc']
audiofile.save(v2_version=3)
audiofile = ID3(music_file)
try:
albumart = urllib.request.urlopen(meta_tags['album']['images'][0]['url'])
audiofile["APIC"] = APIC(encoding=3, mime='image/jpeg', type=3,
desc=u'Cover', data=albumart.read())
albumart.close()
except IndexError:
pass
audiofile.save(v2_version=3)
return True
def embed_m4a(music_file, meta_tags):
"""Embed metadata to M4A files."""
# Apple has specific tags - see mutagen docs -
# http://mutagen.readthedocs.io/en/latest/api/mp4.html
tags = {'album': '\xa9alb',
'artist': '\xa9ART',
'date': '\xa9day',
'title': '\xa9nam',
'originaldate': 'purd',
'comment': '\xa9cmt',
'group': '\xa9grp',
'writer': '\xa9wrt',
'genre': '\xa9gen',
'tracknumber': 'trkn',
'albumartist': 'aART',
'disknumber': 'disk',
'cpil': 'cpil',
'albumart': 'covr',
'copyright': 'cprt',
'tempo': 'tmpo'}
audiofile = MP4(music_file)
audiofile[tags['artist']] = meta_tags['artists'][0]['name']
audiofile[tags['albumartist']] = meta_tags['artists'][0]['name']
audiofile[tags['album']] = meta_tags['album']['name']
audiofile[tags['title']] = meta_tags['name']
audiofile[tags['tracknumber']] = [(meta_tags['track_number'],
meta_tags['total_tracks'])]
audiofile[tags['disknumber']] = [(meta_tags['disc_number'], 0)]
audiofile[tags['date']] = meta_tags['release_date']
audiofile[tags['originaldate']] = meta_tags['release_date']
if meta_tags['genre']:
audiofile[tags['genre']] = meta_tags['genre']
if meta_tags['copyright']:
audiofile[tags['copyright']] = meta_tags['copyright']
try:
albumart = urllib.request.urlopen(meta_tags['album']['images'][0]['url'])
audiofile[tags['albumart']] = [MP4Cover(
albumart.read(), imageformat=MP4Cover.FORMAT_JPEG)]
albumart.close()
except IndexError:
pass
audiofile.save()
return True

View File

@@ -1,141 +0,0 @@
import sys
import os
import argparse
import spotipy.oauth2 as oauth2
from urllib.request import quote
from slugify import slugify
def input_link(links):
"""Let the user input a number."""
while True:
try:
the_chosen_one = int(input('>> Choose your number: '))
if 1 <= the_chosen_one <= len(links):
return links[the_chosen_one - 1]
elif the_chosen_one == 0:
return None
else:
print('Choose a valid number!')
except ValueError:
print('Choose a valid number!')
def trim_song(file):
"""Remove the first song from file."""
with open(file, 'r') as file_in:
data = file_in.read().splitlines(True)
with open(file, 'w') as file_out:
file_out.writelines(data[1:])
def get_arguments():
parser = argparse.ArgumentParser(
description='Download and convert songs from Spotify, Youtube etc.',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
'-s', '--song', help='download song by spotify link or name')
group.add_argument(
'-l', '--list', help='download songs from a file')
group.add_argument(
'-p', '--playlist', help='load songs from playlist URL into <playlist_name>.txt')
group.add_argument(
'-u', '--username',
help="load songs from user's playlist into <playlist_name>.txt")
parser.add_argument(
'-m', '--manual', default=False,
help='choose the song to download manually', action='store_true')
parser.add_argument(
'-nm', '--no-metadata', default=False,
help='do not embed metadata in songs', action='store_true')
parser.add_argument(
'-a', '--avconv', default=False,
help='Use avconv for conversion otherwise set defaults to ffmpeg',
action='store_true')
parser.add_argument(
'-f', '--folder', default='Music/',
help='path to folder where files will be stored in')
parser.add_argument(
'-v', '--verbose', default=False, help='show debug output',
action='store_true')
parser.add_argument(
'-i', '--input_ext', default='.m4a',
help='prefered input format .m4a or .webm (Opus)')
parser.add_argument(
'-o', '--output_ext', default='.mp3',
help='prefered output extension .mp3 or .m4a (AAC)')
return parser.parse_args()
def is_spotify(raw_song):
"""Check if the input song is a Spotify link."""
status = len(raw_song) == 22 and raw_song.replace(" ", "%20") == raw_song
status = status or raw_song.find('spotify') > -1
return status
def is_youtube(raw_song):
"""Check if the input song is a YouTube link."""
status = len(raw_song) == 11 and raw_song.replace(" ", "%20") == raw_song
status = status and not raw_song.lower() == raw_song
status = status or 'youtube.com/watch?v=' in raw_song
return status
def sanitize_title(title):
"""Generate filename of the song to be downloaded."""
title = title.replace(' ', '_')
title = title.replace('/', '_')
# slugify removes any special characters
title = slugify(title, ok='-_()[]{}', lower=False)
return title
def generate_token():
"""Generate the token. Please respect these credentials :)"""
credentials = oauth2.SpotifyClientCredentials(
client_id='4fe3fecfe5334023a1472516cc99d805',
client_secret='0f02b7c483c04257984695007a4a8d5c')
token = credentials.get_access_token()
return token
def generate_search_url(song, viewsort=False):
"""Generate YouTube search URL for the given song."""
# urllib.request.quote() encodes URL with special characters
song = quote(song)
if viewsort:
url = u"https://www.youtube.com/results?q={0}".format(song)
else:
url = u"https://www.youtube.com/results?sp=EgIQAQ%253D%253D&q={0}".format(song)
return url
def filter_path(path):
os.chdir(sys.path[0])
if not os.path.exists(path):
os.makedirs(path)
for temp in os.listdir(path):
if temp.endswith('.temp'):
os.remove(os.path.join(path, temp))
def grace_quit():
print('\n\nExiting.')
sys.exit()
def get_sec(time_str):
v = time_str.split(':', 3)
v.reverse()
sec = 0
if len(v) > 0: #seconds
sec += int(v[0])
if len(v) > 1: # minutes
sec += int(v[1]) * 60
if len(v) > 2: # hours
sec += int(v[2]) * 3600
return sec

View File

@@ -1,8 +0,0 @@
pathlib >= 1.0.1
BeautifulSoup4 >= 0.4.13
youtube_dl >= 2017.5.1
pafy >= 0.5.3.1
spotipy >= 2.4.4
mutagen >= 1.37
unicode-slugify >= 0.1.3
titlecase >= 0.10.0

64
setup.py Executable file
View File

@@ -0,0 +1,64 @@
from setuptools import setup
with open("README.md", "r", encoding="utf-8") as f:
long_description = f.read()
import spotdl
setup(
# 'spotify-downloader' was already taken :/
name="spotdl",
# Tests are included automatically:
# https://docs.python.org/3.6/distutils/sourcedist.html#specifying-the-files-to-distribute
packages=["spotdl"],
version=spotdl.__version__,
install_requires=[
"pathlib >= 1.0.1",
"youtube_dl >= 2017.9.26",
"pafy >= 0.5.3.1",
"spotipy >= 2.4.4",
"mutagen >= 1.41.1",
"beautifulsoup4 >= 4.6.3",
"unicode-slugify >= 0.1.3",
"titlecase >= 0.10.0",
"logzero >= 1.3.1",
"lyricwikia >= 0.1.8",
"PyYAML >= 3.13",
"appdirs >= 1.4.3",
],
description="Download songs from YouTube using Spotify song URLs or playlists with albumart and meta-tags.",
long_description=long_description,
long_description_content_type="text/markdown",
author="Ritiek Malhotra and the spotify-downloader contributors",
author_email="ritiekmalhotra123@gmail.com",
license="MIT",
python_requires=">=3.4",
url="https://github.com/ritiek/spotify-downloader",
download_url="https://pypi.org/project/spotdl/",
keywords=[
"spotify",
"downloader",
"download",
"music",
"youtube",
"mp3",
"album",
"metadata",
],
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3 :: Only",
"Topic :: Multimedia",
"Topic :: Multimedia :: Sound/Audio",
"Topic :: Utilities",
],
entry_points={"console_scripts": ["spotdl = spotdl.spotdl:main"]},
)

417
spotdl.py
View File

@@ -1,417 +0,0 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
from core import metadata
from core import convert
from core import misc
from bs4 import BeautifulSoup
from titlecase import titlecase
from slugify import slugify
import spotipy
import pafy
import urllib.request
import sys
import os
import time
def generate_songname(tags):
"""Generate a string of the format '[artist] - [song]' for the given spotify song."""
raw_song = u'{0} - {1}'.format(tags['artists'][0]['name'], tags['name'])
return raw_song
def generate_metadata(raw_song):
"""Fetch a song's metadata from Spotify."""
if misc.is_spotify(raw_song):
# fetch track information directly if it is spotify link
meta_tags = spotify.track(raw_song)
else:
# otherwise search on spotify and fetch information from first result
try:
meta_tags = spotify.search(raw_song, limit=1)['tracks']['items'][0]
except:
return None
artist = spotify.artist(meta_tags['artists'][0]['id'])
album = spotify.album(meta_tags['album']['id'])
try:
meta_tags[u'genre'] = titlecase(artist['genres'][0])
except IndexError:
meta_tags[u'genre'] = None
try:
meta_tags[u'copyright'] = album['copyrights'][0]['text']
except IndexError:
meta_tags[u'copyright'] = None
try:
meta_tags['isrc']
except KeyError:
meta_tags['isrc'] = None
meta_tags[u'release_date'] = album['release_date']
meta_tags[u'publisher'] = album['label']
meta_tags[u'total_tracks'] = album['tracks']['total']
return meta_tags
def generate_youtube_url(raw_song, tries_remaining=5):
"""Search for the song on YouTube and generate a URL to its video."""
# prevents an infinite loop but allows for a few retries
if tries_remaining == 0:
return
meta_tags = generate_metadata(raw_song)
if meta_tags is None:
song = raw_song
search_url = misc.generate_search_url(song, viewsort=False)
else:
song = generate_songname(meta_tags)
search_url = misc.generate_search_url(song, viewsort=True)
item = urllib.request.urlopen(search_url).read()
# item = unicode(item, 'utf-8')
items_parse = BeautifulSoup(item, "html.parser")
videos = []
for x in items_parse.find_all('div', {'class': 'yt-lockup-dismissable yt-uix-tile'}):
# ensure result is not a channel
if x.find('channel') is not None or 'yt-lockup-channel' in x.parent.attrs['class'] or 'yt-lockup-channel' in x.attrs['class']:
continue
# ensure result is not a mix/playlist
if 'yt-lockup-playlist' in x.parent.attrs['class']:
continue
# confirm the video result is not an advertisement
if x.find('googleads') is not None:
continue
y = x.find('div', class_='yt-lockup-content')
link = y.find('a')['href']
title = y.find('a')['title']
try:
videotime = x.find('span', class_="video-time").get_text()
except AttributeError:
return generate_youtube_url(raw_song, tries_remaining - 1)
youtubedetails = {'link': link, 'title': title, 'videotime': videotime, 'seconds':misc.get_sec(videotime)}
videos.append(youtubedetails)
if meta_tags is None:
break
if not videos:
return None
if args.manual:
print(song)
print('')
print('0. Skip downloading this song')
# fetch all video links on first page on YouTube
for i, v in enumerate(videos):
print(u'{0}. {1} {2} {3}'.format(i+1, v['title'], v['videotime'], "http://youtube.com"+v['link']))
print('')
# let user select the song to download
result = misc.input_link(videos)
if result is None:
return None
else:
if meta_tags is not None:
# filter out videos that do not have a similar length to the Spotify song
duration_tolerance = 10
max_duration_tolerance = 20
possible_videos_by_duration = list()
'''
start with a reasonable duration_tolerance, and increment duration_tolerance
until one of the Youtube results falls within the correct duration or
the duration_tolerance has reached the max_duration_tolerance
'''
while len(possible_videos_by_duration) == 0:
possible_videos_by_duration = list(filter(lambda x: abs(x['seconds'] - (int(meta_tags['duration_ms'])/1000)) <= duration_tolerance, videos))
duration_tolerance += 1
if duration_tolerance > max_duration_tolerance:
print(meta_tags['name'], 'by', meta_tags['artists'][0]['name'], 'was not found')
return None
result = possible_videos_by_duration[0]
else:
# if the metadata could not be acquired, take the first result from Youtube because the proper song length is unknown
result = videos[0]
full_link = None
if result:
full_link = u'youtube.com{0}'.format(result['link'])
return full_link
def go_pafy(raw_song):
"""Parse track from YouTube."""
if misc.is_youtube(raw_song):
track_info = pafy.new(raw_song)
else:
track_url = generate_youtube_url(raw_song)
if track_url is None:
track_info = None
else:
track_info = pafy.new(track_url)
return track_info
def get_youtube_title(content, number=None):
"""Get the YouTube video's title."""
title = content.title
if number is None:
return title
else:
return '{0}. {1}'.format(number, title)
def feed_playlist(username):
"""Fetch user playlists when using the -u option."""
playlists = spotify.user_playlists(username)
links = []
check = 1
while True:
for playlist in playlists['items']:
# in rare cases, playlists may not be found, so playlists['next']
# is None. Skip these. Also see Issue #91.
if playlist['name'] is not None:
print(u'{0:>5}. {1:<30} ({2} tracks)'.format(
check, playlist['name'],
playlist['tracks']['total']))
links.append(playlist)
check += 1
if playlists['next']:
playlists = spotify.next(playlists)
else:
break
print('')
playlist = misc.input_link(links)
print('')
write_tracks(playlist)
def write_tracks(playlist):
results = spotify.user_playlist(
playlist['owner']['id'], playlist['id'], fields='tracks,next')
text_file = u'{0}.txt'.format(slugify(playlist['name'], ok='-_()[]{}'))
print(u'Feeding {0} tracks to {1}'.format(playlist['tracks']['total'], text_file))
tracks = results['tracks']
with open(text_file, 'a') as file_out:
while True:
for item in tracks['items']:
track = item['track']
try:
file_out.write(track['external_urls']['spotify'] + '\n')
except KeyError:
print(u'Skipping track {0} by {1} (local only?)'.format(
track['name'], track['artists'][0]['name']))
# 1 page = 50 results
# check if there are more pages
if tracks['next']:
tracks = spotify.next(tracks)
else:
break
def download_song(file_name, content):
"""Download the audio file from YouTube."""
if args.input_ext == '.webm':
link = content.getbestaudio(preftype='webm')
elif args.input_ext == '.m4a':
link = content.getbestaudio(preftype='m4a')
else:
return False
if link is None:
return False
else:
link.download(
filepath='{0}{1}'.format(os.path.join(args.folder, file_name), args.input_ext))
return True
def check_exists(music_file, raw_song, islist=True):
"""Check if the input song already exists in the given folder."""
songs = os.listdir(args.folder)
for song in songs:
if song.endswith('.temp'):
os.remove(os.path.join(args.folder, song))
continue
# check if any song with similar name is already present in the given folder
file_name = misc.sanitize_title(music_file)
if song.startswith(file_name):
# check if the already downloaded song has correct metadata
already_tagged = metadata.compare(os.path.join(args.folder, song), generate_metadata(raw_song))
# if not, remove it and download again without prompt
if misc.is_spotify(raw_song) and not already_tagged:
os.remove(os.path.join(args.folder, song))
return False
# do not prompt and skip the current song
# if already downloaded when using list
if islist:
print('Song already exists')
return True
# if downloading only single song, prompt to re-download
else:
prompt = input('Song with same name has already been downloaded. '
'Re-download? (y/n): ').lower()
if prompt == 'y':
os.remove(os.path.join(args.folder, song))
return False
else:
return True
return False
def grab_list(text_file):
"""Download all songs from the list."""
with open(text_file, 'r') as listed:
lines = (listed.read()).splitlines()
# ignore blank lines in text_file (if any)
try:
lines.remove('')
except ValueError:
pass
print(u'Total songs in list: {0} songs'.format(len(lines)))
print('')
# nth input song
number = 1
for raw_song in lines:
try:
grab_single(raw_song, number=number)
# token expires after 1 hour
except spotipy.oauth2.SpotifyOauthError:
# refresh token when it expires
new_token = misc.generate_token()
global spotify
spotify = spotipy.Spotify(auth=new_token)
grab_single(raw_song, number=number)
# detect network problems
except (urllib.request.URLError, TypeError, IOError):
lines.append(raw_song)
# remove the downloaded song from .txt
misc.trim_song(text_file)
# and append it to the last line in .txt
with open(text_file, 'a') as myfile:
myfile.write(raw_song + '\n')
print('Failed to download song. Will retry after other songs.')
# wait 0.5 sec to avoid infinite looping
time.sleep(0.5)
continue
except KeyboardInterrupt:
misc.grace_quit()
finally:
print('')
misc.trim_song(text_file)
number += 1
def grab_playlist(playlist):
if '/' in playlist:
if playlist.endswith('/'):
playlist = playlist[:-1]
splits = playlist.split('/')
else:
splits = playlist.split(':')
username = splits[-3]
playlist_id = splits[-1]
playlists = spotify.user_playlists(username)
while True:
for playlist in playlists['items']:
if not playlist['name'] == None:
if playlist['id'] == playlist_id:
playlists['next'] = None
break
if playlists['next']:
playlists = spotify.next(playlists)
else:
break
write_tracks(playlist)
def grab_single(raw_song, number=None):
"""Logic behind downloading a song."""
if number:
islist = True
else:
islist = False
content = go_pafy(raw_song)
if content is None:
return
if misc.is_youtube(raw_song):
raw_song = slugify(content.title).replace('-', ' ')
# print '[number]. [artist] - [song]' if downloading from list
# otherwise print '[artist] - [song]'
print(get_youtube_title(content, number))
# generate file name of the song to download
meta_tags = generate_metadata(raw_song)
songname = content.title
if meta_tags is not None:
refined_songname = generate_songname(meta_tags)
if not refined_songname == ' - ':
songname = refined_songname
file_name = misc.sanitize_title(songname)
if not check_exists(file_name, raw_song, islist=islist):
if download_song(file_name, content):
print('')
input_song = file_name + args.input_ext
output_song = file_name + args.output_ext
convert.song(input_song, output_song, args.folder,
avconv=args.avconv, verbose=args.verbose)
if not args.input_ext == args.output_ext:
os.remove(os.path.join(args.folder, input_song))
if not args.no_metadata:
metadata.embed(os.path.join(args.folder, output_song), meta_tags)
else:
print('No audio streams available')
class TestArgs(object):
manual = False
input_ext = '.m4a'
output_ext = '.mp3'
folder = 'Music/'
# token is mandatory when using Spotify's API
# https://developer.spotify.com/news-stories/2017/01/27/removing-unauthenticated-calls-to-the-web-api/
token = misc.generate_token()
spotify = spotipy.Spotify(auth=token)
if __name__ == '__main__':
os.chdir(sys.path[0])
args = misc.get_arguments()
misc.filter_path(args.folder)
if args.song:
grab_single(raw_song=args.song)
elif args.list:
grab_list(text_file=args.list)
elif args.playlist:
grab_playlist(playlist=args.playlist)
elif args.username:
feed_playlist(username=args.username)
else:
misc.filter_path('Music')
args = TestArgs()

1
spotdl/__init__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "1.2.2"

42
spotdl/const.py Normal file
View File

@@ -0,0 +1,42 @@
import logzero
_log_format = "%(color)s%(levelname)s:%(end_color)s %(message)s"
_formatter = logzero.LogFormatter(fmt=_log_format)
_log_level = 0
# Set up a temporary logger with default log level so that
# it can be used before log level argument is determined
logzero.setup_default_logger(formatter=_formatter, level=_log_level)
# Options
# Initialize an empty object which can be assigned attributes
# (useful when using spotdl as a library)
args = type("", (), {})()
# Apple has specific tags - see mutagen docs -
# http://mutagen.readthedocs.io/en/latest/api/mp4.html
M4A_TAG_PRESET = {
"album": "\xa9alb",
"artist": "\xa9ART",
"date": "\xa9day",
"title": "\xa9nam",
"year": "\xa9day",
"originaldate": "purd",
"comment": "\xa9cmt",
"group": "\xa9grp",
"writer": "\xa9wrt",
"genre": "\xa9gen",
"tracknumber": "trkn",
"albumartist": "aART",
"discnumber": "disk",
"cpil": "cpil",
"albumart": "covr",
"copyright": "cprt",
"tempo": "tmpo",
"lyrics": "\xa9lyr",
"comment": "\xa9cmt",
}
TAG_PRESET = {}
for key in M4A_TAG_PRESET.keys():
TAG_PRESET[key] = key

153
spotdl/convert.py Normal file
View File

@@ -0,0 +1,153 @@
import subprocess
import os
from logzero import logger as log
"""
What are the differences and similarities between ffmpeg, libav, and avconv?
https://stackoverflow.com/questions/9477115
ffmeg encoders high to lower quality
libopus > libvorbis >= libfdk_aac > aac > libmp3lame
libfdk_aac due to copyrights needs to be compiled by end user
on MacOS brew install ffmpeg --with-fdk-aac will do just that. Other OS?
https://trac.ffmpeg.org/wiki/Encode/AAC
"""
def song(input_song, output_song, folder, avconv=False, trim_silence=False):
""" Do the audio format conversion. """
if avconv and trim_silence:
raise ValueError("avconv does not support trim_silence")
if not input_song == output_song:
log.info("Converting {0} to {1}".format(input_song, output_song.split(".")[-1]))
elif input_song.endswith(".m4a"):
log.info('Correcting container in "{}"'.format(input_song))
else:
return 0
convert = Converter(input_song, output_song, folder, delete_original=True)
if avconv:
exit_code, command = convert.with_avconv()
else:
exit_code, command = convert.with_ffmpeg(trim_silence=trim_silence)
return exit_code, command
class Converter:
def __init__(self, input_song, output_song, folder, delete_original):
_, self.input_ext = os.path.splitext(input_song)
_, self.output_ext = os.path.splitext(output_song)
self.output_file = os.path.join(folder, output_song)
rename_to_temp = False
same_file = os.path.abspath(input_song) == os.path.abspath(output_song)
if same_file:
# FFmpeg/avconv cannot have the same file for both input and output
# This would happen when the extensions are same, so rename
# the input track to append ".temp"
log.debug(
'Input file and output file are going will be same during encoding, will append ".temp" to input file just before starting encoding to avoid conflict'
)
input_song = output_song + ".temp"
rename_to_temp = True
delete_original = True
self.input_file = os.path.join(folder, input_song)
self.rename_to_temp = rename_to_temp
self.delete_original = delete_original
def with_avconv(self):
if log.level == 10:
level = "debug"
else:
level = "0"
command = [
"avconv",
"-loglevel",
level,
"-i",
self.input_file,
"-ab",
"192k",
self.output_file,
"-y",
]
if self.rename_to_temp:
os.rename(self.output_file, self.input_file)
log.debug(command)
try:
code = subprocess.call(command)
except FileNotFoundError:
if self.rename_to_temp:
os.rename(self.input_file, self.output_file)
raise
if self.delete_original:
log.debug('Removing original file: "{}"'.format(self.input_file))
os.remove(self.input_file)
return code, command
def with_ffmpeg(self, trim_silence=False):
ffmpeg_pre = "ffmpeg -y "
if not log.level == 10:
ffmpeg_pre += "-hide_banner -nostats -v panic "
ffmpeg_params = ""
if self.input_ext == ".m4a":
if self.output_ext == ".mp3":
ffmpeg_params = "-codec:v copy -codec:a libmp3lame -ar 44100 "
elif self.output_ext == ".webm":
ffmpeg_params = "-codec:a libopus -vbr on "
elif self.output_ext == ".m4a":
ffmpeg_params = "-acodec copy "
elif self.input_ext == ".webm":
if self.output_ext == ".mp3":
ffmpeg_params = "-codec:a libmp3lame -ar 44100 "
elif self.output_ext == ".m4a":
ffmpeg_params = "-cutoff 20000 -codec:a aac -ar 44100 "
if self.output_ext == ".flac":
ffmpeg_params = "-codec:a flac -ar 44100 "
# add common params for any of the above combination
ffmpeg_params += "-b:a 192k -vn "
ffmpeg_pre += "-i "
if trim_silence:
ffmpeg_params += "-af silenceremove=start_periods=1 "
command = (
ffmpeg_pre.split()
+ [self.input_file]
+ ffmpeg_params.split()
+ [self.output_file]
)
if self.rename_to_temp:
os.rename(self.output_file, self.input_file)
log.debug(command)
try:
code = subprocess.call(command)
except FileNotFoundError:
if self.rename_to_temp:
os.rename(self.input_file, self.output_file)
raise
if self.delete_original:
log.debug('Removing original file: "{}"'.format(self.input_file))
os.remove(self.input_file)
return code, command

251
spotdl/downloader.py Normal file
View File

@@ -0,0 +1,251 @@
import spotipy
import urllib
import os
import time
from logzero import logger as log
from spotdl import const
from spotdl import metadata
from spotdl import convert
from spotdl import internals
from spotdl import spotify_tools
from spotdl import youtube_tools
class CheckExists:
def __init__(self, music_file, meta_tags=None):
self.meta_tags = meta_tags
basepath, filename = os.path.split(music_file)
filepath = os.path.join(const.args.folder, basepath)
os.makedirs(filepath, exist_ok=True)
self.filepath = filepath
self.filename = filename
def already_exists(self, raw_song):
""" Check if the input song already exists in the given folder. """
log.debug(
"Cleaning any temp files and checking "
'if "{}" already exists'.format(self.filename)
)
songs = os.listdir(self.filepath)
self._remove_temp_files(songs)
for song in songs:
# check if a song with the same name is already present in the given folder
if self._match_filenames(song):
if internals.is_spotify(raw_song) and not self._has_metadata(song):
return False
log.warning('"{}" already exists'.format(song))
if const.args.overwrite == "prompt":
return self._prompt_song(song)
elif const.args.overwrite == "force":
return self._force_overwrite_song(song)
elif const.args.overwrite == "skip":
return self._skip_song(song)
return False
def _remove_temp_files(self, songs):
for song in songs:
if song.endswith(".temp"):
os.remove(os.path.join(self.filepath, song))
def _has_metadata(self, song):
# check if the already downloaded song has correct metadata
# if not, remove it and download again without prompt
already_tagged = metadata.compare(
os.path.join(self.filepath, song), self.meta_tags
)
log.debug("Checking if it is already tagged correctly? {}", already_tagged)
if not already_tagged:
os.remove(os.path.join(self.filepath, song))
return False
return True
def _prompt_song(self, song):
log.info(
'"{}" has already been downloaded. ' "Re-download? (y/N): ".format(song)
)
prompt = input("> ")
if prompt.lower() == "y":
return self._force_overwrite_song(song)
else:
return self._skip_song(song)
def _force_overwrite_song(self, song):
os.remove(os.path.join(const.args.folder, song))
log.info('Overwriting "{}"'.format(song))
return False
def _skip_song(self, song):
log.info('Skipping "{}"'.format(song))
return True
def _match_filenames(self, song):
if os.path.splitext(song)[0] == self.filename:
log.debug('Found an already existing song: "{}"'.format(song))
return True
return False
class Downloader:
def __init__(self, raw_song, number=None):
self.raw_song = raw_song
self.number = number
self.content, self.meta_tags = youtube_tools.match_video_and_metadata(raw_song)
def download_single(self):
""" Logic behind downloading a song. """
if self._to_skip():
return
# "[number]. [artist] - [song]" if downloading from list
# otherwise "[artist] - [song]"
youtube_title = youtube_tools.get_youtube_title(self.content, self.number)
log.info("{} ({})".format(youtube_title, self.content.watchv_url))
# generate file name of the song to download
songname = self.refine_songname(self.content.title)
if const.args.dry_run:
return
song_existence = CheckExists(songname, self.meta_tags)
if not song_existence.already_exists(self.raw_song):
return self._download_single(songname)
def _download_single(self, songname):
# deal with file formats containing slashes to non-existent directories
songpath = os.path.join(const.args.folder, os.path.dirname(songname))
os.makedirs(songpath, exist_ok=True)
input_song = songname + const.args.input_ext
output_song = songname + const.args.output_ext
if youtube_tools.download_song(input_song, self.content):
print("")
try:
convert.song(
input_song,
output_song,
const.args.folder,
avconv=const.args.avconv,
trim_silence=const.args.trim_silence,
)
except FileNotFoundError:
encoder = "avconv" if const.args.avconv else "ffmpeg"
log.warning("Could not find {0}, skip encoding".format(encoder))
output_song = self.unconverted_filename(songname)
if not const.args.no_metadata and self.meta_tags is not None:
metadata.embed(
os.path.join(const.args.folder, output_song), self.meta_tags
)
return True
def _to_skip(self):
if self.content is None:
log.debug("Found no matching video")
return True
if const.args.download_only_metadata and self.meta_tags is None:
log.info("Found no metadata. Skipping the download")
return True
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
)
log.debug(
'Refining songname from "{0}" to "{1}"'.format(
songname, refined_songname
)
)
if not refined_songname == " - ":
songname = refined_songname
else:
songname = internals.sanitize_title(songname)
return songname
@staticmethod
def unconverted_filename(songname):
const.args.output_ext = const.args.input_ext
output_song = songname + const.args.output_ext
return output_song
class ListDownloader:
def __init__(self, tracks_file, skip_file=None, write_successful_file=None):
self.tracks_file = tracks_file
self.skip_file = skip_file
self.write_successful_file = write_successful_file
self.tracks = internals.get_unique_tracks(self.tracks_file)
def download_list(self):
""" Download all songs from the list. """
# override file with unique tracks
log.info("Overriding {} with unique tracks".format(self.tracks_file))
self._override_file()
# Remove tracks to skip from tracks list
if self.skip_file is not None:
self.tracks = self._filter_tracks_against_skip_file()
log.info(u"Preparing to download {} songs".format(len(self.tracks)))
return self._download_list()
def _download_list(self):
downloaded_songs = []
for number, raw_song in enumerate(self.tracks, 1):
print("")
try:
track_dl = Downloader(raw_song, number=number)
track_dl.download_single()
except (urllib.request.URLError, TypeError, IOError) as e:
# detect network problems
self._cleanup(raw_song, e)
# TODO: remove this sleep once #397 is fixed
# wait 0.5 sec to avoid infinite looping
time.sleep(0.5)
continue
downloaded_songs.append(raw_song)
# Add track to file of successful downloads
if self.write_successful_file is not None:
self._write_successful(raw_song)
log.debug("Removing downloaded song from tracks file")
internals.trim_song(self.tracks_file)
return downloaded_songs
def _override_file(self):
with open(self.tracks_file, "w") as f:
f.write("\n".join(self.tracks))
def _write_successful(self, raw_song):
log.debug("Adding downloaded song to write successful file")
with open(self.write_successful_file, "a") as f:
f.write("\n" + raw_song)
def _cleanup(self, raw_song, exception):
self.tracks.append(raw_song)
# remove the downloaded song from file
internals.trim_song(self.tracks_file)
# and append it at the end of file
with open(self.tracks_file, "a") as f:
f.write("\n" + raw_song)
log.exception(exception)
log.warning("Failed to download song. Will retry after other songs\n")
def _filter_tracks_against_skip_file(self):
skip_tracks = internals.get_unique_tracks(self.skip_file)
len_before = len(self.tracks)
tracks = [track for track in self.tracks if track not in skip_tracks]
log.info("Skipping {} tracks".format(len_before - len(tracks)))
return tracks

323
spotdl/handle.py Normal file
View File

@@ -0,0 +1,323 @@
from logzero import logger as log
import appdirs
import logging
import yaml
import argparse
import mimetypes
import os
import spotdl
from spotdl import internals
_LOG_LEVELS_STR = ["INFO", "WARNING", "ERROR", "DEBUG"]
default_conf = {
"spotify-downloader": {
"manual": False,
"no-metadata": False,
"no-fallback-metadata": False,
"avconv": False,
"folder": internals.get_music_dir(),
"overwrite": "prompt",
"input-ext": ".m4a",
"output-ext": ".mp3",
"write-to": None,
"trim-silence": False,
"download-only-metadata": False,
"dry-run": False,
"music-videos-only": False,
"no-spaces": False,
"file-format": "{artist} - {track_name}",
"search-format": "{artist} - {track_name} lyrics",
"youtube-api-key": None,
"skip": None,
"write-successful": None,
"log-level": "INFO",
"spotify_client_id": "4fe3fecfe5334023a1472516cc99d805",
"spotify_client_secret": "0f02b7c483c04257984695007a4a8d5c"
}
}
def log_leveller(log_level_str):
loggin_levels = [logging.INFO, logging.WARNING, logging.ERROR, logging.DEBUG]
log_level_str_index = _LOG_LEVELS_STR.index(log_level_str)
loggin_level = loggin_levels[log_level_str_index]
return loggin_level
def merge(default, config):
""" Override default dict with config dict. """
merged = default.copy()
merged.update(config)
return merged
def get_config(config_file):
try:
with open(config_file, "r") as ymlfile:
cfg = yaml.safe_load(ymlfile)
except FileNotFoundError:
log.info("Writing default configuration to {0}:".format(config_file))
with open(config_file, "w") as ymlfile:
yaml.dump(default_conf, ymlfile, default_flow_style=False)
cfg = default_conf
for line in yaml.dump(
default_conf["spotify-downloader"], default_flow_style=False
).split("\n"):
if line.strip():
log.info(line.strip())
log.info(
"Please note that command line arguments have higher priority "
"than their equivalents in the configuration file"
)
return cfg["spotify-downloader"]
def override_config(config_file, parser, raw_args=None):
""" Override default dict with config dict passed as comamnd line argument. """
config_file = os.path.realpath(config_file)
config = merge(default_conf["spotify-downloader"], get_config(config_file))
parser.set_defaults(**config)
return parser.parse_args(raw_args)
def get_arguments(raw_args=None, to_group=True, to_merge=True):
parser = argparse.ArgumentParser(
description="Download and convert tracks from Spotify, Youtube etc.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
if to_merge:
config_dir = os.path.join(appdirs.user_config_dir(), "spotdl")
os.makedirs(config_dir, exist_ok=True)
config_file = os.path.join(config_dir, "config.yml")
config = merge(default_conf["spotify-downloader"], get_config(config_file))
else:
config = default_conf["spotify-downloader"]
if to_group:
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"-s", "--song", nargs="+", help="download track by spotify link or name"
)
group.add_argument("-l", "--list", help="download tracks from a file")
group.add_argument(
"-p",
"--playlist",
help="load tracks from playlist URL into <playlist_name>.txt",
)
group.add_argument(
"-b", "--album", help="load tracks from album URL into <album_name>.txt"
)
group.add_argument(
"-ab",
"--all-albums",
help="load all tracks from artist URL into <artist_name>.txt",
)
group.add_argument(
"-u",
"--username",
help="load tracks from user's playlist into <playlist_name>.txt",
)
parser.add_argument(
"--write-m3u",
help="generate an .m3u playlist file with youtube links given "
"a text file containing tracks",
action="store_true",
)
parser.add_argument(
"-m",
"--manual",
default=config["manual"],
help="choose the track to download manually from a list of matching tracks",
action="store_true",
)
parser.add_argument(
"-nm",
"--no-metadata",
default=config["no-metadata"],
help="do not embed metadata in tracks",
action="store_true",
)
parser.add_argument(
"-nf",
"--no-fallback-metadata",
default=config["no-fallback-metadata"],
help="do not use YouTube as fallback for metadata if track not found on Spotify",
action="store_true",
)
parser.add_argument(
"-a",
"--avconv",
default=config["avconv"],
help="use avconv for conversion (otherwise defaults to ffmpeg)",
action="store_true",
)
parser.add_argument(
"-f",
"--folder",
default=os.path.abspath(config["folder"]),
help="path to folder where downloaded tracks will be stored in",
)
parser.add_argument(
"--overwrite",
default=config["overwrite"],
help="change the overwrite policy",
choices={"prompt", "force", "skip"},
)
parser.add_argument(
"-i",
"--input-ext",
default=config["input-ext"],
help="preferred input format .m4a or .webm (Opus)",
choices={".m4a", ".webm"},
)
parser.add_argument(
"-o",
"--output-ext",
default=config["output-ext"],
help="preferred output format .mp3, .m4a (AAC), .flac, etc.",
)
parser.add_argument(
"--write-to",
default=config["write-to"],
help="write tracks from Spotify playlist, album, etc. to this file",
)
parser.add_argument(
"-ff",
"--file-format",
default=config["file-format"],
help="file format to save the downloaded track with, each tag "
"is surrounded by curly braces. Possible formats: "
"{}".format([internals.formats[x] for x in internals.formats]),
)
parser.add_argument(
"--trim-silence",
default=config["trim-silence"],
help="remove silence from the start of the audio",
action="store_true",
)
parser.add_argument(
"-sf",
"--search-format",
default=config["search-format"],
help="search format to search for on YouTube, each tag "
"is surrounded by curly braces. Possible formats: "
"{}".format([internals.formats[x] for x in internals.formats]),
)
parser.add_argument(
"-dm",
"--download-only-metadata",
default=config["download-only-metadata"],
help="download tracks only whose metadata is found",
action="store_true",
)
parser.add_argument(
"-d",
"--dry-run",
default=config["dry-run"],
help="show only track title and YouTube URL, and then skip "
"to the next track (if any)",
action="store_true",
)
parser.add_argument(
"-mo",
"--music-videos-only",
default=config["music-videos-only"],
help="search only for music videos on Youtube (works only "
"when YouTube API key is set",
action="store_true",
)
parser.add_argument(
"-ns",
"--no-spaces",
default=config["no-spaces"],
help="replace spaces with underscores in file names",
action="store_true",
)
parser.add_argument(
"-ll",
"--log-level",
default=config["log-level"],
choices=_LOG_LEVELS_STR,
type=str.upper,
help="set log verbosity",
)
parser.add_argument(
"-yk",
"--youtube-api-key",
default=config["youtube-api-key"],
help=argparse.SUPPRESS,
)
parser.add_argument(
"-sk",
"--skip",
default=config["skip"],
help="path to file containing tracks to skip",
)
parser.add_argument(
"-w",
"--write-successful",
default=config["write-successful"],
help="path to file to write successful tracks to",
)
parser.add_argument(
"-sci",
"--spotify-client-id",
default=config["spotify_client_id"],
help=argparse.SUPPRESS
)
parser.add_argument(
"-scs",
"--spotify-client-secret",
default=config["spotify_client_secret"],
help=argparse.SUPPRESS
)
parser.add_argument(
"-c", "--config", default=None, help="path to custom config.yml file"
)
parser.add_argument(
"-V",
"--version",
action="version",
version="%(prog)s {}".format(spotdl.__version__),
)
parsed = parser.parse_args(raw_args)
if parsed.config is not None and to_merge:
parsed = override_config(parsed.config, parser)
if (
to_group
and parsed.list
and not mimetypes.MimeTypes().guess_type(parsed.list)[0] == "text/plain"
):
parser.error(
"{0} is not of a valid argument to --list, argument must be plain text file".format(
parsed.list
)
)
if parsed.write_m3u and not parsed.list:
parser.error("--write-m3u can only be used with --list")
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")
parsed.log_level = log_leveller(parsed.log_level)
return parsed

264
spotdl/internals.py Normal file
View File

@@ -0,0 +1,264 @@
from logzero import logger as log
import os
import sys
import urllib.request
from spotdl import const
try:
import winreg
except ImportError:
pass
try:
from slugify import SLUG_OK, slugify
except ImportError:
log.error("Oops! `unicode-slugify` was not found.")
log.info("Please remove any other slugify library and install `unicode-slugify`")
sys.exit(5)
formats = {
0: "track_name",
1: "artist",
2: "album",
3: "album_artist",
4: "genre",
5: "disc_number",
6: "duration",
7: "year",
8: "original_date",
9: "track_number",
10: "total_tracks",
11: "isrc",
}
def input_link(links):
""" Let the user input a choice. """
while True:
try:
log.info("Choose your number:")
the_chosen_one = int(input("> "))
if 1 <= the_chosen_one <= len(links):
return links[the_chosen_one - 1]
elif the_chosen_one == 0:
return None
else:
log.warning("Choose a valid number!")
except ValueError:
log.warning("Choose a valid number!")
def trim_song(tracks_file):
""" Remove the first song from file. """
with open(tracks_file, "r") as file_in:
data = file_in.read().splitlines(True)
with open(tracks_file, "w") as file_out:
file_out.writelines(data[1:])
return data[0]
def is_spotify(raw_song):
""" Check if the input song is a Spotify link. """
status = len(raw_song) == 22 and raw_song.replace(" ", "%20") == raw_song
status = status or raw_song.find("spotify") > -1
return status
def is_youtube(raw_song):
""" Check if the input song is a YouTube link. """
status = len(raw_song) == 11 and raw_song.replace(" ", "%20") == raw_song
status = status and not raw_song.lower() == raw_song
status = status or "youtube.com/watch?v=" in raw_song
return status
def format_string(string_format, tags, slugification=False, force_spaces=False):
""" Generate a string of the format '[artist] - [song]' for the given spotify song. """
format_tags = dict(formats)
format_tags[0] = tags["name"]
format_tags[1] = tags["artists"][0]["name"]
format_tags[2] = tags["album"]["name"]
format_tags[3] = tags["artists"][0]["name"]
format_tags[4] = tags["genre"]
format_tags[5] = tags["disc_number"]
format_tags[6] = tags["duration"]
format_tags[7] = tags["year"]
format_tags[8] = tags["release_date"]
format_tags[9] = tags["track_number"]
format_tags[10] = tags["total_tracks"]
format_tags[11] = tags["external_ids"]["isrc"]
format_tags_sanitized = {
k: sanitize_title(str(v), ok="'-_()[]{}") if slugification else str(v)
for k, v in format_tags.items()
}
for x in formats:
format_tag = "{" + formats[x] + "}"
string_format = string_format.replace(format_tag, format_tags_sanitized[x])
if const.args.no_spaces and not force_spaces:
string_format = string_format.replace(" ", "_")
return string_format
def sanitize_title(title, ok="-_()[]{}"):
""" Generate filename of the song to be downloaded. """
if const.args.no_spaces:
title = title.replace(" ", "_")
# replace slashes with "-" to avoid folder creation errors
title = title.replace("/", "-").replace("\\", "-")
# slugify removes any special characters
title = slugify(title, ok=ok, lower=False, spaces=True)
return title
def filter_path(path):
if not os.path.exists(path):
os.makedirs(path)
for temp in os.listdir(path):
if temp.endswith(".temp"):
os.remove(os.path.join(path, temp))
def videotime_from_seconds(time):
if time < 60:
return str(time)
if time < 3600:
return "{0}:{1:02}".format(time // 60, time % 60)
return "{0}:{1:02}:{2:02}".format((time // 60) // 60, (time // 60) % 60, time % 60)
def get_sec(time_str):
if ":" in time_str:
splitter = ":"
elif "." in time_str:
splitter = "."
else:
raise ValueError(
"No expected character found in {} to split" "time values.".format(time_str)
)
v = time_str.split(splitter, 3)
v.reverse()
sec = 0
if len(v) > 0: # seconds
sec += int(v[0])
if len(v) > 1: # minutes
sec += int(v[1]) * 60
if len(v) > 2: # hours
sec += int(v[2]) * 3600
return sec
def extract_spotify_id(raw_string):
"""
Returns a Spotify ID of a playlist, album, etc. after extracting
it from a given HTTP URL or Spotify URI.
"""
if "/" in raw_string:
# Input string is an HTTP URL
if raw_string.endswith("/"):
raw_string = raw_string[:-1]
# We need to manually trim additional text from HTTP URLs
# We could skip this if https://github.com/plamere/spotipy/pull/324
# gets merged,
to_trim = raw_string.find("?")
if not to_trim == -1:
raw_string = raw_string[:to_trim]
splits = raw_string.split("/")
else:
# Input string is a Spotify URI
splits = raw_string.split(":")
spotify_id = splits[-1]
return spotify_id
def get_unique_tracks(tracks_file):
"""
Returns a list of unique tracks given a path to a
file containing tracks.
"""
log.info(
"Checking and removing any duplicate tracks "
"in reading {}".format(tracks_file)
)
with open(tracks_file, "r") as tracks_in:
# Read tracks into a list and remove any duplicates
lines = tracks_in.read().splitlines()
# Remove blank and strip whitespaces from lines (if any)
lines = [line.strip() for line in lines if line.strip()]
lines = remove_duplicates(lines)
return lines
# a hacky way to get user's localized music directory
# (thanks @linusg, issue #203)
def get_music_dir():
home = os.path.expanduser("~")
# On Linux, the localized folder names are the actual ones.
# It's a freedesktop standard though.
if sys.platform.startswith("linux"):
for file_item in (".config/user-dirs.dirs", "user-dirs.dirs"):
path = os.path.join(home, file_item)
if os.path.isfile(path):
with open(path, "r") as f:
for line in f:
if line.startswith("XDG_MUSIC_DIR"):
return os.path.expandvars(
line.strip().split("=")[1].strip('"')
)
# Windows / Cygwin
# Queries registry for 'My Music' folder path (as this can be changed)
if "win" in sys.platform:
try:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders",
0,
winreg.KEY_ALL_ACCESS,
)
return winreg.QueryValueEx(key, "My Music")[0]
except (FileNotFoundError, NameError):
pass
# On both Windows and macOS, the localized folder names you see in
# Explorer and Finder are actually in English on the file system.
# So, defaulting to C:\Users\<user>\Music or /Users/<user>/Music
# respectively is sufficient.
# On Linux, default to /home/<user>/Music if the above method failed.
return os.path.join(home, "Music")
def remove_duplicates(tracks):
"""
Removes duplicates from a list whilst preserving order.
We could directly call `set()` on the list but it changes
the order of elements.
"""
local_set = set()
local_set_add = local_set.add
return [x for x in tracks if not (x in local_set or local_set_add(x))]
def content_available(url):
try:
response = urllib.request.urlopen(url)
except HTTPError:
return False
else:
return response.getcode() < 300

176
spotdl/metadata.py Normal file
View File

@@ -0,0 +1,176 @@
from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3, TORY, TYER, TPUB, APIC, USLT, COMM
from mutagen.mp4 import MP4, MP4Cover
from mutagen.flac import Picture, FLAC
import urllib.request
from logzero import logger as log
from spotdl.const import TAG_PRESET, M4A_TAG_PRESET
def compare(music_file, metadata):
"""Check if the input music file title matches the expected title."""
already_tagged = False
try:
if music_file.endswith(".mp3"):
audiofile = EasyID3(music_file)
already_tagged = audiofile["title"][0] == metadata["name"]
elif music_file.endswith(".m4a"):
audiofile = MP4(music_file)
already_tagged = audiofile["\xa9nam"][0] == metadata["name"]
except (KeyError, TypeError):
pass
return already_tagged
def embed(music_file, meta_tags):
""" Embed metadata. """
embed = EmbedMetadata(music_file, meta_tags)
if music_file.endswith(".m4a"):
log.info("Applying metadata")
return embed.as_m4a()
elif music_file.endswith(".mp3"):
log.info("Applying metadata")
return embed.as_mp3()
elif music_file.endswith(".flac"):
log.info("Applying metadata")
return embed.as_flac()
else:
log.warning("Cannot embed metadata into given output extension")
return False
class EmbedMetadata:
def __init__(self, music_file, meta_tags):
self.music_file = music_file
self.meta_tags = meta_tags
self.spotify_metadata = meta_tags["spotify_metadata"]
self.provider = "spotify" if meta_tags["spotify_metadata"] else "youtube"
def as_mp3(self):
""" Embed metadata to MP3 files. """
music_file = self.music_file
meta_tags = self.meta_tags
# EasyID3 is fun to use ;)
# For supported easyid3 tags:
# https://github.com/quodlibet/mutagen/blob/master/mutagen/easyid3.py
# Check out somewhere at end of above linked file
audiofile = EasyID3(music_file)
self._embed_basic_metadata(audiofile, preset=TAG_PRESET)
audiofile["media"] = meta_tags["type"]
audiofile["author"] = meta_tags["artists"][0]["name"]
audiofile["lyricist"] = meta_tags["artists"][0]["name"]
audiofile["arranger"] = meta_tags["artists"][0]["name"]
audiofile["performer"] = meta_tags["artists"][0]["name"]
audiofile["website"] = meta_tags["external_urls"][self.provider]
audiofile["length"] = str(meta_tags["duration"])
if meta_tags["publisher"]:
audiofile["encodedby"] = meta_tags["publisher"]
if meta_tags["external_ids"]["isrc"]:
audiofile["isrc"] = meta_tags["external_ids"]["isrc"]
audiofile.save(v2_version=3)
# For supported id3 tags:
# https://github.com/quodlibet/mutagen/blob/master/mutagen/id3/_frames.py
# Each class represents an id3 tag
audiofile = ID3(music_file)
audiofile["TORY"] = TORY(encoding=3, text=meta_tags["year"])
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])
if meta_tags["lyrics"]:
audiofile["USLT"] = USLT(
encoding=3, desc=u"Lyrics", text=meta_tags["lyrics"]
)
try:
albumart = urllib.request.urlopen(meta_tags["album"]["images"][0]["url"])
audiofile["APIC"] = APIC(
encoding=3,
mime="image/jpeg",
type=3,
desc=u"Cover",
data=albumart.read(),
)
albumart.close()
except IndexError:
pass
audiofile.save(v2_version=3)
return True
def as_m4a(self):
""" Embed metadata to M4A files. """
music_file = self.music_file
meta_tags = self.meta_tags
audiofile = MP4(music_file)
self._embed_basic_metadata(audiofile, preset=M4A_TAG_PRESET)
audiofile[M4A_TAG_PRESET["year"]] = meta_tags["year"]
audiofile[M4A_TAG_PRESET["comment"]] = meta_tags["external_urls"][self.provider]
if meta_tags["lyrics"]:
audiofile[M4A_TAG_PRESET["lyrics"]] = meta_tags["lyrics"]
try:
albumart = urllib.request.urlopen(meta_tags["album"]["images"][0]["url"])
audiofile[M4A_TAG_PRESET["albumart"]] = [
MP4Cover(albumart.read(), imageformat=MP4Cover.FORMAT_JPEG)
]
albumart.close()
except IndexError:
pass
audiofile.save()
return True
def as_flac(self):
music_file = self.music_file
meta_tags = self.meta_tags
audiofile = FLAC(music_file)
self._embed_basic_metadata(audiofile)
audiofile["year"] = meta_tags["year"]
audiofile["comment"] = meta_tags["external_urls"][self.provider]
if meta_tags["lyrics"]:
audiofile["lyrics"] = meta_tags["lyrics"]
image = Picture()
image.type = 3
image.desc = "Cover"
image.mime = "image/jpeg"
albumart = urllib.request.urlopen(meta_tags["album"]["images"][0]["url"])
image.data = albumart.read()
albumart.close()
audiofile.add_picture(image)
audiofile.save()
return True
def _embed_basic_metadata(self, audiofile, preset=TAG_PRESET):
meta_tags = self.meta_tags
audiofile[preset["artist"]] = meta_tags["artists"][0]["name"]
if meta_tags["album"]["artists"][0]["name"]:
audiofile[preset["albumartist"]] = meta_tags["album"]["artists"][0]["name"]
if meta_tags["album"]["name"]:
audiofile[preset["album"]] = meta_tags["album"]["name"]
audiofile[preset["title"]] = meta_tags["name"]
audiofile[preset["date"]] = meta_tags["release_date"]
audiofile[preset["originaldate"]] = meta_tags["release_date"]
if meta_tags["genre"]:
audiofile[preset["genre"]] = meta_tags["genre"]
if meta_tags["copyright"]:
audiofile[preset["copyright"]] = meta_tags["copyright"]
if self.music_file.endswith(".flac"):
audiofile[preset["discnumber"]] = str(meta_tags["disc_number"])
else:
audiofile[preset["discnumber"]] = [(meta_tags["disc_number"], 0)]
if self.music_file.endswith(".flac"):
audiofile[preset["tracknumber"]] = str(meta_tags["track_number"])
else:
if preset["tracknumber"] == TAG_PRESET["tracknumber"]:
audiofile[preset["tracknumber"]] = "{}/{}".format(
meta_tags["track_number"], meta_tags["total_tracks"]
)
else:
audiofile[preset["tracknumber"]] = [
(meta_tags["track_number"], meta_tags["total_tracks"])
]

55
spotdl/patcher.py Normal file
View File

@@ -0,0 +1,55 @@
from pafy import backend_youtube_dl
import pafy
from spotdl import internals
def _getbestthumb(self):
url = self._ydl_info["thumbnails"][0]["url"]
if url:
return url
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")
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'])):
try:
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):
# 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

74
spotdl/spotdl.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import sys
import platform
import pprint
import logzero
from logzero import logger as log
from spotdl import __version__
from spotdl import const
from spotdl import handle
from spotdl import internals
from spotdl import spotify_tools
from spotdl import youtube_tools
from spotdl import downloader
def debug_sys_info():
log.debug("Python version: {}".format(sys.version))
log.debug("Platform: {}".format(platform.platform()))
log.debug(pprint.pformat(const.args.__dict__))
def match_args():
if const.args.song:
for track in const.args.song:
track_dl = downloader.Downloader(raw_song=track)
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)
else:
list_dl = downloader.ListDownloader(
tracks_file=const.args.list,
skip_file=const.args.skip,
write_successful_file=const.args.write_successful,
)
list_dl.download_list()
elif const.args.playlist:
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)
elif const.args.all_albums:
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)
def main():
const.args = handle.get_arguments()
internals.filter_path(const.args.folder)
youtube_tools.set_api_key()
logzero.setup_default_logger(formatter=const._formatter, level=const.args.log_level)
try:
match_args()
# actually we don't necessarily need this, but yeah...
# explicit is better than implicit!
sys.exit(0)
except KeyboardInterrupt as e:
log.exception(e)
sys.exit(3)
if __name__ == "__main__":
main()

260
spotdl/spotify_tools.py Normal file
View File

@@ -0,0 +1,260 @@
import spotipy
import spotipy.oauth2 as oauth2
import lyricwikia
from slugify import slugify
from titlecase import titlecase
from logzero import logger as log
import pprint
import sys
import os
import functools
from spotdl import const
from spotdl import internals
spotify = None
def generate_token():
""" Generate the token. """
credentials = oauth2.SpotifyClientCredentials(
client_id=const.args.spotify_client_id,
client_secret=const.args.spotify_client_secret,
)
token = credentials.get_access_token()
return token
def must_be_authorized(func, spotify=spotify):
def wrapper(*args, **kwargs):
global spotify
try:
assert spotify
return func(*args, **kwargs)
except (AssertionError, spotipy.client.SpotifyException):
token = generate_token()
spotify = spotipy.Spotify(auth=token)
return func(*args, **kwargs)
return wrapper
@must_be_authorized
def generate_metadata(raw_song):
""" Fetch a song's metadata from Spotify. """
if internals.is_spotify(raw_song):
# fetch track information directly if it is spotify link
log.debug("Fetching metadata for given track URL")
meta_tags = spotify.track(raw_song)
else:
# otherwise search on spotify and fetch information from first result
log.debug('Searching for "{}" on Spotify'.format(raw_song))
try:
meta_tags = spotify.search(raw_song, limit=1)["tracks"]["items"][0]
except IndexError:
return None
artist = spotify.artist(meta_tags["artists"][0]["id"])
album = spotify.album(meta_tags["album"]["id"])
try:
meta_tags[u"genre"] = titlecase(artist["genres"][0])
except IndexError:
meta_tags[u"genre"] = None
try:
meta_tags[u"copyright"] = album["copyrights"][0]["text"]
except IndexError:
meta_tags[u"copyright"] = None
try:
meta_tags[u"external_ids"][u"isrc"]
except KeyError:
meta_tags[u"external_ids"][u"isrc"] = None
meta_tags[u"release_date"] = album["release_date"]
meta_tags[u"publisher"] = album["label"]
meta_tags[u"total_tracks"] = album["tracks"]["total"]
log.debug("Fetching lyrics")
try:
meta_tags["lyrics"] = lyricwikia.get_lyrics(
meta_tags["artists"][0]["name"], meta_tags["name"]
)
except lyricwikia.LyricsNotFound:
meta_tags["lyrics"] = None
# Some sugar
meta_tags["year"], *_ = meta_tags["release_date"].split("-")
meta_tags["duration"] = meta_tags["duration_ms"] / 1000.0
meta_tags["spotify_metadata"] = True
# Remove unwanted parameters
del meta_tags["duration_ms"]
del meta_tags["available_markets"]
del meta_tags["album"]["available_markets"]
log.debug(pprint.pformat(meta_tags))
return meta_tags
@must_be_authorized
def write_user_playlist(username, text_file=None):
""" Write user playlists to text_file """
links = get_playlists(username=username)
playlist = internals.input_link(links)
return write_playlist(playlist, text_file)
@must_be_authorized
def get_playlists(username):
""" Fetch user playlists when using the -u option. """
playlists = spotify.user_playlists(username)
links = []
check = 1
while True:
for playlist in playlists["items"]:
# in rare cases, playlists may not be found, so playlists['next']
# is None. Skip these. Also see Issue #91.
if playlist["name"] is not None:
log.info(
u"{0:>5}. {1:<30} ({2} tracks)".format(
check, playlist["name"], playlist["tracks"]["total"]
)
)
playlist_url = playlist["external_urls"]["spotify"]
log.debug(playlist_url)
links.append(playlist_url)
check += 1
if playlists["next"]:
playlists = spotify.next(playlists)
else:
break
return links
@must_be_authorized
def fetch_playlist(playlist):
try:
playlist_id = internals.extract_spotify_id(playlist)
except IndexError:
# Wrong format, in either case
log.error("The provided playlist URL is not in a recognized format!")
sys.exit(10)
try:
results = spotify.user_playlist(
user=None, playlist_id=playlist_id, fields="tracks,next,name"
)
except spotipy.client.SpotifyException:
log.error("Unable to find playlist")
log.info("Make sure the playlist is set to publicly visible and then try again")
sys.exit(11)
return results
@must_be_authorized
def write_playlist(playlist_url, text_file=None):
playlist = fetch_playlist(playlist_url)
tracks = playlist["tracks"]
if not text_file:
text_file = u"{0}.txt".format(slugify(playlist["name"], ok="-_()[]{}"))
return write_tracks(tracks, text_file)
@must_be_authorized
def fetch_album(album):
album_id = internals.extract_spotify_id(album)
album = spotify.album(album_id)
return album
@must_be_authorized
def fetch_albums_from_artist(artist_url, album_type=None):
"""
This funcction returns all the albums from a give artist_url using the US
market
:param artist_url - spotify artist url
:param album_type - the type of album to fetch (ex: single) the default is
all albums
:param return - the album from the artist
"""
# fetching artist's albums limitting the results to the US to avoid duplicate
# albums from multiple markets
artist_id = internals.extract_spotify_id(artist_url)
results = spotify.artist_albums(artist_id, album_type=album_type, country="US")
albums = results["items"]
# indexing all pages of results
while results["next"]:
results = spotify.next(results)
albums.extend(results["items"])
return albums
@must_be_authorized
def write_all_albums_from_artist(artist_url, text_file=None):
"""
This function gets all albums from an artist and writes it to a file in the
current working directory called [ARTIST].txt, where [ARTIST] is the artist
of the album
:param artist_url - spotify artist url
:param text_file - file to write albums to
"""
album_base_url = "https://open.spotify.com/album/"
# fetching all default albums
albums = fetch_albums_from_artist(artist_url, album_type=None)
# if no file if given, the default save file is in the current working
# directory with the name of the artist
if text_file is None:
text_file = albums[0]["artists"][0]["name"] + ".txt"
for album in albums:
# logging album name
log.info("Fetching album: " + album["name"])
write_album(album_base_url + album["id"], text_file=text_file)
@must_be_authorized
def write_album(album_url, text_file=None):
album = fetch_album(album_url)
tracks = spotify.album_tracks(album["id"])
if not text_file:
text_file = u"{0}.txt".format(slugify(album["name"], ok="-_()[]{}"))
return write_tracks(tracks, text_file)
@must_be_authorized
def write_tracks(tracks, text_file):
log.info(u"Writing {0} tracks to {1}".format(tracks["total"], text_file))
track_urls = []
with open(text_file, "a") as file_out:
while True:
for item in tracks["items"]:
if "track" in item:
track = item["track"]
else:
track = item
try:
track_url = track["external_urls"]["spotify"]
log.debug(track_url)
file_out.write(track_url + "\n")
track_urls.append(track_url)
except KeyError:
log.warning(
u"Skipping track {0} by {1} (local only?)".format(
track["name"], track["artists"][0]["name"]
)
)
# 1 page = 50 results
# check if there are more pages
if tracks["next"]:
tracks = spotify.next(tracks)
else:
break
return track_urls

397
spotdl/youtube_tools.py Normal file
View File

@@ -0,0 +1,397 @@
from bs4 import BeautifulSoup
import urllib
import pafy
from slugify import slugify
from logzero import logger as log
import os
from spotdl import spotify_tools
from spotdl import internals
from spotdl import const
# Fix download speed throttle on short duration tracks
# Read more on mps-youtube/pafy#199
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":
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():
if const.args.youtube_api_key:
key = const.args.youtube_api_key
else:
# Please respect this YouTube token :)
key = "AIzaSyC6cEeKlxtOPybk9sEe5ksFN5sB-7wzYp0"
pafy.set_api_key(key)
def go_pafy(raw_song, meta_tags=None):
""" Parse track from YouTube. """
if internals.is_youtube(raw_song):
track_info = pafy.new(raw_song)
else:
track_url = generate_youtube_url(raw_song, meta_tags)
if track_url:
track_info = pafy.new(track_url)
else:
track_info = None
return track_info
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"
if meta_tags is None:
if const.args.no_fallback_metadata:
log.warning(skip_fallback_metadata_warning)
else:
log.info(fallback_metadata_info)
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)
track = slugify(content.title).replace("-", " ")
if not const.args.no_metadata:
meta_tags = spotify_tools.generate_metadata(track)
meta_tags = fallback_metadata(meta_tags)
elif internals.is_spotify(track):
log.debug("Input song is a Spotify URL")
# Let it generate metadata, YouTube doesn't know Spotify slang
meta_tags = spotify_tools.generate_metadata(track)
content = go_pafy(track, meta_tags)
if const.args.no_metadata:
meta_tags = None
else:
log.debug("Input song is plain text based")
if const.args.no_metadata:
content = go_pafy(track, meta_tags=None)
else:
meta_tags = spotify_tools.generate_metadata(track)
content = go_pafy(track, meta_tags=meta_tags)
meta_tags = fallback_metadata(meta_tags)
return content, meta_tags
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,
}
return meta_tags
def get_youtube_title(content, number=None):
""" Get the YouTube video's title. """
title = content.title
if number:
return "{0}. {1}".format(number, title)
else:
return title
def generate_m3u(track_file):
tracks = internals.get_unique_tracks(track_file)
target_file = "{}.m3u".format(track_file.split(".")[0])
total_tracks = len(tracks)
log.info("Generating {0} from {1} YouTube URLs".format(target_file, total_tracks))
with open(target_file, "w") as output_file:
output_file.write("#EXTM3U\n\n")
videos = []
for n, track in enumerate(tracks, 1):
content, _ = match_video_and_metadata(track)
if content is None:
log.warning("Skipping {}".format(track))
else:
log.info(
"Matched track {0}/{1} ({2})".format(
n, total_tracks, content.watchv_url
)
)
log.debug(track)
m3u_key = "#EXTINF:{duration},{title}\n{youtube_url}\n".format(
duration=internals.get_sec(content.duration),
title=content.title,
youtube_url=content.watchv_url,
)
log.debug(m3u_key)
with open(target_file, "a") as output_file:
output_file.write(m3u_key)
videos.append(content.watchv_url)
return videos
def download_song(file_name, content):
""" Download the audio file from YouTube. """
_, extension = os.path.splitext(file_name)
if extension in (".webm", ".m4a"):
link = content.getbestaudio(preftype=extension[1:])
else:
log.debug("No audio streams available for {} type".format(extension))
return False
if link:
log.debug("Downloading from URL: " + link.url)
filepath = os.path.join(const.args.folder, file_name)
log.debug("Saving to: " + filepath)
link.download(filepath=filepath)
return True
else:
log.debug("No audio streams available")
return False
def generate_search_url(query):
""" Generate YouTube search URL for the given song. """
# urllib.request.quote() encodes string with special characters
quoted_query = urllib.request.quote(query)
# Special YouTube URL filter to search only for videos
url = "https://www.youtube.com/results?sp=EgIQAQ%253D%253D&q={0}".format(
quoted_query
)
return url
def is_video(result):
# ensure result is not a channel
not_video = (
result.find("channel") is not None
or "yt-lockup-channel" in result.parent.attrs["class"]
or "yt-lockup-channel" in result.attrs["class"]
)
# ensure result is not a mix/playlist
not_video = not_video or "yt-lockup-playlist" in result.parent.attrs["class"]
# ensure video result is not an advertisement
not_video = not_video or result.find("googleads") is not None
video = not not_video
return video
def generate_youtube_url(raw_song, meta_tags):
url_fetch = GenerateYouTubeURL(raw_song, meta_tags)
if const.args.youtube_api_key:
url = url_fetch.api()
else:
url = url_fetch.scrape()
return url
class GenerateYouTubeURL:
def __init__(self, raw_song, meta_tags):
self.raw_song = raw_song
self.meta_tags = meta_tags
if meta_tags is None:
self.search_query = raw_song
else:
self.search_query = internals.format_string(
const.args.search_format, meta_tags, force_spaces=True
)
def _best_match(self, videos):
if not videos:
log.error("No videos found on YouTube for a given search")
return None
""" Select the best matching video from a list of videos. """
if const.args.manual:
log.info(self.raw_song)
log.info("0. Skip downloading this song.\n")
# fetch all video links on first page on YouTube
for i, v in enumerate(videos):
log.info(
u"{0}. {1} {2} {3}".format(
i + 1,
v["title"],
v["videotime"],
"http://youtube.com/watch?v=" + v["link"],
)
)
# let user select the song to download
result = internals.input_link(videos)
if result is None:
return None
else:
if not self.meta_tags:
# if the metadata could not be acquired, take the first result
# from Youtube because the proper song length is unknown
result = videos[0]
log.debug(
"Since no metadata found on Spotify, going with the first result"
)
else:
# filter out videos that do not have a similar length to the Spotify song
duration_tolerance = 10
max_duration_tolerance = 20
possible_videos_by_duration = []
# start with a reasonable duration_tolerance, and increment duration_tolerance
# until one of the Youtube results falls within the correct duration or
# the duration_tolerance has reached the max_duration_tolerance
while len(possible_videos_by_duration) == 0:
possible_videos_by_duration = list(
filter(
lambda x: abs(x["seconds"] - self.meta_tags["duration"])
<= duration_tolerance,
videos,
)
)
duration_tolerance += 1
if duration_tolerance > max_duration_tolerance:
log.error(
"{0} by {1} was not found.".format(
self.meta_tags["name"],
self.meta_tags["artists"][0]["name"],
)
)
return None
result = possible_videos_by_duration[0]
if result:
url = "http://youtube.com/watch?v={0}".format(result["link"])
else:
url = None
return url
def scrape(self, bestmatch=True, tries_remaining=5):
""" Search and scrape YouTube to return a list of matching videos. """
# prevents an infinite loop but allows for a few retries
if tries_remaining == 0:
log.debug("No tries left. I quit.")
return
search_url = generate_search_url(self.search_query)
log.debug("Opening URL: {0}".format(search_url))
item = self._fetch_response(search_url).read()
items_parse = BeautifulSoup(item, "html.parser")
videos = []
for x in items_parse.find_all(
"div", {"class": "yt-lockup-dismissable yt-uix-tile"}
):
if not is_video(x):
continue
y = x.find("div", class_="yt-lockup-content")
link = y.find("a")["href"][-11:]
title = y.find("a")["title"]
try:
videotime = x.find("span", class_="video-time").get_text()
except AttributeError:
log.debug("Could not find video duration on YouTube, retrying..")
return self.scrape(
bestmatch=bestmatch, tries_remaining=tries_remaining - 1
)
youtubedetails = {
"link": link,
"title": title,
"videotime": videotime,
"seconds": internals.get_sec(videotime),
}
videos.append(youtubedetails)
if bestmatch:
return self._best_match(videos)
return videos
def api(self, bestmatch=True):
""" Use YouTube API to search and return a list of matching videos. """
query = {"part": "snippet", "maxResults": 50, "type": "video"}
if const.args.music_videos_only:
query["videoCategoryId"] = "10"
if not self.meta_tags:
song = self.raw_song
query["q"] = song
else:
query["q"] = self.search_query
log.debug("query: {0}".format(query))
data = pafy.call_gdata("search", query)
data["items"] = list(
filter(lambda x: x["id"].get("videoId") is not None, data["items"])
)
query_results = {
"part": "contentDetails,snippet,statistics",
"maxResults": 50,
"id": ",".join(i["id"]["videoId"] for i in data["items"]),
}
log.debug("query_results: {0}".format(query_results))
vdata = pafy.call_gdata("videos", query_results)
videos = []
for x in vdata["items"]:
duration_s = pafy.playlist.parseISO8591(x["contentDetails"]["duration"])
youtubedetails = {
"link": x["id"],
"title": x["snippet"]["title"],
"videotime": internals.videotime_from_seconds(duration_s),
"seconds": duration_s,
}
videos.append(youtubedetails)
if bestmatch:
return self._best_match(videos)
return videos
@staticmethod
def _fetch_response(url):
# XXX: This method exists only because it helps us indirectly
# monkey patch `urllib.request.open`, directly monkey patching
# `urllib.request.open` causes us to end up in an infinite recursion
# during the test since `urllib.request.open` would monkeypatch itself.
return urllib.request.urlopen(url)

26
test/loader.py Normal file
View File

@@ -0,0 +1,26 @@
from spotdl import const
from spotdl import handle
from spotdl import spotdl
import urllib
import pytest
def load_defaults():
const.args = handle.get_arguments(raw_args="", to_group=False, to_merge=False)
const.args.overwrite = "skip"
spotdl.args = const.args
spotdl.log = const.logzero.setup_logger(
formatter=const._formatter, level=const.args.log_level
)
# GIST_URL is the monkeypatched version of: https://www.youtube.com/results?search_query=janji+-+heroes
# 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

@@ -0,0 +1,232 @@
import subprocess
import os
from spotdl import const
from spotdl import internals
from spotdl import spotify_tools
from spotdl import youtube_tools
from spotdl import convert
from spotdl import metadata
from spotdl import downloader
import pytest
import loader
loader.load_defaults()
SPOTIFY_TRACK_URL = "https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD"
EXPECTED_YOUTUBE_TITLE = "Janji - Heroes Tonight (feat. Johnning) [NCS Release]"
EXPECTED_SPOTIFY_TITLE = "Janji - Heroes Tonight"
EXPECTED_YOUTUBE_URL = "http://youtube.com/watch?v=3nQNiWdeH2Q"
# GIST_URL is the monkeypatched version of: https://www.youtube.com/results?search_query=janji+-+heroes
# 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 pytest_namespace():
# XXX: We override the value of `content_fixture` later in the tests.
# We do not use an acutal @pytest.fixture because it does not accept
# the monkeypatch parameter and we need to monkeypatch the network
# request before creating the Pafy object.
return {"content_fixture": None}
@pytest.fixture(scope="module")
def metadata_fixture():
meta_tags = spotify_tools.generate_metadata(SPOTIFY_TRACK_URL)
return meta_tags
def test_metadata(metadata_fixture):
expect_number = 24
assert len(metadata_fixture) == expect_number
class TestFileFormat:
def test_with_spaces(self, metadata_fixture):
title = internals.format_string(const.args.file_format, metadata_fixture)
assert title == EXPECTED_SPOTIFY_TITLE
def test_without_spaces(self, metadata_fixture):
const.args.no_spaces = True
title = internals.format_string(const.args.file_format, metadata_fixture)
assert title == EXPECTED_SPOTIFY_TITLE.replace(" ", "_")
def test_youtube_url(metadata_fixture, monkeypatch):
monkeypatch.setattr(
youtube_tools.GenerateYouTubeURL,
"_fetch_response",
loader.monkeypatch_youtube_search_page,
)
url = youtube_tools.generate_youtube_url(SPOTIFY_TRACK_URL, metadata_fixture)
assert url == EXPECTED_YOUTUBE_URL
def test_youtube_title(metadata_fixture, monkeypatch):
monkeypatch.setattr(
youtube_tools.GenerateYouTubeURL,
"_fetch_response",
loader.monkeypatch_youtube_search_page,
)
content = youtube_tools.go_pafy(SPOTIFY_TRACK_URL, metadata_fixture)
pytest.content_fixture = content
title = youtube_tools.get_youtube_title(content)
assert title == EXPECTED_YOUTUBE_TITLE
@pytest.fixture(scope="module")
def filename_fixture(metadata_fixture):
songname = internals.format_string(const.args.file_format, metadata_fixture)
filename = internals.sanitize_title(songname)
return filename
def test_check_track_exists_before_download(tmpdir, metadata_fixture, filename_fixture):
expect_check = False
const.args.folder = str(tmpdir)
# prerequisites for determining filename
track_existence = downloader.CheckExists(filename_fixture, metadata_fixture)
check = track_existence.already_exists(SPOTIFY_TRACK_URL)
assert check == expect_check
class TestDownload:
def blank_audio_generator(self, filepath):
if filepath.endswith(".m4a"):
cmd = "ffmpeg -f lavfi -i anullsrc -t 1 -c:a aac {}".format(filepath)
elif filepath.endswith(".webm"):
cmd = "ffmpeg -f lavfi -i anullsrc -t 1 -c:a libopus {}".format(filepath)
subprocess.call(cmd.split(" "))
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)
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)
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(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
_, command = convert.song(
filename_fixture + ".webm", filename_fixture + ".mp3", const.args.folder
)
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(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
_, command = convert.song(
filename_fixture + ".webm", filename_fixture + ".m4a", const.args.folder
)
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(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
_, command = convert.song(
filename_fixture + ".m4a", filename_fixture + ".mp3", const.args.folder
)
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(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
_, command = convert.song(
filename_fixture + ".m4a", filename_fixture + ".webm", const.args.folder
)
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(
os.path.join(const.args.folder, filename_fixture)
)
monkeypatch.setattr("os.remove", lambda x: None)
_, command = convert.song(
filename_fixture + ".m4a", filename_fixture + ".flac", const.args.folder
)
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(
os.path.join(const.args.folder, filename_fixture)
)
_, command = convert.song(
filename_fixture + ".m4a", filename_fixture + ".m4a", const.args.folder
)
assert " ".join(command) == expect_command
class TestAvconv:
@pytest.mark.skip(reason="avconv is no longer provided with FFmpeg")
def test_convert_from_m4a_to_mp3(self, filename_fixture, monkeypatch):
monkeypatch.setattr("os.remove", lambda x: None)
expect_command = "avconv -loglevel 0 -i {0}.m4a -ab 192k {0}.mp3 -y".format(
os.path.join(const.args.folder, filename_fixture)
)
_, command = convert.song(
filename_fixture + ".m4a",
filename_fixture + ".mp3",
const.args.folder,
avconv=True,
)
assert " ".join(command) == expect_command
@pytest.fixture(scope="module")
def trackpath_fixture(filename_fixture):
trackpath = os.path.join(const.args.folder, filename_fixture)
return trackpath
class TestEmbedMetadata:
def test_embed_in_mp3(self, metadata_fixture, trackpath_fixture):
expect_embed = True
embed = metadata.embed(trackpath_fixture + ".mp3", metadata_fixture)
assert embed == expect_embed
def test_embed_in_m4a(self, metadata_fixture, trackpath_fixture):
expect_embed = True
embed = metadata.embed(trackpath_fixture + ".m4a", metadata_fixture)
os.remove(trackpath_fixture + ".m4a")
assert embed == expect_embed
def test_embed_in_webm(self, metadata_fixture, trackpath_fixture):
expect_embed = False
embed = metadata.embed(trackpath_fixture + ".webm", metadata_fixture)
os.remove(trackpath_fixture + ".webm")
assert embed == expect_embed
def test_embed_in_flac(self, metadata_fixture, trackpath_fixture):
expect_embed = True
embed = metadata.embed(trackpath_fixture + ".flac", metadata_fixture)
os.remove(trackpath_fixture + ".flac")
assert embed == expect_embed
def test_check_track_exists_after_download(
metadata_fixture, filename_fixture, trackpath_fixture
):
expect_check = True
track_existence = downloader.CheckExists(filename_fixture, metadata_fixture)
check = track_existence.already_exists(SPOTIFY_TRACK_URL)
os.remove(trackpath_fixture + ".mp3")
assert check == expect_check

21
test/test_dry_run.py Normal file
View File

@@ -0,0 +1,21 @@
import os
from spotdl import const
from spotdl import downloader
import loader
loader.load_defaults()
TRACK_URL = "http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU"
def test_dry_download_list(tmpdir):
const.args.folder = str(tmpdir)
const.args.dry_run = True
file_path = os.path.join(const.args.folder, "test_list.txt")
with open(file_path, "w") as f:
f.write(TRACK_URL)
list_dl = downloader.ListDownloader(file_path)
downloaded_song, *_ = list_dl.download_list()
assert downloaded_song == TRACK_URL

70
test/test_handle.py Normal file
View File

@@ -0,0 +1,70 @@
import os
import sys
import argparse
from spotdl import handle
import pytest
import yaml
def test_error_m3u_without_list():
with pytest.raises(SystemExit):
handle.get_arguments(raw_args=("-s cool song", "--write-m3u"), to_group=True)
def test_m3u_with_list():
handle.get_arguments(raw_args=("-l cool_list.txt", "--write-m3u"), to_group=True)
def test_log_str_to_int():
expect_levels = [20, 30, 40, 10]
levels = [handle.log_leveller(level) for level in handle._LOG_LEVELS_STR]
assert levels == expect_levels
@pytest.fixture(scope="module")
def config_path_fixture(tmpdir_factory):
config_path = os.path.join(str(tmpdir_factory.mktemp("config")), "config.yml")
return config_path
@pytest.fixture(scope="module")
def modified_config_fixture():
modified_config = dict(handle.default_conf)
return modified_config
class TestConfig:
def test_default_config(self, config_path_fixture):
expect_config = handle.default_conf["spotify-downloader"]
config = handle.get_config(config_path_fixture)
assert config == expect_config
def test_modified_config(self, modified_config_fixture):
modified_config_fixture["spotify-downloader"]["file-format"] = "just_a_test"
merged_config = handle.merge(handle.default_conf, modified_config_fixture)
assert merged_config == modified_config_fixture
def test_custom_config_path(self, config_path_fixture, modified_config_fixture):
parser = argparse.ArgumentParser()
with open(config_path_fixture, "w") as config_file:
yaml.dump(modified_config_fixture, config_file, default_flow_style=False)
overridden_config = handle.override_config(
config_path_fixture, parser, raw_args=""
)
modified_values = [
str(value)
for value in modified_config_fixture["spotify-downloader"].values()
]
overridden_config.folder = os.path.realpath(overridden_config.folder)
overridden_values = [
str(value) for value in overridden_config.__dict__.values()
]
assert sorted(overridden_values) == sorted(modified_values)
def test_grouped_arguments(tmpdir):
sys.path[0] = str(tmpdir)
with pytest.raises(SystemExit):
handle.get_arguments(to_group=True, to_merge=True)

180
test/test_internals.py Normal file
View File

@@ -0,0 +1,180 @@
import sys
import os
import subprocess
from spotdl import internals
import pytest
DUPLICATE_TRACKS_TEST_TABLE = [
(
(
"https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",
"https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",
),
("https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",),
),
(
(
"https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",
"",
"https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD",
),
(
"https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",
"https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD",
),
),
(
(
"ncs fade",
"https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",
"",
"ncs fade",
),
("ncs fade", "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ"),
),
(
("ncs spectre ", " https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", ""),
("ncs spectre", "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ"),
),
]
STRING_IDS_TEST_TABLE = [
(
"https://open.spotify.com/artist/1feoGrmmD8QmNqtK2Gdwy8?si=_cVm-FBRQmi7VWML7E49Ig",
"1feoGrmmD8QmNqtK2Gdwy8",
),
(
"https://open.spotify.com/artist/1feoGrmmD8QmNqtK2Gdwy8",
"1feoGrmmD8QmNqtK2Gdwy8",
),
("spotify:artist:1feoGrmmD8QmNqtK2Gdwy8", "1feoGrmmD8QmNqtK2Gdwy8"),
(
"https://open.spotify.com/album/1d1l3UkeAjtM7kVTDyR8yp?si=LkVQLJGGT--Lh8BWM4MGvg",
"1d1l3UkeAjtM7kVTDyR8yp",
),
("https://open.spotify.com/album/1d1l3UkeAjtM7kVTDyR8yp", "1d1l3UkeAjtM7kVTDyR8yp"),
("spotify:album:1d1l3UkeAjtM7kVTDyR8yp", "1d1l3UkeAjtM7kVTDyR8yp"),
(
"https://open.spotify.com/user/5kkyy50uu8btnagp30pobxz2f/playlist/3SFKRjUXm0IMQJMkEgPHeY?si=8Da4gbE2T9qMkd8Upg22ZA",
"3SFKRjUXm0IMQJMkEgPHeY",
),
(
"https://open.spotify.com/playlist/3SFKRjUXm0IMQJMkEgPHeY?si=8Da4gbE2T9qMkd8Upg22ZA",
"3SFKRjUXm0IMQJMkEgPHeY",
),
(
"https://open.spotify.com/playlist/3SFKRjUXm0IMQJMkEgPHeY",
"3SFKRjUXm0IMQJMkEgPHeY",
),
(
"spotify:user:5kkyy50uu8btnagp30pobxz2f:playlist:3SFKRjUXm0IMQJMkEgPHeY",
"3SFKRjUXm0IMQJMkEgPHeY",
),
(
"https://open.spotify.com/user/uqlakumu7wslkoen46s5bulq0",
"uqlakumu7wslkoen46s5bulq0",
),
]
FROM_SECONDS_TEST_TABLE = [
(35, "35"),
(23, "23"),
(158, "2:38"),
(263, "4:23"),
(4562, "1:16:02"),
(26762, "7:26:02"),
]
TO_SECONDS_TEST_TABLE = [
("0:23", 23),
("0:45", 45),
("2:19", 139),
("3:33", 213),
("7:38", 458),
("1:30:05", 5405),
]
def test_default_music_directory():
if sys.platform.startswith("linux"):
output = subprocess.check_output(["xdg-user-dir", "MUSIC"])
expect_directory = output.decode("utf-8").rstrip()
else:
home = os.path.expanduser("~")
expect_directory = os.path.join(home, "Music")
directory = internals.get_music_dir()
assert directory == expect_directory
@pytest.fixture(scope="module")
def directory_fixture(tmpdir_factory):
dir_path = os.path.join(str(tmpdir_factory.mktemp("tmpdir")), "filter_this_folder")
return dir_path
class TestPathFilterer:
def test_create_directory(self, directory_fixture):
expect_path = True
internals.filter_path(directory_fixture)
is_path = os.path.isdir(directory_fixture)
assert is_path == expect_path
def test_remove_temp_files(self, directory_fixture):
expect_file = False
file_path = os.path.join(directory_fixture, "pesky_file.temp")
open(file_path, "a")
internals.filter_path(directory_fixture)
is_file = os.path.isfile(file_path)
assert is_file == expect_file
@pytest.mark.parametrize("sec_duration, str_duration", FROM_SECONDS_TEST_TABLE)
def test_video_time_from_seconds(sec_duration, str_duration):
duration = internals.videotime_from_seconds(sec_duration)
assert duration == str_duration
@pytest.mark.parametrize("str_duration, sec_duration", TO_SECONDS_TEST_TABLE)
def test_get_seconds_from_video_time(str_duration, sec_duration):
secs = internals.get_sec(str_duration)
assert secs == sec_duration
@pytest.mark.parametrize("duplicates, expected", DUPLICATE_TRACKS_TEST_TABLE)
def test_get_unique_tracks(tmpdir, duplicates, expected):
file_path = os.path.join(str(tmpdir), "test_duplicates.txt")
with open(file_path, "w") as f:
f.write("\n".join(duplicates))
unique_tracks = internals.get_unique_tracks(file_path)
assert tuple(unique_tracks) == expected
@pytest.mark.parametrize("input_str, expected_spotify_id", STRING_IDS_TEST_TABLE)
def test_extract_spotify_id(input_str, expected_spotify_id):
spotify_id = internals.extract_spotify_id(input_str)
assert spotify_id == expected_spotify_id
def test_trim(tmpdir):
text_file = os.path.join(str(tmpdir), "test_trim.txt")
with open(text_file, "w") as track_file:
track_file.write("ncs - spectre\nncs - heroes\nncs - hope")
with open(text_file, "r") as track_file:
tracks = track_file.readlines()
expect_number = len(tracks) - 1
expect_track = tracks[0]
track = internals.trim_song(text_file)
with open(text_file, "r") as track_file:
number = len(track_file.readlines())
assert expect_number == number and expect_track == track

36
test/test_patcher.py Normal file
View File

@@ -0,0 +1,36 @@
from spotdl import patcher
import pafy
import pytest
pafy_patcher = patcher.PatchPafy()
pafy_patcher.patch_getbestthumb()
class TestPafyContentAvailable:
pass
class TestMethodAssignment:
def test_pafy_getbestthumb(self):
pafy.backend_shared.BasePafy.getbestthumb == patcher._getbestthumb
class TestMethodCalls:
@pytest.fixture(scope="module")
def content_fixture(self):
content = pafy.new("http://youtube.com/watch?v=3nQNiWdeH2Q")
return content
def test_pafy_getbestthumb(self, content_fixture):
thumbnail = patcher._getbestthumb(content_fixture)
assert thumbnail == "https://i.ytimg.com/vi/3nQNiWdeH2Q/maxresdefault.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"
def test_pafy_content_available(self):
TestPafyContentAvailable._content_available = patcher._content_available
assert TestPafyContentAvailable()._content_available("https://youtube.com/")

View File

@@ -1,72 +0,0 @@
# -*- coding: UTF-8 -*-
import spotdl
import os
raw_song = "Tony's Videos VERY SHORT VIDEO 28.10.2016"
for x in os.listdir(spotdl.args.folder):
os.remove(os.path.join(spotdl.args.folder, x))
def test_youtube_url():
expect_url = 'youtube.com/watch?v=qOOcy2-tmbk'
url = spotdl.generate_youtube_url(raw_song)
assert url == expect_url
def test_youtube_title():
expect_title = "Tony's Videos VERY SHORT VIDEO 28.10.2016"
global content
content = spotdl.go_pafy(raw_song)
global title
title = spotdl.get_youtube_title(content)
assert title == expect_title
def test_check_exists():
expect_check = False
# prerequisites for determining filename
file_name = spotdl.misc.sanitize_title(title)
check = spotdl.check_exists(file_name, raw_song, islist=True)
assert check == expect_check
def test_download():
expect_download = True
# prerequisites for determining filename
file_name = spotdl.misc.sanitize_title(title)
download = spotdl.download_song(file_name, content)
assert download == expect_download
def test_convert():
# exit code 0 = success
expect_convert = 0
# prerequisites for determining filename
file_name = spotdl.misc.sanitize_title(title)
input_song = file_name + spotdl.args.input_ext
output_song = file_name + spotdl.args.output_ext
convert = spotdl.convert.song(input_song, output_song, spotdl.args.folder)
assert convert == expect_convert
def test_metadata():
expect_metadata = None
# prerequisites for determining filename
meta_tags = spotdl.generate_metadata(raw_song)
meta_tags = spotdl.generate_metadata(raw_song)
file_name = spotdl.misc.sanitize_title(title)
output_song = file_name + spotdl.args.output_ext
metadata_output = spotdl.metadata.embed(os.path.join(spotdl.args.folder, output_song), meta_tags)
input_song = file_name + spotdl.args.input_ext
metadata_input = spotdl.metadata.embed(os.path.join(spotdl.args.folder, input_song), meta_tags)
assert (metadata_output == expect_metadata) and (metadata_input == expect_metadata)
def test_check_exists2():
expect_check = True
# prerequisites for determining filename
file_name = spotdl.misc.sanitize_title(title)
input_song = file_name + spotdl.args.input_ext
os.remove(os.path.join(spotdl.args.folder, input_song))
check = spotdl.check_exists(file_name, raw_song, islist=True)
assert check == expect_check

View File

@@ -1,77 +0,0 @@
# -*- coding: UTF-8 -*-
import spotdl
import os
raw_song = 'http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU'
for x in os.listdir(spotdl.args.folder):
os.remove(os.path.join(spotdl.args.folder, x))
def test_spotify_title():
expect_title = 'David André Østby - Intro'
global meta_tags
meta_tags = spotdl.generate_metadata(raw_song)
title = spotdl.generate_songname(meta_tags)
assert title == expect_title
def youtube_url():
expect_url = 'youtube.com/watch?v=rg1wfcty0BA'
url = spotdl.generate_youtube_url(raw_song)
assert url == expect_url
def youtube_title():
expect_title = 'Intro - David André Østby'
content = spotdl.go_pafy(raw_song)
title = spotdl.get_youtube_title(content)
assert title == expect_title
def test_check_exists():
expect_check = False
# prerequisites for determining filename
songname = spotdl.generate_songname(meta_tags)
global file_name
file_name = spotdl.misc.sanitize_title(songname)
check = spotdl.check_exists(file_name, raw_song, islist=True)
assert check == expect_check
def test_download():
expect_download = True
# prerequisites for determining filename
content = spotdl.go_pafy(raw_song)
download = spotdl.download_song(file_name, content)
assert download == expect_download
def test_convert():
# exit code 0 = success
expect_convert = 0
# prerequisites for determining filename
input_song = file_name + spotdl.args.input_ext
output_song = file_name + spotdl.args.output_ext
convert = spotdl.convert.song(input_song, output_song, spotdl.args.folder)
assert convert == expect_convert
def test_metadata():
expect_metadata = True
# prerequisites for determining filename
output_song = file_name + spotdl.args.output_ext
metadata_output = spotdl.metadata.embed(os.path.join(spotdl.args.folder, output_song), meta_tags)
input_song = file_name + spotdl.args.input_ext
metadata_input = spotdl.metadata.embed(os.path.join(spotdl.args.folder, input_song), meta_tags)
assert metadata_output == (metadata_input == expect_metadata)
def test_check_exists2():
expect_check = True
# prerequisites for determining filename
input_song = file_name + spotdl.args.input_ext
os.remove(os.path.join(spotdl.args.folder, input_song))
check = spotdl.check_exists(file_name, raw_song, islist=True)
assert check == expect_check

178
test/test_spotify_tools.py Normal file
View File

@@ -0,0 +1,178 @@
from spotdl import spotify_tools
from spotdl import const
import spotipy
import os
import pytest
import loader
loader.load_defaults()
def test_generate_token():
token = spotify_tools.generate_token()
assert len(token) == 83
class TestMustBeAuthorizedDecorator:
def test_spotify_instance_is_unset(self):
spotify_tools.spotify = None
@spotify_tools.must_be_authorized
def sample_func():
return True
assert sample_func()
def test_spotify_instance_forces_assertion_error(self):
@spotify_tools.must_be_authorized
def sample_func():
raise AssertionError
with pytest.raises(AssertionError):
sample_func()
def test_fake_token_generator(self, monkeypatch):
spotify_tools.spotify = None
monkeypatch.setattr(spotify_tools, "generate_token", lambda: 123123)
with pytest.raises(spotipy.client.SpotifyException):
spotify_tools.generate_metadata("ncs - spectre")
def test_correct_token(self):
assert spotify_tools.generate_metadata("ncs - spectre")
class TestGenerateMetadata:
@pytest.fixture(scope="module")
def metadata_fixture(self):
metadata = spotify_tools.generate_metadata("ncs - spectre")
return metadata
def test_len(self, metadata_fixture):
assert len(metadata_fixture) == 24
def test_trackname(self, metadata_fixture):
assert metadata_fixture["name"] == "Spectre"
def test_artist(self, metadata_fixture):
assert metadata_fixture["artists"][0]["name"] == "Alan Walker"
def test_duration(self, metadata_fixture):
assert metadata_fixture["duration"] == 230.634
def test_get_playlists():
expect_playlist_ids = [
"34gWCK8gVeYDPKcctB6BQJ",
"04wTU2c2WNQG9XE5oSLYfj",
"0fWBMhGh38y0wsYWwmM9Kt",
]
expect_playlists = [
"https://open.spotify.com/playlist/" + playlist_id
for playlist_id in expect_playlist_ids
]
playlists = spotify_tools.get_playlists("uqlakumu7wslkoen46s5bulq0")
assert playlists == expect_playlists
def test_write_user_playlist(tmpdir, monkeypatch):
expect_tracks = 17
text_file = os.path.join(str(tmpdir), "test_us.txt")
monkeypatch.setattr("builtins.input", lambda x: 1)
spotify_tools.write_user_playlist("uqlakumu7wslkoen46s5bulq0", text_file)
with open(text_file, "r") as f:
tracks = len(f.readlines())
assert tracks == expect_tracks
class TestFetchPlaylist:
@pytest.fixture(scope="module")
def playlist_fixture(self):
playlist = spotify_tools.fetch_playlist(
"https://open.spotify.com/playlist/0fWBMhGh38y0wsYWwmM9Kt"
)
return playlist
def test_name(self, playlist_fixture):
assert playlist_fixture["name"] == "special_test_playlist"
def test_tracks(self, playlist_fixture):
assert playlist_fixture["tracks"]["total"] == 14
def test_write_playlist(tmpdir):
expect_tracks = 14
text_file = os.path.join(str(tmpdir), "test_pl.txt")
spotify_tools.write_playlist(
"https://open.spotify.com/playlist/0fWBMhGh38y0wsYWwmM9Kt", text_file
)
with open(text_file, "r") as f:
tracks = len(f.readlines())
assert tracks == expect_tracks
# XXX: Monkeypatch these tests if they fail in future
class TestFetchAlbum:
@pytest.fixture(scope="module")
def album_fixture(self):
album = spotify_tools.fetch_album(
"https://open.spotify.com/album/499J8bIsEnU7DSrosFDJJg"
)
return album
def test_name(self, album_fixture):
assert album_fixture["name"] == "NCS: Infinity"
def test_tracks(self, album_fixture):
assert album_fixture["tracks"]["total"] == 15
# XXX: Monkeypatch these tests if they fail in future
class TestFetchAlbumsFromArtist:
@pytest.fixture(scope="module")
def albums_from_artist_fixture(self):
albums = spotify_tools.fetch_albums_from_artist(
"https://open.spotify.com/artist/7oPftvlwr6VrsViSDV7fJY"
)
return albums
def test_len(self, albums_from_artist_fixture):
assert len(albums_from_artist_fixture) == 53
def test_zeroth_album_name(self, albums_from_artist_fixture):
assert albums_from_artist_fixture[0]["name"] == "Revolution Radio"
def test_zeroth_album_tracks(self, albums_from_artist_fixture):
assert albums_from_artist_fixture[0]["total_tracks"] == 12
def test_fist_album_name(self, albums_from_artist_fixture):
assert albums_from_artist_fixture[1]["name"] == "Demolicious"
def test_first_album_tracks(self, albums_from_artist_fixture):
assert albums_from_artist_fixture[0]["total_tracks"] == 12
def test_write_all_albums_from_artist(tmpdir):
expect_tracks = 282
text_file = os.path.join(str(tmpdir), "test_ab.txt")
spotify_tools.write_all_albums_from_artist(
"https://open.spotify.com/artist/4dpARuHxo51G3z768sgnrY", text_file
)
with open(text_file, "r") as f:
tracks = len(f.readlines())
assert tracks == expect_tracks
def test_write_album(tmpdir):
expect_tracks = 15
text_file = os.path.join(str(tmpdir), "test_al.txt")
spotify_tools.write_album(
"https://open.spotify.com/album/499J8bIsEnU7DSrosFDJJg", text_file
)
with open(text_file, "r") as f:
tracks = len(f.readlines())
assert tracks == expect_tracks

View File

@@ -1,57 +0,0 @@
# -*- coding: UTF-8 -*-
import spotdl
username = 'alex'
def test_user():
expect_playlists = 7
playlists = spotdl.spotify.user_playlists(username)
playlists = len(playlists['items'])
assert playlists == expect_playlists
def test_playlist():
expect_tracks = 14
playlist = spotdl.spotify.user_playlists(username)['items'][0]
tracks = playlist['tracks']['total']
assert tracks == expect_tracks
def test_tracks():
playlist = spotdl.spotify.user_playlists(username)['items'][0]
expect_lines = playlist['tracks']['total']
result = spotdl.spotify.user_playlist(
playlist['owner']['id'], playlist['id'], fields='tracks,next')
tracks = result['tracks']
with open('list.txt', 'w') as fout:
while True:
for item in tracks['items']:
track = item['track']
try:
fout.write(track['external_urls']['spotify'] + '\n')
except KeyError:
title = track['name'] + ' by '+ track['artists'][0]['name']
print('Skipping track ' + title + ' (local only?)')
# 1 page = 50 results
# check if there are more pages
if tracks['next']:
tracks = spotify.next(tracks)
else:
break
with open('list.txt', 'r') as listed:
expect_song = (listed.read()).splitlines()[0]
spotdl.misc.trim_song('list.txt')
with open('list.txt', 'a') as myfile:
myfile.write(expect_song)
with open('list.txt', 'r') as listed:
songs = (listed.read()).splitlines()
lines = len(songs)
song = songs[-1]
assert (expect_lines == lines and expect_song == song)

223
test/test_youtube_tools.py Normal file
View File

@@ -0,0 +1,223 @@
import os
import builtins
from spotdl import const
from spotdl import internals
from spotdl import spotify_tools
from spotdl import youtube_tools
from spotdl import downloader
import loader
import pytest
loader.load_defaults()
YT_API_KEY = "AIzaSyAnItl3udec-Q1d5bkjKJGL-RgrKO_vU90"
TRACK_SEARCH = "Tony's Videos VERY SHORT VIDEO 28.10.2016"
EXPECTED_TITLE = TRACK_SEARCH
EXPECTED_YT_URL = "http://youtube.com/watch?v=qOOcy2-tmbk"
RESULT_COUNT_SEARCH = "she is still sleeping SAO"
EXPECTED_YT_API_KEY = "AIzaSyC6cEeKlxtOPybk9sEe5ksFN5sB-7wzYp0"
EXPECTED_YT_API_KEY_CUSTOM = "some_api_key"
class TestYouTubeAPIKeys:
def test_custom(self):
const.args.youtube_api_key = EXPECTED_YT_API_KEY_CUSTOM
youtube_tools.set_api_key()
key = youtube_tools.pafy.g.api_key
assert key == EXPECTED_YT_API_KEY_CUSTOM
def test_default(self):
const.args.youtube_api_key = None
youtube_tools.set_api_key()
key = youtube_tools.pafy.g.api_key
assert key == EXPECTED_YT_API_KEY
@pytest.fixture(scope="module")
def metadata_fixture():
metadata = spotify_tools.generate_metadata(TRACK_SEARCH)
return metadata
def test_metadata(metadata_fixture):
expect_metadata = None
assert metadata_fixture == expect_metadata
class TestArgsManualResultCount:
# Regresson test for issue #264
def test_scrape(self):
const.args.manual = True
url = youtube_tools.GenerateYouTubeURL(RESULT_COUNT_SEARCH, meta_tags=None)
video_ids = url.scrape(bestmatch=False)
# Web scraping gives us all videos on the 1st page
assert len(video_ids) == 20
def test_api(self):
url = youtube_tools.GenerateYouTubeURL(RESULT_COUNT_SEARCH, meta_tags=None)
video_ids = url.api(bestmatch=False)
const.args.manual = False
# API gives us 50 videos (or as requested)
assert len(video_ids) == 50
class TestYouTubeURL:
def test_only_music_category(self, metadata_fixture):
const.args.music_videos_only = True
url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture)
assert url == EXPECTED_YT_URL
def test_all_categories(self, metadata_fixture):
const.args.music_videos_only = False
url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture)
assert url == EXPECTED_YT_URL
def test_args_manual(self, metadata_fixture, monkeypatch):
const.args.manual = True
monkeypatch.setattr("builtins.input", lambda x: "1")
url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture)
assert url == EXPECTED_YT_URL
def test_args_manual_none(self, metadata_fixture, monkeypatch):
expect_url = None
monkeypatch.setattr("builtins.input", lambda x: "0")
url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture)
const.args.manual = False
assert url == expect_url
@pytest.fixture(scope="module")
def content_fixture(metadata_fixture):
content = youtube_tools.go_pafy(TRACK_SEARCH, metadata_fixture)
return content
# True = Metadata must be fetched from Spotify
# False = Metadata must be fetched from YouTube
# None = Metadata must be `None`
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)
]
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)
]
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)
]
class TestMetadataOrigin:
def match_metadata(self, track, metadata_type):
_, metadata = youtube_tools.match_video_and_metadata(track)
if metadata_type is None:
assert metadata == metadata_type
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)
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)
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)
const.args.no_metadata = True
self.match_metadata(track, metadata_type)
@pytest.fixture(scope="module")
def title_fixture(content_fixture):
title = youtube_tools.get_youtube_title(content_fixture)
return title
class TestYouTubeTitle:
def test_single_download_with_youtube_api(self, title_fixture):
const.args.youtube_api_key = YT_API_KEY
youtube_tools.set_api_key()
assert title_fixture == EXPECTED_TITLE
def test_download_from_list_without_youtube_api(
self, metadata_fixture, content_fixture
):
const.args.youtube_api_key = None
youtube_tools.set_api_key()
content_fixture = youtube_tools.go_pafy(TRACK_SEARCH, metadata_fixture)
title = youtube_tools.get_youtube_title(content_fixture, 1)
assert title == "1. {0}".format(EXPECTED_TITLE)
@pytest.fixture(scope="module")
def filename_fixture(title_fixture):
filename = internals.sanitize_title(title_fixture)
return filename
def test_check_exists(metadata_fixture, filename_fixture, tmpdir):
expect_check = False
const.args.folder = str(tmpdir)
# prerequisites for determining filename
track_existence = downloader.CheckExists(filename_fixture, metadata_fixture)
check = track_existence.already_exists(TRACK_SEARCH)
assert check == expect_check
def test_generate_m3u(tmpdir, monkeypatch):
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"
"http://www.youtube.com/watch?v=3nQNiWdeH2Q\n"
"#EXTINF:226,Alan Walker - Spectre [NCS Release]\n"
"http://www.youtube.com/watch?v=AOeY-nDp7hI\n"
)
m3u_track_file = os.path.join(str(tmpdir), "m3u_test.txt")
with open(m3u_track_file, "w") as track_file:
track_file.write("\nhttps://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD")
track_file.write("\nhttp://www.youtube.com/watch?v=AOeY-nDp7hI")
youtube_tools.generate_m3u(m3u_track_file)
m3u_file = "{}.m3u".format(m3u_track_file.split(".")[0])
with open(m3u_file, "r") as m3u_in:
m3u = m3u_in.readlines()
assert "".join(m3u) == expect_m3u
class TestDownload:
def test_webm(self, content_fixture, filename_fixture, monkeypatch):
# content_fixture does not have any .webm audiostream
expect_download = False
monkeypatch.setattr("pafy.backend_shared.BaseStream.download", lambda x: None)
download = youtube_tools.download_song(
filename_fixture + ".webm", content_fixture
)
assert download == expect_download
def test_other(self, content_fixture, filename_fixture, monkeypatch):
expect_download = False
monkeypatch.setattr("pafy.backend_shared.BaseStream.download", lambda x: None)
download = youtube_tools.download_song(
filename_fixture + ".fake_extension", content_fixture
)
assert download == expect_download