183 Commits

Author SHA1 Message Date
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
35 changed files with 2187 additions and 1134 deletions

114
.gitignore vendored
View File

@@ -1,6 +1,114 @@
*.pyc
__pycache__/
.cache/
config.yml
Music/
*.txt
upload.sh
.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

@@ -4,12 +4,41 @@ 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
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

13
CHANGES.md Normal file
View File

@@ -0,0 +1,13 @@
# 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]
## 1.0.0-beta.1 - 2018-02-02
### Added
- Initial release, prepare for 1.0.0
[Unreleased]: https://github.com/olivierlacan/keep-a-changelog/compare/v1.0.0-beta.1...HEAD

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

36
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,36 @@
# 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.
- 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`.
- 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

@@ -10,22 +10,14 @@ Please follow the guide below
- [ ] [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-->

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:

191
README.md Executable file → Normal file
View File

@@ -1,196 +1,99 @@
# 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)
[![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 fixes song's meta-tags which include:
- Automatically applies metadata to the downloaded song which include:
- Title
- Artist
- Album
- Album art
- Lyrics (if found on http://lyrics.wikia.com)
- Album artist
- Genre
- Track number
- Disc number
- Release date
- And some more...
- And more...
- Works straight out of the box and does not require to generate or mess with your API keys.
- Works straight out of the box and does not require to generate or mess with your API keys (already included).
That's how your Music library will look like!
That's how your music library will look like!
<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">
Check out the [Installation](https://github.com/ritiek/spotify-downloader/wiki/Installation) wiki page
for OS-specific instructions to get spotify-downloader working on your system.
- **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.
## Usage
- 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
For the most basic usage, downloading tracks is as easy as
```
cd
git clone https://github.com/ritiek/spotify-downloader
cd spotify-downloader
pip install -U -r requirements.txt
$ spotdl --song https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ
$ spotdl --song "ncs - spectre"
```
**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.
You'll also need to install FFmpeg for conversion (use `--avconv` if you'd like to use that instead):
Linux: `sudo apt-get install ffmpeg`
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`.
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
```
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)
$ spotdl --playlist https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD
INFO: Writing 62 tracks to ncs-releases.txt
$ spotdl --list ncs-releases.txt
```
#### Download by Name
Run `spotdl --help` to get a list of all available options in spotify-downloader.
For example
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.
- We want to download Hello by Adele, simply run `python3 spotdl.py --song "adele hello"`.
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.
- 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.
## FAQ
- It will now convert the song to an mp3 and try to fix meta-tags and album-art by looking up on Spotify.
- [
How to specify a custom folder where tracks should be downloaded?](https://github.com/ritiek/spotify-downloader/wiki/FAQ#how-to-specify-a-custom-folder-where-tracks-should-be-downloaded)
#### Download by Spotify Link (Recommended)
Check out our [FAQ wiki page](https://github.com/ritiek/spotify-downloader/wiki/FAQ)
for more info.
For example
## Contributing
- 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.
Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more info.
- 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`)
## Running Tests
```
https://open.spotify.com/track/1MDoll6jK4rrk2BcFRP5i7
the nights avicci
http://open.spotify.com/track/64yrDBpcdwEdNY9loyEGbX
$ python3 -m pytest test
```
- 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`.
- 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`.
- Songs that are already downloaded will be skipped and not be downloaded again.
#### Download playlists
- You can copy the Spotify URL of the playlist and pass it in `--playlist` option.
For example
- `python3 spotdl.py --playlist https://open.spotify.com/user/camillazi/playlist/71MXqcSOKCxsLNtRvONkhF`
- The script will load all the tracks from the playlist into `<playlist_name>.txt`
- 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
```
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

59
setup.py Normal file
View File

@@ -0,0 +1,59 @@
from setuptools import setup
with open('README.md', 'r') as f:
long_description = f.read()
import spotdl
setup(
# 'spotify-downloader' was already taken :/
name='spotdl',
py_modules=['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.8',
'pafy >= 0.5.3.1',
'spotipy >= 2.4.4',
'mutagen >= 1.37',
'beautifulsoup4 >= 4.6.0',
'unicode-slugify >= 0.1.3',
'titlecase >= 0.10.0',
'logzero >= 1.3.1',
'lyricwikia >= 0.1.8',
'PyYAML >= 3.12',
'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/spotify-downloader/',
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 :: 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 Executable file
View File

@@ -0,0 +1 @@
__version__ = '1.0.0'

33
spotdl/const.py Normal file
View File

@@ -0,0 +1,33 @@
import logzero
_log_format = ("%(color)s%(levelname)s:%(end_color)s %(message)s")
_formatter = logzero.LogFormatter(fmt=_log_format)
# options
log = logzero.setup_logger(formatter=_formatter)
args = None
# 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' }
TAG_PRESET = {}
for key in M4A_TAG_PRESET.keys():
TAG_PRESET[key] = key

90
spotdl/convert.py Normal file
View File

@@ -0,0 +1,90 @@
import subprocess
import os
from spotdl.const import 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 input_song == output_song:
return 0
convert = Converter(input_song, output_song, folder, trim_silence)
log.info('Converting {0} to {1}'.format(
input_song, output_song.split('.')[-1]))
if avconv:
exit_code = convert.with_avconv()
else:
exit_code = convert.with_ffmpeg()
return exit_code
class Converter:
def __init__(self, input_song, output_song, folder, trim_silence=False):
self.input_file = os.path.join(folder, input_song)
self.output_file = os.path.join(folder, output_song)
self.trim_silence = trim_silence
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.trim_silence:
log.warning('--trim-silence not supported with avconv')
log.debug(command)
return subprocess.call(command)
def with_ffmpeg(self):
ffmpeg_pre = 'ffmpeg -y '
if not log.level == 10:
ffmpeg_pre += '-hide_banner -nostats -v panic '
_, input_ext = os.path.splitext(self.input_file)
_, output_ext = os.path.splitext(self.output_file)
ffmpeg_params = ''
if input_ext == '.m4a':
if output_ext == '.mp3':
ffmpeg_params = '-codec:v copy -codec:a libmp3lame -ar 44100 '
elif output_ext == '.webm':
ffmpeg_params = '-codec:a libopus -vbr on '
elif input_ext == '.webm':
if output_ext == '.mp3':
ffmpeg_params = '-codec:a libmp3lame -ar 44100 '
elif output_ext == '.m4a':
ffmpeg_params = '-cutoff 20000 -codec:a libfdk_aac -ar 44100 '
if 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 self.trim_silence:
ffmpeg_params += '-af silenceremove=start_periods=1 '
command = ffmpeg_pre.split() + [self.input_file] + ffmpeg_params.split() + [self.output_file]
log.debug(command)
return subprocess.call(command)

190
spotdl/handle.py Normal file
View File

@@ -0,0 +1,190 @@
import appdirs
from spotdl import internals, const
log = const.log
import logging
import yaml
import argparse
import os
import sys
_LOG_LEVELS_STR = ['INFO', 'WARNING', 'ERROR', 'DEBUG']
default_conf = { 'spotify-downloader':
{ 'manual' : False,
'no-metadata' : False,
'avconv' : False,
'folder' : internals.get_music_dir(),
'overwrite' : 'prompt',
'input-ext' : '.m4a',
'output-ext' : '.mp3',
'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,
'log-level' : 'INFO' }
}
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.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',
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(
'-u', '--username',
help="load tracks from user's playlist into <playlist_name>.txt")
group.add_argument(
'-V', '--version',
help="show version and exit",
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(
'-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(
'-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(
'-c', '--config', default=None,
help='path to custom config.yml file')
parsed = parser.parse_args(raw_args)
if parsed.config is not None and to_merge:
parsed = override_config(parsed.config, parser)
parsed.log_level = log_leveller(parsed.log_level)
return parsed

182
spotdl/internals.py Executable file
View File

@@ -0,0 +1,182 @@
import os
import sys
from spotdl import const
log = const.log
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(text_file):
""" Remove the first song from file. """
with open(text_file, 'r') as file_in:
data = file_in.read().splitlines(True)
with open(text_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']
for tag in format_tags:
if slugification:
format_tags[tag] = sanitize_title(format_tags[tag],
ok='-_()[]{}')
else:
format_tags[tag] = str(format_tags[tag])
for x in formats:
format_tag = '{' + formats[x] + '}'
string_format = string_format.replace(format_tag,
format_tags[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(' ', '_')
# 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 get_splits(url):
if '/' in url:
if url.endswith('/'):
url = url[:-1]
splits = url.split('/')
else:
splits = url.split(':')
return splits
# a hacky way to 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('"'))
# 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')

159
spotdl/metadata.py Executable file
View File

@@ -0,0 +1,159 @@
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
from spotdl.const import log, TAG_PRESET, M4A_TAG_PRESET
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)
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
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']['spotify']
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'])
audiofile['TPUB'] = TPUB(encoding=3, text=meta_tags['publisher'])
audiofile['COMM'] = COMM(encoding=3, text=meta_tags['external_urls']['spotify'])
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']
if meta_tags['lyrics']:
audiofile['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']['spotify']
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']
audiofile[preset['albumartist']] = meta_tags['artists'][0]['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'])
]

215
spotdl/spotdl.py Executable file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
from spotdl import __version__
from spotdl import const
from spotdl import handle
from spotdl import metadata
from spotdl import convert
from spotdl import internals
from spotdl import spotify_tools
from spotdl import youtube_tools
from slugify import slugify
import spotipy
import urllib.request
import os
import sys
import time
import platform
import pprint
def check_exists(music_file, raw_song, meta_tags):
""" Check if the input song already exists in the given folder. """
log.debug('Cleaning any temp files and checking '
'if "{}" already exists'.format(music_file))
songs = os.listdir(const.args.folder)
for song in songs:
if song.endswith('.temp'):
os.remove(os.path.join(const.args.folder, song))
continue
# check if a song with the same name is already present in the given folder
if os.path.splitext(song)[0] == music_file:
log.debug('Found an already existing song: "{}"'.format(song))
if internals.is_spotify(raw_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(const.args.folder, song),
meta_tags)
log.debug('Checking if it is already tagged correctly? {}',
already_tagged)
if not already_tagged:
os.remove(os.path.join(const.args.folder, song))
return False
log.warning('"{}" already exists'.format(song))
if const.args.overwrite == 'prompt':
log.info('"{}" has already been downloaded. '
'Re-download? (y/N): '.format(song))
prompt = input('> ')
if prompt.lower() == 'y':
os.remove(os.path.join(const.args.folder, song))
return False
else:
return True
elif const.args.overwrite == 'force':
os.remove(os.path.join(const.args.folder, song))
log.info('Overwriting "{}"'.format(song))
return False
elif const.args.overwrite == 'skip':
log.info('Skipping "{}"'.format(song))
return True
return False
def download_list(text_file):
""" Download all songs from the list. """
with open(text_file, 'r') as listed:
# read tracks into a list and remove any duplicates
lines = listed.read().splitlines()
lines = list(set(lines))
# ignore blank lines in text_file (if any)
try:
lines.remove('')
except ValueError:
pass
log.info(u'Preparing to download {} songs'.format(len(lines)))
downloaded_songs = []
for number, raw_song in enumerate(lines, 1):
print('')
try:
download_single(raw_song, number=number)
# token expires after 1 hour
except spotipy.client.SpotifyException:
# refresh token when it expires
log.debug('Token expired, generating new one and authorizing')
new_token = spotify_tools.generate_token()
spotify_tools.spotify = spotipy.Spotify(auth=new_token)
download_single(raw_song, number=number)
# detect network problems
except (urllib.request.URLError, TypeError, IOError):
lines.append(raw_song)
# remove the downloaded song from file
internals.trim_song(text_file)
# and append it at the end of file
with open(text_file, 'a') as myfile:
myfile.write(raw_song + '\n')
log.warning('Failed to download song. Will retry after other songs\n')
# wait 0.5 sec to avoid infinite looping
time.sleep(0.5)
continue
downloaded_songs.append(raw_song)
log.debug('Removing downloaded song from text file')
internals.trim_song(text_file)
return downloaded_songs
def download_single(raw_song, number=None):
""" Logic behind downloading a song. """
if internals.is_youtube(raw_song):
log.debug('Input song is a YouTube URL')
content = youtube_tools.go_pafy(raw_song, meta_tags=None)
raw_song = slugify(content.title).replace('-', ' ')
meta_tags = spotify_tools.generate_metadata(raw_song)
else:
meta_tags = spotify_tools.generate_metadata(raw_song)
content = youtube_tools.go_pafy(raw_song, meta_tags)
if content is None:
log.debug('Found no matching video')
return
if const.args.download_only_metadata and meta_tags is None:
log.info('Found no metadata. Skipping the download')
return
# "[number]. [artist] - [song]" if downloading from list
# otherwise "[artist] - [song]"
youtube_title = youtube_tools.get_youtube_title(content, number)
log.info('{} ({})'.format(youtube_title, content.watchv_url))
# generate file name of the song to download
songname = content.title
if meta_tags is not None:
refined_songname = internals.format_string(const.args.file_format,
meta_tags,
slugification=True)
log.debug('Refining songname from "{0}" to "{1}"'.format(songname, refined_songname))
if not refined_songname == ' - ':
songname = refined_songname
else:
log.warning('Could not find metadata')
songname = internals.sanitize_title(songname)
if const.args.dry_run:
return
if not check_exists(songname, raw_song, meta_tags):
# 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, 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}, skipping conversion'.format(encoder))
const.args.output_ext = const.args.input_ext
output_song = songname + const.args.output_ext
if not const.args.input_ext == const.args.output_ext:
os.remove(os.path.join(const.args.folder, input_song))
if not const.args.no_metadata and meta_tags is not None:
metadata.embed(os.path.join(const.args.folder, output_song), meta_tags)
return True
def main():
const.args = handle.get_arguments()
if const.args.version:
print('spotdl {version}'.format(version=__version__))
sys.exit()
internals.filter_path(const.args.folder)
youtube_tools.set_api_key()
const.log = const.logzero.setup_logger(formatter=const._formatter,
level=const.args.log_level)
global log
log = const.log
log.debug('Python version: {}'.format(sys.version))
log.debug('Platform: {}'.format(platform.platform()))
log.debug(pprint.pformat(const.args.__dict__))
try:
if const.args.song:
download_single(raw_song=const.args.song)
elif const.args.list:
download_list(text_file=const.args.list)
elif const.args.playlist:
spotify_tools.write_playlist(playlist_url=const.args.playlist)
elif const.args.album:
spotify_tools.write_album(album_url=const.args.album)
elif const.args.username:
spotify_tools.write_user_playlist(username=const.args.username)
# 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()

182
spotdl/spotify_tools.py Normal file
View File

@@ -0,0 +1,182 @@
import spotipy
import spotipy.oauth2 as oauth2
import lyricwikia
from spotdl import internals
from spotdl.const import log
from slugify import slugify
from titlecase import titlecase
import pprint
import sys
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
# 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 = generate_token()
spotify = spotipy.Spotify(auth=token)
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
# 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
def write_user_playlist(username, text_file=None):
links = get_playlists(username=username)
playlist = internals.input_link(links)
return write_playlist(playlist, text_file)
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
def fetch_playlist(playlist):
splits = internals.get_splits(playlist)
try:
username = splits[-3]
except IndexError:
# Wrong format, in either case
log.error('The provided playlist URL is not in a recognized format!')
sys.exit(10)
playlist_id = splits[-1]
try:
results = spotify.user_playlist(username, 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
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)
def fetch_album(album):
splits = internals.get_splits(album)
album_id = splits[-1]
album = spotify.album(album_id)
return album
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)
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

240
spotdl/youtube_tools.py Normal file
View File

@@ -0,0 +1,240 @@
from bs4 import BeautifulSoup
import urllib
import pafy
from spotdl import internals
from spotdl import const
import os
import pprint
log = const.log
# Fix download speed throttle on short duration tracks
# Read more on mps-youtube/pafy#199
pafy.g.opener.addheaders.append(('Range', 'bytes=0-'))
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 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 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):
""" 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.\n".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 = urllib.request.urlopen(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

14
test/loader.py Normal file
View File

@@ -0,0 +1,14 @@
from spotdl import const
from spotdl import handle
from spotdl import spotdl
import pytest
def load_defaults():
const.args = handle.get_arguments(raw_args='', to_group=False, to_merge=False)
const.args.overwrite = 'skip'
const.args.log_level = 10
spotdl.args = const.args
spotdl.log = const.logzero.setup_logger(formatter=const._formatter,
level=const.args.log_level)

18
test/test_dry_run.py Normal file
View File

@@ -0,0 +1,18 @@
from spotdl import const
from spotdl import spotdl
import loader
import os
loader.load_defaults()
def test_dry_download_list(tmpdir):
song = 'http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU'
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 tin:
tin.write(song)
downloaded_song, *_ = spotdl.download_list(file_path)
assert downloaded_song == song

50
test/test_handle.py Normal file
View File

@@ -0,0 +1,50 @@
import yaml
from spotdl import handle
from spotdl import const
import pytest
import os
import sys
import argparse
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
class TestConfig:
def test_default_config(self, tmpdir):
expect_config = handle.default_conf['spotify-downloader']
global config_path
config_path = os.path.join(str(tmpdir), 'config.yml')
config = handle.get_config(config_path)
assert config == expect_config
def test_modified_config(self):
global modified_config
modified_config = dict(handle.default_conf)
modified_config['spotify-downloader']['file-format'] = 'just_a_test'
merged_config = handle.merge(handle.default_conf, modified_config)
assert merged_config == modified_config
def test_custom_config_path(self, tmpdir):
parser = argparse.ArgumentParser()
with open(config_path, 'w') as config_file:
yaml.dump(modified_config, config_file, default_flow_style=False)
overridden_config = handle.override_config(config_path,
parser,
raw_args='')
modified_values = [ str(value) for value in modified_config['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)

82
test/test_internals.py Normal file
View File

@@ -0,0 +1,82 @@
from spotdl import internals
import sys
import os
import subprocess
import pytest
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
class TestPathFilterer:
def test_create_directory(self, tmpdir):
expect_path = True
global folder_path
folder_path = os.path.join(str(tmpdir), 'filter_this_folder')
internals.filter_path(folder_path)
is_path = os.path.isdir(folder_path)
assert is_path == expect_path
def test_remove_temp_files(self, tmpdir):
expect_file = False
file_path = os.path.join(folder_path, 'pesky_file.temp')
open(file_path, 'a')
internals.filter_path(folder_path)
is_file = os.path.isfile(file_path)
assert is_file == expect_file
class TestVideoTimeFromSeconds:
def test_from_seconds(self):
expect_duration = '35'
duration = internals.videotime_from_seconds(35)
assert duration == expect_duration
def test_from_minutes(self):
expect_duration = '2:38'
duration = internals.videotime_from_seconds(158)
assert duration == expect_duration
def test_from_hours(self):
expect_duration = '1:16:02'
duration = internals.videotime_from_seconds(4562)
assert duration == expect_duration
class TestGetSeconds:
def test_from_seconds(self):
expect_secs = 45
secs = internals.get_sec('0:45')
assert secs == expect_secs
secs = internals.get_sec('0.45')
assert secs == expect_secs
def test_from_minutes(self):
expect_secs = 213
secs = internals.get_sec('3.33')
assert secs == expect_secs
secs = internals.get_sec('3:33')
assert secs == expect_secs
def test_from_hours(self):
expect_secs = 5405
secs = internals.get_sec('1.30.05')
assert secs == expect_secs
secs = internals.get_sec('1:30:05')
assert secs == expect_secs
def test_raise_error(self):
with pytest.raises(ValueError):
internals.get_sec('10*05')
with pytest.raises(ValueError):
internals.get_sec('02,28,46')

50
test/test_list.py Normal file
View File

@@ -0,0 +1,50 @@
from spotdl import spotify_tools
from spotdl import const
from spotdl import spotdl
import builtins
import os
def test_user_playlists(tmpdir, monkeypatch):
expect_tracks = 14
text_file = os.path.join(str(tmpdir), 'test_us.txt')
monkeypatch.setattr('builtins.input', lambda x: 1)
spotify_tools.write_user_playlist('alex', text_file)
with open(text_file, 'r') as tin:
tracks = len(tin.readlines())
assert tracks == expect_tracks
def test_playlist(tmpdir):
expect_tracks = 14
text_file = os.path.join(str(tmpdir), 'test_pl.txt')
spotify_tools.write_playlist('https://open.spotify.com/user/alex/playlist/0iWOVoumWlkXIrrBTSJmN8', text_file)
with open(text_file, 'r') as tin:
tracks = len(tin.readlines())
assert tracks == expect_tracks
def test_album(tmpdir):
expect_tracks = 15
global text_file
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 tin:
tracks = len(tin.readlines())
assert tracks == expect_tracks
def test_trim():
with open(text_file, 'r') as track_file:
tracks = track_file.readlines()
expect_number = len(tracks) - 1
expect_track = tracks[0]
track = spotdl.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)

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

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)

154
test/test_with_metadata.py Normal file
View File

@@ -0,0 +1,154 @@
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 spotdl
import loader
import os
loader.load_defaults()
raw_song = 'http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU'
def test_metadata():
expect_number = 23
global meta_tags
meta_tags = spotify_tools.generate_metadata(raw_song)
assert len(meta_tags) == expect_number
class TestFileFormat:
def test_with_spaces(self):
expect_title = 'David André Østby - Intro'
title = internals.format_string(const.args.file_format, meta_tags)
assert title == expect_title
def test_without_spaces(self):
expect_title = 'David_André_Østby_-_Intro'
const.args.no_spaces = True
title = internals.format_string(const.args.file_format, meta_tags)
assert title == expect_title
def test_youtube_url():
expect_url = 'http://youtube.com/watch?v=rg1wfcty0BA'
url = youtube_tools.generate_youtube_url(raw_song, meta_tags)
assert url == expect_url
def test_youtube_title():
expect_title = 'Intro - David André Østby'
global content
content = youtube_tools.go_pafy(raw_song, meta_tags)
title = youtube_tools.get_youtube_title(content)
assert title == expect_title
def test_check_track_exists_before_download(tmpdir):
expect_check = False
const.args.folder = str(tmpdir)
# prerequisites for determining filename
songname = internals.format_string(const.args.file_format, meta_tags)
global file_name
file_name = internals.sanitize_title(songname)
check = spotdl.check_exists(file_name, raw_song, meta_tags)
assert check == expect_check
class TestDownload:
def test_m4a(self):
expect_download = True
download = youtube_tools.download_song(file_name + '.m4a', content)
assert download == expect_download
def test_webm(self):
expect_download = True
download = youtube_tools.download_song(file_name + '.webm', content)
assert download == expect_download
class TestFFmpeg():
def test_convert_from_webm_to_mp3(self):
expect_return_code = 0
return_code = convert.song(file_name + '.webm',
file_name + '.mp3',
const.args.folder)
assert return_code == expect_return_code
def test_convert_from_webm_to_m4a(self):
expect_return_code = 0
return_code = convert.song(file_name + '.webm',
file_name + '.m4a',
const.args.folder)
assert return_code == expect_return_code
def test_convert_from_m4a_to_mp3(self):
expect_return_code = 0
return_code = convert.song(file_name + '.m4a',
file_name + '.mp3',
const.args.folder)
assert return_code == expect_return_code
def test_convert_from_m4a_to_webm(self):
expect_return_code = 0
return_code = convert.song(file_name + '.m4a',
file_name + '.webm',
const.args.folder)
assert return_code == expect_return_code
def test_convert_from_m4a_to_flac(self):
expect_return_code = 0
return_code = convert.song(file_name + '.m4a',
file_name + '.flac',
const.args.folder)
assert return_code == expect_return_code
class TestAvconv:
def test_convert_from_m4a_to_mp3(self):
expect_return_code = 0
return_code = convert.song(file_name + '.m4a',
file_name + '.mp3',
const.args.folder,
avconv=True)
assert return_code == expect_return_code
class TestEmbedMetadata:
def test_embed_in_mp3(self):
expect_embed = True
global track_path
track_path = os.path.join(const.args.folder, file_name)
embed = metadata.embed(track_path + '.mp3', meta_tags)
assert embed == expect_embed
def test_embed_in_m4a(self):
expect_embed = True
embed = metadata.embed(track_path + '.m4a', meta_tags)
os.remove(track_path + '.m4a')
assert embed == expect_embed
def test_embed_in_webm(self):
expect_embed = False
embed = metadata.embed(track_path + '.webm', meta_tags)
os.remove(track_path + '.webm')
assert embed == expect_embed
def test_embed_in_flac(self):
expect_embed = True
embed = metadata.embed(track_path + '.flac', meta_tags)
os.remove(track_path + '.flac')
assert embed == expect_embed
def test_check_track_exists_after_download():
expect_check = True
check = spotdl.check_exists(file_name, raw_song, meta_tags)
os.remove(track_path + '.mp3')
assert check == expect_check

View File

@@ -0,0 +1,129 @@
from spotdl import const
from spotdl import internals
from spotdl import spotify_tools
from spotdl import youtube_tools
from spotdl import spotdl
import loader
import os
import builtins
loader.load_defaults()
raw_song = "Tony's Videos VERY SHORT VIDEO 28.10.2016"
class TestYouTubeAPIKeys:
def test_custom(self):
expect_key = 'some_api_key'
const.args.youtube_api_key = expect_key
youtube_tools.set_api_key()
key = youtube_tools.pafy.g.api_key
assert key == expect_key
def test_default(self):
expect_key = 'AIzaSyC6cEeKlxtOPybk9sEe5ksFN5sB-7wzYp0'
const.args.youtube_api_key = None
youtube_tools.set_api_key()
key = youtube_tools.pafy.g.api_key
assert key == expect_key
def test_metadata():
expect_metadata = None
global metadata
metadata = spotify_tools.generate_metadata(raw_song)
assert metadata == expect_metadata
class TestArgsManualResultCount:
# Regresson test for issue #264
def test_scrape(self):
const.args.manual = True
url = youtube_tools.GenerateYouTubeURL("she is still sleeping SAO",
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("she is still sleeping SAO",
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):
# YouTube keeps changing its results
expect_urls = ('http://youtube.com/watch?v=qOOcy2-tmbk',
'http://youtube.com/watch?v=5USR1Omo7f0')
const.args.music_videos_only = True
url = youtube_tools.generate_youtube_url(raw_song, metadata)
assert url in expect_urls
def test_all_categories(self):
expect_url = 'http://youtube.com/watch?v=qOOcy2-tmbk'
const.args.music_videos_only = False
url = youtube_tools.generate_youtube_url(raw_song, metadata)
assert url == expect_url
def test_args_manual(self, monkeypatch):
expect_url = 'http://youtube.com/watch?v=qOOcy2-tmbk'
const.args.manual = True
monkeypatch.setattr('builtins.input', lambda x: '1')
url = youtube_tools.generate_youtube_url(raw_song, metadata)
assert url == expect_url
def test_args_manual_none(self, monkeypatch):
expect_url = None
monkeypatch.setattr('builtins.input', lambda x: '0')
url = youtube_tools.generate_youtube_url(raw_song, metadata)
const.args.manual = False
assert url == expect_url
class TestYouTubeTitle:
def test_single_download_with_youtube_api(self):
global content
global title
expect_title = "Tony's Videos VERY SHORT VIDEO 28.10.2016"
key = 'AIzaSyAnItl3udec-Q1d5bkjKJGL-RgrKO_vU90'
const.args.youtube_api_key = key
youtube_tools.set_api_key()
content = youtube_tools.go_pafy(raw_song, metadata)
title = youtube_tools.get_youtube_title(content)
assert title == expect_title
def test_download_from_list_without_youtube_api(self):
expect_title = "1. Tony's Videos VERY SHORT VIDEO 28.10.2016"
const.args.youtube_api_key = None
youtube_tools.set_api_key()
content = youtube_tools.go_pafy(raw_song, metadata)
title = youtube_tools.get_youtube_title(content, 1)
assert title == expect_title
def test_check_exists(tmpdir):
expect_check = False
const.args.folder = str(tmpdir)
# prerequisites for determining filename
global file_name
file_name = internals.sanitize_title(title)
check = spotdl.check_exists(file_name, raw_song, metadata)
assert check == expect_check
class TestDownload:
def test_webm(self):
# content does not have any .webm audiostream
expect_download = False
download = youtube_tools.download_song(file_name + '.webm', content)
assert download == expect_download
def test_other(self):
expect_download = False
download = youtube_tools.download_song(file_name + '.fake_extension', content)
assert download == expect_download