Compare commits

..

132 commits

Author SHA1 Message Date
Cooper Hammond 15fae7f8d3
Merge pull request #60 from cooperhammond/crystal-port
Crystal port
2020-05-21 19:45:09 -07:00
Cooper Hammond 42d081ce71 README updata, it's pretty AND useful now 2020-05-21 19:27:39 -07:00
Cooper Hammond 2780f37610 feature: CLI download messages have been updated to be prettier 2020-05-21 19:23:44 -07:00
Cooper Hammond 616e8d7117 feature: unify into album option in config works now 2020-05-21 16:23:30 -07:00
Cooper Hammond 2479777cee 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 5f12aec168 crystal tool format respec 2020-05-12 22:38:03 -07:00
Cooper Hammond a2c312e109 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 c85cb109f7 spotify searcher can now find and compile playlists >100 songs 2020-05-09 16:13:50 -07:00
Cooper Hammond 3a67f3b064 removed a hardcoded path 2020-03-28 15:00:29 -07:00
Cooper Hammond 454e678940 fixed indentation 2020-03-28 01:23:41 -07:00
Cooper Hammond 748ea832d1 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 d1fc3d0e3f Added playlist to the --help screen 2020-03-26 20:55:14 -07:00
Cooper Hammond b8a4bf0a58 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 849e9b7971
Merge pull request #58 from cooperhammond/crystal-port
Updated to v1.0.0
2020-02-25 19:49:58 -07:00
Cooper Hammond 9ca6856c02 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 af50a7ebb0 need to add 'next' feature for spotify searches 2019-06-23 13:39:00 -07:00
Cooper Hammond 907b75edb3 logging for individual song done! 2019-06-21 16:30:14 -07:00
Cooper Hammond c11185be4d Preliminary interception logging done! 2019-06-21 09:45:55 -07:00
Cooper Hammond 0c999d6356 Initial CLI done! 2019-06-20 09:43:31 -07:00
Cooper Hammond 6485324acc song glue done! 2019-06-19 19:06:09 -07:00
Cooper Hammond 9e230ac13f Merge branch 'cystal-port' of https://github.com/cooperhammond/irs into cystal-port 2019-06-19 15:58:30 -07:00
Cooper Hammond 5f236a626b fixed conflict 2019-06-19 15:57:50 -07:00
Cooper Hammond 62d53b66a5 fixed conflict 2019-06-19 15:55:32 -07:00
Cooper Hammond ae5c7d3ed4 tagger edits 2019-06-19 15:54:26 -07:00
Cooper Hammond 03c572e0e8 removed empty folder 2019-06-14 18:03:05 -07:00
Cooper Hammond a786992f7f tagger edits 2019-06-14 18:01:22 -07:00
Cooper Hammond e402bcea14 tagger based off of ffmpeg created 2019-06-14 11:39:51 -07:00
Cooper Hammond efff04947c fixed youtube search ranking algo 2019-06-14 08:22:38 -07:00
Cooper Hammond ba7400819b ripper module finished 2019-06-12 22:11:16 -07:00
Cooper Hammond dfcd8db527 finished youtube searcher 2019-06-12 12:52:37 -07:00
Cooper Hammond 05f43b6fda Spotify searcher is now minimum viable product. 2019-06-11 13:57:57 -07:00
Cooper Hammond 82aa1fd454 init 2019-06-07 22:46:54 -07:00
Cooper Hammond dc596c59a7 correct system exit 2019-05-29 09:58:26 -07:00
Cooper Hammond 74a001ba24 updated env vars and setup.py requires 2019-05-29 08:14:42 -07:00
Cooper Hammond 4bb340efbd update environment variables 2019-05-29 08:10:15 -07:00
Cooper Hammond d59e9b2b12
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 5acd942861 updated readme 2019-05-28 22:40:35 -07:00
Cooper Hammond eb53ecaa4d Added in config and --setup flag 2019-05-28 22:36:23 -07:00
Cooper Hammond db40e60f98 updated install 2019-05-28 10:48:26 -07:00
Cooper Hammond 7a50bb27e9 Added in another status message
and changed default organization method
2019-05-28 10:19:48 -07:00
Cooper Hammond 39321603a1 minor fixes 2019-05-25 11:58:25 -07:00
Cooper Hammond e367ea6de3 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 a67a37f737 Wrote a clean metadata tagger and spotify searcher 2019-05-09 09:57:49 -07:00
Cooper Hammond 4fc24bf78d rewrote youtube link finder 2019-05-04 19:03:19 -07:00
Cooper Hammond bf56f311f2
Merge pull request #47 from kepoorhampond/fix/ffmpeg-recognization
fixed not recognizing ffmpeg
2018-10-21 07:38:15 +00:00
kepoorhampond bfb4e8ce90 fixed not recognizing ffmpeg 2018-10-21 00:36:36 +00:00
Kepoor Hampond 9ec514e11d
Update README.md
Remove build status until I've fixed its weird errors.
2017-11-28 21:35:27 -08:00
Kepoor Hampond 8dd98453eb
Merge pull request #40 from aubguillemette/master
By Viola, did you mean Voilà?
2017-11-28 20:23:07 -08:00
Aub Guillemette 4bdc32e608
Changed Viola for Voilà 2017-11-28 17:40:36 -05:00
Kepoor Hampond 9270379fa7
Update README.md 2017-11-14 07:55:07 -08:00
Kepoor Hampond e00852bb54 Fixed for windows 2017-11-05 23:09:56 -08:00
kepoorhampond 431ed17b83 Update of pypi 2017-10-13 23:10:59 -07:00
kepoorhampond 09914966b2 Merge branch 'master' of https://github.com/kepoorhampond/irs 2017-10-13 23:03:55 -07:00
kepoorhampond c27d8d8d94 Fixed getting stuck in an infinite loop while searching for yt link 2017-10-13 23:03:32 -07:00
Video f307f95df7 Added splinter as a dependency 2017-10-10 08:18:32 -07:00
kepoorhampond 0b03127786 Upload to pypi 2017-09-30 18:36:27 -07:00
Kepoor Hampond ec4258c8d8 Merge pull request #39 from kepoorhampond/exact-album
-e/--exact flag feature
2017-09-30 18:12:42 -07:00
Kepoor Hampond 0a8d0917ca Merge branch 'master' into exact-album 2017-09-30 18:12:34 -07:00
Kepoor Hampond cd81691959 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 f41cd55af1 Merge branch 'master' into captcha-cheat 2017-09-30 18:06:03 -07:00
kepoorhampond 3a7f9918a4 reset ffmpeg-checker 2017-09-18 19:58:37 -07:00
kepoorhampond 6a186b80df -e/--exact flag feature 2017-09-09 22:11:54 -07:00
kepoorhampond 0edad1c255 Committed a tmp file 2017-09-05 12:24:19 -07:00
kepoorhampond f735cd4a54 Fixed to be compatible for 2 types of youtubes HTML/CSS code 2017-09-05 12:22:56 -07:00
Kepoor Hampond da28e7e9a1 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 73ae545949 test 2017-08-09 01:11:58 -07:00
Kepoor Hampond 96e3aa7bb3 Update on pypi 2017-06-28 20:24:34 -07:00
Kepoor Hampond 07343c6c11 Merge pull request #36 from kepoorhampond/issue-35
Issue 35
2017-06-28 20:23:29 -07:00
Kepoor Hampond 14a8da80f3 Remove testing code 2017-06-28 20:18:12 -07:00
Kepoor Hampond 25af928bb0 Hopeful fix 2017-06-28 20:09:48 -07:00
Kepoor Hampond 438f2d7116 Update on pypi 2017-06-13 17:09:53 -07:00
Kepoor Hampond d3fdb916cd Merge pull request #34 from kepoorhampond/issue-33
Fix issue-33
2017-06-13 17:05:24 -07:00
Kepoor Hampond ab97128a1c Fix issue-33 2017-06-13 17:00:47 -07:00
Kepoor Hampond 2e645ad389 Merge pull request #32 from kepoorhampond/cleaner-cli
silence argparse errors
2017-06-11 14:26:50 -07:00
Kepoor Hampond 42efa72063 Create README.md 2017-06-11 14:24:06 -07:00
Kepoor Hampond fea1b9dd70 remove travis ci dependencies 2017-06-11 14:21:14 -07:00
Kepoor Hampond 28d72cd7ad silence argparse errors 2017-06-11 14:18:22 -07:00
Kepoor Hampond e0d887dc26 Update on pypi 2017-06-11 14:11:52 -07:00
Kepoor Hampond da7137846f 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 589acc7b86 travis dependencies 2017-06-11 14:06:18 -07:00
Kepoor Hampond 1d152f7109 Make sure to call irs --setup before anything else 2017-06-11 14:03:56 -07:00
Kepoor Hampond c1ba1906c6 Update on pypi 2017-06-10 19:07:37 -07:00
Kepoor Hampond dfdeda7c35 Update README.md 2017-06-10 19:06:10 -07:00
Kepoor Hampond bb8540b079 Create README.md 2017-06-10 19:05:06 -07:00
Kepoor Hampond 6ee623f16d Update README.md 2017-06-10 19:04:26 -07:00
Kepoor Hampond 7509755a00 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 5c764f8440 Minor bug fixes 2017-06-10 18:53:49 -07:00
Kepoor Hampond 050c43ed97 Maybe again? 2017-06-10 18:48:30 -07:00
Kepoor Hampond 6d623f74e3 Once more? 2017-06-10 18:43:42 -07:00
Kepoor Hampond baeaec8985 Try it again 2017-06-10 18:40:11 -07:00
Kepoor Hampond de326d7781 Better option 2017-06-10 18:33:32 -07:00
Kepoor Hampond 7a68d611ab Apparently it was just some wierd apostrophe in a unicode format 2017-06-10 18:28:26 -07:00
Kepoor Hampond 793e221605 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 a2018be5d0 I hate getting stuff to work locally and remotely. A LOT. 2017-06-10 18:11:00 -07:00
Kepoor Hampond f93d500648 I hate getting stuff to work locally and remotely 2017-06-10 18:08:02 -07:00
Kepoor Hampond 183f45b840 Travis isn't working while my computer is 2017-06-10 17:52:56 -07:00
Kepoor Hampond db5025b8ad Forgot to comment a different line 2017-06-10 17:42:05 -07:00
Kepoor Hampond 828dc04b0b Forgot to uncomment lines 2017-06-10 17:39:15 -07:00
Kepoor Hampond 224c27b96d Python 3 compatability 2017-06-10 17:37:16 -07:00
Kepoor Hampond 6020a18c7a Travis dependencies 2017-06-10 12:31:15 -07:00
Kepoor Hampond 96047c1371 Install ydl-binaries 2017-06-10 12:21:17 -07:00
Kepoor Hampond cd2ee64e1c Now automatically downloads youtube-dl, ffmpeg, and ffprobe binaries 2017-06-10 12:16:51 -07:00
Kepoor Hampond abad58a39c fix travis, for the love of god 2017-06-09 17:05:06 -07:00
Kepoor Hampond c0c8ea168f travis can't find ffprobe 2017-06-09 16:47:51 -07:00
Kepoor Hampond 5ccc9d0fc3 added to README and hopefully fixed the authorization bug. 2017-06-09 16:33:48 -07:00
Kepoor Hampond d035af1559 more python 3.x compatability 2017-06-05 19:53:53 -07:00
Kepoor Hampond 2c8f15ccbb travis ci patch/fix 2017-06-05 19:48:57 -07:00
Kepoor Hampond 2da9ec6093 python 3 compatibility 2017-06-05 19:37:16 -07:00
Kepoor Hampond 6ec1de6251 fixed it 2017-06-05 19:30:23 -07:00
Kepoor Hampond cb4f98c515 Merge pull request #27 from kepoorhampond/osrename-to-shutilmove
Changed `os.rename` to `shutil.move`
2017-05-19 08:25:52 -07:00
kepoorhampond a00857a9b0 did it 2017-05-19 13:59:26 +00:00
Kepoor Hampond d4204351c5 updated pypi 2017-05-12 16:37:21 -07:00
Kepoor Hampond 1c69267d0e Merge pull request #25 from kepoorhampond/better-alias-rerouting
Better alias rerouting
2017-05-12 16:35:52 -07:00
Kepoor Hampond d87aead8c5 better argument rerouting in the aliases for album and playlist 2017-05-12 16:35:02 -07:00
Kepoor Hampond 3c7534565e Updated pypi 2017-05-06 22:42:06 -07:00
Kepoor Hampond 837a251add Merge pull request #24 from kepoorhampond/custom-text
Added custom hook text support
2017-05-06 22:41:29 -07:00
Kepoor Hampond 65be52ce48 Added custom hook text support 2017-05-06 22:37:06 -07:00
Kepoor Hampond 8ec388964d 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 e0240e4e1a Fixed annoying bug where it prints Finding Youtube link ... twice 2017-05-04 21:35:43 -07:00
Kepoor Hampond 6d00f0437f Updated README.md 2017-05-01 22:45:10 -07:00
Kepoor Hampond d2d322b500 Updated README.md 2017-05-01 22:44:14 -07:00
Kepoor Hampond 00d747eb6c Updated version on pypi 2017-04-30 12:34:55 -07:00
Kepoor Hampond fcff1db167 Updated version on pypi 2017-04-30 12:11:24 -07:00
Kepoor Hampond 0063f4fe32 Merge pull request #21 from kepoorhampond/issue-20
Fix issue #20
2017-04-30 12:07:41 -07:00
Kepoor Hampond 093eb52e62 Forgot to finish except loop 2017-04-30 12:02:47 -07:00
Kepoor Hampond 6fa4c49c6e Version error with python 2 and 3 causing issue #20 2017-04-30 11:46:15 -07:00
Kepoor Hampond 8c4f10adb1 updated travis.yml 2017-04-30 11:12:51 -07:00
Kepoor Hampond cab06e00bc testing on issue #20 2017-04-30 11:04:21 -07:00
Kepoor Hampond a4a024990c testing with python 3.6 2017-04-30 10:59:38 -07:00
Kepoor Hampond 2c07e8abf4 Removed partially downloaded tmp file 2017-04-23 11:22:19 -07:00
Kepoor Hampond d307af9e1a Forgot to update on pypi 2017-04-21 19:16:50 -07:00
Kepoor Hampond c6bd18edd2 Mispelling of parse_artist in ripper.py for -a + -A 2017-04-21 19:15:56 -07:00
20 changed files with 211 additions and 773 deletions

3
.gitignore vendored
View file

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

View file

@ -54,8 +54,6 @@ 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"
@ -77,8 +75,6 @@ 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
@ -96,8 +92,6 @@ 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:
@ -124,9 +118,6 @@ 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:
@ -137,10 +128,8 @@ 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.
- `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
Note that there will be more structure created inside that folder, usually
in the format of `music-dir>artist-name>album-name>track`
- `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
@ -152,55 +141,6 @@ 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
@ -234,4 +174,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,10 +1,6 @@
version: 2.0
version: 1.0
shards:
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
github: cooperhammond/ydl-binaries
commit: c82e3937fee20fd076b1c73e24b2d0205e2cf0da

View file

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

View file

@ -1,35 +1,9 @@
require "./spec_helper"
describe CLI do
describe Irs do
# TODO: Write tests
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"])
it "works" do
false.should eq(true)
end
end

View file

@ -1,10 +1,2 @@
require "spec"
# 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
require "../src/irs"

View file

@ -20,10 +20,6 @@ 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)
@ -52,12 +48,6 @@ 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")}
@ -79,35 +69,34 @@ 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(flags: @args)
s.organize_it()
s.grab_it
s.organize_it(Config.music_directory)
exit
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(flags: @args)
a.grab_it
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(flags: @args)
p.grab_it
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,11 +7,8 @@ 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"}:
@ -25,11 +22,8 @@ module Config
extend self
@@arguments = [
"search_terms",
"binary_directory",
"music_directory",
"filename_pattern",
"directory_pattern",
"client_key",
"client_secret",
"single_folder_playlist: enabled",
@ -47,10 +41,6 @@ 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
@ -60,14 +50,6 @@ 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

View file

@ -1,28 +0,0 @@
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 = "1.4.0"
VERSION = "0.1.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 : JSON::Any
def find_it
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()
song.organize_it(@home_music_directory)
end
end

View file

@ -17,9 +17,6 @@ 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")
]
}
@ -27,19 +24,11 @@ abstract class SpotifyList
end
# Finds the list, and downloads all of the songs using the `Song` class
def grab_it(flags = {} of String => String)
ask_url = flags["url"]?
ask_skip = flags["ask_skip"]?
is_playlist = flags["playlist"]?
def grab_it
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)
@ -47,28 +36,22 @@ abstract class SpotifyList
i = 0
contents.each do |datum|
i += 1
if datum["track"]?
datum = datum["track"]
end
data = organize_song_metadata(list, datum)
s_name = data["name"].to_s
s_artist = data["artists"][0]["name"].to_s
song = Song.new(s_name, s_artist)
song = Song.new(data["name"].to_s, data["artists"][0]["name"].to_s)
song.provide_spotify(@spotify_searcher)
song.provide_metadata(data)
puts Style.bold("[#{i}/#{contents.size}]")
puts Style.bold("[#{data["track_number"]}/#{contents.size}]")
song.grab_it
unless ask_skip && skip?(s_name, s_artist, is_playlist)
song.grab_it(flags: flags)
organize(song)
else
puts "Skipping..."
end
organize(song)
i += 1
end
end
@ -77,13 +60,6 @@ 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,5 +1,4 @@
require "json"
require "json_mapping"
class PlaylistExtensionMapper
JSON.mapping(
@ -46,7 +45,6 @@ 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 : JSON::Any
def find_it
@playlist = @spotify_searcher.find_item("playlist", {
"name" => @list_name.as(String),
"username" => @list_author.as(String),
@ -67,10 +67,9 @@ class Playlist < SpotifyList
FileUtils.mkdir_p(strpath)
end
safe_filename = song.filename.gsub(/[\/]/, "").gsub(" ", " ")
FileUtils.cp("./" + song.filename, (path / safe_filename).to_s)
FileUtils.rm("./" + song.filename)
File.rename("./" + song.filename, (path / safe_filename).to_s)
else
song.organize_it()
song.organize_it(@home_music_directory)
end
end
end

View file

@ -4,8 +4,6 @@ require "../search/youtube"
require "../interact/ripper"
require "../interact/tagger"
require "../bottle/config"
require "../bottle/pattern"
require "../bottle/styles"
class Song
@ -26,10 +24,7 @@ class Song
],
"url" => [
" Searching for URL ...\r",
Style.green(" + ") + Style.dim("URL found \n"),
" Validating URL ...\r",
Style.green(" + ") + Style.dim("URL validated \n"),
" URL?: "
Style.green(" + ") + Style.dim("URL found \n")
],
"download" => [
" Downloading video:\n",
@ -52,16 +47,11 @@ 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(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"]?
def grab_it
outputter("intro", 0)
if !@spotify_searcher.authorized? && !@metadata
@ -89,78 +79,43 @@ class Song
end
data = @metadata.as(JSON::Any)
@song_name = data["name"].as_s
@artist_name = data["artists"][0]["name"].as_s
@filename = "#{Pattern.parse(Config.filename_pattern, data)}.mp3"
@filename = data["track_number"].to_s + " - #{data["name"].to_s}.mp3"
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)
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.")
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"].as_s) do |response|
HTTP::Client.get(data["album"]["images"][0]["url"].to_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"].as_s
if data["artists"][-1]["owner"]?
@artist = data["artists"][-1]["name"].to_s
else
@artist = data["artists"][0]["name"].as_s
@artist = data["artists"][0]["name"].to_s
end
@album = data["album"]["name"].as_s
@album = data["album"]["name"].to_s
tagger = Tags.new(@filename)
tagger.add_album_art(temp_albumart_filename)
tagger.add_text_tag("title", data["name"].as_s)
tagger.add_text_tag("title", data["name"].to_s)
tagger.add_text_tag("artist", @artist)
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("album", @album)
tagger.add_text_tag("genre",
@spotify_searcher.find_genre(data["artists"][0]["id"].to_s))
tagger.add_text_tag("track", data["track_number"].to_s)
tagger.add_text_tag("disc", data["disc_number"].to_s)
@ -172,31 +127,26 @@ class Song
outputter("finished", 0)
end
# Will organize the song into the user's provided music directory
# in the user's provided structure
# Will organize the song into the user's provided music directory as
# music_directory > artist_name > album_name > song
# Must be called AFTER the song has been downloaded.
#
# ```
# s = Song.new("Bohemian Rhapsody", "Queen").grab_it
# s.organize_it()
# # With
# # directory_pattern = "{artist}/{album}"
# # filename_pattern = "{track_number} - {title}"
# # Mp3 will be moved to
# s.organize_it("/home/cooper/Music")
# # Will move the mp3 file to
# # /home/cooper/Music/Queen/A Night At The Opera/1 - Bohemian Rhapsody.mp3
# ```
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
def organize_it(music_directory : String)
path = Path[music_directory].expand(home: true)
path = path / @artist_name.gsub(/[\/]/, "").gsub(" ", " ")
path = path / @album.gsub(/[\/]/, "").gsub(" ", " ")
strpath = path.to_s
if !File.directory?(strpath)
FileUtils.mkdir_p(strpath)
end
safe_filename = @filename.gsub(/[\/]/, "").gsub(" ", " ")
FileUtils.cp("./" + @filename, (path / safe_filename).to_s)
FileUtils.rm("./" + @filename)
File.rename("./" + @filename, (path / safe_filename).to_s)
end
# Provide metadata so that it doesn't have to find it. Useful for overwriting

View file

@ -1,157 +0,0 @@
# 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,5 +1,3 @@
require "./future"
class Logger
@done_signal = "---DONE---"

View file

@ -1,144 +0,0 @@
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,10 +60,9 @@ class SpotifySearcher
# ```
def find_item(item_type : String, item_parameters : Hash, offset = 0,
limit = 20) : JSON::Any?
query = generate_query(item_type, item_parameters)
query = generate_query(item_type, item_parameters, offset, limit)
url = "search?q=#{query}&type=#{item_type}&limit=#{limit}&offset=#{offset}"
url = @root_url.join(url).to_s
url = @root_url.join("search?q=#{query}").to_s
response = HTTP::Client.get(url, headers: @access_header)
error_check(response)
@ -205,14 +204,8 @@ class SpotifySearcher
# ```
# SpotifySearcher.new.authorize(...).find_genre("1dfeR4HaWDbWqFHLkxsg1d")
# ```
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
def find_genre(id : String) : String
genre = get_item("artist", id)["genres"][0].to_s
genre = genre.split(" ").map { |x| x.capitalize }.join(" ")
return genre
@ -229,7 +222,8 @@ 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) : String
private def generate_query(item_type : String, item_parameters : Hash,
offset : Int32, limit : Int32) : String
query = ""
# parameter keys to exclude in the api request. These values will be put
@ -241,9 +235,9 @@ class SpotifySearcher
if k == "name"
# will remove the "name:<title>" param from the query
if item_type == "playlist"
query += item_parameters[k] + "+"
query += item_parameters[k].gsub(" ", "+") + "+"
else
query += as_field(item_type, item_parameters[k])
query += param_encode(item_type, item_parameters[k])
end
# check if the key is to be excluded
@ -254,21 +248,14 @@ class SpotifySearcher
# NOTE: playlist names will be inserted into the query normally, without
# a parameter.
else
query += as_field(k, item_parameters[k])
query += param_encode(k, item_parameters[k])
end
end
return URI.encode(query.rchop("+"))
end
# extra api info
query += "&type=#{item_type}&limit=#{limit}&offset=#{offset}"
# 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}+"
return query
end
# Ranks the given items based off of the info from parameters.
@ -334,6 +321,15 @@ 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,13 +1,5 @@
require "http"
require "xml"
require "json"
require "uri"
require "./ranking"
require "../bottle/config"
require "../bottle/styles"
module Youtube
extend self
@ -17,184 +9,172 @@ module Youtube
"yt-uix-tile-link yt-ui-ellipsis yt-ui-ellipsis-2 yt-uix-sessionlink spf-link ",
]
# Note that VID_VALUE_CLASS, VID_METADATA_CLASS, and YT_METADATA_CLASS are found in ranking.cr
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",
]
# 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(spotify_metadata : JSON::Any,
flags = {} of String => String) : String?
def find_url(song_name : String, artist_name : String, search_terms = "",
download_first = false) : String?
query = (song_name + " " + artist_name + " " + search_terms).strip.gsub(" ", "+")
search_terms = Config.search_terms
url = "https://www.youtube.com/results?search_query=" + query
select_link = flags["select"]?
response = HTTP::Client.get(url)
song_name = spotify_metadata["name"].as_s
artist_name = spotify_metadata["artists"][0]["name"].as_s
valid_nodes = get_video_link_nodes(response.body)
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}\""
if valid_nodes.size == 0
puts "There were no results for that query."
return nil
end
root = "https://youtube.com"
ranked = Ranker.rank_videos(spotify_metadata, yt_metadata, human_query)
if select_link
return root + select_link_menu(spotify_metadata, yt_metadata)
end
return root + valid_nodes[0]["href"] if download_first
ranked = rank_videos(song_name, artist_name, query, valid_nodes)
begin
puts Style.dim(" Video: ") + yt_metadata[ranked[0]["index"]]["title"]
return root + yt_metadata[ranked[0]["index"]]["href"]
return root + valid_nodes[ranked[0]["index"]]["href"]
rescue IndexError
return nil
end
exit 1
end
# 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"
# 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,
})
index += 1
if index > 5
break
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
end
end
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.")
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 yt_metadata[input-1]["href"]
return good_phrases - bad_phrases
end
# Finds valid video links from a `HTTP::Client.get` request
# 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("{}")
# 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
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
nodes.each do |node|
if video_link_node?(node)
valid_nodes.push(node)
end
end
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
return valid_nodes
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
# 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
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]
VALID_LINK_CLASSES.each do |valid_class|
if node["class"]?
return true if node["class"].includes?(valid_class)
end
end
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
return false
end
end