Compare commits

..

170 commits

Author SHA1 Message Date
cooperhammond c99e8257e9 updated documentation #85 2022-02-23 10:59:15 -07:00
Cooper Hammond 3bbb0e767a
Merge pull request #84 from imsamuka/master
add option to apply metadata in existing file.

Apologies for the late merge, you sent this pull request right as school was beginning to pick up in earnest and I forgot about it in that rush. Thanks for the great work!
2022-01-27 11:18:58 -07:00
imsamuka 61120f21b0
add option to apply metadata in existing file 2022-01-07 22:15:27 -03:00
Cooper Hammond 390d59b9a0
Merge pull request #83 from imsamuka/fix-options
Fix options and add --ask-skip
2022-01-04 08:46:37 -07:00
imsamuka 3263ff4e07
fix GET requests url encoding 2022-01-03 01:01:58 -03:00
imsamuka 3d4acdeaea
add option to skip tracks on albums/playlists 2022-01-02 20:25:47 -03:00
imsamuka 72938a9b6a
show video title from url 2022-01-02 19:24:54 -03:00
imsamuka f962a0ab75
make youtube url validation safer 2022-01-02 18:04:05 -03:00
imsamuka ac7bc02ec5
fix youtube urls validation 2022-01-02 17:20:37 -03:00
imsamuka bdc63b4c35
fix --url ignoring argument on song.cr 2022-01-02 17:05:00 -03:00
imsamuka 289f1d8c63
fix video selection offset 2022-01-02 15:19:16 -03:00
Cooper Hammond f3776613b4 update version for new binary 2021-07-12 09:10:43 -06:00
Cooper Hammond ff3019e207
Merge pull request #78 from cooperhammond/select-vid-dl
added search terms config option and cli menu
2021-04-15 11:23:38 -06:00
Cooper Hammond fa5f3bb3b7 added search terms config option and cli menu
-S or --select will allow you to choose your song, for playlists or for
albums
2021-04-15 11:22:01 -06:00
Cooper Hammond 8d348031d3 update to 1.3.0 2021-04-15 09:46:55 -06:00
Cooper Hammond 92e8885ae9
Merge pull request #77 from cooperhammond/search-improvement
Search improvement based on song duration
2021-04-15 09:45:27 -06:00
Cooper Hammond 5eaac33345 minor fix to include duration_ms in all song metadata 2021-04-15 09:41:13 -06:00
Cooper Hammond 8c15f7b5e2 song duration now included in ranking 2021-04-14 09:12:08 -06:00
Cooper Hammond 3f12a880e9 minor fix for cross device linking 2021-04-13 22:39:33 -06:00
Cooper Hammond 8f25eae1cb update version 2021-01-09 15:15:19 -07:00
Cooper Hammond 124b425f55
Merge pull request #74 from luca-schlecker/GH-1
make the way mp3s are saved configurable

Looks great dude, happy to see someone take an interest in the project
2021-01-03 13:16:16 -08:00
Luca Schlecker 2e8bc6c8c5 make the way mp3s are saved configurable
Signed-off-by: Luca Schlecker <luca.schlecker@hotmail.com>
2020-12-30 00:52:38 +01:00
Cooper Hammond b38bcd4ad8
Merge pull request #73 from luca-schlecker/master
fix #72: Find the JSON data inside the line and trim the rest
2020-12-29 14:38:56 -08:00
Luca Schlecker 2c364c38c2 fix #72: Find the JSON data inside the line and trim the rest
Signed-off-by: Luca Schlecker <luca.schlecker@hotmail.com>
2020-12-29 21:10:45 +01:00
Cooper Hammond c20f4309d8
Merge pull request #69 from Who23/bug-fix-68
Fix #68

Can't believe I didn't see this earlier. Thanks for the great work! I'll merge it now.
2020-11-08 01:21:30 -07:00
Cooper Hammond 047cc71b0d added a very simple test suite 2020-11-08 01:17:45 -07:00
Cooper Hammond a8a1c4d1c3 fix for updated yt render/metadata code 2020-11-08 00:47:23 -07:00
Cooper Hammond bf29194042 fix for #70 2020-11-08 00:37:16 -07:00
Who23 843a5b9db1 Fix #68
Fix bug where it was assumed that every artist would be tagged with a
genre
2020-09-11 13:48:12 -04:00
Cooper Hammond 58895e2e87
Merge pull request #66 from Who23/youtube-sources
Add ability to specifiy youtube URLs manually

Looks like wonderful stuff, I appreciate the contribution and time investment to my small homebrew project, I hope you've been getting good stuff out of it.
2020-09-08 13:54:58 -06:00
Who23 dd8c74520c URL source for albums/playlists & Youtube module improvements
- Add ability to source youtube URls for albums and playlists, through
  the -g flag, which prompts for user input on each song

- Fix the Youtube.is_valid_url function, which now actually checks
  whether the given URL points to an actual video
2020-09-07 18:16:31 -04:00
Who23 e8a71b2530 Add ability to specifiy youtube URL source
Added a new flag (-u) to specify a youtube URL source when downloading a
single song.
2020-09-04 20:49:07 -04:00
Cooper Hammond ca02d0bdc7
Merge pull request #64 from cooperhammond/update-to-.35
Update to .35
2020-09-01 12:40:41 -06:00
Cooper Hammond 0f32ec6ce3 removed bad files 2020-09-01 12:40:19 -06:00
Cooper Hammond d7a4044d77 Updated to new YT standards 2020-09-01 12:39:27 -06:00
Cooper Hammond 10bd5fd969 up to date with .35 specs and standards 2020-08-31 14:35:22 -06:00
Cooper Hammond 4e1ce855eb Merge branch 'master' of https://github.com/cooperhammond/irs 2020-05-21 22:22:43 -07:00
Cooper Hammond 2d64d7a18d Update README.md 2020-05-21 22:03:44 -07:00
Cooper Hammond e23033d269 Merge pull request #60 from cooperhammond/crystal-port
Crystal port
2020-05-21 19:45:09 -07:00
Cooper Hammond dd23fb5527 README updata, it's pretty AND useful now 2020-05-21 19:27:39 -07:00
Cooper Hammond 5f8acac053 feature: CLI download messages have been updated to be prettier 2020-05-21 19:23:44 -07:00
Cooper Hammond 451ef33cca feature: unify into album option in config works now 2020-05-21 16:23:30 -07:00
Cooper Hammond ce6f77d68d nothing affecting functionality, all meta changes
- changed license to MIT
- wrote out actual README
- spotify client keys are checked for on run in config.cr
- spotify-searcher class doesn't crash now when there's a problem with the keys or authentication,
  rather it just sets @authenticated to false
2020-05-15 09:16:50 -07:00
Cooper Hammond de219cbe66 crystal tool format respec 2020-05-12 22:38:03 -07:00
Cooper Hammond d1657ba86d Changed a bunch of stuff. Almost ready for release.
- updated cli for config viewing and changes
- updated config method. an environment variable `IRS_CONFIG_LOCATION` will be set pointing to a yaml file with a config
- moved album metadata changing from a janky homebrew json modifier to the core json lib
- mapper.cr is the collection of classes for messing around with json metadata
- playlists are almost done, they still need the ability to (optionally) change the metadata of the songs downloaded in
the playlist, but they (optionally) will place all downloaded playlist songs in a single folder
- added a getter to the filename in song.cr
2020-05-12 22:28:03 -07:00
Cooper Hammond 4c20735abd spotify searcher can now find and compile playlists >100 songs 2020-05-09 16:13:50 -07:00
Cooper Hammond 5c611c9af5 removed a hardcoded path 2020-03-28 15:00:29 -07:00
Cooper Hammond 4e0ba7ec79 fixed indentation 2020-03-28 01:23:41 -07:00
Cooper Hammond eb3f332521 Improved ability of spotify.cr to find playlists
If the general search doesn't return a playlist, it will bootstrap
your terms into a specific call for the user's playlists and search
through them
2020-03-28 01:17:07 -07:00
Cooper Hammond abe769bfcd Added playlist to the --help screen 2020-03-26 20:55:14 -07:00
Cooper Hammond 5881d21f48 Cleaned up list inheritance, songs are automatically organized
Moved more permanent variables to the config. I need to start
thinking about custom configs rather than a hard-coded one.
2020-03-10 14:12:12 -06:00
Cooper Hammond a24703d7bf Merge pull request #58 from cooperhammond/crystal-port
Updated to v1.0.0
2020-02-25 19:49:58 -07:00
Cooper Hammond ff1b30b845 Updated to v1.0.0
Added -i flag to install youtube-dl, ffmpeg, and ffprobe
2020-02-21 12:00:27 -07:00
Cooper Hammond f849e61045 need to add 'next' feature for spotify searches 2019-06-23 13:39:00 -07:00
Cooper Hammond 8cfb59a368 logging for individual song done! 2019-06-21 16:30:14 -07:00
Cooper Hammond 80cb034ce1 Preliminary interception logging done! 2019-06-21 09:45:55 -07:00
Cooper Hammond 76a624d32f Initial CLI done! 2019-06-20 09:43:31 -07:00
Cooper Hammond acd2abb1d0 song glue done! 2019-06-19 19:06:09 -07:00
Cooper Hammond f8dc95265f Merge branch 'cystal-port' of https://github.com/cooperhammond/irs into cystal-port 2019-06-19 15:58:30 -07:00
Cooper Hammond 201c2f5421 fixed conflict 2019-06-19 15:57:50 -07:00
Cooper Hammond d3bad389f1 fixed conflict 2019-06-19 15:55:32 -07:00
Cooper Hammond 6c32571ccc tagger edits 2019-06-19 15:54:26 -07:00
Cooper Hammond c3dc25c23e removed empty folder 2019-06-14 18:03:05 -07:00
Cooper Hammond cce8e2aeaf tagger edits 2019-06-14 18:01:22 -07:00
Cooper Hammond 219cc4bc53 tagger based off of ffmpeg created 2019-06-14 11:39:51 -07:00
Cooper Hammond f82affb589 fixed youtube search ranking algo 2019-06-14 08:22:38 -07:00
Cooper Hammond 253efd1e11 ripper module finished 2019-06-12 22:11:16 -07:00
Cooper Hammond 45f4d998a4 finished youtube searcher 2019-06-12 12:52:37 -07:00
Cooper Hammond ddcd611585 Spotify searcher is now minimum viable product. 2019-06-11 13:57:57 -07:00
Cooper Hammond 09154058bc init 2019-06-07 22:46:54 -07:00
Cooper Hammond c4ff915ff2 correct system exit 2019-05-29 09:58:26 -07:00
Cooper Hammond 8482a1e2bb updated env vars and setup.py requires 2019-05-29 08:14:42 -07:00
Cooper Hammond 2d909ff694 update environment variables 2019-05-29 08:10:15 -07:00
Cooper Hammond 0a9e7d3c61 Merge pull request #55 from cooperhammond/rework
Reworked the entire thing. It's beautiful now.
2019-05-28 22:41:31 -07:00
Cooper Hammond aa269830ac updated readme 2019-05-28 22:40:35 -07:00
Cooper Hammond 1b044b615a Added in config and --setup flag 2019-05-28 22:36:23 -07:00
Cooper Hammond 422fdd232c updated install 2019-05-28 10:48:26 -07:00
Cooper Hammond 7f5fe6b953 Added in another status message
and changed default organization method
2019-05-28 10:19:48 -07:00
Cooper Hammond ffa85fd190 minor fixes 2019-05-25 11:58:25 -07:00
Cooper Hammond 72d7108d48 I believe this is a very very minimal MVP. It's clean. I love it. 2019-05-25 11:46:24 -07:00
Cooper Hammond f081a37581 Wrote a clean metadata tagger and spotify searcher 2019-05-09 09:57:49 -07:00
Cooper Hammond 1bbea0086b rewrote youtube link finder 2019-05-04 19:03:19 -07:00
Cooper Hammond 13800ad087 Merge pull request #47 from kepoorhampond/fix/ffmpeg-recognization
fixed not recognizing ffmpeg
2018-10-21 07:38:15 +00:00
kepoorhampond e0d01a1cfd fixed not recognizing ffmpeg 2018-10-21 00:36:36 +00:00
Kepoor Hampond 1c4e11efbe Update README.md
Remove build status until I've fixed its weird errors.
2017-11-28 21:35:27 -08:00
Kepoor Hampond 11fe7acd76 Merge pull request #40 from aubguillemette/master
By Viola, did you mean Voilà?
2017-11-28 20:23:07 -08:00
Aub Guillemette 325d2cc6c2 Changed Viola for Voilà 2017-11-28 17:40:36 -05:00
Kepoor Hampond cb90b4e325 Update README.md 2017-11-14 07:55:07 -08:00
Kepoor Hampond 4a40bb8dca Fixed for windows 2017-11-05 23:09:56 -08:00
kepoorhampond d751d80644 Update of pypi 2017-10-13 23:10:59 -07:00
kepoorhampond 5d0091b570 Merge branch 'master' of https://github.com/kepoorhampond/irs 2017-10-13 23:03:55 -07:00
kepoorhampond 727368fdd1 Fixed getting stuck in an infinite loop while searching for yt link 2017-10-13 23:03:32 -07:00
Video ec46908cc3 Added splinter as a dependency 2017-10-10 08:18:32 -07:00
kepoorhampond 6e915b8937 Upload to pypi 2017-09-30 18:36:27 -07:00
Kepoor Hampond 32b52df309 Merge pull request #39 from kepoorhampond/exact-album
-e/--exact flag feature
2017-09-30 18:12:42 -07:00
Kepoor Hampond 8c5b64f21c Merge branch 'master' into exact-album 2017-09-30 18:12:34 -07:00
Kepoor Hampond a1da7869ad Merge pull request #38 from kepoorhampond/captcha-cheat
Bypass captcha in case google catches onto you abusing their special child youtube 😉
2017-09-30 18:06:45 -07:00
Kepoor Hampond 979201b66f Merge branch 'master' into captcha-cheat 2017-09-30 18:06:03 -07:00
kepoorhampond d21ac18059 reset ffmpeg-checker 2017-09-18 19:58:37 -07:00
kepoorhampond aa365b4b85 -e/--exact flag feature 2017-09-09 22:11:54 -07:00
kepoorhampond 18ae50b484 Committed a tmp file 2017-09-05 12:24:19 -07:00
kepoorhampond a4915cf85d Fixed to be compatible for 2 types of youtubes HTML/CSS code 2017-09-05 12:22:56 -07:00
Kepoor Hampond 13c71da9c4 Merge pull request #37 from kepoorhampond/replace-and-and-&
removing & and 'and' in `ObjManip.blank` order to increase accuracy.
2017-08-09 13:20:29 -07:00
Kepoor Hampond 7f68a606f6 test 2017-08-09 01:11:58 -07:00
Kepoor Hampond 0720126a4f Update on pypi 2017-06-28 20:24:34 -07:00
Kepoor Hampond e5041446a9 Merge pull request #36 from kepoorhampond/issue-35
Issue 35
2017-06-28 20:23:29 -07:00
Kepoor Hampond a0600f5f9c Remove testing code 2017-06-28 20:18:12 -07:00
Kepoor Hampond 88c4c4a890 Hopeful fix 2017-06-28 20:09:48 -07:00
Kepoor Hampond b7daa1f76f Update on pypi 2017-06-13 17:09:53 -07:00
Kepoor Hampond f95c176d9c Merge pull request #34 from kepoorhampond/issue-33
Fix issue-33
2017-06-13 17:05:24 -07:00
Kepoor Hampond beb16da710 Fix issue-33 2017-06-13 17:00:47 -07:00
Kepoor Hampond a43e90bbc4 Merge pull request #32 from kepoorhampond/cleaner-cli
silence argparse errors
2017-06-11 14:26:50 -07:00
Kepoor Hampond a72174d59b Create README.md 2017-06-11 14:24:06 -07:00
Kepoor Hampond a2852c4ea6 remove travis ci dependencies 2017-06-11 14:21:14 -07:00
Kepoor Hampond d97d25ef8c silence argparse errors 2017-06-11 14:18:22 -07:00
Kepoor Hampond d1eb77c004 Update on pypi 2017-06-11 14:11:52 -07:00
Kepoor Hampond 72dc6f3c3f Merge pull request #31 from kepoorhampond/better-organization-of-setup
Make sure to call `irs --setup` before anything else
2017-06-11 14:11:03 -07:00
Kepoor Hampond b2ba980028 travis dependencies 2017-06-11 14:06:18 -07:00
Kepoor Hampond 00523cae88 Make sure to call irs --setup before anything else 2017-06-11 14:03:56 -07:00
Kepoor Hampond 0937978496 Update on pypi 2017-06-10 19:07:37 -07:00
Kepoor Hampond 935c75dd7d Update README.md 2017-06-10 19:06:10 -07:00
Kepoor Hampond 61f318ba99 Create README.md 2017-06-10 19:05:06 -07:00
Kepoor Hampond 46aeaa8bfa Update README.md 2017-06-10 19:04:26 -07:00
Kepoor Hampond 24f3681b36 Merge pull request #30 from kepoorhampond/issue-14
Apparently it was just some wierd apostrophe in a unicode format
2017-06-10 19:01:29 -07:00
Kepoor Hampond c919e1720e Minor bug fixes 2017-06-10 18:53:49 -07:00
Kepoor Hampond e61036dc51 Maybe again? 2017-06-10 18:48:30 -07:00
Kepoor Hampond fa64378c2b Once more? 2017-06-10 18:43:42 -07:00
Kepoor Hampond 5d2537b236 Try it again 2017-06-10 18:40:11 -07:00
Kepoor Hampond c2b0573040 Better option 2017-06-10 18:33:32 -07:00
Kepoor Hampond f01fd21ef9 Apparently it was just some wierd apostrophe in a unicode format 2017-06-10 18:28:26 -07:00
Kepoor Hampond 573bc7c656 Merge pull request #28 from kepoorhampond/random-unicode-character-errors
fixed random unicode character error and removed the need to install ffmpeg.
2017-06-10 18:16:43 -07:00
Kepoor Hampond 18d12f54e3 I hate getting stuff to work locally and remotely. A LOT. 2017-06-10 18:11:00 -07:00
Kepoor Hampond 58eef229e1 I hate getting stuff to work locally and remotely 2017-06-10 18:08:02 -07:00
Kepoor Hampond 70e8af6a60 Travis isn't working while my computer is 2017-06-10 17:52:56 -07:00
Kepoor Hampond 12a9231abc Forgot to comment a different line 2017-06-10 17:42:05 -07:00
Kepoor Hampond 153de2475d Forgot to uncomment lines 2017-06-10 17:39:15 -07:00
Kepoor Hampond d1c7c49f71 Python 3 compatability 2017-06-10 17:37:16 -07:00
Kepoor Hampond af9331efd3 Travis dependencies 2017-06-10 12:31:15 -07:00
Kepoor Hampond 4243b1e41d Install ydl-binaries 2017-06-10 12:21:17 -07:00
Kepoor Hampond 8e6f76c312 Now automatically downloads youtube-dl, ffmpeg, and ffprobe binaries 2017-06-10 12:16:51 -07:00
Kepoor Hampond 48ad9ac19e fix travis, for the love of god 2017-06-09 17:05:06 -07:00
Kepoor Hampond acf746aca7 travis can't find ffprobe 2017-06-09 16:47:51 -07:00
Kepoor Hampond 10334d8e19 added to README and hopefully fixed the authorization bug. 2017-06-09 16:33:48 -07:00
Kepoor Hampond 40ae6b3561 more python 3.x compatability 2017-06-05 19:53:53 -07:00
Kepoor Hampond fa35d224ac travis ci patch/fix 2017-06-05 19:48:57 -07:00
Kepoor Hampond 73c7f9676a python 3 compatibility 2017-06-05 19:37:16 -07:00
Kepoor Hampond db850af7c1 fixed it 2017-06-05 19:30:23 -07:00
Kepoor Hampond de425ea27b Merge pull request #27 from kepoorhampond/osrename-to-shutilmove
Changed `os.rename` to `shutil.move`
2017-05-19 08:25:52 -07:00
kepoorhampond f711801d9d did it 2017-05-19 13:59:26 +00:00
Kepoor Hampond 2f4c707711 updated pypi 2017-05-12 16:37:21 -07:00
Kepoor Hampond f10f1ef36b Merge pull request #25 from kepoorhampond/better-alias-rerouting
Better alias rerouting
2017-05-12 16:35:52 -07:00
Kepoor Hampond 94090621c2 better argument rerouting in the aliases for album and playlist 2017-05-12 16:35:02 -07:00
Kepoor Hampond 2230621e86 Updated pypi 2017-05-06 22:42:06 -07:00
Kepoor Hampond 630800fded Merge pull request #24 from kepoorhampond/custom-text
Added custom hook text support
2017-05-06 22:41:29 -07:00
Kepoor Hampond 12d351f74f Added custom hook text support 2017-05-06 22:37:06 -07:00
Kepoor Hampond cb04a697d9 Merge pull request #23 from kepoorhampond/find-yt-link-twice
Fixed annoying bug where it prints Finding Youtube link ... twice
2017-05-04 21:43:19 -07:00
Kepoor Hampond 386abf62d0 Fixed annoying bug where it prints Finding Youtube link ... twice 2017-05-04 21:35:43 -07:00
Kepoor Hampond 5a2bc55e90 Updated README.md 2017-05-01 22:45:10 -07:00
Kepoor Hampond ec369a8421 Updated README.md 2017-05-01 22:44:14 -07:00
Kepoor Hampond e627d8cfca Updated version on pypi 2017-04-30 12:34:55 -07:00
Kepoor Hampond b4272c2405 Updated version on pypi 2017-04-30 12:11:24 -07:00
Kepoor Hampond e3dfba8180 Merge pull request #21 from kepoorhampond/issue-20
Fix issue #20
2017-04-30 12:07:41 -07:00
Kepoor Hampond 248b272c4b Forgot to finish except loop 2017-04-30 12:02:47 -07:00
Kepoor Hampond 350f2c6b55 Version error with python 2 and 3 causing issue #20 2017-04-30 11:46:15 -07:00
Kepoor Hampond 92b2de0716 updated travis.yml 2017-04-30 11:12:51 -07:00
Kepoor Hampond 7d34d52d8c testing on issue #20 2017-04-30 11:04:21 -07:00
Kepoor Hampond 11d5cd340a testing with python 3.6 2017-04-30 10:59:38 -07:00
Kepoor Hampond 33f6c1cc38 Removed partially downloaded tmp file 2017-04-23 11:22:19 -07:00
Kepoor Hampond 6ce2a50247 Forgot to update on pypi 2017-04-21 19:16:50 -07:00
Kepoor Hampond ce0d815d4c Mispelling of parse_artist in ripper.py for -a + -A 2017-04-21 19:15:56 -07:00
20 changed files with 774 additions and 212 deletions

3
.gitignore vendored
View file

@ -11,4 +11,5 @@
.ripper.log
ffmpeg
ffprobe
youtube-dl
youtube-dl
*.temp

View file

@ -54,6 +54,8 @@ Arguments:
-s, --song <song> Specify song name to download
-A, --album <album> Specify the album name to download
-p, --playlist <playlist> Specify the playlist name to download
-u, --url <url> Specify the youtube url to download from (for single songs only)
-g, --give-url Specify the youtube url sources while downloading (for albums or playlists only only)
Examples:
$ irs --song "Bohemian Rhapsody" --artist "Queen"
@ -75,6 +77,8 @@ Examples:
Just download the latest release for your platform
[here](https://github.com/cooperhammond/irs/releases).
Note that the binaries right now have only been tested on WSL. They *should* run on most linux distros, and OS X, but if they don't please make an issue above.
### From Source
If you're one of those cool people who compiles from source
@ -92,6 +96,8 @@ If you're one of those cool people who compiles from source
```yaml
binary_directory: ~/.irs/bin
music_directory: ~/Music
filename_pattern: "{track_number} - {title}"
directory_pattern: "{artist}/{album}"
client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
single_folder_playlist:
@ -118,6 +124,9 @@ Here's what they do:
```yaml
binary_directory: ~/.irs/bin
music_directory: ~/Music
search_terms: "lyrics"
filename_pattern: "{track_number} - {title}"
directory_pattern: "{artist}/{album}"
client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
single_folder_playlist:
@ -128,8 +137,10 @@ single_folder_playlist:
- `binary_directory`: a path specifying where the downloaded binaries should
be placed
- `music_directory`: a path specifying where downloaded mp3s should be placed.
Note that there will be more structure created inside that folder, usually
in the format of `music-dir>artist-name>album-name>track`
- `search_terms`: additional search terms to plug into youtube, which can be
potentially useful for not grabbing erroneous audio.
- `filename_pattern`: a pattern for the output filename of the mp3
- `directory_pattern`: a pattern for the folder structure your mp3s are saved in
- `client_key`: a client key from your spotify API application
- `client_secret`: a client secret key from your spotify API application
- `single_folder_playlist/enabled`: if set to true, all mp3s from a downloaded
@ -141,6 +152,55 @@ single_folder_playlist:
the album name and album image of the mp3 with the title of your playlist
and the image for your playlist respectively
In a pattern following keywords will be replaced:
| Keyword | Replacement | Example |
| :----: | :----: | :----: |
| `{artist}` | Artist Name | Queen |
| `{title}` | Track Title | Bohemian Rhapsody |
| `{album}` | Album Name | Stone Cold Classics |
| `{track_number}` | Track Number | 9 |
| `{total_tracks}` | Total Tracks in Album | 14 |
| `{disc_number}` | Disc Number | 1 |
| `{day}` | Release Day | 01 |
| `{month}` | Release Month | 01 |
| `{year}` | Release Year | 2006 |
| `{id}` | Spotify ID | 6l8GvAyoUZwWDgF1e4822w |
Beware OS-restrictions when naming your mp3s.
Pattern Examples:
```yaml
music_directory: ~/Music
filename_pattern: "{track_number} - {title}"
directory_pattern: "{artist}/{album}"
```
Outputs: `~/Music/Queen/Stone Cold Classics/9 - Bohemian Rhapsody.mp3`
<br><br>
```yaml
music_directory: ~/Music
filename_pattern: "{artist} - {title}"
directory_pattern: ""
```
Outputs: `~/Music/Queen - Bohemian Rhapsody.mp3`
<br><br>
```yaml
music_directory: ~/Music
filename_pattern: "{track_number} of {total_tracks} - {title}"
directory_pattern: "{year}/{artist}/{album}"
```
Outputs: `~/Music/2006/Queen/Stone Cold Classics/9 of 14 - Bohemian Rhapsody.mp3`
<br><br>
```yaml
music_directory: ~/Music
filename_pattern: "{track_number}. {title}"
directory_pattern: "irs/{artist} - {album}"
```
Outputs: `~/Music/irs/Queen - Stone Cold Classics/9. Bohemian Rhapsody.mp3`
<br>
## How it works
**At it's core** `irs` downloads individual songs. It does this by interfacing
@ -174,4 +234,4 @@ doing here, _pretty please_ shoot me an email.
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request
5. Create a new Pull Request

View file

@ -1,6 +1,10 @@
version: 1.0
version: 2.0
shards:
ydl_binaries:
github: cooperhammond/ydl-binaries
commit: c82e3937fee20fd076b1c73e24b2d0205e2cf0da
json_mapping:
git: https://github.com/crystal-lang/json_mapping.cr.git
version: 0.1.0
ydl_binaries:
git: https://github.com/cooperhammond/ydl-binaries.git
version: 1.1.1+git.commit.c82e3937fee20fd076b1c73e24b2d0205e2cf0da

View file

@ -1,5 +1,5 @@
name: irs
version: 1.0.0
version: 1.4.0
authors:
- Cooper Hammond <kepoorh@gmail.com>
@ -12,4 +12,6 @@ license: MIT
dependencies:
ydl_binaries:
github: cooperhammond/ydl-binaries
github: cooperhammond/ydl-binaries
json_mapping:
github: crystal-lang/json_mapping.cr

View file

@ -1,9 +1,35 @@
require "./spec_helper"
describe Irs do
describe CLI do
# TODO: Write tests
it "works" do
false.should eq(true)
it "can show help" do
run_CLI_with_args(["--help"])
end
it "can show version" do
run_CLI_with_args(["--version"])
end
# !!TODO: make a long and short version of the test suite
# TODO: makes so this doesn't need user input
it "can install ytdl and ffmpeg binaries" do
# run_CLI_with_args(["--install"])
end
it "can show config file loc" do
run_CLI_with_args(["--config"])
end
it "can download a single song" do
run_CLI_with_args(["--song", "Bohemian Rhapsody", "--artist", "Queen"])
end
it "can download an album" do
run_CLI_with_args(["--artist", "Arctic Monkeys", "--album", "Da Frame 2R / Matador"])
end
it "can download a playlist" do
run_CLI_with_args(["--artist", "prakkillian", "--playlist", "IRS Testing"])
end
end

View file

@ -1,2 +1,10 @@
require "spec"
require "../src/irs"
# https://github.com/mosop/stdio
require "../src/bottle/cli"
def run_CLI_with_args(argv : Array(String))
cli = CLI.new(argv)
cli.act_on_args
end

View file

@ -20,6 +20,10 @@ class CLI
[["-s", "--song"], "song", "string"],
[["-A", "--album"], "album", "string"],
[["-p", "--playlist"], "playlist", "string"],
[["-u", "--url"], "url", "string"],
[["-S", "--select"], "select", "bool"],
[["--ask-skip"], "ask_skip", "bool"],
[["--apply"], "apply_file", "string"]
]
@args : Hash(String, String)
@ -48,6 +52,12 @@ class CLI
#{Style.blue "-s, --song <song>"} Specify song name to download
#{Style.blue "-A, --album <album>"} Specify the album name to download
#{Style.blue "-p, --playlist <playlist>"} Specify the playlist name to download
#{Style.blue "-u, --url <url>"} Specify the youtube url to download from
#{Style.blue " "} (for albums and playlists, the command-line
#{Style.blue " "} argument is ignored, and it should be '')
#{Style.blue "-S, --select"} Use a menu to choose each song's video source
#{Style.blue "--ask-skip"} Before every playlist/album song, ask to skip
#{Style.blue "--apply <file>"} Apply metadata to a existing file
#{Style.bold "Examples:"}
$ #{Style.green %(irs --song "Bohemian Rhapsody" --artist "Queen")}
@ -69,34 +79,35 @@ class CLI
if @args["help"]? || @args.keys.size == 0
help
exit
elsif @args["version"]?
version
exit
elsif @args["install"]?
YdlBinaries.get_both(Config.binary_location)
exit
elsif @args["config"]?
puts ENV["IRS_CONFIG_LOCATION"]?
exit
elsif @args["song"]? && @args["artist"]?
s = Song.new(@args["song"], @args["artist"])
s.provide_client_keys(Config.client_key, Config.client_secret)
s.grab_it
s.organize_it(Config.music_directory)
exit
s.grab_it(flags: @args)
s.organize_it()
elsif @args["album"]? && @args["artist"]?
a = Album.new(@args["album"], @args["artist"])
a.provide_client_keys(Config.client_key, Config.client_secret)
a.grab_it
a.grab_it(flags: @args)
elsif @args["playlist"]? && @args["artist"]?
p = Playlist.new(@args["playlist"], @args["artist"])
p.provide_client_keys(Config.client_key, Config.client_secret)
p.grab_it
p.grab_it(flags: @args)
else
puts Style.red("Those arguments don't do anything when used that way.")
puts "Type `irs -h` to see usage."
exit 1
end
end

View file

@ -7,8 +7,11 @@ require "../search/spotify"
EXAMPLE_CONFIG = <<-EOP
#{Style.dim "exampleconfig.yml"}
#{Style.dim "===="}
#{Style.blue "search_terms"}: #{Style.green "\"lyrics\""}
#{Style.blue "binary_directory"}: #{Style.green "~/.irs/bin"}
#{Style.blue "music_directory"}: #{Style.green "~/Music"}
#{Style.blue "filename_pattern"}: #{Style.green "\"{track_number} - {title}\""}
#{Style.blue "directory_pattern"}: #{Style.green "\"{artist}/{album}\""}
#{Style.blue "client_key"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
#{Style.blue "client_secret"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
#{Style.blue "single_folder_playlist"}:
@ -22,8 +25,11 @@ module Config
extend self
@@arguments = [
"search_terms",
"binary_directory",
"music_directory",
"filename_pattern",
"directory_pattern",
"client_key",
"client_secret",
"single_folder_playlist: enabled",
@ -41,6 +47,10 @@ module Config
exit 1
end
def search_terms : String
return @@conf["search_terms"].to_s
end
def binary_location : String
path = @@conf["binary_directory"].to_s
return Path[path].expand(home: true).to_s
@ -50,6 +60,14 @@ module Config
path = @@conf["music_directory"].to_s
return Path[path].expand(home: true).to_s
end
def filename_pattern : String
return @@conf["filename_pattern"].to_s
end
def directory_pattern : String
return @@conf["directory_pattern"].to_s
end
def client_key : String
return @@conf["client_key"].to_s

28
src/bottle/pattern.cr Normal file
View file

@ -0,0 +1,28 @@
module Pattern
extend self
def parse(formatString : String, metadata : JSON::Any)
formatted : String = formatString
date : Array(String) = (metadata["album"]? || JSON.parse("{}"))["release_date"]?.to_s.split('-')
keys : Hash(String, String) = {
"artist" => ((metadata.dig?("artists") || JSON.parse("{}"))[0]? || JSON.parse("{}"))["name"]?.to_s,
"title" => metadata["name"]?.to_s,
"album" => (metadata["album"]? || JSON.parse("{}"))["name"]?.to_s,
"track_number" => metadata["track_number"]?.to_s,
"disc_number" => metadata["disc_number"]?.to_s,
"total_tracks" => (metadata["album"]? || JSON.parse("{}"))["total_tracks"]?.to_s,
"year" => date[0]?.to_s,
"month" => date[1]?.to_s,
"day" => date[2]?.to_s,
"id" => metadata["id"]?.to_s
}
keys.each do |pair|
formatted = formatted.gsub("{#{pair[0]}}", pair[1] || "")
end
return formatted
end
end

View file

@ -1,3 +1,3 @@
module IRS
VERSION = "0.1.0"
VERSION = "1.4.0"
end

View file

@ -9,7 +9,7 @@ class Album < SpotifyList
# Uses the `spotify_searcher` defined in parent `SpotifyList` to find the
# correct metadata of the list
def find_it
def find_it : JSON::Any
album = @spotify_searcher.find_item("album", {
"name" => @list_name.as(String),
"artist" => @list_author.as(String),
@ -42,6 +42,6 @@ class Album < SpotifyList
end
private def organize(song : Song)
song.organize_it(@home_music_directory)
song.organize_it()
end
end

View file

@ -17,6 +17,9 @@ abstract class SpotifyList
"searching" => [
Style.bold("Searching for %l by %a ... \r"),
Style.green("+ ") + Style.bold("%l by %a \n")
],
"url" => [
Style.bold("When prompted for a URL, provide a youtube URL or press enter to scrape for one\n")
]
}
@ -24,11 +27,19 @@ abstract class SpotifyList
end
# Finds the list, and downloads all of the songs using the `Song` class
def grab_it
def grab_it(flags = {} of String => String)
ask_url = flags["url"]?
ask_skip = flags["ask_skip"]?
is_playlist = flags["playlist"]?
if !@spotify_searcher.authorized?
raise("Need to call provide_client_keys on Album or Playlist class.")
end
if ask_url
outputter("url", 0)
end
outputter("searching", 0)
list = find_it()
outputter("searching", 1)
@ -36,22 +47,28 @@ abstract class SpotifyList
i = 0
contents.each do |datum|
i += 1
if datum["track"]?
datum = datum["track"]
end
data = organize_song_metadata(list, datum)
song = Song.new(data["name"].to_s, data["artists"][0]["name"].to_s)
s_name = data["name"].to_s
s_artist = data["artists"][0]["name"].to_s
song = Song.new(s_name, s_artist)
song.provide_spotify(@spotify_searcher)
song.provide_metadata(data)
puts Style.bold("[#{data["track_number"]}/#{contents.size}]")
song.grab_it
puts Style.bold("[#{i}/#{contents.size}]")
organize(song)
i += 1
unless ask_skip && skip?(s_name, s_artist, is_playlist)
song.grab_it(flags: flags)
organize(song)
else
puts "Skipping..."
end
end
end
@ -60,6 +77,13 @@ abstract class SpotifyList
@spotify_searcher.authorize(client_key, client_secret)
end
private def skip?(name, artist, is_playlist)
print "Skip #{Style.blue name}" +
(is_playlist ? " (by #{Style.green artist})": "") + "? "
response = gets
return response && response.lstrip.downcase.starts_with? "y"
end
private def outputter(key : String, index : Int32)
text = @outputs[key][index]
.gsub("%l", @list_name)

View file

@ -1,4 +1,5 @@
require "json"
require "json_mapping"
class PlaylistExtensionMapper
JSON.mapping(
@ -45,6 +46,7 @@ class TrackMapper
type: Int32,
setter: true
},
duration_ms: Int32,
type: String,
uri: String
)

View file

@ -13,7 +13,7 @@ class Playlist < SpotifyList
# Uses the `spotify_searcher` defined in parent `SpotifyList` to find the
# correct metadata of the list
def find_it
def find_it : JSON::Any
@playlist = @spotify_searcher.find_item("playlist", {
"name" => @list_name.as(String),
"username" => @list_author.as(String),
@ -67,9 +67,10 @@ class Playlist < SpotifyList
FileUtils.mkdir_p(strpath)
end
safe_filename = song.filename.gsub(/[\/]/, "").gsub(" ", " ")
File.rename("./" + song.filename, (path / safe_filename).to_s)
FileUtils.cp("./" + song.filename, (path / safe_filename).to_s)
FileUtils.rm("./" + song.filename)
else
song.organize_it(@home_music_directory)
song.organize_it()
end
end
end

View file

@ -4,6 +4,8 @@ require "../search/youtube"
require "../interact/ripper"
require "../interact/tagger"
require "../bottle/config"
require "../bottle/pattern"
require "../bottle/styles"
class Song
@ -24,7 +26,10 @@ class Song
],
"url" => [
" Searching for URL ...\r",
Style.green(" + ") + Style.dim("URL found \n")
Style.green(" + ") + Style.dim("URL found \n"),
" Validating URL ...\r",
Style.green(" + ") + Style.dim("URL validated \n"),
" URL?: "
],
"download" => [
" Downloading video:\n",
@ -47,11 +52,16 @@ class Song
end
# Find, downloads, and tags the mp3 song that this class represents.
# Optionally takes a youtube URL to download from
#
# ```
# Song.new("Bohemian Rhapsody", "Queen").grab_it
# ```
def grab_it
def grab_it(url : (String | Nil) = nil, flags = {} of String => String)
passed_url : (String | Nil) = flags["url"]?
passed_file : (String | Nil) = flags["apply_file"]?
select_link = flags["select"]?
outputter("intro", 0)
if !@spotify_searcher.authorized? && !@metadata
@ -79,43 +89,78 @@ class Song
end
data = @metadata.as(JSON::Any)
@filename = data["track_number"].to_s + " - #{data["name"].to_s}.mp3"
@song_name = data["name"].as_s
@artist_name = data["artists"][0]["name"].as_s
@filename = "#{Pattern.parse(Config.filename_pattern, data)}.mp3"
outputter("url", 0)
url = Youtube.find_url(@song_name, @artist_name, search_terms: "lyrics")
if !url
raise("There was no url found on youtube for " +
%("#{@song_name}" by "#{@artist_name}. ) +
"Check your input and try again.")
if passed_file
puts Style.green(" +") + Style.dim(" Moving file: ") + passed_file
File.rename(passed_file, @filename)
else
if passed_url
if passed_url.strip != ""
url = passed_url
else
outputter("url", 4)
url = gets
if !url.nil? && url.strip == ""
url = nil
end
end
end
if !url
outputter("url", 0)
url = Youtube.find_url(data, flags: flags)
if !url
raise("There was no url found on youtube for " +
%("#{@song_name}" by "#{@artist_name}. ) +
"Check your input and try again.")
end
outputter("url", 1)
else
outputter("url", 2)
url = Youtube.validate_url(url)
if !url
raise("The url is an invalid youtube URL " +
"Check the URL and try again")
end
outputter("url", 3)
end
outputter("download", 0)
Ripper.download_mp3(url.as(String), @filename)
outputter("download", 1)
end
outputter("url", 1)
outputter("download", 0)
Ripper.download_mp3(url.as(String), @filename)
outputter("download", 1)
outputter("albumart", 0)
temp_albumart_filename = ".tempalbumart.jpg"
HTTP::Client.get(data["album"]["images"][0]["url"].to_s) do |response|
HTTP::Client.get(data["album"]["images"][0]["url"].as_s) do |response|
File.write(temp_albumart_filename, response.body_io)
end
outputter("albumart", 0)
# check if song's metadata has been modded in playlist, update artist accordingly
if data["artists"][-1]["owner"]?
@artist = data["artists"][-1]["name"].to_s
if data["artists"][-1]["owner"]?
@artist = data["artists"][-1]["name"].as_s
else
@artist = data["artists"][0]["name"].to_s
@artist = data["artists"][0]["name"].as_s
end
@album = data["album"]["name"].to_s
@album = data["album"]["name"].as_s
tagger = Tags.new(@filename)
tagger.add_album_art(temp_albumart_filename)
tagger.add_text_tag("title", data["name"].to_s)
tagger.add_text_tag("title", data["name"].as_s)
tagger.add_text_tag("artist", @artist)
tagger.add_text_tag("album", @album)
tagger.add_text_tag("genre",
@spotify_searcher.find_genre(data["artists"][0]["id"].to_s))
if !@album.empty?
tagger.add_text_tag("album", @album)
end
if genre = @spotify_searcher.find_genre(data["artists"][0]["id"].as_s)
tagger.add_text_tag("genre", genre)
end
tagger.add_text_tag("track", data["track_number"].to_s)
tagger.add_text_tag("disc", data["disc_number"].to_s)
@ -127,26 +172,31 @@ class Song
outputter("finished", 0)
end
# Will organize the song into the user's provided music directory as
# music_directory > artist_name > album_name > song
# Will organize the song into the user's provided music directory
# in the user's provided structure
# Must be called AFTER the song has been downloaded.
#
# ```
# s = Song.new("Bohemian Rhapsody", "Queen").grab_it
# s.organize_it("/home/cooper/Music")
# # Will move the mp3 file to
# s.organize_it()
# # With
# # directory_pattern = "{artist}/{album}"
# # filename_pattern = "{track_number} - {title}"
# # Mp3 will be moved to
# # /home/cooper/Music/Queen/A Night At The Opera/1 - Bohemian Rhapsody.mp3
# ```
def organize_it(music_directory : String)
path = Path[music_directory].expand(home: true)
path = path / @artist_name.gsub(/[\/]/, "").gsub(" ", " ")
path = path / @album.gsub(/[\/]/, "").gsub(" ", " ")
def organize_it()
path = Path[Config.music_directory].expand(home: true)
Pattern.parse(Config.directory_pattern, @metadata.as(JSON::Any)).split('/').each do |dir|
path = path / dir.gsub(/[\/]/, "").gsub(" ", " ")
end
strpath = path.to_s
if !File.directory?(strpath)
FileUtils.mkdir_p(strpath)
end
safe_filename = @filename.gsub(/[\/]/, "").gsub(" ", " ")
File.rename("./" + @filename, (path / safe_filename).to_s)
FileUtils.cp("./" + @filename, (path / safe_filename).to_s)
FileUtils.rm("./" + @filename)
end
# Provide metadata so that it doesn't have to find it. Useful for overwriting

157
src/interact/future.cr Normal file
View file

@ -0,0 +1,157 @@
# copy and pasted from crystal 0.33.1
# https://github.com/crystal-lang/crystal/blob/18e76172444c7bd07f58bf360bc21981b667668d/src/concurrent/future.cr#L138
# :nodoc:
class Concurrent::Future(R)
enum State
Idle
Delayed
Running
Completed
Canceled
end
@value : R?
@error : Exception?
@delay : Float64
def initialize(run_immediately = true, delay = 0.0, &@block : -> R)
@state = State::Idle
@value = nil
@error = nil
@channel = Channel(Nil).new
@delay = delay.to_f
@cancel_msg = nil
spawn_compute if run_immediately
end
def get
wait
value_or_raise
end
def success?
completed? && !@error
end
def failure?
completed? && @error
end
def canceled?
@state == State::Canceled
end
def completed?
@state == State::Completed
end
def running?
@state == State::Running
end
def delayed?
@state == State::Delayed
end
def idle?
@state == State::Idle
end
def cancel(msg = "Future canceled, you reached the [End of Time]")
return if @state >= State::Completed
@state = State::Canceled
@cancel_msg = msg
@channel.close
nil
end
private def compute
return if @state >= State::Delayed
run_compute
end
private def spawn_compute
return if @state >= State::Delayed
@state = @delay > 0 ? State::Delayed : State::Running
spawn { run_compute }
end
private def run_compute
delay = @delay
if delay > 0
sleep delay
return if @state >= State::Canceled
@state = State::Running
end
begin
@value = @block.call
rescue ex
@error = ex
ensure
@channel.close
@state = State::Completed
end
end
private def wait
return if @state >= State::Completed
compute
@channel.receive?
end
private def value_or_raise
raise Exception.new(@cancel_msg) if @state == State::Canceled
value = @value
if value.is_a?(R)
value
elsif error = @error
raise error
else
raise "compiler bug"
end
end
end
# Spawns a `Fiber` to compute *&block* in the background after *delay* has elapsed.
# Access to get is synchronized between fibers. *&block* is only called once.
# May be canceled before *&block* is called by calling `cancel`.
# ```
# d = delay(1) { Process.kill(Process.pid) }
# long_operation
# d.cancel
# ```
def delay(delay, &block : -> _)
Concurrent::Future.new delay: delay, &block
end
# Spawns a `Fiber` to compute *&block* in the background.
# Access to get is synchronized between fibers. *&block* is only called once.
# ```
# f = future { http_request }
# ... other actions ...
# f.get #=> String
# ```
def future(&exp : -> _)
Concurrent::Future.new &exp
end
# Conditionally spawns a `Fiber` to run *&block* in the background.
# Access to get is synchronized between fibers. *&block* is only called once.
# *&block* doesn't run by default, only when `get` is called.
# ```
# l = lazy { expensive_computation }
# spawn { maybe_use_computation(l) }
# spawn { maybe_use_computation(l) }
# ```
def lazy(&block : -> _)
Concurrent::Future.new run_immediately: false, &block
end

View file

@ -1,3 +1,5 @@
require "./future"
class Logger
@done_signal = "---DONE---"

144
src/search/ranking.cr Normal file
View file

@ -0,0 +1,144 @@
alias VID_VALUE_CLASS = String
alias VID_METADATA_CLASS = Hash(String, VID_VALUE_CLASS)
alias YT_METADATA_CLASS = Array(VID_METADATA_CLASS)
module Ranker
extend self
GARBAGE_PHRASES = [
"cover", "album", "live", "clean", "version", "full", "full album", "row",
"at", "@", "session", "how to", "npr music", "reimagined", "version",
"trailer"
]
GOLDEN_PHRASES = [
"official video", "official music video",
]
# Will rank videos according to their title and the user input, returns a sorted array of hashes
# of the points a song was assigned and its original index
# *spotify_metadata* is the metadate (from spotify) of the song that you want
# *yt_metadata* is an array of hashes with metadata scraped from the youtube search result page
# *query* is the query that you submitted to youtube for the results you now have
# ```
# Ranker.rank_videos(spotify_metadata, yt_metadata, query)
# => [
# {"points" => x, "index" => x},
# ...
# ]
# ```
# "index" corresponds to the original index of the song in yt_metadata
def rank_videos(spotify_metadata : JSON::Any, yt_metadata : YT_METADATA_CLASS,
query : String) : Array(Hash(String, Int32))
points = [] of Hash(String, Int32)
index = 0
actual_song_name = spotify_metadata["name"].as_s
actual_artist_name = spotify_metadata["artists"][0]["name"].as_s
yt_metadata.each do |vid|
pts = 0
pts += points_string_compare(actual_song_name, vid["title"])
pts += points_string_compare(actual_artist_name, vid["title"])
pts += count_buzzphrases(query, vid["title"])
pts += compare_timestamps(spotify_metadata, vid)
points.push({
"points" => pts,
"index" => index,
})
index += 1
end
# Sort first by points and then by original index of the song
points.sort! { |a, b|
if b["points"] == a["points"]
a["index"] <=> b["index"]
else
b["points"] <=> a["points"]
end
}
return points
end
# SINGULAR COMPONENT OF RANKING ALGORITHM
private def compare_timestamps(spotify_metadata : JSON::Any, node : VID_METADATA_CLASS) : Int32
# puts spotify_metadata.to_pretty_json()
actual_time = spotify_metadata["duration_ms"].as_i
vid_time = node["duration_ms"].to_i
difference = (actual_time - vid_time).abs
# puts "actual: #{actual_time}, vid: #{vid_time}"
# puts "\tdiff: #{difference}"
# puts "\ttitle: #{node["title"]}"
if difference <= 1000
return 3
elsif difference <= 2000
return 2
elsif difference <= 5000
return 1
else
return 0
end
end
# SINGULAR COMPONENT OF RANKING ALGORITHM
# Returns an `Int` based off the number of points worth assigning to the
# matchiness of the string. First the strings are downcased and then all
# nonalphanumeric characters are stripped.
# If *item1* includes *item2*, return 3 pts.
# If after the items have been blanked, *item1* includes *item2*,
# return 1 pts.
# Else, return 0 pts.
private def points_string_compare(item1 : String, item2 : String) : Int32
if item2.includes?(item1)
return 3
end
item1 = item1.downcase.gsub(/[^a-z0-9]/, "")
item2 = item2.downcase.gsub(/[^a-z0-9]/, "")
if item2.includes?(item1)
return 1
else
return 0
end
end
# SINGULAR COMPONENT OF RANKING ALGORITHM
# Checks if there are any phrases in the title of the video that would
# indicate audio having what we want.
# *video_name* is the title of the video, and *query* is what the user the
# program searched for. *query* is needed in order to make sure we're not
# subtracting points from something that's naturally in the title
private def count_buzzphrases(query : String, video_name : String) : Int32
good_phrases = 0
bad_phrases = 0
GOLDEN_PHRASES.each do |gold_phrase|
gold_phrase = gold_phrase.downcase.gsub(/[^a-z0-9]/, "")
if query.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
next
elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
good_phrases += 1
end
end
GARBAGE_PHRASES.each do |garbage_phrase|
garbage_phrase = garbage_phrase.downcase.gsub(/[^a-z0-9]/, "")
if query.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
next
elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
bad_phrases += 1
end
end
return good_phrases - bad_phrases
end
end

View file

@ -60,9 +60,10 @@ class SpotifySearcher
# ```
def find_item(item_type : String, item_parameters : Hash, offset = 0,
limit = 20) : JSON::Any?
query = generate_query(item_type, item_parameters, offset, limit)
query = generate_query(item_type, item_parameters)
url = @root_url.join("search?q=#{query}").to_s
url = "search?q=#{query}&type=#{item_type}&limit=#{limit}&offset=#{offset}"
url = @root_url.join(url).to_s
response = HTTP::Client.get(url, headers: @access_header)
error_check(response)
@ -204,8 +205,14 @@ class SpotifySearcher
# ```
# SpotifySearcher.new.authorize(...).find_genre("1dfeR4HaWDbWqFHLkxsg1d")
# ```
def find_genre(id : String) : String
genre = get_item("artist", id)["genres"][0].to_s
def find_genre(id : String) : String | Nil
genre = get_item("artist", id)["genres"]
if genre.as_a.empty?
return nil
end
genre = genre[0].to_s
genre = genre.split(" ").map { |x| x.capitalize }.join(" ")
return genre
@ -222,8 +229,7 @@ class SpotifySearcher
# Generates url to run a GET request against to the Spotify open API
# Returns a `String.`
private def generate_query(item_type : String, item_parameters : Hash,
offset : Int32, limit : Int32) : String
private def generate_query(item_type : String, item_parameters : Hash) : String
query = ""
# parameter keys to exclude in the api request. These values will be put
@ -235,9 +241,9 @@ class SpotifySearcher
if k == "name"
# will remove the "name:<title>" param from the query
if item_type == "playlist"
query += item_parameters[k].gsub(" ", "+") + "+"
query += item_parameters[k] + "+"
else
query += param_encode(item_type, item_parameters[k])
query += as_field(item_type, item_parameters[k])
end
# check if the key is to be excluded
@ -248,14 +254,21 @@ class SpotifySearcher
# NOTE: playlist names will be inserted into the query normally, without
# a parameter.
else
query += param_encode(k, item_parameters[k])
query += as_field(k, item_parameters[k])
end
end
# extra api info
query += "&type=#{item_type}&limit=#{limit}&offset=#{offset}"
return URI.encode(query.rchop("+"))
end
return query
# Returns a `String` encoded for the spotify api
#
# ```
# query_encode("album", "A Night At The Opera")
# => "album:A Night At The Opera+"
# ```
private def as_field(key, value) : String
return "#{key}:#{value}+"
end
# Ranks the given items based off of the info from parameters.
@ -321,15 +334,6 @@ class SpotifySearcher
end
end
# Returns a `String` encoded for the spotify api
#
# ```
# query_encode("album", "A Night At The Opera")
# => "album:A+Night+At+The+Opera"
# ```
private def param_encode(key : String, value : String) : String
return key.gsub(" ", "+") + ":" + value.gsub(" ", "+") + "+"
end
end
# puts SpotifySearcher.new()

View file

@ -1,5 +1,13 @@
require "http"
require "xml"
require "json"
require "uri"
require "./ranking"
require "../bottle/config"
require "../bottle/styles"
module Youtube
extend self
@ -9,172 +17,184 @@ module Youtube
"yt-uix-tile-link yt-ui-ellipsis yt-ui-ellipsis-2 yt-uix-sessionlink spf-link ",
]
GARBAGE_PHRASES = [
"cover", "album", "live", "clean", "version", "full", "full album", "row",
"at", "@", "session", "how to", "npr music", "reimagined", "hr version",
"trailer",
]
GOLDEN_PHRASES = [
"official video", "official music video",
]
# Note that VID_VALUE_CLASS, VID_METADATA_CLASS, and YT_METADATA_CLASS are found in ranking.cr
# Finds a youtube url based off of the given information.
# The query to youtube is constructed like this:
# "<song_name> <artist_name> <search terms>"
# If *download_first* is provided, the first link found will be downloaded.
# If *select_link* is provided, a menu of options will be shown for the user to choose their poison
#
# ```
# Youtube.find_url("Bohemian Rhapsody", "Queen")
# => "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
# ```
def find_url(song_name : String, artist_name : String, search_terms = "",
download_first = false) : String?
query = (song_name + " " + artist_name + " " + search_terms).strip.gsub(" ", "+")
def find_url(spotify_metadata : JSON::Any,
flags = {} of String => String) : String?
url = "https://www.youtube.com/results?search_query=" + query
search_terms = Config.search_terms
response = HTTP::Client.get(url)
select_link = flags["select"]?
valid_nodes = get_video_link_nodes(response.body)
song_name = spotify_metadata["name"].as_s
artist_name = spotify_metadata["artists"][0]["name"].as_s
if valid_nodes.size == 0
puts "There were no results for that query."
human_query = "#{song_name} #{artist_name} #{search_terms.strip}"
params = HTTP::Params.encode({"search_query" => human_query})
response = HTTP::Client.get("https://www.youtube.com/results?#{params}")
yt_metadata = get_yt_search_metadata(response.body)
if yt_metadata.size == 0
puts "There were no results for this query on youtube: \"#{human_query}\""
return nil
end
root = "https://youtube.com"
ranked = Ranker.rank_videos(spotify_metadata, yt_metadata, human_query)
return root + valid_nodes[0]["href"] if download_first
ranked = rank_videos(song_name, artist_name, query, valid_nodes)
if select_link
return root + select_link_menu(spotify_metadata, yt_metadata)
end
begin
return root + valid_nodes[ranked[0]["index"]]["href"]
puts Style.dim(" Video: ") + yt_metadata[ranked[0]["index"]]["title"]
return root + yt_metadata[ranked[0]["index"]]["href"]
rescue IndexError
return nil
end
exit 1
end
# Will rank videos according to their title and the user input
# Return:
# [
# {"points" => x, "index" => x},
# ...
# ]
private def rank_videos(song_name : String, artist_name : String,
query : String, nodes : Array(XML::Node)) : Array(Hash(String, Int32))
points = [] of Hash(String, Int32)
index = 0
nodes.each do |node|
pts = 0
pts += points_compare(song_name, node["title"])
pts += points_compare(artist_name, node["title"])
pts += count_buzzphrases(query, node["title"])
points.push({
"points" => pts,
"index" => index,
})
# Presents a menu with song info for the user to choose which url they want to download
private def select_link_menu(spotify_metadata : JSON::Any,
yt_metadata : YT_METADATA_CLASS) : String
puts Style.dim(" Spotify info: ") +
Style.bold("\"" + spotify_metadata["name"].to_s) + "\" by \"" +
Style.bold(spotify_metadata["artists"][0]["name"].to_s + "\"") +
" @ " + Style.blue((spotify_metadata["duration_ms"].as_i / 1000).to_i.to_s) + "s"
puts " Choose video to download:"
index = 1
yt_metadata.each do |vid|
print " " + Style.bold(index.to_s + " ")
puts "\"" + vid["title"] + "\" @ " + Style.blue((vid["duration_ms"].to_i / 1000).to_i.to_s) + "s"
index += 1
end
# Sort first by points and then by original index of the song
points.sort! { |a, b|
if b["points"] == a["points"]
a["index"] <=> b["index"]
else
b["points"] <=> a["points"]
end
}
return points
end
# Returns an `Int` based off the number of points worth assigning to the
# matchiness of the string. First the strings are downcased and then all
# nonalphanumeric characters are stripped.
# If *item1* includes *item2*, return 3 pts.
# If after the items have been blanked, *item1* includes *item2*,
# return 1 pts.
# Else, return 0 pts.
private def points_compare(item1 : String, item2 : String) : Int32
if item2.includes?(item1)
return 3
end
item1 = item1.downcase.gsub(/[^a-z0-9]/, "")
item2 = item2.downcase.gsub(/[^a-z0-9]/, "")
if item2.includes?(item1)
return 1
else
return 0
end
end
# Checks if there are any phrases in the title of the video that would
# indicate audio having what we want.
# *video_name* is the title of the video, and *query* is what the user the
# program searched for. *query* is needed in order to make sure we're not
# subtracting points from something that's naturally in the title
private def count_buzzphrases(query : String, video_name : String) : Int32
good_phrases = 0
bad_phrases = 0
GOLDEN_PHRASES.each do |gold_phrase|
gold_phrase = gold_phrase.downcase.gsub(/[^a-z0-9]/, "")
if query.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
next
elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
good_phrases += 1
if index > 5
break
end
end
GARBAGE_PHRASES.each do |garbage_phrase|
garbage_phrase = garbage_phrase.downcase.gsub(/[^a-z0-9]/, "")
if query.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
next
elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
bad_phrases += 1
input = 0
while true # not between 1 and 5
begin
print Style.bold(" > ")
input = gets.not_nil!.chomp.to_i
if input < 6 && input > 0
break
end
rescue
puts Style.red(" Invalid input, try again.")
end
end
return good_phrases - bad_phrases
return yt_metadata[input-1]["href"]
end
# Finds valid video links from a `HTTP::Client.get` request
# Returns an `Array` of `XML::Node`
private def get_video_link_nodes(doc : String) : Array(XML::Node)
nodes = XML.parse(doc).xpath_nodes("//a")
valid_nodes = [] of XML::Node
# Returns an `Array` of `NODES_CLASS` containing additional metadata from Youtube
private def get_yt_search_metadata(response_body : String) : YT_METADATA_CLASS
yt_initial_data : JSON::Any = JSON.parse("{}")
nodes.each do |node|
if video_link_node?(node)
valid_nodes.push(node)
response_body.each_line do |line|
# timestamp 11/8/2020:
# youtube's html page has a line previous to this literally with 'scraper_data_begin' as a comment
if line.includes?("var ytInitialData")
# Extract JSON data from line
data = line.split(" = ")[2].delete(';')
dataEnd = (data.index("</script>") || 0) - 1
begin
yt_initial_data = JSON.parse(data[0..dataEnd])
rescue
break
end
end
end
return valid_nodes
if yt_initial_data == JSON.parse("{}")
puts "Youtube has changed the way it organizes its webpage, submit a bug"
puts "saying it has done so on https://github.com/cooperhammond/irs"
exit(1)
end
# where the vid metadata lives
yt_initial_data = yt_initial_data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"]["sectionListRenderer"]["contents"]
video_metadata = [] of VID_METADATA_CLASS
i = 0
while true
begin
# video title
raw_metadata = yt_initial_data[0]["itemSectionRenderer"]["contents"][i]["videoRenderer"]
metadata = {} of String => VID_VALUE_CLASS
metadata["title"] = raw_metadata["title"]["runs"][0]["text"].as_s
metadata["href"] = raw_metadata["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
timestamp = raw_metadata["lengthText"]["simpleText"].as_s
metadata["timestamp"] = timestamp
metadata["duration_ms"] = ((timestamp.split(":")[0].to_i * 60 +
timestamp.split(":")[1].to_i) * 1000).to_s
video_metadata.push(metadata)
rescue IndexError
break
rescue Exception
end
i += 1
end
return video_metadata
end
# Tests if the provided `XML::Node` has a valid link to a video
# Returns a `Bool`
private def video_link_node?(node : XML::Node) : Bool
# If this passes, then the node links to a playlist, not a video
if node["href"]?
return false if node["href"].includes?("&list=")
end
# Returns as a valid URL if possible
#
# ```
# Youtube.validate_url("https://www.youtube.com/watch?v=NOTANACTUALVIDEOID")
# => nil
# ```
def validate_url(url : String) : String | Nil
uri = URI.parse url
return nil if !uri
VALID_LINK_CLASSES.each do |valid_class|
if node["class"]?
return true if node["class"].includes?(valid_class)
query = uri.query
return nil if !query
# find the video ID
vID = nil
query.split('&').each do |q|
if q.starts_with?("v=")
vID = q[2..-1]
end
end
return false
return nil if !vID
url = "https://www.youtube.com/watch?v=#{vID}"
# this is an internal endpoint to validate the video ID
params = HTTP::Params.encode({"format" => "json", "url" => url})
response = HTTP::Client.get "https://www.youtube.com/oembed?#{params}"
return nil unless response.success?
res_json = JSON.parse(response.body)
title = res_json["title"].as_s
puts Style.dim(" Video: ") + title
return url
end
end