mirror of
https://github.com/cooperhammond/irs.git
synced 2025-08-18 00:01:04 +00:00
Compare commits
32 commits
Author | SHA1 | Date | |
---|---|---|---|
|
c99e8257e9 | ||
|
3bbb0e767a | ||
|
61120f21b0 | ||
|
390d59b9a0 | ||
|
3263ff4e07 | ||
|
3d4acdeaea | ||
|
72938a9b6a | ||
|
f962a0ab75 | ||
|
ac7bc02ec5 | ||
|
bdc63b4c35 | ||
|
289f1d8c63 | ||
|
f3776613b4 | ||
|
ff3019e207 | ||
|
fa5f3bb3b7 | ||
|
8d348031d3 | ||
|
92e8885ae9 | ||
|
5eaac33345 | ||
|
8c15f7b5e2 | ||
|
3f12a880e9 | ||
|
8f25eae1cb | ||
|
124b425f55 | ||
|
2e8bc6c8c5 | ||
|
b38bcd4ad8 | ||
|
2c364c38c2 | ||
|
c20f4309d8 | ||
|
047cc71b0d | ||
|
a8a1c4d1c3 | ||
|
bf29194042 | ||
|
843a5b9db1 | ||
|
58895e2e87 | ||
|
dd8c74520c | ||
|
e8a71b2530 |
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -12,3 +12,4 @@
|
||||||
ffmpeg
|
ffmpeg
|
||||||
ffprobe
|
ffprobe
|
||||||
youtube-dl
|
youtube-dl
|
||||||
|
*.temp
|
62
README.md
62
README.md
|
@ -54,6 +54,8 @@ Arguments:
|
||||||
-s, --song <song> Specify song name to download
|
-s, --song <song> Specify song name to download
|
||||||
-A, --album <album> Specify the album name to download
|
-A, --album <album> Specify the album name to download
|
||||||
-p, --playlist <playlist> Specify the playlist name to download
|
-p, --playlist <playlist> Specify the playlist name to download
|
||||||
|
-u, --url <url> Specify the youtube url to download from (for single songs only)
|
||||||
|
-g, --give-url Specify the youtube url sources while downloading (for albums or playlists only only)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$ irs --song "Bohemian Rhapsody" --artist "Queen"
|
$ irs --song "Bohemian Rhapsody" --artist "Queen"
|
||||||
|
@ -94,6 +96,8 @@ If you're one of those cool people who compiles from source
|
||||||
```yaml
|
```yaml
|
||||||
binary_directory: ~/.irs/bin
|
binary_directory: ~/.irs/bin
|
||||||
music_directory: ~/Music
|
music_directory: ~/Music
|
||||||
|
filename_pattern: "{track_number} - {title}"
|
||||||
|
directory_pattern: "{artist}/{album}"
|
||||||
client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
single_folder_playlist:
|
single_folder_playlist:
|
||||||
|
@ -120,6 +124,9 @@ Here's what they do:
|
||||||
```yaml
|
```yaml
|
||||||
binary_directory: ~/.irs/bin
|
binary_directory: ~/.irs/bin
|
||||||
music_directory: ~/Music
|
music_directory: ~/Music
|
||||||
|
search_terms: "lyrics"
|
||||||
|
filename_pattern: "{track_number} - {title}"
|
||||||
|
directory_pattern: "{artist}/{album}"
|
||||||
client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
single_folder_playlist:
|
single_folder_playlist:
|
||||||
|
@ -130,8 +137,10 @@ single_folder_playlist:
|
||||||
- `binary_directory`: a path specifying where the downloaded binaries should
|
- `binary_directory`: a path specifying where the downloaded binaries should
|
||||||
be placed
|
be placed
|
||||||
- `music_directory`: a path specifying where downloaded mp3s should be placed.
|
- `music_directory`: a path specifying where downloaded mp3s should be placed.
|
||||||
Note that there will be more structure created inside that folder, usually
|
- `search_terms`: additional search terms to plug into youtube, which can be
|
||||||
in the format of `music-dir>artist-name>album-name>track`
|
potentially useful for not grabbing erroneous audio.
|
||||||
|
- `filename_pattern`: a pattern for the output filename of the mp3
|
||||||
|
- `directory_pattern`: a pattern for the folder structure your mp3s are saved in
|
||||||
- `client_key`: a client key from your spotify API application
|
- `client_key`: a client key from your spotify API application
|
||||||
- `client_secret`: a client secret key from your spotify API application
|
- `client_secret`: a client secret key from your spotify API application
|
||||||
- `single_folder_playlist/enabled`: if set to true, all mp3s from a downloaded
|
- `single_folder_playlist/enabled`: if set to true, all mp3s from a downloaded
|
||||||
|
@ -143,6 +152,55 @@ single_folder_playlist:
|
||||||
the album name and album image of the mp3 with the title of your playlist
|
the album name and album image of the mp3 with the title of your playlist
|
||||||
and the image for your playlist respectively
|
and the image for your playlist respectively
|
||||||
|
|
||||||
|
|
||||||
|
In a pattern following keywords will be replaced:
|
||||||
|
|
||||||
|
| Keyword | Replacement | Example |
|
||||||
|
| :----: | :----: | :----: |
|
||||||
|
| `{artist}` | Artist Name | Queen |
|
||||||
|
| `{title}` | Track Title | Bohemian Rhapsody |
|
||||||
|
| `{album}` | Album Name | Stone Cold Classics |
|
||||||
|
| `{track_number}` | Track Number | 9 |
|
||||||
|
| `{total_tracks}` | Total Tracks in Album | 14 |
|
||||||
|
| `{disc_number}` | Disc Number | 1 |
|
||||||
|
| `{day}` | Release Day | 01 |
|
||||||
|
| `{month}` | Release Month | 01 |
|
||||||
|
| `{year}` | Release Year | 2006 |
|
||||||
|
| `{id}` | Spotify ID | 6l8GvAyoUZwWDgF1e4822w |
|
||||||
|
|
||||||
|
Beware OS-restrictions when naming your mp3s.
|
||||||
|
|
||||||
|
Pattern Examples:
|
||||||
|
```yaml
|
||||||
|
music_directory: ~/Music
|
||||||
|
filename_pattern: "{track_number} - {title}"
|
||||||
|
directory_pattern: "{artist}/{album}"
|
||||||
|
```
|
||||||
|
Outputs: `~/Music/Queen/Stone Cold Classics/9 - Bohemian Rhapsody.mp3`
|
||||||
|
<br><br>
|
||||||
|
```yaml
|
||||||
|
music_directory: ~/Music
|
||||||
|
filename_pattern: "{artist} - {title}"
|
||||||
|
directory_pattern: ""
|
||||||
|
```
|
||||||
|
Outputs: `~/Music/Queen - Bohemian Rhapsody.mp3`
|
||||||
|
<br><br>
|
||||||
|
```yaml
|
||||||
|
music_directory: ~/Music
|
||||||
|
filename_pattern: "{track_number} of {total_tracks} - {title}"
|
||||||
|
directory_pattern: "{year}/{artist}/{album}"
|
||||||
|
```
|
||||||
|
Outputs: `~/Music/2006/Queen/Stone Cold Classics/9 of 14 - Bohemian Rhapsody.mp3`
|
||||||
|
<br><br>
|
||||||
|
```yaml
|
||||||
|
music_directory: ~/Music
|
||||||
|
filename_pattern: "{track_number}. {title}"
|
||||||
|
directory_pattern: "irs/{artist} - {album}"
|
||||||
|
```
|
||||||
|
Outputs: `~/Music/irs/Queen - Stone Cold Classics/9. Bohemian Rhapsody.mp3`
|
||||||
|
<br>
|
||||||
|
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
**At it's core** `irs` downloads individual songs. It does this by interfacing
|
**At it's core** `irs` downloads individual songs. It does this by interfacing
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
name: irs
|
name: irs
|
||||||
version: 1.0.1
|
version: 1.4.0
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Cooper Hammond <kepoorh@gmail.com>
|
- Cooper Hammond <kepoorh@gmail.com>
|
||||||
|
|
|
@ -1,9 +1,35 @@
|
||||||
require "./spec_helper"
|
require "./spec_helper"
|
||||||
|
|
||||||
describe Irs do
|
describe CLI do
|
||||||
# TODO: Write tests
|
# TODO: Write tests
|
||||||
|
|
||||||
it "works" do
|
it "can show help" do
|
||||||
false.should eq(true)
|
run_CLI_with_args(["--help"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "can show version" do
|
||||||
|
run_CLI_with_args(["--version"])
|
||||||
|
end
|
||||||
|
|
||||||
|
# !!TODO: make a long and short version of the test suite
|
||||||
|
# TODO: makes so this doesn't need user input
|
||||||
|
it "can install ytdl and ffmpeg binaries" do
|
||||||
|
# run_CLI_with_args(["--install"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "can show config file loc" do
|
||||||
|
run_CLI_with_args(["--config"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "can download a single song" do
|
||||||
|
run_CLI_with_args(["--song", "Bohemian Rhapsody", "--artist", "Queen"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "can download an album" do
|
||||||
|
run_CLI_with_args(["--artist", "Arctic Monkeys", "--album", "Da Frame 2R / Matador"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "can download a playlist" do
|
||||||
|
run_CLI_with_args(["--artist", "prakkillian", "--playlist", "IRS Testing"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,2 +1,10 @@
|
||||||
require "spec"
|
require "spec"
|
||||||
require "../src/irs"
|
|
||||||
|
# https://github.com/mosop/stdio
|
||||||
|
|
||||||
|
require "../src/bottle/cli"
|
||||||
|
|
||||||
|
def run_CLI_with_args(argv : Array(String))
|
||||||
|
cli = CLI.new(argv)
|
||||||
|
cli.act_on_args
|
||||||
|
end
|
|
@ -20,6 +20,10 @@ class CLI
|
||||||
[["-s", "--song"], "song", "string"],
|
[["-s", "--song"], "song", "string"],
|
||||||
[["-A", "--album"], "album", "string"],
|
[["-A", "--album"], "album", "string"],
|
||||||
[["-p", "--playlist"], "playlist", "string"],
|
[["-p", "--playlist"], "playlist", "string"],
|
||||||
|
[["-u", "--url"], "url", "string"],
|
||||||
|
[["-S", "--select"], "select", "bool"],
|
||||||
|
[["--ask-skip"], "ask_skip", "bool"],
|
||||||
|
[["--apply"], "apply_file", "string"]
|
||||||
]
|
]
|
||||||
|
|
||||||
@args : Hash(String, String)
|
@args : Hash(String, String)
|
||||||
|
@ -48,6 +52,12 @@ class CLI
|
||||||
#{Style.blue "-s, --song <song>"} Specify song name to download
|
#{Style.blue "-s, --song <song>"} Specify song name to download
|
||||||
#{Style.blue "-A, --album <album>"} Specify the album name to download
|
#{Style.blue "-A, --album <album>"} Specify the album name to download
|
||||||
#{Style.blue "-p, --playlist <playlist>"} Specify the playlist name to download
|
#{Style.blue "-p, --playlist <playlist>"} Specify the playlist name to download
|
||||||
|
#{Style.blue "-u, --url <url>"} Specify the youtube url to download from
|
||||||
|
#{Style.blue " "} (for albums and playlists, the command-line
|
||||||
|
#{Style.blue " "} argument is ignored, and it should be '')
|
||||||
|
#{Style.blue "-S, --select"} Use a menu to choose each song's video source
|
||||||
|
#{Style.blue "--ask-skip"} Before every playlist/album song, ask to skip
|
||||||
|
#{Style.blue "--apply <file>"} Apply metadata to a existing file
|
||||||
|
|
||||||
#{Style.bold "Examples:"}
|
#{Style.bold "Examples:"}
|
||||||
$ #{Style.green %(irs --song "Bohemian Rhapsody" --artist "Queen")}
|
$ #{Style.green %(irs --song "Bohemian Rhapsody" --artist "Queen")}
|
||||||
|
@ -69,34 +79,35 @@ class CLI
|
||||||
|
|
||||||
if @args["help"]? || @args.keys.size == 0
|
if @args["help"]? || @args.keys.size == 0
|
||||||
help
|
help
|
||||||
exit
|
|
||||||
elsif @args["version"]?
|
elsif @args["version"]?
|
||||||
version
|
version
|
||||||
exit
|
|
||||||
elsif @args["install"]?
|
elsif @args["install"]?
|
||||||
YdlBinaries.get_both(Config.binary_location)
|
YdlBinaries.get_both(Config.binary_location)
|
||||||
exit
|
|
||||||
elsif @args["config"]?
|
elsif @args["config"]?
|
||||||
puts ENV["IRS_CONFIG_LOCATION"]?
|
puts ENV["IRS_CONFIG_LOCATION"]?
|
||||||
exit
|
|
||||||
elsif @args["song"]? && @args["artist"]?
|
elsif @args["song"]? && @args["artist"]?
|
||||||
s = Song.new(@args["song"], @args["artist"])
|
s = Song.new(@args["song"], @args["artist"])
|
||||||
s.provide_client_keys(Config.client_key, Config.client_secret)
|
s.provide_client_keys(Config.client_key, Config.client_secret)
|
||||||
s.grab_it
|
s.grab_it(flags: @args)
|
||||||
s.organize_it(Config.music_directory)
|
s.organize_it()
|
||||||
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(flags: @args)
|
||||||
|
|
||||||
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(flags: @args)
|
||||||
|
|
||||||
else
|
else
|
||||||
puts Style.red("Those arguments don't do anything when used that way.")
|
puts Style.red("Those arguments don't do anything when used that way.")
|
||||||
puts "Type `irs -h` to see usage."
|
puts "Type `irs -h` to see usage."
|
||||||
exit 1
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,11 @@ require "../search/spotify"
|
||||||
EXAMPLE_CONFIG = <<-EOP
|
EXAMPLE_CONFIG = <<-EOP
|
||||||
#{Style.dim "exampleconfig.yml"}
|
#{Style.dim "exampleconfig.yml"}
|
||||||
#{Style.dim "===="}
|
#{Style.dim "===="}
|
||||||
|
#{Style.blue "search_terms"}: #{Style.green "\"lyrics\""}
|
||||||
#{Style.blue "binary_directory"}: #{Style.green "~/.irs/bin"}
|
#{Style.blue "binary_directory"}: #{Style.green "~/.irs/bin"}
|
||||||
#{Style.blue "music_directory"}: #{Style.green "~/Music"}
|
#{Style.blue "music_directory"}: #{Style.green "~/Music"}
|
||||||
|
#{Style.blue "filename_pattern"}: #{Style.green "\"{track_number} - {title}\""}
|
||||||
|
#{Style.blue "directory_pattern"}: #{Style.green "\"{artist}/{album}\""}
|
||||||
#{Style.blue "client_key"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
|
#{Style.blue "client_key"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
|
||||||
#{Style.blue "client_secret"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
|
#{Style.blue "client_secret"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
|
||||||
#{Style.blue "single_folder_playlist"}:
|
#{Style.blue "single_folder_playlist"}:
|
||||||
|
@ -22,8 +25,11 @@ module Config
|
||||||
extend self
|
extend self
|
||||||
|
|
||||||
@@arguments = [
|
@@arguments = [
|
||||||
|
"search_terms",
|
||||||
"binary_directory",
|
"binary_directory",
|
||||||
"music_directory",
|
"music_directory",
|
||||||
|
"filename_pattern",
|
||||||
|
"directory_pattern",
|
||||||
"client_key",
|
"client_key",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
"single_folder_playlist: enabled",
|
"single_folder_playlist: enabled",
|
||||||
|
@ -41,6 +47,10 @@ module Config
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def search_terms : String
|
||||||
|
return @@conf["search_terms"].to_s
|
||||||
|
end
|
||||||
|
|
||||||
def binary_location : String
|
def binary_location : String
|
||||||
path = @@conf["binary_directory"].to_s
|
path = @@conf["binary_directory"].to_s
|
||||||
return Path[path].expand(home: true).to_s
|
return Path[path].expand(home: true).to_s
|
||||||
|
@ -51,6 +61,14 @@ module Config
|
||||||
return Path[path].expand(home: true).to_s
|
return Path[path].expand(home: true).to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def filename_pattern : String
|
||||||
|
return @@conf["filename_pattern"].to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def directory_pattern : String
|
||||||
|
return @@conf["directory_pattern"].to_s
|
||||||
|
end
|
||||||
|
|
||||||
def client_key : String
|
def client_key : String
|
||||||
return @@conf["client_key"].to_s
|
return @@conf["client_key"].to_s
|
||||||
end
|
end
|
||||||
|
|
28
src/bottle/pattern.cr
Normal file
28
src/bottle/pattern.cr
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
module Pattern
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def parse(formatString : String, metadata : JSON::Any)
|
||||||
|
formatted : String = formatString
|
||||||
|
|
||||||
|
date : Array(String) = (metadata["album"]? || JSON.parse("{}"))["release_date"]?.to_s.split('-')
|
||||||
|
|
||||||
|
keys : Hash(String, String) = {
|
||||||
|
"artist" => ((metadata.dig?("artists") || JSON.parse("{}"))[0]? || JSON.parse("{}"))["name"]?.to_s,
|
||||||
|
"title" => metadata["name"]?.to_s,
|
||||||
|
"album" => (metadata["album"]? || JSON.parse("{}"))["name"]?.to_s,
|
||||||
|
"track_number" => metadata["track_number"]?.to_s,
|
||||||
|
"disc_number" => metadata["disc_number"]?.to_s,
|
||||||
|
"total_tracks" => (metadata["album"]? || JSON.parse("{}"))["total_tracks"]?.to_s,
|
||||||
|
"year" => date[0]?.to_s,
|
||||||
|
"month" => date[1]?.to_s,
|
||||||
|
"day" => date[2]?.to_s,
|
||||||
|
"id" => metadata["id"]?.to_s
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.each do |pair|
|
||||||
|
formatted = formatted.gsub("{#{pair[0]}}", pair[1] || "")
|
||||||
|
end
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,3 +1,3 @@
|
||||||
module IRS
|
module IRS
|
||||||
VERSION = "0.1.0"
|
VERSION = "1.4.0"
|
||||||
end
|
end
|
||||||
|
|
|
@ -42,6 +42,6 @@ class Album < SpotifyList
|
||||||
end
|
end
|
||||||
|
|
||||||
private def organize(song : Song)
|
private def organize(song : Song)
|
||||||
song.organize_it(@home_music_directory)
|
song.organize_it()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,6 +17,9 @@ abstract class SpotifyList
|
||||||
"searching" => [
|
"searching" => [
|
||||||
Style.bold("Searching for %l by %a ... \r"),
|
Style.bold("Searching for %l by %a ... \r"),
|
||||||
Style.green("+ ") + Style.bold("%l by %a \n")
|
Style.green("+ ") + Style.bold("%l by %a \n")
|
||||||
|
],
|
||||||
|
"url" => [
|
||||||
|
Style.bold("When prompted for a URL, provide a youtube URL or press enter to scrape for one\n")
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,11 +27,19 @@ abstract class SpotifyList
|
||||||
end
|
end
|
||||||
|
|
||||||
# Finds the list, and downloads all of the songs using the `Song` class
|
# Finds the list, and downloads all of the songs using the `Song` class
|
||||||
def grab_it
|
def grab_it(flags = {} of String => String)
|
||||||
|
ask_url = flags["url"]?
|
||||||
|
ask_skip = flags["ask_skip"]?
|
||||||
|
is_playlist = flags["playlist"]?
|
||||||
|
|
||||||
if !@spotify_searcher.authorized?
|
if !@spotify_searcher.authorized?
|
||||||
raise("Need to call provide_client_keys on Album or Playlist class.")
|
raise("Need to call provide_client_keys on Album or Playlist class.")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if ask_url
|
||||||
|
outputter("url", 0)
|
||||||
|
end
|
||||||
|
|
||||||
outputter("searching", 0)
|
outputter("searching", 0)
|
||||||
list = find_it()
|
list = find_it()
|
||||||
outputter("searching", 1)
|
outputter("searching", 1)
|
||||||
|
@ -36,22 +47,28 @@ abstract class SpotifyList
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
contents.each do |datum|
|
contents.each do |datum|
|
||||||
|
i += 1
|
||||||
if datum["track"]?
|
if datum["track"]?
|
||||||
datum = datum["track"]
|
datum = datum["track"]
|
||||||
end
|
end
|
||||||
|
|
||||||
data = organize_song_metadata(list, datum)
|
data = organize_song_metadata(list, datum)
|
||||||
|
|
||||||
song = Song.new(data["name"].to_s, data["artists"][0]["name"].to_s)
|
s_name = data["name"].to_s
|
||||||
|
s_artist = data["artists"][0]["name"].to_s
|
||||||
|
|
||||||
|
song = Song.new(s_name, s_artist)
|
||||||
song.provide_spotify(@spotify_searcher)
|
song.provide_spotify(@spotify_searcher)
|
||||||
song.provide_metadata(data)
|
song.provide_metadata(data)
|
||||||
|
|
||||||
puts Style.bold("[#{data["track_number"]}/#{contents.size}]")
|
puts Style.bold("[#{i}/#{contents.size}]")
|
||||||
song.grab_it
|
|
||||||
|
|
||||||
organize(song)
|
unless ask_skip && skip?(s_name, s_artist, is_playlist)
|
||||||
|
song.grab_it(flags: flags)
|
||||||
i += 1
|
organize(song)
|
||||||
|
else
|
||||||
|
puts "Skipping..."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -60,6 +77,13 @@ abstract class SpotifyList
|
||||||
@spotify_searcher.authorize(client_key, client_secret)
|
@spotify_searcher.authorize(client_key, client_secret)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private def skip?(name, artist, is_playlist)
|
||||||
|
print "Skip #{Style.blue name}" +
|
||||||
|
(is_playlist ? " (by #{Style.green artist})": "") + "? "
|
||||||
|
response = gets
|
||||||
|
return response && response.lstrip.downcase.starts_with? "y"
|
||||||
|
end
|
||||||
|
|
||||||
private def outputter(key : String, index : Int32)
|
private def outputter(key : String, index : Int32)
|
||||||
text = @outputs[key][index]
|
text = @outputs[key][index]
|
||||||
.gsub("%l", @list_name)
|
.gsub("%l", @list_name)
|
||||||
|
|
|
@ -46,6 +46,7 @@ class TrackMapper
|
||||||
type: Int32,
|
type: Int32,
|
||||||
setter: true
|
setter: true
|
||||||
},
|
},
|
||||||
|
duration_ms: Int32,
|
||||||
type: String,
|
type: String,
|
||||||
uri: String
|
uri: String
|
||||||
)
|
)
|
||||||
|
|
|
@ -67,9 +67,10 @@ class Playlist < SpotifyList
|
||||||
FileUtils.mkdir_p(strpath)
|
FileUtils.mkdir_p(strpath)
|
||||||
end
|
end
|
||||||
safe_filename = song.filename.gsub(/[\/]/, "").gsub(" ", " ")
|
safe_filename = song.filename.gsub(/[\/]/, "").gsub(" ", " ")
|
||||||
File.rename("./" + song.filename, (path / safe_filename).to_s)
|
FileUtils.cp("./" + song.filename, (path / safe_filename).to_s)
|
||||||
|
FileUtils.rm("./" + song.filename)
|
||||||
else
|
else
|
||||||
song.organize_it(@home_music_directory)
|
song.organize_it()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
109
src/glue/song.cr
109
src/glue/song.cr
|
@ -4,6 +4,8 @@ require "../search/youtube"
|
||||||
require "../interact/ripper"
|
require "../interact/ripper"
|
||||||
require "../interact/tagger"
|
require "../interact/tagger"
|
||||||
|
|
||||||
|
require "../bottle/config"
|
||||||
|
require "../bottle/pattern"
|
||||||
require "../bottle/styles"
|
require "../bottle/styles"
|
||||||
|
|
||||||
class Song
|
class Song
|
||||||
|
@ -24,7 +26,10 @@ class Song
|
||||||
],
|
],
|
||||||
"url" => [
|
"url" => [
|
||||||
" Searching for URL ...\r",
|
" Searching for URL ...\r",
|
||||||
Style.green(" + ") + Style.dim("URL found \n")
|
Style.green(" + ") + Style.dim("URL found \n"),
|
||||||
|
" Validating URL ...\r",
|
||||||
|
Style.green(" + ") + Style.dim("URL validated \n"),
|
||||||
|
" URL?: "
|
||||||
],
|
],
|
||||||
"download" => [
|
"download" => [
|
||||||
" Downloading video:\n",
|
" Downloading video:\n",
|
||||||
|
@ -47,11 +52,16 @@ class Song
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find, downloads, and tags the mp3 song that this class represents.
|
# Find, downloads, and tags the mp3 song that this class represents.
|
||||||
|
# Optionally takes a youtube URL to download from
|
||||||
#
|
#
|
||||||
# ```
|
# ```
|
||||||
# Song.new("Bohemian Rhapsody", "Queen").grab_it
|
# Song.new("Bohemian Rhapsody", "Queen").grab_it
|
||||||
# ```
|
# ```
|
||||||
def grab_it
|
def grab_it(url : (String | Nil) = nil, flags = {} of String => String)
|
||||||
|
passed_url : (String | Nil) = flags["url"]?
|
||||||
|
passed_file : (String | Nil) = flags["apply_file"]?
|
||||||
|
select_link = flags["select"]?
|
||||||
|
|
||||||
outputter("intro", 0)
|
outputter("intro", 0)
|
||||||
|
|
||||||
if !@spotify_searcher.authorized? && !@metadata
|
if !@spotify_searcher.authorized? && !@metadata
|
||||||
|
@ -79,43 +89,78 @@ class Song
|
||||||
end
|
end
|
||||||
|
|
||||||
data = @metadata.as(JSON::Any)
|
data = @metadata.as(JSON::Any)
|
||||||
@filename = data["track_number"].to_s + " - #{data["name"].to_s}.mp3"
|
@song_name = data["name"].as_s
|
||||||
|
@artist_name = data["artists"][0]["name"].as_s
|
||||||
|
@filename = "#{Pattern.parse(Config.filename_pattern, data)}.mp3"
|
||||||
|
|
||||||
outputter("url", 0)
|
if passed_file
|
||||||
url = Youtube.find_url(@song_name, @artist_name, search_terms: "lyrics")
|
puts Style.green(" +") + Style.dim(" Moving file: ") + passed_file
|
||||||
if !url
|
File.rename(passed_file, @filename)
|
||||||
raise("There was no url found on youtube for " +
|
else
|
||||||
%("#{@song_name}" by "#{@artist_name}. ) +
|
if passed_url
|
||||||
"Check your input and try again.")
|
if passed_url.strip != ""
|
||||||
|
url = passed_url
|
||||||
|
else
|
||||||
|
outputter("url", 4)
|
||||||
|
url = gets
|
||||||
|
if !url.nil? && url.strip == ""
|
||||||
|
url = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if !url
|
||||||
|
outputter("url", 0)
|
||||||
|
url = Youtube.find_url(data, flags: flags)
|
||||||
|
if !url
|
||||||
|
raise("There was no url found on youtube for " +
|
||||||
|
%("#{@song_name}" by "#{@artist_name}. ) +
|
||||||
|
"Check your input and try again.")
|
||||||
|
end
|
||||||
|
outputter("url", 1)
|
||||||
|
else
|
||||||
|
outputter("url", 2)
|
||||||
|
url = Youtube.validate_url(url)
|
||||||
|
if !url
|
||||||
|
raise("The url is an invalid youtube URL " +
|
||||||
|
"Check the URL and try again")
|
||||||
|
end
|
||||||
|
outputter("url", 3)
|
||||||
|
end
|
||||||
|
|
||||||
|
outputter("download", 0)
|
||||||
|
Ripper.download_mp3(url.as(String), @filename)
|
||||||
|
outputter("download", 1)
|
||||||
end
|
end
|
||||||
outputter("url", 1)
|
|
||||||
|
|
||||||
outputter("download", 0)
|
|
||||||
Ripper.download_mp3(url.as(String), @filename)
|
|
||||||
outputter("download", 1)
|
|
||||||
|
|
||||||
outputter("albumart", 0)
|
outputter("albumart", 0)
|
||||||
temp_albumart_filename = ".tempalbumart.jpg"
|
temp_albumart_filename = ".tempalbumart.jpg"
|
||||||
HTTP::Client.get(data["album"]["images"][0]["url"].to_s) do |response|
|
HTTP::Client.get(data["album"]["images"][0]["url"].as_s) do |response|
|
||||||
File.write(temp_albumart_filename, response.body_io)
|
File.write(temp_albumart_filename, response.body_io)
|
||||||
end
|
end
|
||||||
outputter("albumart", 0)
|
outputter("albumart", 0)
|
||||||
|
|
||||||
# check if song's metadata has been modded in playlist, update artist accordingly
|
# check if song's metadata has been modded in playlist, update artist accordingly
|
||||||
if data["artists"][-1]["owner"]?
|
if data["artists"][-1]["owner"]?
|
||||||
@artist = data["artists"][-1]["name"].to_s
|
@artist = data["artists"][-1]["name"].as_s
|
||||||
else
|
else
|
||||||
@artist = data["artists"][0]["name"].to_s
|
@artist = data["artists"][0]["name"].as_s
|
||||||
end
|
end
|
||||||
@album = data["album"]["name"].to_s
|
@album = data["album"]["name"].as_s
|
||||||
|
|
||||||
tagger = Tags.new(@filename)
|
tagger = Tags.new(@filename)
|
||||||
tagger.add_album_art(temp_albumart_filename)
|
tagger.add_album_art(temp_albumart_filename)
|
||||||
tagger.add_text_tag("title", data["name"].to_s)
|
tagger.add_text_tag("title", data["name"].as_s)
|
||||||
tagger.add_text_tag("artist", @artist)
|
tagger.add_text_tag("artist", @artist)
|
||||||
tagger.add_text_tag("album", @album)
|
|
||||||
tagger.add_text_tag("genre",
|
if !@album.empty?
|
||||||
@spotify_searcher.find_genre(data["artists"][0]["id"].to_s))
|
tagger.add_text_tag("album", @album)
|
||||||
|
end
|
||||||
|
|
||||||
|
if genre = @spotify_searcher.find_genre(data["artists"][0]["id"].as_s)
|
||||||
|
tagger.add_text_tag("genre", genre)
|
||||||
|
end
|
||||||
|
|
||||||
tagger.add_text_tag("track", data["track_number"].to_s)
|
tagger.add_text_tag("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)
|
||||||
|
|
||||||
|
@ -127,20 +172,24 @@ class Song
|
||||||
outputter("finished", 0)
|
outputter("finished", 0)
|
||||||
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
|
||||||
# music_directory > artist_name > album_name > song
|
# in the user's provided structure
|
||||||
# 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()
|
||||||
# # Will move the mp3 file to
|
# # With
|
||||||
|
# # directory_pattern = "{artist}/{album}"
|
||||||
|
# # filename_pattern = "{track_number} - {title}"
|
||||||
|
# # Mp3 will be moved to
|
||||||
# # /home/cooper/Music/Queen/A Night At The Opera/1 - Bohemian Rhapsody.mp3
|
# # /home/cooper/Music/Queen/A Night At The Opera/1 - Bohemian Rhapsody.mp3
|
||||||
# ```
|
# ```
|
||||||
def organize_it(music_directory : String)
|
def organize_it()
|
||||||
path = Path[music_directory].expand(home: true)
|
path = Path[Config.music_directory].expand(home: true)
|
||||||
path = path / @artist_name.gsub(/[\/]/, "").gsub(" ", " ")
|
Pattern.parse(Config.directory_pattern, @metadata.as(JSON::Any)).split('/').each do |dir|
|
||||||
path = path / @album.gsub(/[\/]/, "").gsub(" ", " ")
|
path = path / dir.gsub(/[\/]/, "").gsub(" ", " ")
|
||||||
|
end
|
||||||
strpath = path.to_s
|
strpath = path.to_s
|
||||||
if !File.directory?(strpath)
|
if !File.directory?(strpath)
|
||||||
FileUtils.mkdir_p(strpath)
|
FileUtils.mkdir_p(strpath)
|
||||||
|
|
144
src/search/ranking.cr
Normal file
144
src/search/ranking.cr
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
alias VID_VALUE_CLASS = String
|
||||||
|
alias VID_METADATA_CLASS = Hash(String, VID_VALUE_CLASS)
|
||||||
|
alias YT_METADATA_CLASS = Array(VID_METADATA_CLASS)
|
||||||
|
|
||||||
|
module Ranker
|
||||||
|
extend self
|
||||||
|
|
||||||
|
GARBAGE_PHRASES = [
|
||||||
|
"cover", "album", "live", "clean", "version", "full", "full album", "row",
|
||||||
|
"at", "@", "session", "how to", "npr music", "reimagined", "version",
|
||||||
|
"trailer"
|
||||||
|
]
|
||||||
|
|
||||||
|
GOLDEN_PHRASES = [
|
||||||
|
"official video", "official music video",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Will rank videos according to their title and the user input, returns a sorted array of hashes
|
||||||
|
# of the points a song was assigned and its original index
|
||||||
|
# *spotify_metadata* is the metadate (from spotify) of the song that you want
|
||||||
|
# *yt_metadata* is an array of hashes with metadata scraped from the youtube search result page
|
||||||
|
# *query* is the query that you submitted to youtube for the results you now have
|
||||||
|
# ```
|
||||||
|
# Ranker.rank_videos(spotify_metadata, yt_metadata, query)
|
||||||
|
# => [
|
||||||
|
# {"points" => x, "index" => x},
|
||||||
|
# ...
|
||||||
|
# ]
|
||||||
|
# ```
|
||||||
|
# "index" corresponds to the original index of the song in yt_metadata
|
||||||
|
def rank_videos(spotify_metadata : JSON::Any, yt_metadata : YT_METADATA_CLASS,
|
||||||
|
query : String) : Array(Hash(String, Int32))
|
||||||
|
points = [] of Hash(String, Int32)
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
actual_song_name = spotify_metadata["name"].as_s
|
||||||
|
actual_artist_name = spotify_metadata["artists"][0]["name"].as_s
|
||||||
|
|
||||||
|
yt_metadata.each do |vid|
|
||||||
|
pts = 0
|
||||||
|
|
||||||
|
pts += points_string_compare(actual_song_name, vid["title"])
|
||||||
|
pts += points_string_compare(actual_artist_name, vid["title"])
|
||||||
|
pts += count_buzzphrases(query, vid["title"])
|
||||||
|
pts += compare_timestamps(spotify_metadata, vid)
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
"points" => pts,
|
||||||
|
"index" => index,
|
||||||
|
})
|
||||||
|
index += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sort first by points and then by original index of the song
|
||||||
|
points.sort! { |a, b|
|
||||||
|
if b["points"] == a["points"]
|
||||||
|
a["index"] <=> b["index"]
|
||||||
|
else
|
||||||
|
b["points"] <=> a["points"]
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
end
|
||||||
|
|
||||||
|
# SINGULAR COMPONENT OF RANKING ALGORITHM
|
||||||
|
private def compare_timestamps(spotify_metadata : JSON::Any, node : VID_METADATA_CLASS) : Int32
|
||||||
|
# puts spotify_metadata.to_pretty_json()
|
||||||
|
actual_time = spotify_metadata["duration_ms"].as_i
|
||||||
|
vid_time = node["duration_ms"].to_i
|
||||||
|
|
||||||
|
difference = (actual_time - vid_time).abs
|
||||||
|
|
||||||
|
# puts "actual: #{actual_time}, vid: #{vid_time}"
|
||||||
|
# puts "\tdiff: #{difference}"
|
||||||
|
# puts "\ttitle: #{node["title"]}"
|
||||||
|
|
||||||
|
if difference <= 1000
|
||||||
|
return 3
|
||||||
|
elsif difference <= 2000
|
||||||
|
return 2
|
||||||
|
elsif difference <= 5000
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# SINGULAR COMPONENT OF RANKING ALGORITHM
|
||||||
|
# Returns an `Int` based off the number of points worth assigning to the
|
||||||
|
# matchiness of the string. First the strings are downcased and then all
|
||||||
|
# nonalphanumeric characters are stripped.
|
||||||
|
# If *item1* includes *item2*, return 3 pts.
|
||||||
|
# If after the items have been blanked, *item1* includes *item2*,
|
||||||
|
# return 1 pts.
|
||||||
|
# Else, return 0 pts.
|
||||||
|
private def points_string_compare(item1 : String, item2 : String) : Int32
|
||||||
|
if item2.includes?(item1)
|
||||||
|
return 3
|
||||||
|
end
|
||||||
|
|
||||||
|
item1 = item1.downcase.gsub(/[^a-z0-9]/, "")
|
||||||
|
item2 = item2.downcase.gsub(/[^a-z0-9]/, "")
|
||||||
|
|
||||||
|
if item2.includes?(item1)
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# SINGULAR COMPONENT OF RANKING ALGORITHM
|
||||||
|
# Checks if there are any phrases in the title of the video that would
|
||||||
|
# indicate audio having what we want.
|
||||||
|
# *video_name* is the title of the video, and *query* is what the user the
|
||||||
|
# program searched for. *query* is needed in order to make sure we're not
|
||||||
|
# subtracting points from something that's naturally in the title
|
||||||
|
private def count_buzzphrases(query : String, video_name : String) : Int32
|
||||||
|
good_phrases = 0
|
||||||
|
bad_phrases = 0
|
||||||
|
|
||||||
|
GOLDEN_PHRASES.each do |gold_phrase|
|
||||||
|
gold_phrase = gold_phrase.downcase.gsub(/[^a-z0-9]/, "")
|
||||||
|
|
||||||
|
if query.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
|
||||||
|
next
|
||||||
|
elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
|
||||||
|
good_phrases += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
GARBAGE_PHRASES.each do |garbage_phrase|
|
||||||
|
garbage_phrase = garbage_phrase.downcase.gsub(/[^a-z0-9]/, "")
|
||||||
|
|
||||||
|
if query.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
|
||||||
|
next
|
||||||
|
elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
|
||||||
|
bad_phrases += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return good_phrases - bad_phrases
|
||||||
|
end
|
||||||
|
end
|
|
@ -60,9 +60,10 @@ class SpotifySearcher
|
||||||
# ```
|
# ```
|
||||||
def find_item(item_type : String, item_parameters : Hash, offset = 0,
|
def find_item(item_type : String, item_parameters : Hash, offset = 0,
|
||||||
limit = 20) : JSON::Any?
|
limit = 20) : JSON::Any?
|
||||||
query = generate_query(item_type, item_parameters, offset, limit)
|
query = generate_query(item_type, item_parameters)
|
||||||
|
|
||||||
url = @root_url.join("search?q=#{query}").to_s
|
url = "search?q=#{query}&type=#{item_type}&limit=#{limit}&offset=#{offset}"
|
||||||
|
url = @root_url.join(url).to_s
|
||||||
|
|
||||||
response = HTTP::Client.get(url, headers: @access_header)
|
response = HTTP::Client.get(url, headers: @access_header)
|
||||||
error_check(response)
|
error_check(response)
|
||||||
|
@ -204,8 +205,14 @@ class SpotifySearcher
|
||||||
# ```
|
# ```
|
||||||
# SpotifySearcher.new.authorize(...).find_genre("1dfeR4HaWDbWqFHLkxsg1d")
|
# SpotifySearcher.new.authorize(...).find_genre("1dfeR4HaWDbWqFHLkxsg1d")
|
||||||
# ```
|
# ```
|
||||||
def find_genre(id : String) : String
|
def find_genre(id : String) : String | Nil
|
||||||
genre = get_item("artist", id)["genres"][0].to_s
|
genre = get_item("artist", id)["genres"]
|
||||||
|
|
||||||
|
if genre.as_a.empty?
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
genre = genre[0].to_s
|
||||||
genre = genre.split(" ").map { |x| x.capitalize }.join(" ")
|
genre = genre.split(" ").map { |x| x.capitalize }.join(" ")
|
||||||
|
|
||||||
return genre
|
return genre
|
||||||
|
@ -222,8 +229,7 @@ class SpotifySearcher
|
||||||
|
|
||||||
# Generates url to run a GET request against to the Spotify open API
|
# Generates url to run a GET request against to the Spotify open API
|
||||||
# Returns a `String.`
|
# Returns a `String.`
|
||||||
private def generate_query(item_type : String, item_parameters : Hash,
|
private def generate_query(item_type : String, item_parameters : Hash) : 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
|
||||||
|
@ -235,9 +241,9 @@ class SpotifySearcher
|
||||||
if k == "name"
|
if k == "name"
|
||||||
# will remove the "name:<title>" param from the query
|
# will remove the "name:<title>" param from the query
|
||||||
if item_type == "playlist"
|
if item_type == "playlist"
|
||||||
query += item_parameters[k].gsub(" ", "+") + "+"
|
query += item_parameters[k] + "+"
|
||||||
else
|
else
|
||||||
query += param_encode(item_type, item_parameters[k])
|
query += as_field(item_type, item_parameters[k])
|
||||||
end
|
end
|
||||||
|
|
||||||
# check if the key is to be excluded
|
# check if the key is to be excluded
|
||||||
|
@ -248,14 +254,21 @@ class SpotifySearcher
|
||||||
# NOTE: playlist names will be inserted into the query normally, without
|
# 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 += as_field(k, item_parameters[k])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# extra api info
|
return URI.encode(query.rchop("+"))
|
||||||
query += "&type=#{item_type}&limit=#{limit}&offset=#{offset}"
|
end
|
||||||
|
|
||||||
return query
|
# Returns a `String` encoded for the spotify api
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# query_encode("album", "A Night At The Opera")
|
||||||
|
# => "album:A Night At The Opera+"
|
||||||
|
# ```
|
||||||
|
private def as_field(key, value) : String
|
||||||
|
return "#{key}:#{value}+"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Ranks the given items based off of the info from parameters.
|
# Ranks the given items based off of the info from parameters.
|
||||||
|
@ -321,15 +334,6 @@ class SpotifySearcher
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns a `String` encoded for the spotify api
|
|
||||||
#
|
|
||||||
# ```
|
|
||||||
# query_encode("album", "A Night At The Opera")
|
|
||||||
# => "album:A+Night+At+The+Opera"
|
|
||||||
# ```
|
|
||||||
private def param_encode(key : String, value : String) : String
|
|
||||||
return key.gsub(" ", "+") + ":" + value.gsub(" ", "+") + "+"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# puts SpotifySearcher.new()
|
# puts SpotifySearcher.new()
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
require "http"
|
require "http"
|
||||||
require "xml"
|
require "xml"
|
||||||
require "json"
|
require "json"
|
||||||
|
require "uri"
|
||||||
|
|
||||||
|
require "./ranking"
|
||||||
|
|
||||||
|
require "../bottle/config"
|
||||||
|
require "../bottle/styles"
|
||||||
|
|
||||||
|
|
||||||
module Youtube
|
module Youtube
|
||||||
|
@ -11,167 +17,123 @@ module Youtube
|
||||||
"yt-uix-tile-link yt-ui-ellipsis yt-ui-ellipsis-2 yt-uix-sessionlink spf-link ",
|
"yt-uix-tile-link yt-ui-ellipsis yt-ui-ellipsis-2 yt-uix-sessionlink spf-link ",
|
||||||
]
|
]
|
||||||
|
|
||||||
GARBAGE_PHRASES = [
|
# Note that VID_VALUE_CLASS, VID_METADATA_CLASS, and YT_METADATA_CLASS are found in ranking.cr
|
||||||
"cover", "album", "live", "clean", "version", "full", "full album", "row",
|
|
||||||
"at", "@", "session", "how to", "npr music", "reimagined", "hr version",
|
|
||||||
"trailer",
|
|
||||||
]
|
|
||||||
|
|
||||||
GOLDEN_PHRASES = [
|
|
||||||
"official video", "official music video",
|
|
||||||
]
|
|
||||||
|
|
||||||
alias NODES_CLASS = Array(Hash(String, String))
|
|
||||||
|
|
||||||
# Finds a youtube url based off of the given information.
|
# Finds a youtube url based off of the given information.
|
||||||
# The query to youtube is constructed like this:
|
# The query to youtube is constructed like this:
|
||||||
# "<song_name> <artist_name> <search terms>"
|
# "<song_name> <artist_name> <search terms>"
|
||||||
# If *download_first* is provided, the first link found will be downloaded.
|
# If *download_first* is provided, the first link found will be downloaded.
|
||||||
|
# If *select_link* is provided, a menu of options will be shown for the user to choose their poison
|
||||||
#
|
#
|
||||||
# ```
|
# ```
|
||||||
# Youtube.find_url("Bohemian Rhapsody", "Queen")
|
# Youtube.find_url("Bohemian Rhapsody", "Queen")
|
||||||
# => "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
# => "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||||
# ```
|
# ```
|
||||||
def find_url(song_name : String, artist_name : String, search_terms = "",
|
def find_url(spotify_metadata : JSON::Any,
|
||||||
download_first = false) : String?
|
flags = {} of String => String) : String?
|
||||||
query = (song_name + " " + artist_name + " " + search_terms).strip.gsub(" ", "+")
|
|
||||||
|
|
||||||
url = "https://www.youtube.com/results?search_query=" + query
|
search_terms = Config.search_terms
|
||||||
|
|
||||||
response = HTTP::Client.get(url)
|
select_link = flags["select"]?
|
||||||
|
|
||||||
valid_nodes = get_video_link_nodes(response.body)
|
song_name = spotify_metadata["name"].as_s
|
||||||
|
artist_name = spotify_metadata["artists"][0]["name"].as_s
|
||||||
|
|
||||||
if valid_nodes.size == 0
|
human_query = "#{song_name} #{artist_name} #{search_terms.strip}"
|
||||||
puts "There were no results for that query."
|
params = HTTP::Params.encode({"search_query" => human_query})
|
||||||
|
|
||||||
|
response = HTTP::Client.get("https://www.youtube.com/results?#{params}")
|
||||||
|
|
||||||
|
yt_metadata = get_yt_search_metadata(response.body)
|
||||||
|
|
||||||
|
if yt_metadata.size == 0
|
||||||
|
puts "There were no results for this query on youtube: \"#{human_query}\""
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
root = "https://youtube.com"
|
root = "https://youtube.com"
|
||||||
|
ranked = Ranker.rank_videos(spotify_metadata, yt_metadata, human_query)
|
||||||
|
|
||||||
return root + valid_nodes[0]["href"] if download_first
|
if select_link
|
||||||
|
return root + select_link_menu(spotify_metadata, yt_metadata)
|
||||||
ranked = rank_videos(song_name, artist_name, query, valid_nodes)
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
return root + valid_nodes[ranked[0]["index"]]["href"]
|
puts Style.dim(" Video: ") + yt_metadata[ranked[0]["index"]]["title"]
|
||||||
|
return root + yt_metadata[ranked[0]["index"]]["href"]
|
||||||
rescue IndexError
|
rescue IndexError
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
# Will rank videos according to their title and the user input
|
# Presents a menu with song info for the user to choose which url they want to download
|
||||||
# Return:
|
private def select_link_menu(spotify_metadata : JSON::Any,
|
||||||
# [
|
yt_metadata : YT_METADATA_CLASS) : String
|
||||||
# {"points" => x, "index" => x},
|
puts Style.dim(" Spotify info: ") +
|
||||||
# ...
|
Style.bold("\"" + spotify_metadata["name"].to_s) + "\" by \"" +
|
||||||
# ]
|
Style.bold(spotify_metadata["artists"][0]["name"].to_s + "\"") +
|
||||||
private def rank_videos(song_name : String, artist_name : String,
|
" @ " + Style.blue((spotify_metadata["duration_ms"].as_i / 1000).to_i.to_s) + "s"
|
||||||
query : String, nodes : Array(Hash(String, String))) : Array(Hash(String, Int32))
|
puts " Choose video to download:"
|
||||||
points = [] of Hash(String, Int32)
|
index = 1
|
||||||
index = 0
|
yt_metadata.each do |vid|
|
||||||
|
print " " + Style.bold(index.to_s + " ")
|
||||||
nodes.each do |node|
|
puts "\"" + vid["title"] + "\" @ " + Style.blue((vid["duration_ms"].to_i / 1000).to_i.to_s) + "s"
|
||||||
pts = 0
|
|
||||||
|
|
||||||
pts += points_compare(song_name, node["title"])
|
|
||||||
pts += points_compare(artist_name, node["title"])
|
|
||||||
pts += count_buzzphrases(query, node["title"])
|
|
||||||
|
|
||||||
points.push({
|
|
||||||
"points" => pts,
|
|
||||||
"index" => index,
|
|
||||||
})
|
|
||||||
index += 1
|
index += 1
|
||||||
end
|
if index > 5
|
||||||
|
break
|
||||||
# Sort first by points and then by original index of the song
|
|
||||||
points.sort! { |a, b|
|
|
||||||
if b["points"] == a["points"]
|
|
||||||
a["index"] <=> b["index"]
|
|
||||||
else
|
|
||||||
b["points"] <=> a["points"]
|
|
||||||
end
|
|
||||||
}
|
|
||||||
|
|
||||||
return points
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns an `Int` based off the number of points worth assigning to the
|
|
||||||
# matchiness of the string. First the strings are downcased and then all
|
|
||||||
# nonalphanumeric characters are stripped.
|
|
||||||
# If *item1* includes *item2*, return 3 pts.
|
|
||||||
# If after the items have been blanked, *item1* includes *item2*,
|
|
||||||
# return 1 pts.
|
|
||||||
# Else, return 0 pts.
|
|
||||||
private def points_compare(item1 : String, item2 : String) : Int32
|
|
||||||
if item2.includes?(item1)
|
|
||||||
return 3
|
|
||||||
end
|
|
||||||
|
|
||||||
item1 = item1.downcase.gsub(/[^a-z0-9]/, "")
|
|
||||||
item2 = item2.downcase.gsub(/[^a-z0-9]/, "")
|
|
||||||
|
|
||||||
if item2.includes?(item1)
|
|
||||||
return 1
|
|
||||||
else
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Checks if there are any phrases in the title of the video that would
|
|
||||||
# indicate audio having what we want.
|
|
||||||
# *video_name* is the title of the video, and *query* is what the user the
|
|
||||||
# program searched for. *query* is needed in order to make sure we're not
|
|
||||||
# subtracting points from something that's naturally in the title
|
|
||||||
private def count_buzzphrases(query : String, video_name : String) : Int32
|
|
||||||
good_phrases = 0
|
|
||||||
bad_phrases = 0
|
|
||||||
|
|
||||||
GOLDEN_PHRASES.each do |gold_phrase|
|
|
||||||
gold_phrase = gold_phrase.downcase.gsub(/[^a-z0-9]/, "")
|
|
||||||
|
|
||||||
if query.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
|
|
||||||
next
|
|
||||||
elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
|
|
||||||
good_phrases += 1
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
GARBAGE_PHRASES.each do |garbage_phrase|
|
input = 0
|
||||||
garbage_phrase = garbage_phrase.downcase.gsub(/[^a-z0-9]/, "")
|
while true # not between 1 and 5
|
||||||
|
begin
|
||||||
if query.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
|
print Style.bold(" > ")
|
||||||
next
|
input = gets.not_nil!.chomp.to_i
|
||||||
elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(garbage_phrase)
|
if input < 6 && input > 0
|
||||||
bad_phrases += 1
|
break
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
puts Style.red(" Invalid input, try again.")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return good_phrases - bad_phrases
|
return yt_metadata[input-1]["href"]
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Finds valid video links from a `HTTP::Client.get` request
|
# Finds valid video links from a `HTTP::Client.get` request
|
||||||
# Returns an `Array` of `XML::Node`
|
# Returns an `Array` of `NODES_CLASS` containing additional metadata from Youtube
|
||||||
private def get_video_link_nodes(response_body : String) : NODES_CLASS
|
private def get_yt_search_metadata(response_body : String) : YT_METADATA_CLASS
|
||||||
yt_initial_data : JSON::Any = JSON.parse("{}")
|
yt_initial_data : JSON::Any = JSON.parse("{}")
|
||||||
|
|
||||||
response_body.each_line do |line|
|
response_body.each_line do |line|
|
||||||
if line.includes?("window[\"ytInitialData\"]")
|
# timestamp 11/8/2020:
|
||||||
yt_initial_data = JSON.parse(line.split(" = ")[1][0..-2])
|
# youtube's html page has a line previous to this literally with 'scraper_data_begin' as a comment
|
||||||
|
if line.includes?("var ytInitialData")
|
||||||
|
# Extract JSON data from line
|
||||||
|
data = line.split(" = ")[2].delete(';')
|
||||||
|
dataEnd = (data.index("</script>") || 0) - 1
|
||||||
|
|
||||||
|
begin
|
||||||
|
yt_initial_data = JSON.parse(data[0..dataEnd])
|
||||||
|
rescue
|
||||||
|
break
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if yt_initial_data == JSON.parse("{}")
|
if yt_initial_data == JSON.parse("{}")
|
||||||
puts "Youtube has changed the way it organizes its webpage, submit a bug"
|
puts "Youtube has changed the way it organizes its webpage, submit a bug"
|
||||||
puts "on https://github.com/cooperhammond/irs"
|
puts "saying it has done so on https://github.com/cooperhammond/irs"
|
||||||
exit(1)
|
exit(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
# where the vid metadata lives
|
# where the vid metadata lives
|
||||||
yt_initial_data = yt_initial_data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"]["sectionListRenderer"]["contents"]
|
yt_initial_data = yt_initial_data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"]["sectionListRenderer"]["contents"]
|
||||||
|
|
||||||
video_metadata = [] of Hash(String, String)
|
video_metadata = [] of VID_METADATA_CLASS
|
||||||
|
|
||||||
i = 0
|
i = 0
|
||||||
while true
|
while true
|
||||||
|
@ -179,10 +141,15 @@ module Youtube
|
||||||
# video title
|
# video title
|
||||||
raw_metadata = yt_initial_data[0]["itemSectionRenderer"]["contents"][i]["videoRenderer"]
|
raw_metadata = yt_initial_data[0]["itemSectionRenderer"]["contents"][i]["videoRenderer"]
|
||||||
|
|
||||||
metadata = {} of String => String
|
metadata = {} of String => VID_VALUE_CLASS
|
||||||
|
|
||||||
metadata["title"] = raw_metadata["title"]["runs"][0]["text"].as_s
|
metadata["title"] = raw_metadata["title"]["runs"][0]["text"].as_s
|
||||||
metadata["href"] = raw_metadata["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
|
metadata["href"] = raw_metadata["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
|
||||||
|
timestamp = raw_metadata["lengthText"]["simpleText"].as_s
|
||||||
|
metadata["timestamp"] = timestamp
|
||||||
|
metadata["duration_ms"] = ((timestamp.split(":")[0].to_i * 60 +
|
||||||
|
timestamp.split(":")[1].to_i) * 1000).to_s
|
||||||
|
|
||||||
|
|
||||||
video_metadata.push(metadata)
|
video_metadata.push(metadata)
|
||||||
rescue IndexError
|
rescue IndexError
|
||||||
|
@ -194,4 +161,40 @@ module Youtube
|
||||||
|
|
||||||
return video_metadata
|
return video_metadata
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Returns as a valid URL if possible
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# Youtube.validate_url("https://www.youtube.com/watch?v=NOTANACTUALVIDEOID")
|
||||||
|
# => nil
|
||||||
|
# ```
|
||||||
|
def validate_url(url : String) : String | Nil
|
||||||
|
uri = URI.parse url
|
||||||
|
return nil if !uri
|
||||||
|
|
||||||
|
query = uri.query
|
||||||
|
return nil if !query
|
||||||
|
|
||||||
|
# find the video ID
|
||||||
|
vID = nil
|
||||||
|
query.split('&').each do |q|
|
||||||
|
if q.starts_with?("v=")
|
||||||
|
vID = q[2..-1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil if !vID
|
||||||
|
|
||||||
|
url = "https://www.youtube.com/watch?v=#{vID}"
|
||||||
|
|
||||||
|
# this is an internal endpoint to validate the video ID
|
||||||
|
params = HTTP::Params.encode({"format" => "json", "url" => url})
|
||||||
|
response = HTTP::Client.get "https://www.youtube.com/oembed?#{params}"
|
||||||
|
return nil unless response.success?
|
||||||
|
|
||||||
|
res_json = JSON.parse(response.body)
|
||||||
|
title = res_json["title"].as_s
|
||||||
|
puts Style.dim(" Video: ") + title
|
||||||
|
|
||||||
|
return url
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue