├── src ├── bottle │ ├── version.cr │ ├── styles.cr │ ├── pattern.cr │ ├── config.cr │ └── cli.cr ├── irs.cr ├── glue │ ├── mapper.cr │ ├── album.cr │ ├── playlist.cr │ ├── list.cr │ └── song.cr ├── interact │ ├── ripper.cr │ ├── tagger.cr │ ├── logger.cr │ └── future.cr └── search │ ├── ranking.cr │ ├── youtube.cr │ └── spotify.cr ├── .gitignore ├── .travis.yml ├── .editorconfig ├── spec ├── spec_helper.cr └── irs_spec.cr ├── shard.lock ├── shard.yml ├── LICENSE └── README.md /src/bottle/version.cr: -------------------------------------------------------------------------------- 1 | module IRS 2 | VERSION = "1.4.0" 3 | end 4 | -------------------------------------------------------------------------------- /src/irs.cr: -------------------------------------------------------------------------------- 1 | require "./bottle/cli" 2 | 3 | def main 4 | cli = CLI.new(ARGV) 5 | cli.act_on_args 6 | end 7 | 8 | main() 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /logs/ 5 | /.shards/ 6 | /Music/ 7 | *.dwarf 8 | 9 | *.mp3 10 | *.webm* 11 | .ripper.log 12 | ffmpeg 13 | ffprobe 14 | youtube-dl 15 | *.temp -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | 3 | # Uncomment the following if you'd like Travis to run specs and check code formatting 4 | # script: 5 | # - crystal spec 6 | # - crystal tool format --check 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | 3 | # https://github.com/mosop/stdio 4 | 5 | require "../src/bottle/cli" 6 | 7 | def run_CLI_with_args(argv : Array(String)) 8 | cli = CLI.new(argv) 9 | cli.act_on_args 10 | end -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | json_mapping: 4 | git: https://github.com/crystal-lang/json_mapping.cr.git 5 | version: 0.1.0 6 | 7 | ydl_binaries: 8 | git: https://github.com/cooperhammond/ydl-binaries.git 9 | version: 1.1.1+git.commit.c82e3937fee20fd076b1c73e24b2d0205e2cf0da 10 | 11 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: irs 2 | version: 1.4.0 3 | 4 | authors: 5 | - Cooper Hammond 6 | 7 | targets: 8 | irs: 9 | main: src/irs.cr 10 | 11 | license: MIT 12 | 13 | dependencies: 14 | ydl_binaries: 15 | github: cooperhammond/ydl-binaries 16 | json_mapping: 17 | github: crystal-lang/json_mapping.cr 18 | -------------------------------------------------------------------------------- /src/bottle/styles.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | 3 | class Style 4 | def self.bold(txt) 5 | txt.colorize.mode(:bold).to_s 6 | end 7 | 8 | def self.dim(txt) 9 | txt.colorize.mode(:dim).to_s 10 | end 11 | 12 | def self.blue(txt) 13 | txt.colorize(:light_blue).to_s 14 | end 15 | 16 | def self.green(txt) 17 | txt.colorize(:light_green).to_s 18 | end 19 | 20 | def self.red(txt) 21 | txt.colorize(:light_red).to_s 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/irs_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe CLI do 4 | # TODO: Write tests 5 | 6 | it "can show help" do 7 | run_CLI_with_args(["--help"]) 8 | end 9 | 10 | it "can show version" do 11 | run_CLI_with_args(["--version"]) 12 | end 13 | 14 | # !!TODO: make a long and short version of the test suite 15 | # TODO: makes so this doesn't need user input 16 | it "can install ytdl and ffmpeg binaries" do 17 | # run_CLI_with_args(["--install"]) 18 | end 19 | 20 | it "can show config file loc" do 21 | run_CLI_with_args(["--config"]) 22 | end 23 | 24 | it "can download a single song" do 25 | run_CLI_with_args(["--song", "Bohemian Rhapsody", "--artist", "Queen"]) 26 | end 27 | 28 | it "can download an album" do 29 | run_CLI_with_args(["--artist", "Arctic Monkeys", "--album", "Da Frame 2R / Matador"]) 30 | end 31 | 32 | it "can download a playlist" do 33 | run_CLI_with_args(["--artist", "prakkillian", "--playlist", "IRS Testing"]) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /src/bottle/pattern.cr: -------------------------------------------------------------------------------- 1 | module Pattern 2 | extend self 3 | 4 | def parse(formatString : String, metadata : JSON::Any) 5 | formatted : String = formatString 6 | 7 | date : Array(String) = (metadata["album"]? || JSON.parse("{}"))["release_date"]?.to_s.split('-') 8 | 9 | keys : Hash(String, String) = { 10 | "artist" => ((metadata.dig?("artists") || JSON.parse("{}"))[0]? || JSON.parse("{}"))["name"]?.to_s, 11 | "title" => metadata["name"]?.to_s, 12 | "album" => (metadata["album"]? || JSON.parse("{}"))["name"]?.to_s, 13 | "track_number" => metadata["track_number"]?.to_s, 14 | "disc_number" => metadata["disc_number"]?.to_s, 15 | "total_tracks" => (metadata["album"]? || JSON.parse("{}"))["total_tracks"]?.to_s, 16 | "year" => date[0]?.to_s, 17 | "month" => date[1]?.to_s, 18 | "day" => date[2]?.to_s, 19 | "id" => metadata["id"]?.to_s 20 | } 21 | 22 | keys.each do |pair| 23 | formatted = formatted.gsub("{#{pair[0]}}", pair[1] || "") 24 | end 25 | 26 | return formatted 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Cooper Hammond 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/glue/mapper.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "json_mapping" 3 | 4 | class PlaylistExtensionMapper 5 | JSON.mapping( 6 | tracks: { 7 | type: PlaylistTracksMapper, 8 | setter: true, 9 | }, 10 | id: String, 11 | images: JSON::Any, 12 | name: String, 13 | owner: JSON::Any, 14 | type: String 15 | ) 16 | end 17 | 18 | class PlaylistTracksMapper 19 | JSON.mapping( 20 | items: { 21 | type: Array(JSON::Any), 22 | setter: true, 23 | }, 24 | total: Int32 25 | ) 26 | end 27 | 28 | class TrackMapper 29 | JSON.mapping( 30 | album: { 31 | type: JSON::Any, 32 | nilable: true, 33 | setter: true, 34 | }, 35 | artists: { 36 | type: Array(JSON::Any), 37 | setter: true 38 | }, 39 | disc_number: { 40 | type: Int32, 41 | setter: true 42 | }, 43 | id: String, 44 | name: String, 45 | track_number: { 46 | type: Int32, 47 | setter: true 48 | }, 49 | duration_ms: Int32, 50 | type: String, 51 | uri: String 52 | ) 53 | end 54 | 55 | def parse_to_json(string_json : String) : JSON::Any 56 | return JSON.parse(string_json) 57 | end 58 | -------------------------------------------------------------------------------- /src/glue/album.cr: -------------------------------------------------------------------------------- 1 | require "../bottle/config" 2 | 3 | require "./mapper" 4 | require "./song" 5 | require "./list" 6 | 7 | class Album < SpotifyList 8 | @home_music_directory = Config.music_directory 9 | 10 | # Uses the `spotify_searcher` defined in parent `SpotifyList` to find the 11 | # correct metadata of the list 12 | def find_it : JSON::Any 13 | album = @spotify_searcher.find_item("album", { 14 | "name" => @list_name.as(String), 15 | "artist" => @list_author.as(String), 16 | }) 17 | if album 18 | return album.as(JSON::Any) 19 | else 20 | puts "No album was found by that name and artist." 21 | exit 1 22 | end 23 | end 24 | 25 | # Will define specific metadata that may not be included in the raw return 26 | # of spotify's album json. Moves the title of the album and the album art 27 | # to the json of the single song 28 | def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any 29 | album_metadata = parse_to_json(%( 30 | { 31 | "name": "#{list["name"]}", 32 | "images": [{"url": "#{list["images"][0]["url"]}"}] 33 | } 34 | )) 35 | 36 | prepped_data = TrackMapper.from_json(datum.to_json) 37 | prepped_data.album = album_metadata 38 | 39 | data = parse_to_json(prepped_data.to_json) 40 | 41 | return data 42 | end 43 | 44 | private def organize(song : Song) 45 | song.organize_it() 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /src/interact/ripper.cr: -------------------------------------------------------------------------------- 1 | require "./logger" 2 | require "../bottle/config" 3 | require "../bottle/styles" 4 | 5 | module Ripper 6 | extend self 7 | 8 | BIN_LOC = Path[Config.binary_location] 9 | 10 | # Downloads the video from the given *video_url* using the youtube-dl binary 11 | # Will create any directories that don't exist specified in *output_filename* 12 | # 13 | # ``` 14 | # Ripper.download_mp3("https://youtube.com/watch?v=0xnciFWAqa0", 15 | # "Queen/A Night At The Opera/Bohemian Rhapsody.mp3") 16 | # ``` 17 | def download_mp3(video_url : String, output_filename : String) 18 | ydl_loc = BIN_LOC.join("youtube-dl") 19 | 20 | # remove the extension that will be added on by ydl 21 | output_filename = output_filename.split(".")[..-2].join(".") 22 | 23 | options = { 24 | "--output" => %("#{output_filename}.%(ext)s"), # auto-add correct ext 25 | # "--quiet" => "", 26 | "--verbose" => "", 27 | "--ffmpeg-location" => BIN_LOC, 28 | "--extract-audio" => "", 29 | "--audio-format" => "mp3", 30 | "--audio-quality" => "0", 31 | } 32 | 33 | command = ydl_loc.to_s + " " + video_url 34 | options.keys.each do |option| 35 | command += " #{option} #{options[option]}" 36 | end 37 | 38 | l = Logger.new(command, ".ripper.log") 39 | o = RipperOutputCensor.new 40 | 41 | return l.start do |line, index| 42 | o.censor_output(line, index) 43 | end 44 | end 45 | 46 | # An internal class that will keep track of what to output to the user or 47 | # what should be hidden. 48 | private class RipperOutputCensor 49 | @dl_status_index = 0 50 | 51 | def censor_output(line : String, index : Int32) 52 | case line 53 | when .includes? "[download]" 54 | if @dl_status_index != 0 55 | print "\e[1A" 56 | print "\e[0K\r" 57 | end 58 | puts line.sub("[download]", " ") 59 | @dl_status_index += 1 60 | 61 | if line.includes? "100%" 62 | print " Converting to mp3 ..." 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /src/interact/tagger.cr: -------------------------------------------------------------------------------- 1 | require "../bottle/config" 2 | 3 | # Uses FFMPEG binary to add metadata to mp3 files 4 | # ``` 5 | # t = Tags.new("bohem rap.mp3") 6 | # t.add_album_art("a night at the opera album cover.jpg") 7 | # t.add_text_tag("title", "Bohemian Rhapsody") 8 | # t.save 9 | # ``` 10 | class Tags 11 | # TODO: export this path to a config file 12 | @BIN_LOC = Config.binary_location 13 | @query_args = [] of String 14 | 15 | # initialize the class with an already created MP3 16 | def initialize(@filename : String) 17 | if !File.exists?(@filename) 18 | raise "MP3 not found at location: #{@filename}" 19 | end 20 | 21 | @query_args.push(%(-i "#{@filename}")) 22 | end 23 | 24 | # Add album art to the mp3. Album art must be added BEFORE text tags are. 25 | # Check the usage above to see a working example. 26 | def add_album_art(image_location : String) : Nil 27 | if !File.exists?(image_location) 28 | raise "Image file not found at location: #{image_location}" 29 | end 30 | 31 | @query_args.push(%(-i "#{image_location}")) 32 | @query_args.push("-map 0:0 -map 1:0") 33 | @query_args.push("-c copy") 34 | @query_args.push("-id3v2_version 3") 35 | @query_args.push(%(-metadata:s:v title="Album cover")) 36 | @query_args.push(%(-metadata:s:v comment="Cover (front)")) 37 | @query_args.push(%(-metadata:s:v title="Album cover")) 38 | end 39 | 40 | # Add a text tag to the mp3. If you want to see what text tags are supported, 41 | # check out: https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata 42 | def add_text_tag(key : String, value : String) : Nil 43 | @query_args.push(%(-metadata #{key}="#{value}")) 44 | end 45 | 46 | # Run the necessary commands to attach album art to the mp3 47 | def save : Nil 48 | @query_args.push(%("_#{@filename}")) 49 | command = @BIN_LOC + "/ffmpeg " + @query_args.join(" ") 50 | 51 | l = Logger.new(command, ".tagger.log") 52 | l.start { |line, start| } 53 | 54 | File.delete(@filename) 55 | File.rename("_" + @filename, @filename) 56 | end 57 | end 58 | 59 | # a = Tags.new("test.mp3") 60 | # a.add_text_tag("title", "Warwick Avenue") 61 | # a.add_album_art("file.png") 62 | # a.save() 63 | -------------------------------------------------------------------------------- /src/glue/playlist.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | require "../bottle/config" 4 | 5 | require "./song" 6 | require "./list" 7 | require "./mapper" 8 | 9 | class Playlist < SpotifyList 10 | @song_index = 1 11 | @home_music_directory = Config.music_directory 12 | @playlist : JSON::Any? 13 | 14 | # Uses the `spotify_searcher` defined in parent `SpotifyList` to find the 15 | # correct metadata of the list 16 | def find_it : JSON::Any 17 | @playlist = @spotify_searcher.find_item("playlist", { 18 | "name" => @list_name.as(String), 19 | "username" => @list_author.as(String), 20 | }) 21 | if @playlist 22 | return @playlist.as(JSON::Any) 23 | else 24 | puts "No playlists were found by that name and user." 25 | exit 1 26 | end 27 | end 28 | 29 | # Will define specific metadata that may not be included in the raw return 30 | # of spotify's album json. Moves the title of the album and the album art 31 | # to the json of the single song 32 | def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any 33 | data = datum 34 | 35 | if Config.retain_playlist_order? 36 | track = TrackMapper.from_json(data.to_json) 37 | track.track_number = @song_index 38 | track.disc_number = 1 39 | data = JSON.parse(track.to_json) 40 | end 41 | 42 | if Config.unify_into_album? 43 | track = TrackMapper.from_json(data.to_json) 44 | track.album = JSON.parse(%({ 45 | "name": "#{list["name"]}", 46 | "images": [{"url": "#{list["images"][0]["url"]}"}] 47 | })) 48 | track.artists.push(JSON.parse(%({ 49 | "name": "#{list["owner"]["display_name"]}", 50 | "owner": true 51 | }))) 52 | data = JSON.parse(track.to_json) 53 | end 54 | 55 | @song_index += 1 56 | 57 | return data 58 | end 59 | 60 | private def organize(song : Song) 61 | if Config.single_folder_playlist? 62 | path = Path[@home_music_directory].expand(home: true) 63 | path = path / @playlist.as(JSON::Any)["name"].to_s 64 | .gsub(/[\/]/, "").gsub(" ", " ") 65 | strpath = path.to_s 66 | if !File.directory?(strpath) 67 | FileUtils.mkdir_p(strpath) 68 | end 69 | safe_filename = song.filename.gsub(/[\/]/, "").gsub(" ", " ") 70 | FileUtils.cp("./" + song.filename, (path / safe_filename).to_s) 71 | FileUtils.rm("./" + song.filename) 72 | else 73 | song.organize_it() 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /src/interact/logger.cr: -------------------------------------------------------------------------------- 1 | require "./future" 2 | 3 | class Logger 4 | @done_signal = "---DONE---" 5 | 6 | @command : String 7 | 8 | # *command* is the bash command that you want to run and capture the output 9 | # of. *@log_name* is the name of the log file you want to temporarily create. 10 | # *@sleept* is the time you want to wait before rechecking if the command has 11 | # started yet, probably something you don't want to worry about 12 | def initialize(command : String, @log_name : String, @sleept = 0.01) 13 | # Have the command output its information to a log and after the command is 14 | # finished, append an end signal to the document 15 | @command = "#{command} > #{@log_name} " # standard output to log 16 | @command += "2> #{@log_name} && " # errors to log 17 | @command += "echo #{@done_signal} >> #{@log_name}" # 18 | end 19 | 20 | # Run @command in the background and pipe its output to the log file, with 21 | # something constantly monitoring the log file and yielding each new line to 22 | # the block call. Useful for changing the output of binaries you don't have 23 | # much control over. 24 | # Note that the created temp log will be deleted unless the command fails 25 | # its exit or .start is called with delete_file: false 26 | # 27 | # ``` 28 | # l = Logger.new(".temp.log", %(echo "CIA spying" && sleep 2 && echo "new veggie tales season")) 29 | # l.start do |output, index| 30 | # case output 31 | # when "CIA spying" 32 | # puts "i sleep" 33 | # when .includes?("veggie tales") 34 | # puts "real shit" 35 | # end 36 | # end 37 | # ``` 38 | def start(delete_file = true, &block) : Bool 39 | # Delete the log if it already exists 40 | File.delete(@log_name) if File.exists?(@log_name) 41 | 42 | # Run the command in the background 43 | called = future { 44 | system(@command) 45 | } 46 | 47 | # Wait for the log file to be written to 48 | while !File.exists?(@log_name) 49 | sleep @sleept 50 | end 51 | 52 | log = File.open(@log_name) 53 | log_content = read_file(log) 54 | index = 0 55 | 56 | while true 57 | temp_content = read_file(log) 58 | 59 | # make sure that there is new data 60 | if temp_content.size > 0 && log_content != temp_content 61 | log_content = temp_content 62 | 63 | # break the loop if the command has completed 64 | break if log_content[0] == @done_signal 65 | 66 | # give the line and index to the block 67 | yield log_content[0], index 68 | index += 1 69 | end 70 | end 71 | 72 | status = called.get 73 | if status == true && delete_file == true 74 | log.delete 75 | end 76 | 77 | return called.get 78 | end 79 | 80 | # Reads each line of the file into an Array of Strings 81 | private def read_file(file : IO) : Array(String) 82 | content = [] of String 83 | 84 | file.each_line do |line| 85 | content.push(line) 86 | end 87 | 88 | return content 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /src/glue/list.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | require "../search/spotify" 4 | require "../search/youtube" 5 | 6 | require "../interact/ripper" 7 | require "../interact/tagger" 8 | 9 | require "./song" 10 | 11 | # A parent class for downloading albums and playlists from spotify 12 | abstract class SpotifyList 13 | @spotify_searcher = SpotifySearcher.new 14 | @file_names = [] of String 15 | 16 | @outputs : Hash(String, Array(String)) = { 17 | "searching" => [ 18 | Style.bold("Searching for %l by %a ... \r"), 19 | Style.green("+ ") + Style.bold("%l by %a \n") 20 | ], 21 | "url" => [ 22 | Style.bold("When prompted for a URL, provide a youtube URL or press enter to scrape for one\n") 23 | ] 24 | } 25 | 26 | def initialize(@list_name : String, @list_author : String?) 27 | end 28 | 29 | # Finds the list, and downloads all of the songs using the `Song` class 30 | def grab_it(flags = {} of String => String) 31 | ask_url = flags["url"]? 32 | ask_skip = flags["ask_skip"]? 33 | is_playlist = flags["playlist"]? 34 | 35 | if !@spotify_searcher.authorized? 36 | raise("Need to call provide_client_keys on Album or Playlist class.") 37 | end 38 | 39 | if ask_url 40 | outputter("url", 0) 41 | end 42 | 43 | outputter("searching", 0) 44 | list = find_it() 45 | outputter("searching", 1) 46 | contents = list["tracks"]["items"].as_a 47 | 48 | i = 0 49 | contents.each do |datum| 50 | i += 1 51 | if datum["track"]? 52 | datum = datum["track"] 53 | end 54 | 55 | data = organize_song_metadata(list, datum) 56 | 57 | s_name = data["name"].to_s 58 | s_artist = data["artists"][0]["name"].to_s 59 | 60 | song = Song.new(s_name, s_artist) 61 | song.provide_spotify(@spotify_searcher) 62 | song.provide_metadata(data) 63 | 64 | puts Style.bold("[#{i}/#{contents.size}]") 65 | 66 | unless ask_skip && skip?(s_name, s_artist, is_playlist) 67 | song.grab_it(flags: flags) 68 | organize(song) 69 | else 70 | puts "Skipping..." 71 | end 72 | end 73 | end 74 | 75 | # Will authorize the class associated `SpotifySearcher` 76 | def provide_client_keys(client_key : String, client_secret : String) 77 | @spotify_searcher.authorize(client_key, client_secret) 78 | end 79 | 80 | private def skip?(name, artist, is_playlist) 81 | print "Skip #{Style.blue name}" + 82 | (is_playlist ? " (by #{Style.green artist})": "") + "? " 83 | response = gets 84 | return response && response.lstrip.downcase.starts_with? "y" 85 | end 86 | 87 | private def outputter(key : String, index : Int32) 88 | text = @outputs[key][index] 89 | .gsub("%l", @list_name) 90 | .gsub("%a", @list_author) 91 | print text 92 | end 93 | 94 | # Defined in subclasses, will return the appropriate information or call an 95 | # error if the info is not found and exit 96 | abstract def find_it : JSON::Any 97 | 98 | # If there's a need to organize the individual song data so that the `Song` 99 | # class can better handle it, this function will be defined in the subclass 100 | private abstract def organize_song_metadata(list : JSON::Any, 101 | datum : JSON::Any) : JSON::Any 102 | 103 | # Will define the specific type of organization for a list of songs. 104 | # Needed because most people want albums sorted by artist, but playlists all 105 | # in one folder 106 | private abstract def organize(song : Song) 107 | end 108 | -------------------------------------------------------------------------------- /src/interact/future.cr: -------------------------------------------------------------------------------- 1 | # copy and pasted from crystal 0.33.1 2 | # https://github.com/crystal-lang/crystal/blob/18e76172444c7bd07f58bf360bc21981b667668d/src/concurrent/future.cr#L138 3 | 4 | 5 | # :nodoc: 6 | class Concurrent::Future(R) 7 | enum State 8 | Idle 9 | Delayed 10 | Running 11 | Completed 12 | Canceled 13 | end 14 | 15 | @value : R? 16 | @error : Exception? 17 | @delay : Float64 18 | 19 | def initialize(run_immediately = true, delay = 0.0, &@block : -> R) 20 | @state = State::Idle 21 | @value = nil 22 | @error = nil 23 | @channel = Channel(Nil).new 24 | @delay = delay.to_f 25 | @cancel_msg = nil 26 | 27 | spawn_compute if run_immediately 28 | end 29 | 30 | def get 31 | wait 32 | value_or_raise 33 | end 34 | 35 | def success? 36 | completed? && !@error 37 | end 38 | 39 | def failure? 40 | completed? && @error 41 | end 42 | 43 | def canceled? 44 | @state == State::Canceled 45 | end 46 | 47 | def completed? 48 | @state == State::Completed 49 | end 50 | 51 | def running? 52 | @state == State::Running 53 | end 54 | 55 | def delayed? 56 | @state == State::Delayed 57 | end 58 | 59 | def idle? 60 | @state == State::Idle 61 | end 62 | 63 | def cancel(msg = "Future canceled, you reached the [End of Time]") 64 | return if @state >= State::Completed 65 | @state = State::Canceled 66 | @cancel_msg = msg 67 | @channel.close 68 | nil 69 | end 70 | 71 | private def compute 72 | return if @state >= State::Delayed 73 | run_compute 74 | end 75 | 76 | private def spawn_compute 77 | return if @state >= State::Delayed 78 | 79 | @state = @delay > 0 ? State::Delayed : State::Running 80 | 81 | spawn { run_compute } 82 | end 83 | 84 | private def run_compute 85 | delay = @delay 86 | 87 | if delay > 0 88 | sleep delay 89 | return if @state >= State::Canceled 90 | @state = State::Running 91 | end 92 | 93 | begin 94 | @value = @block.call 95 | rescue ex 96 | @error = ex 97 | ensure 98 | @channel.close 99 | @state = State::Completed 100 | end 101 | end 102 | 103 | private def wait 104 | return if @state >= State::Completed 105 | compute 106 | @channel.receive? 107 | end 108 | 109 | private def value_or_raise 110 | raise Exception.new(@cancel_msg) if @state == State::Canceled 111 | 112 | value = @value 113 | if value.is_a?(R) 114 | value 115 | elsif error = @error 116 | raise error 117 | else 118 | raise "compiler bug" 119 | end 120 | end 121 | end 122 | 123 | # Spawns a `Fiber` to compute *&block* in the background after *delay* has elapsed. 124 | # Access to get is synchronized between fibers. *&block* is only called once. 125 | # May be canceled before *&block* is called by calling `cancel`. 126 | # ``` 127 | # d = delay(1) { Process.kill(Process.pid) } 128 | # long_operation 129 | # d.cancel 130 | # ``` 131 | def delay(delay, &block : -> _) 132 | Concurrent::Future.new delay: delay, &block 133 | end 134 | 135 | # Spawns a `Fiber` to compute *&block* in the background. 136 | # Access to get is synchronized between fibers. *&block* is only called once. 137 | # ``` 138 | # f = future { http_request } 139 | # ... other actions ... 140 | # f.get #=> String 141 | # ``` 142 | def future(&exp : -> _) 143 | Concurrent::Future.new &exp 144 | end 145 | 146 | # Conditionally spawns a `Fiber` to run *&block* in the background. 147 | # Access to get is synchronized between fibers. *&block* is only called once. 148 | # *&block* doesn't run by default, only when `get` is called. 149 | # ``` 150 | # l = lazy { expensive_computation } 151 | # spawn { maybe_use_computation(l) } 152 | # spawn { maybe_use_computation(l) } 153 | # ``` 154 | def lazy(&block : -> _) 155 | Concurrent::Future.new run_immediately: false, &block 156 | end 157 | 158 | -------------------------------------------------------------------------------- /src/bottle/config.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | require "./styles" 4 | 5 | require "../search/spotify" 6 | 7 | EXAMPLE_CONFIG = <<-EOP 8 | #{Style.dim "exampleconfig.yml"} 9 | #{Style.dim "===="} 10 | #{Style.blue "search_terms"}: #{Style.green "\"lyrics\""} 11 | #{Style.blue "binary_directory"}: #{Style.green "~/.irs/bin"} 12 | #{Style.blue "music_directory"}: #{Style.green "~/Music"} 13 | #{Style.blue "filename_pattern"}: #{Style.green "\"{track_number} - {title}\""} 14 | #{Style.blue "directory_pattern"}: #{Style.green "\"{artist}/{album}\""} 15 | #{Style.blue "client_key"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"} 16 | #{Style.blue "client_secret"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"} 17 | #{Style.blue "single_folder_playlist"}: 18 | #{Style.blue "enabled"}: #{Style.green "true"} 19 | #{Style.blue "retain_playlist_order"}: #{Style.green "true"} 20 | #{Style.blue "unify_into_album"}: #{Style.green "false"} 21 | #{Style.dim "===="} 22 | EOP 23 | 24 | module Config 25 | extend self 26 | 27 | @@arguments = [ 28 | "search_terms", 29 | "binary_directory", 30 | "music_directory", 31 | "filename_pattern", 32 | "directory_pattern", 33 | "client_key", 34 | "client_secret", 35 | "single_folder_playlist: enabled", 36 | "single_folder_playlist: retain_playlist_order", 37 | "single_folder_playlist: unify_into_album", 38 | ] 39 | 40 | @@conf = YAML.parse("") 41 | begin 42 | @@conf = YAML.parse(File.read(ENV["IRS_CONFIG_LOCATION"])) 43 | rescue 44 | puts Style.red "Before anything else, define the environment variable IRS_CONFIG_LOCATION pointing to a .yml file like this one." 45 | puts EXAMPLE_CONFIG 46 | puts Style.bold "See https://github.com/cooperhammond/irs for more information on the config file" 47 | exit 1 48 | end 49 | 50 | def search_terms : String 51 | return @@conf["search_terms"].to_s 52 | end 53 | 54 | def binary_location : String 55 | path = @@conf["binary_directory"].to_s 56 | return Path[path].expand(home: true).to_s 57 | end 58 | 59 | def music_directory : String 60 | path = @@conf["music_directory"].to_s 61 | return Path[path].expand(home: true).to_s 62 | end 63 | 64 | def filename_pattern : String 65 | return @@conf["filename_pattern"].to_s 66 | end 67 | 68 | def directory_pattern : String 69 | return @@conf["directory_pattern"].to_s 70 | end 71 | 72 | def client_key : String 73 | return @@conf["client_key"].to_s 74 | end 75 | 76 | def client_secret : String 77 | return @@conf["client_secret"].to_s 78 | end 79 | 80 | def single_folder_playlist? : Bool 81 | return @@conf["single_folder_playlist"]["enabled"].as_bool 82 | end 83 | 84 | def retain_playlist_order? : Bool 85 | return @@conf["single_folder_playlist"]["retain_playlist_order"].as_bool 86 | end 87 | 88 | def unify_into_album? : Bool 89 | return @@conf["single_folder_playlist"]["unify_into_album"].as_bool 90 | end 91 | 92 | def check_necessities 93 | missing_configs = [] of String 94 | @@arguments.each do |argument| 95 | if !check_conf(argument) 96 | missing_configs.push(argument) 97 | end 98 | end 99 | if missing_configs.size > 0 100 | puts Style.red("You are missing the following key(s) in your YAML config file:") 101 | missing_configs.each do |config| 102 | puts " " + config 103 | end 104 | puts "\nHere's an example of what your config should look like:" 105 | puts EXAMPLE_CONFIG 106 | puts Style.bold "See https://github.com/cooperhammond/irs for more information on the config file" 107 | exit 1 108 | end 109 | spotify = SpotifySearcher.new 110 | spotify.authorize(self.client_key, self.client_secret) 111 | if !spotify.authorized? 112 | puts Style.red("There's something wrong with your client key and/or client secret") 113 | puts "Get your keys from https://developer.spotify.com/dashboard, and enter them in your config file" 114 | puts "Your config file is at #{ENV["IRS_CONFIG_LOCATION"]}" 115 | puts EXAMPLE_CONFIG 116 | puts Style.bold "See https://github.com/cooperhammond/irs for more information on the config file" 117 | exit 1 118 | end 119 | end 120 | 121 | private def check_conf(key : String) : YAML::Any? 122 | if key.includes?(": ") 123 | args = key.split(": ") 124 | if @@conf[args[0]]? 125 | return @@conf[args[0]][args[1]]? 126 | else 127 | return @@conf[args[0]]? 128 | end 129 | else 130 | return @@conf[key]? 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /src/search/ranking.cr: -------------------------------------------------------------------------------- 1 | alias VID_VALUE_CLASS = String 2 | alias VID_METADATA_CLASS = Hash(String, VID_VALUE_CLASS) 3 | alias YT_METADATA_CLASS = Array(VID_METADATA_CLASS) 4 | 5 | module Ranker 6 | extend self 7 | 8 | GARBAGE_PHRASES = [ 9 | "cover", "album", "live", "clean", "version", "full", "full album", "row", 10 | "at", "@", "session", "how to", "npr music", "reimagined", "version", 11 | "trailer" 12 | ] 13 | 14 | GOLDEN_PHRASES = [ 15 | "official video", "official music video", 16 | ] 17 | 18 | # Will rank videos according to their title and the user input, returns a sorted array of hashes 19 | # of the points a song was assigned and its original index 20 | # *spotify_metadata* is the metadate (from spotify) of the song that you want 21 | # *yt_metadata* is an array of hashes with metadata scraped from the youtube search result page 22 | # *query* is the query that you submitted to youtube for the results you now have 23 | # ``` 24 | # Ranker.rank_videos(spotify_metadata, yt_metadata, query) 25 | # => [ 26 | # {"points" => x, "index" => x}, 27 | # ... 28 | # ] 29 | # ``` 30 | # "index" corresponds to the original index of the song in yt_metadata 31 | def rank_videos(spotify_metadata : JSON::Any, yt_metadata : YT_METADATA_CLASS, 32 | query : String) : Array(Hash(String, Int32)) 33 | points = [] of Hash(String, Int32) 34 | index = 0 35 | 36 | actual_song_name = spotify_metadata["name"].as_s 37 | actual_artist_name = spotify_metadata["artists"][0]["name"].as_s 38 | 39 | yt_metadata.each do |vid| 40 | pts = 0 41 | 42 | pts += points_string_compare(actual_song_name, vid["title"]) 43 | pts += points_string_compare(actual_artist_name, vid["title"]) 44 | pts += count_buzzphrases(query, vid["title"]) 45 | pts += compare_timestamps(spotify_metadata, vid) 46 | 47 | points.push({ 48 | "points" => pts, 49 | "index" => index, 50 | }) 51 | index += 1 52 | end 53 | 54 | # Sort first by points and then by original index of the song 55 | points.sort! { |a, b| 56 | if b["points"] == a["points"] 57 | a["index"] <=> b["index"] 58 | else 59 | b["points"] <=> a["points"] 60 | end 61 | } 62 | 63 | return points 64 | end 65 | 66 | # SINGULAR COMPONENT OF RANKING ALGORITHM 67 | private def compare_timestamps(spotify_metadata : JSON::Any, node : VID_METADATA_CLASS) : Int32 68 | # puts spotify_metadata.to_pretty_json() 69 | actual_time = spotify_metadata["duration_ms"].as_i 70 | vid_time = node["duration_ms"].to_i 71 | 72 | difference = (actual_time - vid_time).abs 73 | 74 | # puts "actual: #{actual_time}, vid: #{vid_time}" 75 | # puts "\tdiff: #{difference}" 76 | # puts "\ttitle: #{node["title"]}" 77 | 78 | if difference <= 1000 79 | return 3 80 | elsif difference <= 2000 81 | return 2 82 | elsif difference <= 5000 83 | return 1 84 | else 85 | return 0 86 | end 87 | end 88 | 89 | # SINGULAR COMPONENT OF RANKING ALGORITHM 90 | # Returns an `Int` based off the number of points worth assigning to the 91 | # matchiness of the string. First the strings are downcased and then all 92 | # nonalphanumeric characters are stripped. 93 | # If *item1* includes *item2*, return 3 pts. 94 | # If after the items have been blanked, *item1* includes *item2*, 95 | # return 1 pts. 96 | # Else, return 0 pts. 97 | private def points_string_compare(item1 : String, item2 : String) : Int32 98 | if item2.includes?(item1) 99 | return 3 100 | end 101 | 102 | item1 = item1.downcase.gsub(/[^a-z0-9]/, "") 103 | item2 = item2.downcase.gsub(/[^a-z0-9]/, "") 104 | 105 | if item2.includes?(item1) 106 | return 1 107 | else 108 | return 0 109 | end 110 | end 111 | 112 | # SINGULAR COMPONENT OF RANKING ALGORITHM 113 | # Checks if there are any phrases in the title of the video that would 114 | # indicate audio having what we want. 115 | # *video_name* is the title of the video, and *query* is what the user the 116 | # program searched for. *query* is needed in order to make sure we're not 117 | # subtracting points from something that's naturally in the title 118 | private def count_buzzphrases(query : String, video_name : String) : Int32 119 | good_phrases = 0 120 | bad_phrases = 0 121 | 122 | GOLDEN_PHRASES.each do |gold_phrase| 123 | gold_phrase = gold_phrase.downcase.gsub(/[^a-z0-9]/, "") 124 | 125 | if query.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase) 126 | next 127 | elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase) 128 | good_phrases += 1 129 | end 130 | end 131 | 132 | GARBAGE_PHRASES.each do |garbage_phrase| 133 | garbage_phrase = garbage_phrase.downcase.gsub(/[^a-z0-9]/, "") 134 | 135 | if query.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase) 136 | next 137 | elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase) 138 | bad_phrases += 1 139 | end 140 | end 141 | 142 | return good_phrases - bad_phrases 143 | end 144 | end -------------------------------------------------------------------------------- /src/bottle/cli.cr: -------------------------------------------------------------------------------- 1 | require "ydl_binaries" 2 | 3 | require "./config" 4 | require "./styles" 5 | require "./version" 6 | 7 | require "../glue/song" 8 | require "../glue/album" 9 | require "../glue/playlist" 10 | 11 | class CLI 12 | # layout: 13 | # [[shortflag, longflag], key, type] 14 | @options = [ 15 | [["-h", "--help"], "help", "bool"], 16 | [["-v", "--version"], "version", "bool"], 17 | [["-i", "--install"], "install", "bool"], 18 | [["-c", "--config"], "config", "bool"], 19 | [["-a", "--artist"], "artist", "string"], 20 | [["-s", "--song"], "song", "string"], 21 | [["-A", "--album"], "album", "string"], 22 | [["-p", "--playlist"], "playlist", "string"], 23 | [["-u", "--url"], "url", "string"], 24 | [["-S", "--select"], "select", "bool"], 25 | [["--ask-skip"], "ask_skip", "bool"], 26 | [["--apply"], "apply_file", "string"] 27 | ] 28 | 29 | @args : Hash(String, String) 30 | 31 | def initialize(argv : Array(String)) 32 | @args = parse_args(argv) 33 | end 34 | 35 | def version 36 | puts "irs v#{IRS::VERSION}" 37 | end 38 | 39 | def help 40 | msg = <<-EOP 41 | #{Style.bold "Usage: irs [--help] [--version] [--install]"} 42 | #{Style.bold " [-s -a ]"} 43 | #{Style.bold " [-A -a ]"} 44 | #{Style.bold " [-p -a ]"} 45 | 46 | #{Style.bold "Arguments:"} 47 | #{Style.blue "-h, --help"} Show this help message and exit 48 | #{Style.blue "-v, --version"} Show the program version and exit 49 | #{Style.blue "-i, --install"} Download binaries to config location 50 | #{Style.blue "-c, --config"} Show config file location 51 | #{Style.blue "-a, --artist "} Specify artist name for downloading 52 | #{Style.blue "-s, --song "} Specify song name to download 53 | #{Style.blue "-A, --album "} Specify the album name to download 54 | #{Style.blue "-p, --playlist "} Specify the playlist name to download 55 | #{Style.blue "-u, --url "} Specify the youtube url to download from 56 | #{Style.blue " "} (for albums and playlists, the command-line 57 | #{Style.blue " "} argument is ignored, and it should be '') 58 | #{Style.blue "-S, --select"} Use a menu to choose each song's video source 59 | #{Style.blue "--ask-skip"} Before every playlist/album song, ask to skip 60 | #{Style.blue "--apply "} Apply metadata to a existing file 61 | 62 | #{Style.bold "Examples:"} 63 | $ #{Style.green %(irs --song "Bohemian Rhapsody" --artist "Queen")} 64 | #{Style.dim %(# => downloads the song "Bohemian Rhapsody" by "Queen")} 65 | $ #{Style.green %(irs --album "Demon Days" --artist "Gorillaz")} 66 | #{Style.dim %(# => downloads the album "Demon Days" by "Gorillaz")} 67 | $ #{Style.green %(irs --playlist "a different drummer" --artist "prakkillian")} 68 | #{Style.dim %(# => downloads the playlist "a different drummer" by the user prakkillian)} 69 | 70 | #{Style.bold "This project is licensed under the MIT license."} 71 | #{Style.bold "Project page: "} 72 | EOP 73 | 74 | puts msg 75 | end 76 | 77 | def act_on_args 78 | Config.check_necessities 79 | 80 | if @args["help"]? || @args.keys.size == 0 81 | help 82 | 83 | elsif @args["version"]? 84 | version 85 | 86 | elsif @args["install"]? 87 | YdlBinaries.get_both(Config.binary_location) 88 | 89 | elsif @args["config"]? 90 | puts ENV["IRS_CONFIG_LOCATION"]? 91 | 92 | elsif @args["song"]? && @args["artist"]? 93 | s = Song.new(@args["song"], @args["artist"]) 94 | s.provide_client_keys(Config.client_key, Config.client_secret) 95 | s.grab_it(flags: @args) 96 | s.organize_it() 97 | 98 | elsif @args["album"]? && @args["artist"]? 99 | a = Album.new(@args["album"], @args["artist"]) 100 | a.provide_client_keys(Config.client_key, Config.client_secret) 101 | a.grab_it(flags: @args) 102 | 103 | elsif @args["playlist"]? && @args["artist"]? 104 | p = Playlist.new(@args["playlist"], @args["artist"]) 105 | p.provide_client_keys(Config.client_key, Config.client_secret) 106 | p.grab_it(flags: @args) 107 | 108 | else 109 | puts Style.red("Those arguments don't do anything when used that way.") 110 | puts "Type `irs -h` to see usage." 111 | end 112 | end 113 | 114 | private def parse_args(argv : Array(String)) : Hash(String, String) 115 | arguments = {} of String => String 116 | 117 | i = 0 118 | current_key = "" 119 | pass_next_arg = false 120 | argv.each do |arg| 121 | # If the previous arg was an arg flag, this is an arg, so pass it 122 | if pass_next_arg 123 | pass_next_arg = false 124 | i += 1 125 | next 126 | end 127 | 128 | flag = [] of Array(String) | String 129 | valid_flag = false 130 | 131 | @options.each do |option| 132 | if option[0].includes?(arg) 133 | flag = option 134 | valid_flag = true 135 | break 136 | end 137 | end 138 | 139 | # ensure the flag is actually defined 140 | if !valid_flag 141 | arg_error argv, i, %("#{arg}" is an invalid flag or argument.) 142 | end 143 | 144 | # ensure there's an argument if the program needs one 145 | if flag[2] == "string" && i + 1 >= argv.size 146 | arg_error argv, i, %("#{arg}" needs an argument.) 147 | end 148 | 149 | key = flag[1].as(String) 150 | if flag[2] == "string" 151 | arguments[key] = argv[i + 1] 152 | pass_next_arg = true 153 | elsif flag[2] == "bool" 154 | arguments[key] = "true" 155 | end 156 | 157 | i += 1 158 | end 159 | 160 | return arguments 161 | end 162 | 163 | private def arg_error(argv : Array(String), arg : Int32, msg : String) : Nil 164 | precursor = "irs" 165 | 166 | precursor += " " if arg != 0 167 | 168 | if arg == 0 169 | start = [] of String 170 | else 171 | start = argv[..arg - 1] 172 | end 173 | last = argv[arg + 1..] 174 | 175 | distance = (precursor + start.join(" ")).size 176 | 177 | print Style.dim(precursor + start.join(" ")) 178 | print Style.bold(Style.red(" " + argv[arg]).to_s) 179 | puts Style.dim (" " + last.join(" ")) 180 | 181 | (0..distance).each do |i| 182 | print " " 183 | end 184 | puts "^" 185 | 186 | puts Style.red(Style.bold(msg).to_s) 187 | puts "Type `irs -h` to see usage." 188 | exit 1 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /src/search/youtube.cr: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "xml" 3 | require "json" 4 | require "uri" 5 | 6 | require "./ranking" 7 | 8 | require "../bottle/config" 9 | require "../bottle/styles" 10 | 11 | 12 | module Youtube 13 | extend self 14 | 15 | VALID_LINK_CLASSES = [ 16 | "yt-simple-endpoint style-scope ytd-video-renderer", 17 | "yt-uix-tile-link yt-ui-ellipsis yt-ui-ellipsis-2 yt-uix-sessionlink spf-link ", 18 | ] 19 | 20 | # Note that VID_VALUE_CLASS, VID_METADATA_CLASS, and YT_METADATA_CLASS are found in ranking.cr 21 | 22 | # Finds a youtube url based off of the given information. 23 | # The query to youtube is constructed like this: 24 | # " " 25 | # If *download_first* is provided, the first link found will be downloaded. 26 | # If *select_link* is provided, a menu of options will be shown for the user to choose their poison 27 | # 28 | # ``` 29 | # Youtube.find_url("Bohemian Rhapsody", "Queen") 30 | # => "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 31 | # ``` 32 | def find_url(spotify_metadata : JSON::Any, 33 | flags = {} of String => String) : String? 34 | 35 | search_terms = Config.search_terms 36 | 37 | select_link = flags["select"]? 38 | 39 | song_name = spotify_metadata["name"].as_s 40 | artist_name = spotify_metadata["artists"][0]["name"].as_s 41 | 42 | human_query = "#{song_name} #{artist_name} #{search_terms.strip}" 43 | params = HTTP::Params.encode({"search_query" => human_query}) 44 | 45 | response = HTTP::Client.get("https://www.youtube.com/results?#{params}") 46 | 47 | yt_metadata = get_yt_search_metadata(response.body) 48 | 49 | if yt_metadata.size == 0 50 | puts "There were no results for this query on youtube: \"#{human_query}\"" 51 | return nil 52 | end 53 | 54 | root = "https://youtube.com" 55 | ranked = Ranker.rank_videos(spotify_metadata, yt_metadata, human_query) 56 | 57 | if select_link 58 | return root + select_link_menu(spotify_metadata, yt_metadata) 59 | end 60 | 61 | begin 62 | puts Style.dim(" Video: ") + yt_metadata[ranked[0]["index"]]["title"] 63 | return root + yt_metadata[ranked[0]["index"]]["href"] 64 | rescue IndexError 65 | return nil 66 | end 67 | 68 | exit 1 69 | end 70 | 71 | # Presents a menu with song info for the user to choose which url they want to download 72 | private def select_link_menu(spotify_metadata : JSON::Any, 73 | yt_metadata : YT_METADATA_CLASS) : String 74 | puts Style.dim(" Spotify info: ") + 75 | Style.bold("\"" + spotify_metadata["name"].to_s) + "\" by \"" + 76 | Style.bold(spotify_metadata["artists"][0]["name"].to_s + "\"") + 77 | " @ " + Style.blue((spotify_metadata["duration_ms"].as_i / 1000).to_i.to_s) + "s" 78 | puts " Choose video to download:" 79 | index = 1 80 | yt_metadata.each do |vid| 81 | print " " + Style.bold(index.to_s + " ") 82 | puts "\"" + vid["title"] + "\" @ " + Style.blue((vid["duration_ms"].to_i / 1000).to_i.to_s) + "s" 83 | index += 1 84 | if index > 5 85 | break 86 | end 87 | end 88 | 89 | input = 0 90 | while true # not between 1 and 5 91 | begin 92 | print Style.bold(" > ") 93 | input = gets.not_nil!.chomp.to_i 94 | if input < 6 && input > 0 95 | break 96 | end 97 | rescue 98 | puts Style.red(" Invalid input, try again.") 99 | end 100 | end 101 | 102 | return yt_metadata[input-1]["href"] 103 | 104 | end 105 | 106 | # Finds valid video links from a `HTTP::Client.get` request 107 | # Returns an `Array` of `NODES_CLASS` containing additional metadata from Youtube 108 | private def get_yt_search_metadata(response_body : String) : YT_METADATA_CLASS 109 | yt_initial_data : JSON::Any = JSON.parse("{}") 110 | 111 | response_body.each_line do |line| 112 | # timestamp 11/8/2020: 113 | # youtube's html page has a line previous to this literally with 'scraper_data_begin' as a comment 114 | if line.includes?("var ytInitialData") 115 | # Extract JSON data from line 116 | data = line.split(" = ")[2].delete(';') 117 | dataEnd = (data.index("") || 0) - 1 118 | 119 | begin 120 | yt_initial_data = JSON.parse(data[0..dataEnd]) 121 | rescue 122 | break 123 | end 124 | end 125 | end 126 | 127 | if yt_initial_data == JSON.parse("{}") 128 | puts "Youtube has changed the way it organizes its webpage, submit a bug" 129 | puts "saying it has done so on https://github.com/cooperhammond/irs" 130 | exit(1) 131 | end 132 | 133 | # where the vid metadata lives 134 | yt_initial_data = yt_initial_data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"]["sectionListRenderer"]["contents"] 135 | 136 | video_metadata = [] of VID_METADATA_CLASS 137 | 138 | i = 0 139 | while true 140 | begin 141 | # video title 142 | raw_metadata = yt_initial_data[0]["itemSectionRenderer"]["contents"][i]["videoRenderer"] 143 | 144 | metadata = {} of String => VID_VALUE_CLASS 145 | 146 | metadata["title"] = raw_metadata["title"]["runs"][0]["text"].as_s 147 | metadata["href"] = raw_metadata["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s 148 | timestamp = raw_metadata["lengthText"]["simpleText"].as_s 149 | metadata["timestamp"] = timestamp 150 | metadata["duration_ms"] = ((timestamp.split(":")[0].to_i * 60 + 151 | timestamp.split(":")[1].to_i) * 1000).to_s 152 | 153 | 154 | video_metadata.push(metadata) 155 | rescue IndexError 156 | break 157 | rescue Exception 158 | end 159 | i += 1 160 | end 161 | 162 | return video_metadata 163 | end 164 | 165 | # Returns as a valid URL if possible 166 | # 167 | # ``` 168 | # Youtube.validate_url("https://www.youtube.com/watch?v=NOTANACTUALVIDEOID") 169 | # => nil 170 | # ``` 171 | def validate_url(url : String) : String | Nil 172 | uri = URI.parse url 173 | return nil if !uri 174 | 175 | query = uri.query 176 | return nil if !query 177 | 178 | # find the video ID 179 | vID = nil 180 | query.split('&').each do |q| 181 | if q.starts_with?("v=") 182 | vID = q[2..-1] 183 | end 184 | end 185 | return nil if !vID 186 | 187 | url = "https://www.youtube.com/watch?v=#{vID}" 188 | 189 | # this is an internal endpoint to validate the video ID 190 | params = HTTP::Params.encode({"format" => "json", "url" => url}) 191 | response = HTTP::Client.get "https://www.youtube.com/oembed?#{params}" 192 | return nil unless response.success? 193 | 194 | res_json = JSON.parse(response.body) 195 | title = res_json["title"].as_s 196 | puts Style.dim(" Video: ") + title 197 | 198 | return url 199 | end 200 | end 201 | -------------------------------------------------------------------------------- /src/glue/song.cr: -------------------------------------------------------------------------------- 1 | require "../search/spotify" 2 | require "../search/youtube" 3 | 4 | require "../interact/ripper" 5 | require "../interact/tagger" 6 | 7 | require "../bottle/config" 8 | require "../bottle/pattern" 9 | require "../bottle/styles" 10 | 11 | class Song 12 | @spotify_searcher = SpotifySearcher.new 13 | @client_id = "" 14 | @client_secret = "" 15 | 16 | @metadata : JSON::Any? 17 | getter filename = "" 18 | @artist = "" 19 | @album = "" 20 | 21 | @outputs : Hash(String, Array(String)) = { 22 | "intro" => [Style.bold("[%s by %a]\n")], 23 | "metadata" => [ 24 | " Searching for metadata ...\r", 25 | Style.green(" + ") + Style.dim("Metadata found \n") 26 | ], 27 | "url" => [ 28 | " Searching for URL ...\r", 29 | Style.green(" + ") + Style.dim("URL found \n"), 30 | " Validating URL ...\r", 31 | Style.green(" + ") + Style.dim("URL validated \n"), 32 | " URL?: " 33 | ], 34 | "download" => [ 35 | " Downloading video:\n", 36 | Style.green("\r + ") + Style.dim("Converted to mp3 \n") 37 | ], 38 | "albumart" => [ 39 | " Downloading album art ...\r", 40 | Style.green(" + ") + Style.dim("Album art downloaded \n") 41 | ], 42 | "tagging" => [ 43 | " Attaching metadata ...\r", 44 | Style.green(" + ") + Style.dim("Metadata attached \n") 45 | ], 46 | "finished" => [ 47 | Style.green(" + ") + "Finished!\n" 48 | ] 49 | } 50 | 51 | def initialize(@song_name : String, @artist_name : String) 52 | end 53 | 54 | # Find, downloads, and tags the mp3 song that this class represents. 55 | # Optionally takes a youtube URL to download from 56 | # 57 | # ``` 58 | # Song.new("Bohemian Rhapsody", "Queen").grab_it 59 | # ``` 60 | def grab_it(url : (String | Nil) = nil, flags = {} of String => String) 61 | passed_url : (String | Nil) = flags["url"]? 62 | passed_file : (String | Nil) = flags["apply_file"]? 63 | select_link = flags["select"]? 64 | 65 | outputter("intro", 0) 66 | 67 | if !@spotify_searcher.authorized? && !@metadata 68 | if @client_id != "" && @client_secret != "" 69 | @spotify_searcher.authorize(@client_id, @client_secret) 70 | else 71 | raise("Need to call either `provide_metadata`, `provide_spotify`, " + 72 | "or `provide_client_keys` so that Spotify can be interfaced with.") 73 | end 74 | end 75 | 76 | if !@metadata 77 | outputter("metadata", 0) 78 | @metadata = @spotify_searcher.find_item("track", { 79 | "name" => @song_name, 80 | "artist" => @artist_name, 81 | }) 82 | 83 | if !@metadata 84 | raise("There was no metadata found on Spotify for " + 85 | %("#{@song_name}" by "#{@artist_name}". ) + 86 | "Check your input and try again.") 87 | end 88 | outputter("metadata", 1) 89 | end 90 | 91 | data = @metadata.as(JSON::Any) 92 | @song_name = data["name"].as_s 93 | @artist_name = data["artists"][0]["name"].as_s 94 | @filename = "#{Pattern.parse(Config.filename_pattern, data)}.mp3" 95 | 96 | if passed_file 97 | puts Style.green(" +") + Style.dim(" Moving file: ") + passed_file 98 | File.rename(passed_file, @filename) 99 | else 100 | if passed_url 101 | if passed_url.strip != "" 102 | url = passed_url 103 | else 104 | outputter("url", 4) 105 | url = gets 106 | if !url.nil? && url.strip == "" 107 | url = nil 108 | end 109 | end 110 | end 111 | 112 | if !url 113 | outputter("url", 0) 114 | url = Youtube.find_url(data, flags: flags) 115 | if !url 116 | raise("There was no url found on youtube for " + 117 | %("#{@song_name}" by "#{@artist_name}. ) + 118 | "Check your input and try again.") 119 | end 120 | outputter("url", 1) 121 | else 122 | outputter("url", 2) 123 | url = Youtube.validate_url(url) 124 | if !url 125 | raise("The url is an invalid youtube URL " + 126 | "Check the URL and try again") 127 | end 128 | outputter("url", 3) 129 | end 130 | 131 | outputter("download", 0) 132 | Ripper.download_mp3(url.as(String), @filename) 133 | outputter("download", 1) 134 | end 135 | 136 | outputter("albumart", 0) 137 | temp_albumart_filename = ".tempalbumart.jpg" 138 | HTTP::Client.get(data["album"]["images"][0]["url"].as_s) do |response| 139 | File.write(temp_albumart_filename, response.body_io) 140 | end 141 | outputter("albumart", 0) 142 | 143 | # check if song's metadata has been modded in playlist, update artist accordingly 144 | if data["artists"][-1]["owner"]? 145 | @artist = data["artists"][-1]["name"].as_s 146 | else 147 | @artist = data["artists"][0]["name"].as_s 148 | end 149 | @album = data["album"]["name"].as_s 150 | 151 | tagger = Tags.new(@filename) 152 | tagger.add_album_art(temp_albumart_filename) 153 | tagger.add_text_tag("title", data["name"].as_s) 154 | tagger.add_text_tag("artist", @artist) 155 | 156 | if !@album.empty? 157 | tagger.add_text_tag("album", @album) 158 | end 159 | 160 | if genre = @spotify_searcher.find_genre(data["artists"][0]["id"].as_s) 161 | tagger.add_text_tag("genre", genre) 162 | end 163 | 164 | tagger.add_text_tag("track", data["track_number"].to_s) 165 | tagger.add_text_tag("disc", data["disc_number"].to_s) 166 | 167 | outputter("tagging", 0) 168 | tagger.save 169 | File.delete(temp_albumart_filename) 170 | outputter("tagging", 1) 171 | 172 | outputter("finished", 0) 173 | end 174 | 175 | # Will organize the song into the user's provided music directory 176 | # in the user's provided structure 177 | # Must be called AFTER the song has been downloaded. 178 | # 179 | # ``` 180 | # s = Song.new("Bohemian Rhapsody", "Queen").grab_it 181 | # s.organize_it() 182 | # # With 183 | # # directory_pattern = "{artist}/{album}" 184 | # # filename_pattern = "{track_number} - {title}" 185 | # # Mp3 will be moved to 186 | # # /home/cooper/Music/Queen/A Night At The Opera/1 - Bohemian Rhapsody.mp3 187 | # ``` 188 | def organize_it() 189 | path = Path[Config.music_directory].expand(home: true) 190 | Pattern.parse(Config.directory_pattern, @metadata.as(JSON::Any)).split('/').each do |dir| 191 | path = path / dir.gsub(/[\/]/, "").gsub(" ", " ") 192 | end 193 | strpath = path.to_s 194 | if !File.directory?(strpath) 195 | FileUtils.mkdir_p(strpath) 196 | end 197 | safe_filename = @filename.gsub(/[\/]/, "").gsub(" ", " ") 198 | FileUtils.cp("./" + @filename, (path / safe_filename).to_s) 199 | FileUtils.rm("./" + @filename) 200 | end 201 | 202 | # Provide metadata so that it doesn't have to find it. Useful for overwriting 203 | # metadata. Must be called if provide_client_keys and provide_spotify are not 204 | # called. 205 | # 206 | # ``` 207 | # Song.new(...).provide_metadata(...).grab_it 208 | # ``` 209 | def provide_metadata(metadata : JSON::Any) : self 210 | @metadata = metadata 211 | return self 212 | end 213 | 214 | # Provide an already authenticated `SpotifySearcher` class. Useful to avoid 215 | # authenticating over and over again. Must be called if provide_metadata and 216 | # provide_client_keys are not called. 217 | # 218 | # ``` 219 | # Song.new(...).provide_spotify(SpotifySearcher.new 220 | # .authenticate("XXXXXXXXXX", "XXXXXXXXXXX")).grab_it 221 | # ``` 222 | def provide_spotify(spotify : SpotifySearcher) : self 223 | @spotify_searcher = spotify 224 | return self 225 | end 226 | 227 | # Provide spotify client keys. Must be called if provide_metadata and 228 | # provide_spotify are not called. 229 | # 230 | # ``` 231 | # Song.new(...).provide_client_keys("XXXXXXXXXX", "XXXXXXXXX").grab_it 232 | # ``` 233 | def provide_client_keys(client_id : String, client_secret : String) : self 234 | @client_id = client_id 235 | @client_secret = client_secret 236 | return self 237 | end 238 | 239 | private def outputter(key : String, index : Int32) 240 | text = @outputs[key][index] 241 | .gsub("%s", @song_name) 242 | .gsub("%a", @artist_name) 243 | print text 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # irs: The Ironic Repositioning System 2 | 3 | [![made-with-crystal](https://img.shields.io/badge/Made%20with-Crystal-1f425f.svg?style=flat-square)](https://crystal-lang.org/) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](https://github.com/cooperhammond/irs/blob/master/LICENSE) 5 | [![Say Thanks](https://img.shields.io/badge/say-thanks-ff69b4.svg?style=flat-square)](https://saythanks.io/to/kepoorh%40gmail.com) 6 | 7 | > A music scraper that understands your metadata needs. 8 | 9 | `irs` is a command-line application that downloads audio and metadata in order 10 | to package an mp3 with both. Extensible, the user can download individual 11 | songs, entire albums, or playlists from Spotify. 12 | 13 |

14 | 15 |

16 |

22 | 23 | --- 24 | 25 | ## Table of Contents 26 | 27 | - [Usage](#usage) 28 | - [Demo](#demo) 29 | - [Installation](#installation) 30 | - [Pre-built](#pre-built) 31 | - [From source](#from-source) 32 | - [Set up](#setup) 33 | - [Config](#config) 34 | - [How it works](#how-it-works) 35 | - [Contributing](#contributing) 36 | 37 | 38 | ## Usage 39 | 40 | ``` 41 | ~ $ irs -h 42 | 43 | Usage: irs [--help] [--version] [--install] 44 | [-s -a ] 45 | [-A -a ] 46 | [-p -a ] 47 | 48 | Arguments: 49 | -h, --help Show this help message and exit 50 | -v, --version Show the program version and exit 51 | -i, --install Download binaries to config location 52 | -c, --config Show config file location 53 | -a, --artist Specify artist name for downloading 54 | -s, --song Specify song name to download 55 | -A, --album Specify the album name to download 56 | -p, --playlist Specify the playlist name to download 57 | -u, --url Specify the youtube url to download from (for single songs only) 58 | -g, --give-url Specify the youtube url sources while downloading (for albums or playlists only only) 59 | 60 | Examples: 61 | $ irs --song "Bohemian Rhapsody" --artist "Queen" 62 | # => downloads the song "Bohemian Rhapsody" by "Queen" 63 | $ irs --album "Demon Days" --artist "Gorillaz" 64 | # => downloads the album "Demon Days" by "Gorillaz" 65 | $ irs --playlist "a different drummer" --artist "prakkillian" 66 | # => downloads the playlist "a different drummer" by the user prakkillian 67 | ``` 68 | 69 | ### Demo 70 | 71 | [![asciicast](https://asciinema.org/a/332793.svg)](https://asciinema.org/a/332793) 72 | 73 | ## Installation 74 | 75 | ### Pre-built 76 | 77 | Just download the latest release for your platform 78 | [here](https://github.com/cooperhammond/irs/releases). 79 | 80 | 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. 81 | 82 | ### From Source 83 | 84 | If you're one of those cool people who compiles from source 85 | 86 | 1. Install crystal-lang 87 | ([`https://crystal-lang.org/install/`](https://crystal-lang.org/install/)) 88 | 1. Clone it (`git clone https://github.com/cooperhammond/irs`) 89 | 1. CD it (`cd irs`) 90 | 1. Build it (`shards build`) 91 | 92 | ### Setup 93 | 94 | 1. Create a `.yaml` config file somewhere on your system (usually `~/.irs/`) 95 | 1. Copy the following into it 96 | ```yaml 97 | binary_directory: ~/.irs/bin 98 | music_directory: ~/Music 99 | filename_pattern: "{track_number} - {title}" 100 | directory_pattern: "{artist}/{album}" 101 | client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 102 | client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 103 | single_folder_playlist: 104 | enabled: true 105 | retain_playlist_order: true 106 | unify_into_album: false 107 | ``` 108 | 1. Set the environment variable `IRS_CONFIG_LOCATION` pointing to that file 109 | 1. Go to [`https://developer.spotify.com/dashboard/`](https://developer.spotify.com/dashboard/) 110 | 1. Log in or create an account 111 | 1. Click `CREATE A CLIENT ID` 112 | 1. Enter all necessary info, true or false, continue 113 | 1. Find your client key and client secret 114 | 1. Copy each respectively into the X's in your config file 115 | 1. Run `irs --install` and answer the prompts! 116 | 117 | You should be good to go! Run the file from your command line to get more help on 118 | usage or keep reading! 119 | 120 | # Config 121 | 122 | You may have noticed that there's a config file with more than a few options. 123 | Here's what they do: 124 | ```yaml 125 | binary_directory: ~/.irs/bin 126 | music_directory: ~/Music 127 | search_terms: "lyrics" 128 | filename_pattern: "{track_number} - {title}" 129 | directory_pattern: "{artist}/{album}" 130 | client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 131 | client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 132 | single_folder_playlist: 133 | enabled: true 134 | retain_playlist_order: true 135 | unify_into_album: false 136 | ``` 137 | - `binary_directory`: a path specifying where the downloaded binaries should 138 | be placed 139 | - `music_directory`: a path specifying where downloaded mp3s should be placed. 140 | - `search_terms`: additional search terms to plug into youtube, which can be 141 | potentially useful for not grabbing erroneous audio. 142 | - `filename_pattern`: a pattern for the output filename of the mp3 143 | - `directory_pattern`: a pattern for the folder structure your mp3s are saved in 144 | - `client_key`: a client key from your spotify API application 145 | - `client_secret`: a client secret key from your spotify API application 146 | - `single_folder_playlist/enabled`: if set to true, all mp3s from a downloaded 147 | playlist will be placed in the same folder. 148 | - `single_folder_playlist/retain_playlist_order`: if set to true, the track 149 | numbers of the mp3s of the playlist will be overwritten to correspond to 150 | their place in the playlist 151 | - `single_folder_playlist/unify_into_album`: if set to true, will overwrite 152 | the album name and album image of the mp3 with the title of your playlist 153 | and the image for your playlist respectively 154 | 155 | 156 | In a pattern following keywords will be replaced: 157 | 158 | | Keyword | Replacement | Example | 159 | | :----: | :----: | :----: | 160 | | `{artist}` | Artist Name | Queen | 161 | | `{title}` | Track Title | Bohemian Rhapsody | 162 | | `{album}` | Album Name | Stone Cold Classics | 163 | | `{track_number}` | Track Number | 9 | 164 | | `{total_tracks}` | Total Tracks in Album | 14 | 165 | | `{disc_number}` | Disc Number | 1 | 166 | | `{day}` | Release Day | 01 | 167 | | `{month}` | Release Month | 01 | 168 | | `{year}` | Release Year | 2006 | 169 | | `{id}` | Spotify ID | 6l8GvAyoUZwWDgF1e4822w | 170 | 171 | Beware OS-restrictions when naming your mp3s. 172 | 173 | Pattern Examples: 174 | ```yaml 175 | music_directory: ~/Music 176 | filename_pattern: "{track_number} - {title}" 177 | directory_pattern: "{artist}/{album}" 178 | ``` 179 | Outputs: `~/Music/Queen/Stone Cold Classics/9 - Bohemian Rhapsody.mp3` 180 |

181 | ```yaml 182 | music_directory: ~/Music 183 | filename_pattern: "{artist} - {title}" 184 | directory_pattern: "" 185 | ``` 186 | Outputs: `~/Music/Queen - Bohemian Rhapsody.mp3` 187 |

188 | ```yaml 189 | music_directory: ~/Music 190 | filename_pattern: "{track_number} of {total_tracks} - {title}" 191 | directory_pattern: "{year}/{artist}/{album}" 192 | ``` 193 | Outputs: `~/Music/2006/Queen/Stone Cold Classics/9 of 14 - Bohemian Rhapsody.mp3` 194 |

195 | ```yaml 196 | music_directory: ~/Music 197 | filename_pattern: "{track_number}. {title}" 198 | directory_pattern: "irs/{artist} - {album}" 199 | ``` 200 | Outputs: `~/Music/irs/Queen - Stone Cold Classics/9. Bohemian Rhapsody.mp3` 201 |
202 | 203 | 204 | ## How it works 205 | 206 | **At it's core** `irs` downloads individual songs. It does this by interfacing 207 | with the Spotify API, grabbing metadata, and then searching Youtube for a video 208 | containing the song's audio. It will download the video using 209 | [`youtube-dl`](https://github.com/ytdl-org/youtube-dl), extract the audio using 210 | [`ffmpeg`](https://ffmpeg.org/), and then pack the audio and metadata together 211 | into an MP3. 212 | 213 | From the core, it has been extended to download the index of albums and 214 | playlists through the spotify API, and then iteratively use the method above 215 | for downloading each song. 216 | 217 | It used to be in python, but 218 | 1. I wasn't a fan of python's limited ability to distribute standalone binaries 219 | 1. It was a charlie foxtrot of code that I made when I was little and I wanted 220 | to refine it 221 | 1. `crystal-lang` made some promises and I was interested in seeing how well it 222 | did (verdict: if you're building high-level tools you want to run quickly 223 | and distribute, it's perfect) 224 | 225 | 226 | ## Contributing 227 | 228 | Any and all contributions are welcome. If you think of a cool feature, send a 229 | PR or shoot me an [email](mailto:kepoorh@gmail.com). If you think something 230 | could be implemented better, _please_ shoot me an email. If you like what I'm 231 | doing here, _pretty please_ shoot me an email. 232 | 233 | 1. Fork it () 234 | 2. Create your feature branch (`git checkout -b my-new-feature`) 235 | 3. Commit your changes (`git commit -am 'Add some feature'`) 236 | 4. Push to the branch (`git push origin my-new-feature`) 237 | 5. Create a new Pull Request 238 | -------------------------------------------------------------------------------- /src/search/spotify.cr: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "json" 3 | require "base64" 4 | 5 | require "../glue/mapper" 6 | 7 | class SpotifySearcher 8 | @root_url = Path["https://api.spotify.com/v1/"] 9 | 10 | @access_header : (HTTP::Headers | Nil) = nil 11 | @authorized = false 12 | 13 | # Saves an access token for future program use with spotify using client IDs. 14 | # Specs defined on spotify's developer api: 15 | # https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow 16 | # 17 | # ``` 18 | # SpotifySearcher.new.authorize("XXXXXXXXXX", "XXXXXXXXXX") 19 | # ``` 20 | def authorize(client_id : String, client_secret : String) : self 21 | auth_url = "https://accounts.spotify.com/api/token" 22 | 23 | headers = HTTP::Headers{ 24 | "Authorization" => "Basic " + 25 | Base64.strict_encode "#{client_id}:#{client_secret}", 26 | } 27 | 28 | payload = "grant_type=client_credentials" 29 | 30 | response = HTTP::Client.post(auth_url, headers: headers, form: payload) 31 | if response.status_code != 200 32 | @authorized = false 33 | return self 34 | end 35 | 36 | access_token = JSON.parse(response.body)["access_token"] 37 | 38 | @access_header = HTTP::Headers{ 39 | "Authorization" => "Bearer #{access_token}", 40 | } 41 | 42 | @authorized = true 43 | 44 | return self 45 | end 46 | 47 | # Check if the class is authorized or not 48 | def authorized? : Bool 49 | return @authorized 50 | end 51 | 52 | # Searches spotify with the specified parameters for the specified items 53 | # 54 | # ``` 55 | # spotify_searcher.find_item("track", { 56 | # "artist" => "Queen", 57 | # "track" => "Bohemian Rhapsody" 58 | # }) 59 | # => {track metadata} 60 | # ``` 61 | def find_item(item_type : String, item_parameters : Hash, offset = 0, 62 | limit = 20) : JSON::Any? 63 | query = generate_query(item_type, item_parameters) 64 | 65 | url = "search?q=#{query}&type=#{item_type}&limit=#{limit}&offset=#{offset}" 66 | url = @root_url.join(url).to_s 67 | 68 | response = HTTP::Client.get(url, headers: @access_header) 69 | error_check(response) 70 | 71 | items = JSON.parse(response.body)[item_type + "s"]["items"].as_a 72 | 73 | points = rank_items(items, item_parameters) 74 | 75 | to_return = nil 76 | 77 | begin 78 | # this means no points were assigned so don't return the "best guess" 79 | if points[0][0] <= 0 80 | to_return = nil 81 | else 82 | to_return = get_item(item_type, items[points[0][1]]["id"].to_s) 83 | end 84 | rescue IndexError 85 | to_return = nil 86 | end 87 | 88 | # if this triggers, it means that a playlist has failed to be found, so 89 | # the search will be bootstrapped into find_user_playlist 90 | if to_return == nil && item_type == "playlist" 91 | return find_user_playlist( 92 | item_parameters["username"], 93 | item_parameters["name"] 94 | ) 95 | end 96 | 97 | return to_return 98 | end 99 | 100 | # Grabs a users playlists and searches through it for the specified playlist 101 | # 102 | # ``` 103 | # spotify_searcher.find_user_playlist("prakkillian", "the little man") 104 | # => {playlist metadata} 105 | # ``` 106 | def find_user_playlist(username : String, name : String, offset = 0, 107 | limit = 20) : JSON::Any? 108 | url = "users/#{username}/playlists?limit=#{limit}&offset=#{offset}" 109 | url = @root_url.join(url).to_s 110 | 111 | response = HTTP::Client.get(url, headers: @access_header) 112 | error_check(response) 113 | body = JSON.parse(response.body) 114 | 115 | items = body["items"] 116 | points = [] of Array(Int32) 117 | 118 | items.as_a.each_index do |i| 119 | points.push([points_compare(items[i]["name"].to_s, name), i]) 120 | end 121 | points.sort! { |a, b| b[0] <=> a[0] } 122 | 123 | begin 124 | if points[0][0] < 3 125 | return find_user_playlist(username, name, offset + limit, limit) 126 | else 127 | return get_item("playlist", items[points[0][1]]["id"].to_s) 128 | end 129 | rescue IndexError 130 | return nil 131 | end 132 | end 133 | 134 | # Get the complete metadata of an item based off of its id 135 | # 136 | # ``` 137 | # SpotifySearcher.new.authorize(...).get_item("artist", "1dfeR4HaWDbWqFHLkxsg1d") 138 | # ``` 139 | def get_item(item_type : String, id : String, offset = 0, 140 | limit = 100) : JSON::Any 141 | if item_type == "playlist" 142 | return get_playlist(id, offset, limit) 143 | end 144 | 145 | url = "#{item_type}s/#{id}?limit=#{limit}&offset=#{offset}" 146 | url = @root_url.join(url).to_s 147 | 148 | response = HTTP::Client.get(url, headers: @access_header) 149 | error_check(response) 150 | 151 | body = JSON.parse(response.body) 152 | 153 | return body 154 | end 155 | 156 | # The only way this method differs from `get_item` is that it makes sure to 157 | # insert ALL tracks from the playlist into the `JSON::Any` 158 | # 159 | # ``` 160 | # SpotifySearcher.new.authorize(...).get_playlist("122Fc9gVuSZoksEjKEx7L0") 161 | # ``` 162 | def get_playlist(id, offset = 0, limit = 100) : JSON::Any 163 | url = "playlists/#{id}?limit=#{limit}&offset=#{offset}" 164 | url = @root_url.join(url).to_s 165 | 166 | response = HTTP::Client.get(url, headers: @access_header) 167 | error_check(response) 168 | body = JSON.parse(response.body) 169 | parent = PlaylistExtensionMapper.from_json(response.body) 170 | 171 | more_tracks = body["tracks"]["total"].as_i > offset + limit 172 | if more_tracks 173 | return playlist_extension(parent, id, offset = offset + limit) 174 | end 175 | 176 | return body 177 | end 178 | 179 | # This method exists to loop through spotify API requests and combine all 180 | # tracks that may not be captured by the limit of 100. 181 | private def playlist_extension(parent : PlaylistExtensionMapper, 182 | id : String, offset = 0, limit = 100) : JSON::Any 183 | url = "playlists/#{id}/tracks?limit=#{limit}&offset=#{offset}" 184 | url = @root_url.join(url).to_s 185 | 186 | response = HTTP::Client.get(url, headers: @access_header) 187 | error_check(response) 188 | body = JSON.parse(response.body) 189 | new_tracks = PlaylistTracksMapper.from_json(response.body) 190 | 191 | new_tracks.items.each do |track| 192 | parent.tracks.items.push(track) 193 | end 194 | 195 | more_tracks = body["total"].as_i > offset + limit 196 | if more_tracks 197 | return playlist_extension(parent, id, offset = offset + limit) 198 | end 199 | 200 | return JSON.parse(parent.to_json) 201 | end 202 | 203 | # Find the genre of an artist based off of their id 204 | # 205 | # ``` 206 | # SpotifySearcher.new.authorize(...).find_genre("1dfeR4HaWDbWqFHLkxsg1d") 207 | # ``` 208 | def find_genre(id : String) : String | Nil 209 | genre = get_item("artist", id)["genres"] 210 | 211 | if genre.as_a.empty? 212 | return nil 213 | end 214 | 215 | genre = genre[0].to_s 216 | genre = genre.split(" ").map { |x| x.capitalize }.join(" ") 217 | 218 | return genre 219 | end 220 | 221 | # Checks for errors in HTTP requests and raises one if found 222 | private def error_check(response : HTTP::Client::Response) : Nil 223 | if response.status_code != 200 224 | raise("There was an error with your request.\n" + 225 | "Status code: #{response.status_code}\n" + 226 | "Response: \n#{response.body}") 227 | end 228 | end 229 | 230 | # Generates url to run a GET request against to the Spotify open API 231 | # Returns a `String.` 232 | private def generate_query(item_type : String, item_parameters : Hash) : String 233 | query = "" 234 | 235 | # parameter keys to exclude in the api request. These values will be put 236 | # in, just not their keys. 237 | query_exclude = ["username"] 238 | 239 | item_parameters.keys.each do |k| 240 | # This will map album and track names from the name key to the query 241 | if k == "name" 242 | # will remove the "name:" param from the query 243 | if item_type == "playlist" 244 | query += item_parameters[k] + "+" 245 | else 246 | query += as_field(item_type, item_parameters[k]) 247 | end 248 | 249 | # check if the key is to be excluded 250 | elsif query_exclude.includes?(k) 251 | next 252 | 253 | # if it's none of the above, treat it normally 254 | # NOTE: playlist names will be inserted into the query normally, without 255 | # a parameter. 256 | else 257 | query += as_field(k, item_parameters[k]) 258 | end 259 | end 260 | 261 | return URI.encode(query.rchop("+")) 262 | end 263 | 264 | # Returns a `String` encoded for the spotify api 265 | # 266 | # ``` 267 | # query_encode("album", "A Night At The Opera") 268 | # => "album:A Night At The Opera+" 269 | # ``` 270 | private def as_field(key, value) : String 271 | return "#{key}:#{value}+" 272 | end 273 | 274 | # Ranks the given items based off of the info from parameters. 275 | # Meant to find the item that the user desires. 276 | # Returns an `Array` of `Array(Int32)` or [[3, 1], [...], ...] 277 | private def rank_items(items : Array, 278 | parameters : Hash) : Array(Array(Int32)) 279 | points = [] of Array(Int32) 280 | index = 0 281 | 282 | items.each do |item| 283 | pts = 0 284 | 285 | # Think about whether this following logic is worth having in one method. 286 | # Is it nice to have a single method that handles it all or having a few 287 | # methods for each of the item types? (track, album, playlist) 288 | parameters.keys.each do |k| 289 | val = parameters[k] 290 | 291 | # The key to compare to for artist 292 | if k == "artist" 293 | pts += points_compare(item["artists"][0]["name"].to_s, val) 294 | end 295 | 296 | # The key to compare to for playlists 297 | if k == "username" 298 | pts_to_add = points_compare(item["owner"]["display_name"].to_s, val) 299 | pts += pts_to_add 300 | pts += -10 if pts_to_add == 0 301 | end 302 | 303 | # The key regardless of whether item is track, album,or playlist 304 | if k == "name" 305 | pts += points_compare(item["name"].to_s, val) 306 | end 307 | end 308 | 309 | points.push([pts, index]) 310 | index += 1 311 | end 312 | 313 | points.sort! { |a, b| b[0] <=> a[0] } 314 | 315 | return points 316 | end 317 | 318 | # Returns an `Int` based off the number of points worth assigning to the 319 | # matchiness of the string. First the strings are downcased and then all 320 | # nonalphanumeric characters are stripped. 321 | # If the strings are the exact same, return 3 pts. 322 | # If *item1* includes *item2*, return 1 pt. 323 | # Else, return 0 pts. 324 | private def points_compare(item1 : String, item2 : String) : Int32 325 | item1 = item1.downcase.gsub(/[^a-z0-9]/, "") 326 | item2 = item2.downcase.gsub(/[^a-z0-9]/, "") 327 | 328 | if item1 == item2 329 | return 3 330 | elsif item1.includes?(item2) 331 | return 1 332 | else 333 | return 0 334 | end 335 | end 336 | 337 | end 338 | 339 | # puts SpotifySearcher.new() 340 | # .authorize("XXXXXXXXXXXXXXX", 341 | # "XXXXXXXXXXXXXXX") 342 | # .find_item("playlist", { 343 | # "name" => "Brain Food", 344 | # "username" => "spotify" 345 | # # "name " => "A Night At The Opera", 346 | # # "artist" => "Queen" 347 | # # "track" => "Bohemian Rhapsody", 348 | # # "artist" => "Queen" 349 | # }) 350 | --------------------------------------------------------------------------------