crystal tool format respec

This commit is contained in:
Cooper Hammond 2020-05-12 22:38:03 -07:00
parent d1657ba86d
commit de219cbe66
15 changed files with 157 additions and 191 deletions

View file

@ -8,9 +8,7 @@ require "../glue/song"
require "../glue/album" require "../glue/album"
require "../glue/playlist" require "../glue/playlist"
class CLI class CLI
# layout: # layout:
# [[shortflag, longflag], key, type] # [[shortflag, longflag], key, type]
@options = [ @options = [
@ -21,10 +19,9 @@ class CLI
[["-a", "--artist"], "artist", "string"], [["-a", "--artist"], "artist", "string"],
[["-s", "--song"], "song", "string"], [["-s", "--song"], "song", "string"],
[["-A", "--album"], "album", "string"], [["-A", "--album"], "album", "string"],
[["-p", "--playlist"], "playlist", "string"] [["-p", "--playlist"], "playlist", "string"],
] ]
@args : Hash(String, String) @args : Hash(String, String)
def initialize(argv : Array(String)) def initialize(argv : Array(String))
@ -68,8 +65,7 @@ class CLI
end end
def act_on_args def act_on_args
Config.check_necessities
Config.check_necessities()
if @args["help"]? || @args.keys.size == 0 if @args["help"]? || @args.keys.size == 0
help help
@ -86,17 +82,17 @@ class CLI
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() s.grab_it
s.organize_it(Config.music_directory) s.organize_it(Config.music_directory)
exit 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() 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() 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."
@ -111,12 +107,11 @@ class CLI
current_key = "" current_key = ""
pass_next_arg = false pass_next_arg = false
argv.each do |arg| argv.each do |arg|
# If the previous arg was an arg flag, this is an arg, so pass it # If the previous arg was an arg flag, this is an arg, so pass it
if pass_next_arg if pass_next_arg
pass_next_arg = false pass_next_arg = false
i += 1 i += 1
next next
end end
flag = [] of Array(String) | String flag = [] of Array(String) | String
@ -140,7 +135,6 @@ class CLI
arg_error argv, i, %("#{arg}" needs an argument.) arg_error argv, i, %("#{arg}" needs an argument.)
end end
key = flag[1].as(String) key = flag[1].as(String)
if flag[2] == "string" if flag[2] == "string"
arguments[key] = argv[i + 1] arguments[key] = argv[i + 1]
@ -182,4 +176,4 @@ class CLI
puts "Type `irs -h` to see usage." puts "Type `irs -h` to see usage."
exit 1 exit 1
end end
end end

View file

@ -2,7 +2,6 @@ require "yaml"
require "./styles" require "./styles"
EXAMPLE_CONFIG = <<-EOP EXAMPLE_CONFIG = <<-EOP
#{Style.dim "exampleconfig.yml"} #{Style.dim "exampleconfig.yml"}
#{Style.dim "===="} #{Style.dim "===="}
@ -18,17 +17,16 @@ single_folder_playlist:
EOP EOP
module Config module Config
extend self extend self
@@arguments = [ @@arguments = [
"binary_directory", "binary_directory",
"music_directory", "music_directory",
"client_key", "client_key",
"client_secret", "client_secret",
"single_folder_playlist: enabled", "single_folder_playlist: enabled",
"single_folder_playlist: retain_playlist_order", "single_folder_playlist: retain_playlist_order",
"single_folder_playlist: overwrite_album" "single_folder_playlist: overwrite_album",
] ]
@@conf = YAML.parse("") @@conf = YAML.parse("")
@ -69,14 +67,14 @@ module Config
def overwrite_album? : Bool def overwrite_album? : Bool
return @@conf["single_folder_playlist"]["overwrite_album"].as_bool return @@conf["single_folder_playlist"]["overwrite_album"].as_bool
end end
def check_necessities def check_necessities
missing_configs = [] of String missing_configs = [] of String
@@arguments.each do |argument| @@arguments.each do |argument|
if !check_conf(argument) if !check_conf(argument)
missing_configs.push(argument) missing_configs.push(argument)
end end
end end
if missing_configs.size > 0 if missing_configs.size > 0
puts Style.red("You are missing the following key(s) in your YAML config file:") puts Style.red("You are missing the following key(s) in your YAML config file:")
@ -102,4 +100,4 @@ module Config
return @@conf[key]? return @@conf[key]?
end end
end end
end end

View file

@ -20,4 +20,4 @@ class Style
def self.red(txt) def self.red(txt)
txt.colorize(:light_red) txt.colorize(:light_red)
end end
end end

View file

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

View file

@ -4,17 +4,15 @@ require "./mapper"
require "./song" require "./song"
require "./list" require "./list"
class Album < SpotifyList class Album < SpotifyList
@home_music_directory = Config.music_directory @home_music_directory = Config.music_directory
# Uses the `spotify_searcher` defined in parent `SpotifyList` to find the # Uses the `spotify_searcher` defined in parent `SpotifyList` to find the
# correct metadata of the list # correct metadata of the list
def find_it def find_it
album = @spotify_searcher.find_item("album", { album = @spotify_searcher.find_item("album", {
"name" => @list_name.as(String), "name" => @list_name.as(String),
"artist" => @list_author.as(String) "artist" => @list_author.as(String),
}) })
if album if album
return album.as(JSON::Any) return album.as(JSON::Any)
@ -24,7 +22,7 @@ class Album < SpotifyList
end end
end end
# Will define specific metadata that may not be included in the raw return # Will define specific metadata that may not be included in the raw return
# of spotify's album json. Moves the title of the album and the album art # of spotify's album json. Moves the title of the album and the album art
# to the json of the single song # to the json of the single song
def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any

View file

@ -8,10 +8,9 @@ require "../interact/tagger"
require "./song" require "./song"
# A parent class for downloading albums and playlists from spotify # A parent class for downloading albums and playlists from spotify
abstract class SpotifyList abstract class SpotifyList
@spotify_searcher = SpotifySearcher.new() @spotify_searcher = SpotifySearcher.new
@file_names = [] of String @file_names = [] of String
def initialize(@list_name : String, @list_author : String?) def initialize(@list_name : String, @list_author : String?)
@ -19,7 +18,6 @@ abstract class SpotifyList
# 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 def grab_it
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
@ -38,7 +36,7 @@ abstract class SpotifyList
song = Song.new(data["name"].to_s, data["artists"][0]["name"].to_s) song = Song.new(data["name"].to_s, data["artists"][0]["name"].to_s)
song.provide_spotify(@spotify_searcher) song.provide_spotify(@spotify_searcher)
song.provide_metadata(data) song.provide_metadata(data)
song.grab_it() song.grab_it
organize(song) organize(song)
@ -57,12 +55,11 @@ abstract class SpotifyList
# If there's a need to organize the individual song data so that the `Song` # If there's a need to organize the individual song data so that the `Song`
# class can better handle it, this function will be defined in the subclass # class can better handle it, this function will be defined in the subclass
private abstract def organize_song_metadata(list : JSON::Any, private abstract def organize_song_metadata(list : JSON::Any,
datum : JSON::Any) : JSON::Any datum : JSON::Any) : JSON::Any
# Will define the specific type of organization for a list of songs. # Will define the specific type of organization for a list of songs.
# Needed because most people want albums sorted by artist, but playlists all # Needed because most people want albums sorted by artist, but playlists all
# in one folder # in one folder
private abstract def organize(song : Song) private abstract def organize(song : Song)
end
end

View file

@ -3,8 +3,8 @@ require "json"
class PlaylistExtensionMapper class PlaylistExtensionMapper
JSON.mapping( JSON.mapping(
tracks: { tracks: {
type: PlaylistTracksMapper, type: PlaylistTracksMapper,
setter: true setter: true,
}, },
id: String, id: String,
images: JSON::Any, images: JSON::Any,
@ -17,8 +17,8 @@ end
class PlaylistTracksMapper class PlaylistTracksMapper
JSON.mapping( JSON.mapping(
items: { items: {
type: Array(JSON::Any), type: Array(JSON::Any),
setter: true setter: true,
}, },
total: Int32 total: Int32
) )
@ -26,10 +26,10 @@ end
class AlbumTracksMapper class AlbumTracksMapper
JSON.mapping( JSON.mapping(
album: { album: {
type: JSON::Any, type: JSON::Any,
nilable: true, nilable: true,
setter: true setter: true,
}, },
artists: JSON::Any, artists: JSON::Any,
disc_number: Int32, disc_number: Int32,
@ -41,7 +41,6 @@ class AlbumTracksMapper
) )
end end
def parse_to_json(string_json : String) : JSON::Any def parse_to_json(string_json : String) : JSON::Any
return JSON.parse(string_json) return JSON.parse(string_json)
end end

View file

@ -3,18 +3,16 @@ require "../bottle/config"
require "./song" require "./song"
require "./list" require "./list"
class Playlist < SpotifyList class Playlist < SpotifyList
@home_music_directory = Config.music_directory @home_music_directory = Config.music_directory
@playlist : JSON::Any? @playlist : JSON::Any?
# Uses the `spotify_searcher` defined in parent `SpotifyList` to find the # Uses the `spotify_searcher` defined in parent `SpotifyList` to find the
# correct metadata of the list # correct metadata of the list
def find_it def find_it
@playlist = @spotify_searcher.find_item("playlist", { @playlist = @spotify_searcher.find_item("playlist", {
"name" => @list_name.as(String), "name" => @list_name.as(String),
"username" => @list_author.as(String) "username" => @list_author.as(String),
}) })
if @playlist if @playlist
return @playlist.as(JSON::Any) return @playlist.as(JSON::Any)
@ -24,7 +22,7 @@ class Playlist < SpotifyList
end end
end end
# Will define specific metadata that may not be included in the raw return # Will define specific metadata that may not be included in the raw return
# of spotify's album json. Moves the title of the album and the album art # of spotify's album json. Moves the title of the album and the album art
# to the json of the single song # to the json of the single song
def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any

View file

@ -4,9 +4,8 @@ require "../search/youtube"
require "../interact/ripper" require "../interact/ripper"
require "../interact/tagger" require "../interact/tagger"
class Song class Song
@spotify_searcher = SpotifySearcher.new() @spotify_searcher = SpotifySearcher.new
@client_id = "" @client_id = ""
@client_secret = "" @client_secret = ""
@ -21,29 +20,29 @@ class Song
# Find, downloads, and tags the mp3 song that this class represents. # Find, downloads, and tags the mp3 song that this class represents.
# #
# ``` # ```
# Song.new("Bohemian Rhapsody", "Queen").grab_it() # Song.new("Bohemian Rhapsody", "Queen").grab_it
# ``` # ```
def grab_it def grab_it
if !@spotify_searcher.authorized? && !@metadata if !@spotify_searcher.authorized? && !@metadata
if @client_id != "" && @client_secret != "" if @client_id != "" && @client_secret != ""
@spotify_searcher.authorize(@client_id, @client_secret) @spotify_searcher.authorize(@client_id, @client_secret)
else else
raise("Need to call either `provide_metadata`, `provide_spotify`, " + raise("Need to call either `provide_metadata`, `provide_spotify`, " +
"or `provide_client_keys` so that Spotify can be interfaced with.") "or `provide_client_keys` so that Spotify can be interfaced with.")
end end
end end
if !@metadata if !@metadata
puts "Searching for metadata ..." puts "Searching for metadata ..."
@metadata = @spotify_searcher.find_item("track", { @metadata = @spotify_searcher.find_item("track", {
"name" => @song_name, "name" => @song_name,
"artist" => @artist_name "artist" => @artist_name,
}) })
if !@metadata if !@metadata
raise("There was no metadata found on Spotify for\n" + raise("There was no metadata found on Spotify for\n" +
%("#{@song_name}" by "#{@artist_name}\n) + %("#{@song_name}" by "#{@artist_name}\n) +
"Check your input and try again.") "Check your input and try again.")
end end
end end
@ -54,12 +53,12 @@ class Song
# TODO: should this search_term be here? # TODO: should this search_term be here?
url = Youtube.find_url(@song_name, @artist_name, search_terms: "lyrics") url = Youtube.find_url(@song_name, @artist_name, search_terms: "lyrics")
if !url if !url
raise("There was no url found on youtube for\n" + raise("There was no url found on youtube for\n" +
%("#{@song_name}" by "#{@artist_name}\n) + %("#{@song_name}" by "#{@artist_name}\n) +
"Check your input and try again.") "Check your input and try again.")
end end
puts "Downloading video:" puts "Downloading video:"
Ripper.download_mp3(url.as(String), @filename) Ripper.download_mp3(url.as(String), @filename)
@ -76,27 +75,26 @@ class Song
tagger.add_text_tag("title", data["name"].to_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) tagger.add_text_tag("album", @album)
tagger.add_text_tag("genre", tagger.add_text_tag("genre",
@spotify_searcher.find_genre(data["artists"][0]["id"].to_s)) @spotify_searcher.find_genre(data["artists"][0]["id"].to_s))
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)
puts "Tagging metadata ..." puts "Tagging metadata ..."
tagger.save() tagger.save
File.delete(temp_albumart_filename) File.delete(temp_albumart_filename)
puts %("#{data["name"].to_s}" by "#{data["artists"][0]["name"].to_s}" downloaded.) puts %("#{data["name"].to_s}" by "#{data["artists"][0]["name"].to_s}" downloaded.)
end end
# Will organize the song into the user's provided music directory as # Will organize the song into the user's provided music directory as
# music_directory > artist_name > album_name > song # 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("/home/cooper/Music") # s.organize_it("/home/cooper/Music")
# # Will move the mp3 file to # # Will move the mp3 file 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(music_directory : String) def organize_it(music_directory : String)
@ -116,20 +114,20 @@ class Song
# called. # called.
# #
# ``` # ```
# Song.new(...).provide_metadata(...).grab_it() # Song.new(...).provide_metadata(...).grab_it
# ``` # ```
def provide_metadata(metadata : JSON::Any) : self def provide_metadata(metadata : JSON::Any) : self
@metadata = metadata @metadata = metadata
return self return self
end end
# Provide an already authenticated `SpotifySearcher` class. Useful to avoid # Provide an already authenticated `SpotifySearcher` class. Useful to avoid
# authenticating over and over again. Must be called if provide_metadata and # authenticating over and over again. Must be called if provide_metadata and
# provide_client_keys are not called. # provide_client_keys are not called.
# #
# ``` # ```
# Song.new(...).provide_spotify(SpotifySearcher.new() # Song.new(...).provide_spotify(SpotifySearcher.new
# .authenticate("XXXXXXXXXX", "XXXXXXXXXXX")).grab_it() # .authenticate("XXXXXXXXXX", "XXXXXXXXXXX")).grab_it
# ``` # ```
def provide_spotify(spotify : SpotifySearcher) : self def provide_spotify(spotify : SpotifySearcher) : self
@spotify_searcher = spotify @spotify_searcher = spotify
@ -140,11 +138,11 @@ class Song
# provide_spotify are not called. # provide_spotify are not called.
# #
# ``` # ```
# Song.new(...).provide_client_keys("XXXXXXXXXX", "XXXXXXXXX").grab_it() # Song.new(...).provide_client_keys("XXXXXXXXXX", "XXXXXXXXX").grab_it
# ``` # ```
def provide_client_keys(client_id : String, client_secret : String) : self def provide_client_keys(client_id : String, client_secret : String) : self
@client_id = client_id @client_id = client_id
@client_secret = client_secret @client_secret = client_secret
return self return self
end end
end end

View file

@ -1,5 +1,4 @@
class Logger class Logger
@done_signal = "---DONE---" @done_signal = "---DONE---"
@command : String @command : String
@ -11,16 +10,16 @@ class Logger
def initialize(command : String, @log_name : String, @sleept = 0.01) def initialize(command : String, @log_name : String, @sleept = 0.01)
# Have the command output its information to a log and after the command is # Have the command output its information to a log and after the command is
# finished, append an end signal to the document # finished, append an end signal to the document
@command = "#{command} > #{@log_name} " # standard output to log @command = "#{command} > #{@log_name} " # standard output to log
@command += "2> #{@log_name} && " # errors to log @command += "2> #{@log_name} && " # errors to log
@command += "echo #{@done_signal} >> #{@log_name}" # @command += "echo #{@done_signal} >> #{@log_name}" #
end end
# Run @command in the background and pipe its output to the log file, with # Run @command in the background and pipe its output to the log file, with
# something constantly monitoring the log file and yielding each new line to # something constantly monitoring the log file and yielding each new line to
# the block call. Useful for changing the output of binaries you don't have # the block call. Useful for changing the output of binaries you don't have
# much control over. # much control over.
# Note that the created temp log will be deleted unless the command fails # Note that the created temp log will be deleted unless the command fails
# its exit or .start is called with delete_file: false # its exit or .start is called with delete_file: false
# #
# ``` # ```
@ -34,7 +33,7 @@ class Logger
# end # end
# end # end
# ``` # ```
def start(delete_file=true, &block) : Bool def start(delete_file = true, &block) : Bool
# Delete the log if it already exists # Delete the log if it already exists
File.delete(@log_name) if File.exists?(@log_name) File.delete(@log_name) if File.exists?(@log_name)
@ -44,7 +43,7 @@ class Logger
} }
# Wait for the log file to be written to # Wait for the log file to be written to
while !File.exists?(@log_name) while !File.exists?(@log_name)
sleep @sleept sleep @sleept
end end
@ -68,12 +67,12 @@ class Logger
end end
end end
status = called.get() status = called.get
if status == true && delete_file == true if status == true && delete_file == true
log.delete() log.delete
end end
return called.get() return called.get
end end
# Reads each line of the file into an Array of Strings # Reads each line of the file into an Array of Strings
@ -86,4 +85,4 @@ class Logger
return content return content
end end
end end

View file

@ -2,7 +2,6 @@ require "./logger"
require "../bottle/config" require "../bottle/config"
module Ripper module Ripper
extend self extend self
BIN_LOC = Path[Config.binary_location] BIN_LOC = Path[Config.binary_location]
@ -11,23 +10,23 @@ module Ripper
# Will create any directories that don't exist specified in *output_filename* # Will create any directories that don't exist specified in *output_filename*
# #
# ``` # ```
# Ripper.download_mp3("https://youtube.com/watch?v=0xnciFWAqa0", # Ripper.download_mp3("https://youtube.com/watch?v=0xnciFWAqa0",
# "Queen/A Night At The Opera/Bohemian Rhapsody.mp3") # "Queen/A Night At The Opera/Bohemian Rhapsody.mp3")
# ``` # ```
def download_mp3(video_url : String, output_filename : String) def download_mp3(video_url : String, output_filename : String)
ydl_loc = BIN_LOC.join("youtube-dl") ydl_loc = BIN_LOC.join("youtube-dl")
# remove the extension that will be added on by ydl # remove the extension that will be added on by ydl
output_filename = output_filename.split(".")[..-2].join(".") output_filename = output_filename.split(".")[..-2].join(".")
options = { options = {
"--output" => %("#{output_filename}.%(ext)s"), # auto-add correct ext "--output" => %("#{output_filename}.%(ext)s"), # auto-add correct ext
# "--quiet" => "", # "--quiet" => "",
"--verbose" => "", "--verbose" => "",
"--ffmpeg-location" => BIN_LOC, "--ffmpeg-location" => BIN_LOC,
"--extract-audio" => "", "--extract-audio" => "",
"--audio-format" => "mp3", "--audio-format" => "mp3",
"--audio-quality" => "0", "--audio-quality" => "0",
} }
command = ydl_loc.to_s + " " + video_url command = ydl_loc.to_s + " " + video_url
@ -35,7 +34,6 @@ module Ripper
command += " #{option} #{options[option]}" command += " #{option} #{options[option]}"
end end
l = Logger.new(command, ".ripper.log") l = Logger.new(command, ".ripper.log")
o = RipperOutputCensor.new o = RipperOutputCensor.new
@ -64,6 +62,5 @@ module Ripper
end end
end end
end end
end end
end end

View file

@ -5,10 +5,9 @@ require "../bottle/config"
# t = Tags.new("bohem rap.mp3") # t = Tags.new("bohem rap.mp3")
# t.add_album_art("a night at the opera album cover.jpg") # t.add_album_art("a night at the opera album cover.jpg")
# t.add_text_tag("title", "Bohemian Rhapsody") # t.add_text_tag("title", "Bohemian Rhapsody")
# t.save() # t.save
# ``` # ```
class Tags class Tags
# TODO: export this path to a config file # TODO: export this path to a config file
@BIN_LOC = Config.binary_location @BIN_LOC = Config.binary_location
@query_args = [] of String @query_args = [] of String
@ -20,7 +19,6 @@ class Tags
end end
@query_args.push(%(-i "#{@filename}")) @query_args.push(%(-i "#{@filename}"))
end end
# Add album art to the mp3. Album art must be added BEFORE text tags are. # Add album art to the mp3. Album art must be added BEFORE text tags are.
@ -52,7 +50,7 @@ class Tags
l = Logger.new(command, ".tagger.log") l = Logger.new(command, ".tagger.log")
l.start { |line, start| } l.start { |line, start| }
File.delete(@filename) File.delete(@filename)
File.rename("_" + @filename, @filename) File.rename("_" + @filename, @filename)
end end
@ -61,4 +59,4 @@ end
# a = Tags.new("test.mp3") # a = Tags.new("test.mp3")
# a.add_text_tag("title", "Warwick Avenue") # a.add_text_tag("title", "Warwick Avenue")
# a.add_album_art("file.png") # a.add_album_art("file.png")
# a.save() # a.save()

View file

@ -2,7 +2,7 @@ require "./bottle/cli"
def main def main
cli = CLI.new(ARGV) cli = CLI.new(ARGV)
cli.act_on_args() cli.act_on_args
end end
main() main()

View file

@ -11,18 +11,18 @@ class SpotifySearcher
@authorized = false @authorized = false
# Saves an access token for future program use with spotify using client IDs. # Saves an access token for future program use with spotify using client IDs.
# Specs defined on spotify's developer api: # Specs defined on spotify's developer api:
# https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow # https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
# #
# ``` # ```
# SpotifySearcher.new().authorize("XXXXXXXXXX", "XXXXXXXXXX") # SpotifySearcher.new.authorize("XXXXXXXXXX", "XXXXXXXXXX")
# ``` # ```
def authorize(client_id : String, client_secret : String) : self def authorize(client_id : String, client_secret : String) : self
auth_url = "https://accounts.spotify.com/api/token" auth_url = "https://accounts.spotify.com/api/token"
headers = HTTP::Headers{ headers = HTTP::Headers{
"Authorization" => "Basic " + "Authorization" => "Basic " +
Base64.strict_encode "#{client_id}:#{client_secret}" Base64.strict_encode "#{client_id}:#{client_secret}",
} }
payload = "grant_type=client_credentials" payload = "grant_type=client_credentials"
@ -31,9 +31,9 @@ class SpotifySearcher
error_check(response) error_check(response)
access_token = JSON.parse(response.body)["access_token"] access_token = JSON.parse(response.body)["access_token"]
@access_header = HTTP::Headers{ @access_header = HTTP::Headers{
"Authorization" => "Bearer #{access_token}" "Authorization" => "Bearer #{access_token}",
} }
@authorized = true @authorized = true
@ -55,12 +55,11 @@ class SpotifySearcher
# }) # })
# => {track metadata} # => {track metadata}
# ``` # ```
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, offset, limit) query = generate_query(item_type, item_parameters, offset, limit)
url = @root_url.join("search?q=#{query}").to_s() url = @root_url.join("search?q=#{query}").to_s
response = HTTP::Client.get(url, headers: @access_header) response = HTTP::Client.get(url, headers: @access_header)
error_check(response) error_check(response)
@ -74,7 +73,7 @@ class SpotifySearcher
begin begin
# this means no points were assigned so don't return the "best guess" # this means no points were assigned so don't return the "best guess"
if points[0][0] <= 0 if points[0][0] <= 0
to_return = nil to_return = nil
else else
to_return = get_item(item_type, items[points[0][1]]["id"].to_s) to_return = get_item(item_type, items[points[0][1]]["id"].to_s)
end end
@ -82,15 +81,15 @@ class SpotifySearcher
to_return = nil to_return = nil
end end
# if this triggers, it means that a playlist has failed to be found, so # if this triggers, it means that a playlist has failed to be found, so
# the search will be bootstrapped into find_user_playlist # the search will be bootstrapped into find_user_playlist
if to_return == nil && item_type == "playlist" if to_return == nil && item_type == "playlist"
return find_user_playlist( return find_user_playlist(
item_parameters["username"], item_parameters["username"],
item_parameters["name"] item_parameters["name"]
) )
end end
return to_return return to_return
end end
@ -100,9 +99,8 @@ class SpotifySearcher
# spotify_searcher.find_user_playlist("prakkillian", "the little man") # spotify_searcher.find_user_playlist("prakkillian", "the little man")
# => {playlist metadata} # => {playlist metadata}
# ``` # ```
def find_user_playlist(username : String, name : String, offset=0, def find_user_playlist(username : String, name : String, offset = 0,
limit=20) : JSON::Any? limit = 20) : JSON::Any?
url = "users/#{username}/playlists?limit=#{limit}&offset=#{offset}" url = "users/#{username}/playlists?limit=#{limit}&offset=#{offset}"
url = @root_url.join(url).to_s url = @root_url.join(url).to_s
@ -116,14 +114,14 @@ class SpotifySearcher
items.as_a.each_index do |i| items.as_a.each_index do |i|
points.push([points_compare(items[i]["name"].to_s, name), i]) points.push([points_compare(items[i]["name"].to_s, name), i])
end end
points.sort!{ |a, b| b[0] <=> a[0] } points.sort! { |a, b| b[0] <=> a[0] }
begin begin
if points[0][0] < 3 if points[0][0] < 3
return find_user_playlist(username, name, offset + limit, limit) return find_user_playlist(username, name, offset + limit, limit)
else else
return get_item("playlist", items[points[0][1]]["id"].to_s) return get_item("playlist", items[points[0][1]]["id"].to_s)
end end
rescue IndexError rescue IndexError
return nil return nil
end end
@ -132,18 +130,17 @@ class SpotifySearcher
# Get the complete metadata of an item based off of its id # Get the complete metadata of an item based off of its id
# #
# ``` # ```
# SpotifySearcher.new().authorize(...).get_item("artist", "1dfeR4HaWDbWqFHLkxsg1d") # SpotifySearcher.new.authorize(...).get_item("artist", "1dfeR4HaWDbWqFHLkxsg1d")
# ``` # ```
def get_item(item_type : String, id : String, offset=0, def get_item(item_type : String, id : String, offset = 0,
limit=100) : JSON::Any limit = 100) : JSON::Any
if item_type == "playlist" if item_type == "playlist"
return get_playlist(id, offset, limit) return get_playlist(id, offset, limit)
end end
url = "#{item_type}s/#{id}?limit=#{limit}&offset=#{offset}" url = "#{item_type}s/#{id}?limit=#{limit}&offset=#{offset}"
url = @root_url.join(url).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)
@ -156,12 +153,12 @@ class SpotifySearcher
# insert ALL tracks from the playlist into the `JSON::Any` # insert ALL tracks from the playlist into the `JSON::Any`
# #
# ``` # ```
# SpotifySearcher.new().authorize(...).get_playlist("122Fc9gVuSZoksEjKEx7L0") # SpotifySearcher.new.authorize(...).get_playlist("122Fc9gVuSZoksEjKEx7L0")
# ``` # ```
def get_playlist(id, offset=0, limit=100) : JSON::Any def get_playlist(id, offset = 0, limit = 100) : JSON::Any
url = "playlists/#{id}?limit=#{limit}&offset=#{offset}" url = "playlists/#{id}?limit=#{limit}&offset=#{offset}"
url = @root_url.join(url).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)
body = JSON.parse(response.body) body = JSON.parse(response.body)
@ -169,7 +166,7 @@ class SpotifySearcher
more_tracks = body["tracks"]["total"].as_i > offset + limit more_tracks = body["tracks"]["total"].as_i > offset + limit
if more_tracks if more_tracks
return playlist_extension(parent, id, offset=offset + limit) return playlist_extension(parent, id, offset = offset + limit)
end end
return body return body
@ -177,10 +174,10 @@ class SpotifySearcher
# This method exists to loop through spotify API requests and combine all # This method exists to loop through spotify API requests and combine all
# tracks that may not be captured by the limit of 100. # tracks that may not be captured by the limit of 100.
private def playlist_extension(parent : PlaylistExtensionMapper, private def playlist_extension(parent : PlaylistExtensionMapper,
id : String, offset=0, limit=100) : JSON::Any id : String, offset = 0, limit = 100) : JSON::Any
url = "playlists/#{id}/tracks?limit=#{limit}&offset=#{offset}" url = "playlists/#{id}/tracks?limit=#{limit}&offset=#{offset}"
url = @root_url.join(url).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)
@ -193,7 +190,7 @@ class SpotifySearcher
more_tracks = body["total"].as_i > offset + limit more_tracks = body["total"].as_i > offset + limit
if more_tracks if more_tracks
return playlist_extension(parent, id, offset=offset + limit) return playlist_extension(parent, id, offset = offset + limit)
end end
return JSON.parse(parent.to_json) return JSON.parse(parent.to_json)
@ -202,10 +199,9 @@ class SpotifySearcher
# Find the genre of an artist based off of their id # Find the genre of an artist based off of their id
# #
# ``` # ```
# SpotifySearcher.new().authorize(...).find_genre("1dfeR4HaWDbWqFHLkxsg1d") # SpotifySearcher.new.authorize(...).find_genre("1dfeR4HaWDbWqFHLkxsg1d")
# ``` # ```
def find_genre(id : String) : String def find_genre(id : String) : String
genre = get_item("artist", id)["genres"][0].to_s genre = get_item("artist", id)["genres"][0].to_s
genre = genre.split(" ").map { |x| x.capitalize }.join(" ") genre = genre.split(" ").map { |x| x.capitalize }.join(" ")
@ -216,15 +212,15 @@ class SpotifySearcher
private def error_check(response : HTTP::Client::Response) : Nil private def error_check(response : HTTP::Client::Response) : Nil
if response.status_code != 200 if response.status_code != 200
raise("There was an error with your request.\n" + raise("There was an error with your request.\n" +
"Status code: #{response.status_code}\n" + "Status code: #{response.status_code}\n" +
"Response: \n#{response.body}") "Response: \n#{response.body}")
end end
end end
# 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, private def generate_query(item_type : String, item_parameters : Hash,
offset : Int32, limit : Int32) : String 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
@ -234,21 +230,20 @@ class SpotifySearcher
item_parameters.keys.each do |k| item_parameters.keys.each do |k|
# This will map album and track names from the name key to the query # This will map album and track names from the name key to the query
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].gsub(" ", "+") + "+" query += item_parameters[k].gsub(" ", "+") + "+"
else else
query += param_encode(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
elsif query_exclude.includes?(k) elsif query_exclude.includes?(k)
next next
# if it's none of the above, treat it normally # if it's none of the above, treat it normally
# 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 += param_encode(k, item_parameters[k]) query += param_encode(k, item_parameters[k])
end end
@ -264,7 +259,7 @@ class SpotifySearcher
# Meant to find the item that the user desires. # Meant to find the item that the user desires.
# Returns an `Array` of `Array(Int32)` or [[3, 1], [...], ...] # Returns an `Array` of `Array(Int32)` or [[3, 1], [...], ...]
private def rank_items(items : Array, private def rank_items(items : Array,
parameters : Hash) : Array(Array(Int32)) parameters : Hash) : Array(Array(Int32))
points = [] of Array(Int32) points = [] of Array(Int32)
index = 0 index = 0
@ -274,7 +269,7 @@ class SpotifySearcher
# Think about whether this following logic is worth having in one method. # Think about whether this following logic is worth having in one method.
# Is it nice to have a single method that handles it all or having a few # Is it nice to have a single method that handles it all or having a few
# methods for each of the item types? (track, album, playlist) # methods for each of the item types? (track, album, playlist)
parameters.keys.each do |k| parameters.keys.each do |k|
val = parameters[k] val = parameters[k]
# The key to compare to for artist # The key to compare to for artist
@ -286,7 +281,7 @@ class SpotifySearcher
if k == "username" if k == "username"
pts_to_add = points_compare(item["owner"]["display_name"].to_s, val) pts_to_add = points_compare(item["owner"]["display_name"].to_s, val)
pts += pts_to_add pts += pts_to_add
pts += -10 if pts_to_add == 0 pts += -10 if pts_to_add == 0
end end
# The key regardless of whether item is track, album,or playlist # The key regardless of whether item is track, album,or playlist
@ -299,12 +294,12 @@ class SpotifySearcher
index += 1 index += 1
end end
points.sort!{ |a, b| b[0] <=> a[0] } points.sort! { |a, b| b[0] <=> a[0] }
return points return points
end end
# Returns an `Int` based off the number of points worth assigning to the # 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 # matchiness of the string. First the strings are downcased and then all
# nonalphanumeric characters are stripped. # nonalphanumeric characters are stripped.
# If the strings are the exact same, return 3 pts. # If the strings are the exact same, return 3 pts.
@ -332,18 +327,16 @@ class SpotifySearcher
private def param_encode(key : String, value : String) : String private def param_encode(key : String, value : String) : String
return key.gsub(" ", "+") + ":" + value.gsub(" ", "+") + "+" return key.gsub(" ", "+") + ":" + value.gsub(" ", "+") + "+"
end end
end end
# puts SpotifySearcher.new() # puts SpotifySearcher.new()
# .authorize("XXXXXXXXXXXXXXX", # .authorize("XXXXXXXXXXXXXXX",
# "XXXXXXXXXXXXXXX") # "XXXXXXXXXXXXXXX")
# .find_item("playlist", { # .find_item("playlist", {
# "name" => "Brain Food", # "name" => "Brain Food",
# "username" => "spotify" # "username" => "spotify"
# # "name " => "A Night At The Opera", # # "name " => "A Night At The Opera",
# # "artist" => "Queen" # # "artist" => "Queen"
# # "track" => "Bohemian Rhapsody", # # "track" => "Bohemian Rhapsody",
# # "artist" => "Queen" # # "artist" => "Queen"
# }) # })

View file

@ -1,24 +1,22 @@
require "http" require "http"
require "xml" require "xml"
module Youtube module Youtube
extend self extend self
VALID_LINK_CLASSES = [ VALID_LINK_CLASSES = [
"yt-simple-endpoint style-scope ytd-video-renderer", "yt-simple-endpoint style-scope ytd-video-renderer",
"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 ",
] ]
GARBAGE_PHRASES = [ GARBAGE_PHRASES = [
"cover", "album", "live", "clean", "version", "full", "full album", "row", "cover", "album", "live", "clean", "version", "full", "full album", "row",
"at", "@", "session", "how to", "npr music", "reimagined", "hr version", "at", "@", "session", "how to", "npr music", "reimagined", "hr version",
"trailer" "trailer",
] ]
GOLDEN_PHRASES = [ GOLDEN_PHRASES = [
"official video", "official music video" "official video", "official music video",
] ]
# Finds a youtube url based off of the given information. # Finds a youtube url based off of the given information.
@ -30,11 +28,11 @@ module Youtube
# 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(song_name : String, artist_name : String, search_terms = "", def find_url(song_name : String, artist_name : String, search_terms = "",
download_first = false) : String? download_first = false) : String?
query = (song_name + " " + artist_name + " " + search_terms).strip.gsub(" ", "+") query = (song_name + " " + artist_name + " " + search_terms).strip.gsub(" ", "+")
url = "https://www.youtube.com/results?search_query=" + query url = "https://www.youtube.com/results?search_query=" + query
response = HTTP::Client.get(url) response = HTTP::Client.get(url)
@ -61,11 +59,11 @@ module Youtube
# Will rank videos according to their title and the user input # Will rank videos according to their title and the user input
# Return: # Return:
# [ # [
# {"points" => x, "index" => x}, # {"points" => x, "index" => x},
# ... # ...
# ] # ]
private def rank_videos(song_name : String, artist_name : String, private def rank_videos(song_name : String, artist_name : String,
query : String, nodes : Array(XML::Node)) : Array(Hash(String, Int32)) query : String, nodes : Array(XML::Node)) : Array(Hash(String, Int32))
points = [] of Hash(String, Int32) points = [] of Hash(String, Int32)
index = 0 index = 0
@ -78,14 +76,13 @@ module Youtube
points.push({ points.push({
"points" => pts, "points" => pts,
"index" => index "index" => index,
}) })
index += 1 index += 1
end end
# Sort first by points and then by original index of the song # Sort first by points and then by original index of the song
points.sort!{ |a, b| points.sort! { |a, b|
if b["points"] == a["points"] if b["points"] == a["points"]
a["index"] <=> b["index"] a["index"] <=> b["index"]
else else
@ -96,11 +93,11 @@ module Youtube
return points return points
end end
# Returns an `Int` based off the number of points worth assigning to the # 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 # matchiness of the string. First the strings are downcased and then all
# nonalphanumeric characters are stripped. # nonalphanumeric characters are stripped.
# If *item1* includes *item2*, return 3 pts. # If *item1* includes *item2*, return 3 pts.
# If after the items have been blanked, *item1* includes *item2*, # If after the items have been blanked, *item1* includes *item2*,
# return 1 pts. # return 1 pts.
# Else, return 0 pts. # Else, return 0 pts.
private def points_compare(item1 : String, item2 : String) : Int32 private def points_compare(item1 : String, item2 : String) : Int32
@ -118,10 +115,10 @@ module Youtube
end end
end end
# Checks if there are any phrases in the title of the video that would # Checks if there are any phrases in the title of the video that would
# indicate audio having what we want. # indicate audio having what we want.
# *video_name* is the title of the video, and *query* is what the user the # *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 # program searched for. *query* is needed in order to make sure we're not
# subtracting points from something that's naturally in the title # subtracting points from something that's naturally in the title
private def count_buzzphrases(query : String, video_name : String) : Int32 private def count_buzzphrases(query : String, video_name : String) : Int32
good_phrases = 0 good_phrases = 0
@ -129,7 +126,7 @@ module Youtube
GOLDEN_PHRASES.each do |gold_phrase| GOLDEN_PHRASES.each do |gold_phrase|
gold_phrase = gold_phrase.downcase.gsub(/[^a-z0-9]/, "") gold_phrase = gold_phrase.downcase.gsub(/[^a-z0-9]/, "")
if query.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase) if query.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
next next
elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase) elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
@ -180,4 +177,4 @@ module Youtube
end end
return false return false
end end
end end