Compare commits

..

No commits in common. "master" and "1.0.1" have entirely different histories.

17 changed files with 194 additions and 570 deletions

3
.gitignore vendored
View file

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

View file

@ -54,8 +54,6 @@ Arguments:
-s, --song <song> Specify song name to download -s, --song <song> Specify song name to download
-A, --album <album> Specify the album name to download -A, --album <album> Specify the album name to download
-p, --playlist <playlist> Specify the playlist 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: Examples:
$ irs --song "Bohemian Rhapsody" --artist "Queen" $ irs --song "Bohemian Rhapsody" --artist "Queen"
@ -96,8 +94,6 @@ If you're one of those cool people who compiles from source
```yaml ```yaml
binary_directory: ~/.irs/bin binary_directory: ~/.irs/bin
music_directory: ~/Music music_directory: ~/Music
filename_pattern: "{track_number} - {title}"
directory_pattern: "{artist}/{album}"
client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
single_folder_playlist: single_folder_playlist:
@ -124,9 +120,6 @@ Here's what they do:
```yaml ```yaml
binary_directory: ~/.irs/bin binary_directory: ~/.irs/bin
music_directory: ~/Music music_directory: ~/Music
search_terms: "lyrics"
filename_pattern: "{track_number} - {title}"
directory_pattern: "{artist}/{album}"
client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
single_folder_playlist: single_folder_playlist:
@ -137,10 +130,8 @@ single_folder_playlist:
- `binary_directory`: a path specifying where the downloaded binaries should - `binary_directory`: a path specifying where the downloaded binaries should
be placed be placed
- `music_directory`: a path specifying where downloaded mp3s 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 Note that there will be more structure created inside that folder, usually
potentially useful for not grabbing erroneous audio. in the format of `music-dir>artist-name>album-name>track`
- `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_key`: a client key from your spotify API application
- `client_secret`: a client secret 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 - `single_folder_playlist/enabled`: if set to true, all mp3s from a downloaded
@ -152,55 +143,6 @@ single_folder_playlist:
the album name and album image of the mp3 with the title of your playlist the album name and album image of the mp3 with the title of your playlist
and the image for your playlist respectively 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 ## How it works
**At it's core** `irs` downloads individual songs. It does this by interfacing **At it's core** `irs` downloads individual songs. It does this by interfacing

View file

@ -1,5 +1,5 @@
name: irs name: irs
version: 1.4.0 version: 1.0.1
authors: authors:
- Cooper Hammond <kepoorh@gmail.com> - Cooper Hammond <kepoorh@gmail.com>

View file

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

View file

@ -1,10 +1,2 @@
require "spec" 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,10 +20,6 @@ class CLI
[["-s", "--song"], "song", "string"], [["-s", "--song"], "song", "string"],
[["-A", "--album"], "album", "string"], [["-A", "--album"], "album", "string"],
[["-p", "--playlist"], "playlist", "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) @args : Hash(String, String)
@ -52,12 +48,6 @@ class CLI
#{Style.blue "-s, --song <song>"} Specify song name to download #{Style.blue "-s, --song <song>"} Specify song name to download
#{Style.blue "-A, --album <album>"} Specify the album 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 "-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.bold "Examples:"}
$ #{Style.green %(irs --song "Bohemian Rhapsody" --artist "Queen")} $ #{Style.green %(irs --song "Bohemian Rhapsody" --artist "Queen")}
@ -79,35 +69,34 @@ class CLI
if @args["help"]? || @args.keys.size == 0 if @args["help"]? || @args.keys.size == 0
help help
exit
elsif @args["version"]? elsif @args["version"]?
version version
exit
elsif @args["install"]? elsif @args["install"]?
YdlBinaries.get_both(Config.binary_location) YdlBinaries.get_both(Config.binary_location)
exit
elsif @args["config"]? elsif @args["config"]?
puts ENV["IRS_CONFIG_LOCATION"]? puts ENV["IRS_CONFIG_LOCATION"]?
exit
elsif @args["song"]? && @args["artist"]? elsif @args["song"]? && @args["artist"]?
s = Song.new(@args["song"], @args["artist"]) s = Song.new(@args["song"], @args["artist"])
s.provide_client_keys(Config.client_key, Config.client_secret) s.provide_client_keys(Config.client_key, Config.client_secret)
s.grab_it(flags: @args) s.grab_it
s.organize_it() s.organize_it(Config.music_directory)
exit
elsif @args["album"]? && @args["artist"]? elsif @args["album"]? && @args["artist"]?
a = Album.new(@args["album"], @args["artist"]) a = Album.new(@args["album"], @args["artist"])
a.provide_client_keys(Config.client_key, Config.client_secret) a.provide_client_keys(Config.client_key, Config.client_secret)
a.grab_it(flags: @args) a.grab_it
elsif @args["playlist"]? && @args["artist"]? elsif @args["playlist"]? && @args["artist"]?
p = Playlist.new(@args["playlist"], @args["artist"]) p = Playlist.new(@args["playlist"], @args["artist"])
p.provide_client_keys(Config.client_key, Config.client_secret) p.provide_client_keys(Config.client_key, Config.client_secret)
p.grab_it(flags: @args) p.grab_it
else else
puts Style.red("Those arguments don't do anything when used that way.") puts Style.red("Those arguments don't do anything when used that way.")
puts "Type `irs -h` to see usage." puts "Type `irs -h` to see usage."
exit 1
end end
end end

View file

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

View file

@ -42,6 +42,6 @@ class Album < SpotifyList
end end
private def organize(song : Song) private def organize(song : Song)
song.organize_it() song.organize_it(@home_music_directory)
end end
end end

View file

@ -17,9 +17,6 @@ abstract class SpotifyList
"searching" => [ "searching" => [
Style.bold("Searching for %l by %a ... \r"), Style.bold("Searching for %l by %a ... \r"),
Style.green("+ ") + Style.bold("%l by %a \n") 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 end
# Finds the list, and downloads all of the songs using the `Song` class # Finds the list, and downloads all of the songs using the `Song` class
def grab_it(flags = {} of String => String) def grab_it
ask_url = flags["url"]?
ask_skip = flags["ask_skip"]?
is_playlist = flags["playlist"]?
if !@spotify_searcher.authorized? if !@spotify_searcher.authorized?
raise("Need to call provide_client_keys on Album or Playlist class.") raise("Need to call provide_client_keys on Album or Playlist class.")
end end
if ask_url
outputter("url", 0)
end
outputter("searching", 0) outputter("searching", 0)
list = find_it() list = find_it()
outputter("searching", 1) outputter("searching", 1)
@ -47,28 +36,22 @@ abstract class SpotifyList
i = 0 i = 0
contents.each do |datum| contents.each do |datum|
i += 1
if datum["track"]? if datum["track"]?
datum = datum["track"] datum = datum["track"]
end end
data = organize_song_metadata(list, datum) data = organize_song_metadata(list, datum)
s_name = data["name"].to_s song = Song.new(data["name"].to_s, data["artists"][0]["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_spotify(@spotify_searcher)
song.provide_metadata(data) 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) organize(song)
song.grab_it(flags: flags)
organize(song) i += 1
else
puts "Skipping..."
end
end end
end end
@ -77,13 +60,6 @@ abstract class SpotifyList
@spotify_searcher.authorize(client_key, client_secret) @spotify_searcher.authorize(client_key, client_secret)
end 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) private def outputter(key : String, index : Int32)
text = @outputs[key][index] text = @outputs[key][index]
.gsub("%l", @list_name) .gsub("%l", @list_name)

View file

@ -46,7 +46,6 @@ class TrackMapper
type: Int32, type: Int32,
setter: true setter: true
}, },
duration_ms: Int32,
type: String, type: String,
uri: String uri: String
) )

View file

@ -67,10 +67,9 @@ class Playlist < SpotifyList
FileUtils.mkdir_p(strpath) FileUtils.mkdir_p(strpath)
end end
safe_filename = song.filename.gsub(/[\/]/, "").gsub(" ", " ") safe_filename = song.filename.gsub(/[\/]/, "").gsub(" ", " ")
FileUtils.cp("./" + song.filename, (path / safe_filename).to_s) File.rename("./" + song.filename, (path / safe_filename).to_s)
FileUtils.rm("./" + song.filename)
else else
song.organize_it() song.organize_it(@home_music_directory)
end end
end end
end end

View file

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

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

View file

@ -1,12 +1,6 @@
require "http" require "http"
require "xml" require "xml"
require "json" require "json"
require "uri"
require "./ranking"
require "../bottle/config"
require "../bottle/styles"
module Youtube module Youtube
@ -17,123 +11,167 @@ module Youtube
"yt-uix-tile-link yt-ui-ellipsis yt-ui-ellipsis-2 yt-uix-sessionlink spf-link ", "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",
]
alias NODES_CLASS = Array(Hash(String, String))
# Finds a youtube url based off of the given information. # Finds a youtube url based off of the given information.
# The query to youtube is constructed like this: # The query to youtube is constructed like this:
# "<song_name> <artist_name> <search terms>" # "<song_name> <artist_name> <search terms>"
# If *download_first* is provided, the first link found will be downloaded. # 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") # Youtube.find_url("Bohemian Rhapsody", "Queen")
# => "https://www.youtube.com/watch?v=dQw4w9WgXcQ" # => "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
# ``` # ```
def find_url(spotify_metadata : JSON::Any, def find_url(song_name : String, artist_name : String, search_terms = "",
flags = {} of String => String) : String? 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 valid_nodes = get_video_link_nodes(response.body)
artist_name = spotify_metadata["artists"][0]["name"].as_s
human_query = "#{song_name} #{artist_name} #{search_terms.strip}" if valid_nodes.size == 0
params = HTTP::Params.encode({"search_query" => human_query}) puts "There were no results for that 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 return nil
end end
root = "https://youtube.com" root = "https://youtube.com"
ranked = Ranker.rank_videos(spotify_metadata, yt_metadata, human_query)
if select_link return root + valid_nodes[0]["href"] if download_first
return root + select_link_menu(spotify_metadata, yt_metadata)
end ranked = rank_videos(song_name, artist_name, query, valid_nodes)
begin begin
puts Style.dim(" Video: ") + yt_metadata[ranked[0]["index"]]["title"] return root + valid_nodes[ranked[0]["index"]]["href"]
return root + yt_metadata[ranked[0]["index"]]["href"]
rescue IndexError rescue IndexError
return nil return nil
end end
exit 1
end end
# Presents a menu with song info for the user to choose which url they want to download # Will rank videos according to their title and the user input
private def select_link_menu(spotify_metadata : JSON::Any, # Return:
yt_metadata : YT_METADATA_CLASS) : String # [
puts Style.dim(" Spotify info: ") + # {"points" => x, "index" => x},
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" private def rank_videos(song_name : String, artist_name : String,
puts " Choose video to download:" query : String, nodes : Array(Hash(String, String))) : Array(Hash(String, Int32))
index = 1 points = [] of Hash(String, Int32)
yt_metadata.each do |vid| index = 0
print " " + Style.bold(index.to_s + " ")
puts "\"" + vid["title"] + "\" @ " + Style.blue((vid["duration_ms"].to_i / 1000).to_i.to_s) + "s" 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 index += 1
if index > 5 end
break
# 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
end end
input = 0 GARBAGE_PHRASES.each do |garbage_phrase|
while true # not between 1 and 5 garbage_phrase = garbage_phrase.downcase.gsub(/[^a-z0-9]/, "")
begin
print Style.bold(" > ") if query.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
input = gets.not_nil!.chomp.to_i next
if input < 6 && input > 0 elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
break bad_phrases += 1
end
rescue
puts Style.red(" Invalid input, try again.")
end end
end end
return yt_metadata[input-1]["href"] return good_phrases - bad_phrases
end end
# Finds valid video links from a `HTTP::Client.get` request # Finds valid video links from a `HTTP::Client.get` request
# Returns an `Array` of `NODES_CLASS` containing additional metadata from Youtube # Returns an `Array` of `XML::Node`
private def get_yt_search_metadata(response_body : String) : YT_METADATA_CLASS private def get_video_link_nodes(response_body : String) : NODES_CLASS
yt_initial_data : JSON::Any = JSON.parse("{}") yt_initial_data : JSON::Any = JSON.parse("{}")
response_body.each_line do |line| response_body.each_line do |line|
# timestamp 11/8/2020: if line.includes?("window[\"ytInitialData\"]")
# youtube's html page has a line previous to this literally with 'scraper_data_begin' as a comment yt_initial_data = JSON.parse(line.split(" = ")[1][0..-2])
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
end end
if yt_initial_data == JSON.parse("{}") if yt_initial_data == JSON.parse("{}")
puts "Youtube has changed the way it organizes its webpage, submit a bug" 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" puts "on https://github.com/cooperhammond/irs"
exit(1) exit(1)
end end
# where the vid metadata lives # where the vid metadata lives
yt_initial_data = yt_initial_data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"]["sectionListRenderer"]["contents"] yt_initial_data = yt_initial_data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"]["sectionListRenderer"]["contents"]
video_metadata = [] of VID_METADATA_CLASS video_metadata = [] of Hash(String, String)
i = 0 i = 0
while true while true
@ -141,16 +179,11 @@ module Youtube
# video title # video title
raw_metadata = yt_initial_data[0]["itemSectionRenderer"]["contents"][i]["videoRenderer"] raw_metadata = yt_initial_data[0]["itemSectionRenderer"]["contents"][i]["videoRenderer"]
metadata = {} of String => VID_VALUE_CLASS metadata = {} of String => String
metadata["title"] = raw_metadata["title"]["runs"][0]["text"].as_s metadata["title"] = raw_metadata["title"]["runs"][0]["text"].as_s
metadata["href"] = raw_metadata["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].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) video_metadata.push(metadata)
rescue IndexError rescue IndexError
break break
@ -161,40 +194,4 @@ module Youtube
return video_metadata return video_metadata
end 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
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 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 end