Full Code of cooperhammond/irs for AI

master c99e8257e928 cached
26 files
67.0 KB
18.8k tokens
1 requests
Download .txt
Repository: cooperhammond/irs
Branch: master
Commit: c99e8257e928
Files: 26
Total size: 67.0 KB

Directory structure:
gitextract_npef2uk3/

├── .editorconfig
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── shard.yml
├── spec/
│   ├── irs_spec.cr
│   └── spec_helper.cr
└── src/
    ├── bottle/
    │   ├── cli.cr
    │   ├── config.cr
    │   ├── pattern.cr
    │   ├── styles.cr
    │   └── version.cr
    ├── glue/
    │   ├── album.cr
    │   ├── list.cr
    │   ├── mapper.cr
    │   ├── playlist.cr
    │   └── song.cr
    ├── interact/
    │   ├── future.cr
    │   ├── logger.cr
    │   ├── ripper.cr
    │   └── tagger.cr
    ├── irs.cr
    └── search/
        ├── ranking.cr
        ├── spotify.cr
        └── youtube.cr

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
root = true

[*.cr]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true


================================================
FILE: .gitignore
================================================
/docs/
/lib/
/bin/
/logs/
/.shards/
/Music/
*.dwarf

*.mp3
*.webm*
.ripper.log
ffmpeg
ffprobe
youtube-dl
*.temp

================================================
FILE: .travis.yml
================================================
language: crystal

# Uncomment the following if you'd like Travis to run specs and check code formatting
# script:
#   - crystal spec
#   - crystal tool format --check


================================================
FILE: LICENSE
================================================
The MIT License (MIT)

Copyright (c) 2020 Cooper Hammond

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.


================================================
FILE: README.md
================================================
# irs: The Ironic Repositioning System

[![made-with-crystal](https://img.shields.io/badge/Made%20with-Crystal-1f425f.svg?style=flat-square)](https://crystal-lang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](https://github.com/cooperhammond/irs/blob/master/LICENSE)
[![Say Thanks](https://img.shields.io/badge/say-thanks-ff69b4.svg?style=flat-square)](https://saythanks.io/to/kepoorh%40gmail.com)

> A music scraper that understands your metadata needs.

`irs` is a command-line application that downloads audio and metadata in order
to package an mp3 with both. Extensible, the user can download individual 
songs, entire albums, or playlists from Spotify.

<p align="center">
    <img src="https://i.imgur.com/7QTM6rD.png" height="400" title="#1F816D" />
</p>
<p align="center"

[![forthebadge](https://forthebadge.com/images/badges/compatibility-betamax.svg)](https://forthebadge.com)
[![forthebadge](https://forthebadge.com/images/badges/ages-18.svg)](https://forthebadge.com)
[![forthebadge](https://forthebadge.com/images/badges/built-by-codebabes.svg)](https://forthebadge.com)
</p>

---

## Table of Contents

- [Usage](#usage)
    - [Demo](#demo)
- [Installation](#installation)
    - [Pre-built](#pre-built)
    - [From source](#from-source)
    - [Set up](#setup)
- [Config](#config)
- [How it works](#how-it-works)
- [Contributing](#contributing)


## Usage

```
~ $ irs -h

Usage: irs [--help] [--version] [--install]
           [-s <song> -a <artist>]
           [-A <album> -a <artist>]
           [-p <playlist> -a <username>]

Arguments:
    -h, --help                  Show this help message and exit
    -v, --version               Show the program version and exit
    -i, --install               Download binaries to config location
    -c, --config                Show config file location
    -a, --artist <artist>       Specify artist name for downloading
    -s, --song <song>           Specify song name to download
    -A, --album <album>         Specify the album name to download
    -p, --playlist <playlist>   Specify the playlist name to download
    -u, --url <url>             Specify the youtube url to download from (for single songs only)
    -g, --give-url              Specify the youtube url sources while downloading (for albums or playlists only only)

Examples:
    $ irs --song "Bohemian Rhapsody" --artist "Queen"
    # => downloads the song "Bohemian Rhapsody" by "Queen"
    $ irs --album "Demon Days" --artist "Gorillaz"
    # => downloads the album "Demon Days" by "Gorillaz"
    $ irs --playlist "a different drummer" --artist "prakkillian"
    # => downloads the playlist "a different drummer" by the user prakkillian
```

### Demo

[![asciicast](https://asciinema.org/a/332793.svg)](https://asciinema.org/a/332793)

## Installation

### Pre-built 

Just download the latest release for your platform 
[here](https://github.com/cooperhammond/irs/releases).

Note that the binaries right now have only been tested on WSL. They *should* run on most linux distros, and OS X, but if they don't please make an issue above.

### From Source

If you're one of those cool people who compiles from source

1. Install crystal-lang 
    ([`https://crystal-lang.org/install/`](https://crystal-lang.org/install/))
1. Clone it (`git clone https://github.com/cooperhammond/irs`)
1. CD it (`cd irs`)
1. Build it (`shards build`)

### Setup

1. Create a `.yaml` config file somewhere on your system (usually `~/.irs/`)
1. Copy the following into it
    ```yaml
    binary_directory: ~/.irs/bin
    music_directory: ~/Music
    filename_pattern: "{track_number} - {title}"
    directory_pattern: "{artist}/{album}"
    client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    single_folder_playlist:
        enabled: true
        retain_playlist_order: true
        unify_into_album: false
    ```
1. Set the environment variable `IRS_CONFIG_LOCATION` pointing to that file
1. Go to [`https://developer.spotify.com/dashboard/`](https://developer.spotify.com/dashboard/)
1. Log in or create an account
1. Click `CREATE A CLIENT ID`
1. Enter all necessary info, true or false, continue
1. Find your client key and client secret
1. Copy each respectively into the X's in your config file
1. Run `irs --install` and answer the prompts!

You should be good to go! Run the file from your command line to get more help on
usage or keep reading!

# Config

You may have noticed that there's a config file with more than a few options. 
Here's what they do:
```yaml
binary_directory: ~/.irs/bin
music_directory: ~/Music
search_terms: "lyrics"
filename_pattern: "{track_number} - {title}"
directory_pattern: "{artist}/{album}"
client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
single_folder_playlist:
    enabled: true
    retain_playlist_order: true
    unify_into_album: false
```
 - `binary_directory`: a path specifying where the downloaded binaries should
    be placed
 - `music_directory`: a path specifying where downloaded mp3s should be placed.
 - `search_terms`: additional search terms to plug into youtube, which can be
    potentially useful for not grabbing erroneous audio.
 - `filename_pattern`: a pattern for the output filename of the mp3
 - `directory_pattern`: a pattern for the folder structure your mp3s are saved in
 - `client_key`: a client key from your spotify API application
 - `client_secret`: a client secret key from your spotify API application
 - `single_folder_playlist/enabled`: if set to true, all mp3s from a downloaded
    playlist will be placed in the same folder.
 - `single_folder_playlist/retain_playlist_order`: if set to true, the track 
    numbers of the mp3s of the playlist will be overwritten to correspond to
    their place in the playlist
 - `single_folder_playlist/unify_into_album`: if set to true, will overwrite
    the album name and album image of the mp3 with the title of your playlist
    and the image for your playlist respectively


In a pattern following keywords will be replaced:

| Keyword | Replacement | Example |
| :----: | :----: | :----: |
| `{artist}` | Artist Name | Queen |
| `{title}` | Track Title | Bohemian Rhapsody |
| `{album}` | Album Name | Stone Cold Classics |
| `{track_number}` | Track Number | 9 |
| `{total_tracks}` | Total Tracks in Album | 14 |
| `{disc_number}` | Disc Number | 1 |
| `{day}` | Release Day | 01 |
| `{month}` | Release Month | 01 |
| `{year}` | Release Year | 2006 |
| `{id}` | Spotify ID | 6l8GvAyoUZwWDgF1e4822w |

Beware OS-restrictions when naming your mp3s.

Pattern Examples:
```yaml
music_directory: ~/Music
filename_pattern: "{track_number} - {title}"
directory_pattern: "{artist}/{album}"
```
Outputs: `~/Music/Queen/Stone Cold Classics/9 - Bohemian Rhapsody.mp3`
<br><br>
```yaml
music_directory: ~/Music
filename_pattern: "{artist} - {title}"
directory_pattern: ""
```
Outputs: `~/Music/Queen - Bohemian Rhapsody.mp3`
<br><br>
```yaml
music_directory: ~/Music
filename_pattern: "{track_number} of {total_tracks} - {title}"
directory_pattern: "{year}/{artist}/{album}"
```
Outputs: `~/Music/2006/Queen/Stone Cold Classics/9 of 14 - Bohemian Rhapsody.mp3`
<br><br>
```yaml
music_directory: ~/Music
filename_pattern: "{track_number}. {title}"
directory_pattern: "irs/{artist} - {album}"
```
Outputs: `~/Music/irs/Queen - Stone Cold Classics/9. Bohemian Rhapsody.mp3`
<br>


## How it works

**At it's core** `irs` downloads individual songs. It does this by interfacing
with the Spotify API, grabbing metadata, and then searching Youtube for a video
containing the song's audio. It will download the video using 
[`youtube-dl`](https://github.com/ytdl-org/youtube-dl), extract the audio using
[`ffmpeg`](https://ffmpeg.org/), and then pack the audio and metadata together
into an MP3.

From the core, it has been extended to download the index of albums and 
playlists through the spotify API, and then iteratively use the method above
for downloading each song.

It used to be in python, but
1. I wasn't a fan of python's limited ability to distribute standalone binaries
1. It was a charlie foxtrot of code that I made when I was little and I wanted
    to refine it
1. `crystal-lang` made some promises and I was interested in seeing how well it
    did (verdict: if you're building high-level tools you want to run quickly 
    and distribute, it's perfect)


## Contributing

Any and all contributions are welcome. If you think of a cool feature, send a 
PR or shoot me an [email](mailto:kepoorh@gmail.com). If you think something 
could be implemented better, _please_ shoot me an email. If you like what I'm
doing here, _pretty please_ shoot me an email.

1. Fork it (<https://github.com/your-github-user/irs/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request


================================================
FILE: shard.yml
================================================
name: irs
version: 1.4.0

authors:
  - Cooper Hammond <kepoorh@gmail.com>

targets:
  irs:
    main: src/irs.cr

license: MIT

dependencies:
  ydl_binaries:
    github: cooperhammond/ydl-binaries
  json_mapping:
    github: crystal-lang/json_mapping.cr


================================================
FILE: spec/irs_spec.cr
================================================
require "./spec_helper"

describe CLI do
  # TODO: Write tests

  it "can show help" do
    run_CLI_with_args(["--help"])
  end

  it "can show version" do
    run_CLI_with_args(["--version"])
  end

  # !!TODO: make a long and short version of the test suite
  # TODO: makes so this doesn't need user input
  it "can install ytdl and ffmpeg binaries" do
    # run_CLI_with_args(["--install"])
  end

  it "can show config file loc" do
    run_CLI_with_args(["--config"])
  end

  it "can download a single song" do
    run_CLI_with_args(["--song", "Bohemian Rhapsody", "--artist", "Queen"])
  end

  it "can download an album" do
    run_CLI_with_args(["--artist", "Arctic Monkeys", "--album", "Da Frame 2R / Matador"])
  end

  it "can download a playlist" do
    run_CLI_with_args(["--artist", "prakkillian", "--playlist", "IRS Testing"])
  end
end


================================================
FILE: spec/spec_helper.cr
================================================
require "spec"

# https://github.com/mosop/stdio

require "../src/bottle/cli"

def run_CLI_with_args(argv : Array(String))
    cli = CLI.new(argv)
    cli.act_on_args
end

================================================
FILE: src/bottle/cli.cr
================================================
require "ydl_binaries"

require "./config"
require "./styles"
require "./version"

require "../glue/song"
require "../glue/album"
require "../glue/playlist"

class CLI
  # layout:
  # [[shortflag, longflag], key, type]
  @options = [
    [["-h", "--help"], "help", "bool"],
    [["-v", "--version"], "version", "bool"],
    [["-i", "--install"], "install", "bool"],
    [["-c", "--config"], "config", "bool"],
    [["-a", "--artist"], "artist", "string"],
    [["-s", "--song"], "song", "string"],
    [["-A", "--album"], "album", "string"],
    [["-p", "--playlist"], "playlist", "string"],
    [["-u", "--url"], "url", "string"],
    [["-S", "--select"], "select", "bool"],
    [["--ask-skip"], "ask_skip", "bool"],
    [["--apply"], "apply_file", "string"]
  ]

  @args : Hash(String, String)

  def initialize(argv : Array(String))
    @args = parse_args(argv)
  end

  def version
    puts "irs v#{IRS::VERSION}"
  end

  def help
    msg = <<-EOP
    #{Style.bold "Usage: irs [--help] [--version] [--install]"}
    #{Style.bold "           [-s <song> -a <artist>]"}
    #{Style.bold "           [-A <album> -a <artist>]"}
    #{Style.bold "           [-p <playlist> -a <username>]"}

    #{Style.bold "Arguments:"}
        #{Style.blue "-h, --help"}                  Show this help message and exit
        #{Style.blue "-v, --version"}               Show the program version and exit
        #{Style.blue "-i, --install"}               Download binaries to config location
        #{Style.blue "-c, --config"}                Show config file location
        #{Style.blue "-a, --artist <artist>"}       Specify artist name for downloading
        #{Style.blue "-s, --song <song>"}           Specify song name to download
        #{Style.blue "-A, --album <album>"}         Specify the album name to download
        #{Style.blue "-p, --playlist <playlist>"}   Specify the playlist name to download
        #{Style.blue "-u, --url <url>"}             Specify the youtube url to download from
        #{Style.blue "                 "}           (for albums and playlists, the command-line
        #{Style.blue "                 "}           argument is ignored, and it should be '')
        #{Style.blue "-S, --select"}                Use a menu to choose each song's video source
        #{Style.blue "--ask-skip"}                  Before every playlist/album song, ask to skip
        #{Style.blue "--apply <file>"}              Apply metadata to a existing file

    #{Style.bold "Examples:"}
        $ #{Style.green %(irs --song "Bohemian Rhapsody" --artist "Queen")}
        #{Style.dim %(# => downloads the song "Bohemian Rhapsody" by "Queen")}
        $ #{Style.green %(irs --album "Demon Days" --artist "Gorillaz")}
        #{Style.dim %(# => downloads the album "Demon Days" by "Gorillaz")}
        $ #{Style.green %(irs --playlist "a different drummer" --artist "prakkillian")}
        #{Style.dim %(# => downloads the playlist "a different drummer" by the user prakkillian)}

    #{Style.bold "This project is licensed under the MIT license."}
    #{Style.bold "Project page: <https://github.com/cooperhammond/irs>"}
    EOP

    puts msg
  end

  def act_on_args
    Config.check_necessities

    if @args["help"]? || @args.keys.size == 0
      help

    elsif @args["version"]?
      version

    elsif @args["install"]?
      YdlBinaries.get_both(Config.binary_location)

    elsif @args["config"]?
      puts ENV["IRS_CONFIG_LOCATION"]?

    elsif @args["song"]? && @args["artist"]?
      s = Song.new(@args["song"], @args["artist"])
      s.provide_client_keys(Config.client_key, Config.client_secret)
      s.grab_it(flags: @args)
      s.organize_it()

    elsif @args["album"]? && @args["artist"]?
      a = Album.new(@args["album"], @args["artist"])
      a.provide_client_keys(Config.client_key, Config.client_secret)
      a.grab_it(flags: @args)

    elsif @args["playlist"]? && @args["artist"]?
      p = Playlist.new(@args["playlist"], @args["artist"])
      p.provide_client_keys(Config.client_key, Config.client_secret)
      p.grab_it(flags: @args)

    else
      puts Style.red("Those arguments don't do anything when used that way.")
      puts "Type `irs -h` to see usage."
    end
  end

  private def parse_args(argv : Array(String)) : Hash(String, String)
    arguments = {} of String => String

    i = 0
    current_key = ""
    pass_next_arg = false
    argv.each do |arg|
      # If the previous arg was an arg flag, this is an arg, so pass it
      if pass_next_arg
        pass_next_arg = false
        i += 1
        next
      end

      flag = [] of Array(String) | String
      valid_flag = false

      @options.each do |option|
        if option[0].includes?(arg)
          flag = option
          valid_flag = true
          break
        end
      end

      # ensure the flag is actually defined
      if !valid_flag
        arg_error argv, i, %("#{arg}" is an invalid flag or argument.)
      end

      # ensure there's an argument if the program needs one
      if flag[2] == "string" && i + 1 >= argv.size
        arg_error argv, i, %("#{arg}" needs an argument.)
      end

      key = flag[1].as(String)
      if flag[2] == "string"
        arguments[key] = argv[i + 1]
        pass_next_arg = true
      elsif flag[2] == "bool"
        arguments[key] = "true"
      end

      i += 1
    end

    return arguments
  end

  private def arg_error(argv : Array(String), arg : Int32, msg : String) : Nil
    precursor = "irs"

    precursor += " " if arg != 0

    if arg == 0
      start = [] of String
    else
      start = argv[..arg - 1]
    end
    last = argv[arg + 1..]

    distance = (precursor + start.join(" ")).size

    print Style.dim(precursor + start.join(" "))
    print Style.bold(Style.red(" " + argv[arg]).to_s)
    puts Style.dim (" " + last.join(" "))

    (0..distance).each do |i|
      print " "
    end
    puts "^"

    puts Style.red(Style.bold(msg).to_s)
    puts "Type `irs -h` to see usage."
    exit 1
  end
end


================================================
FILE: src/bottle/config.cr
================================================
require "yaml"

require "./styles"

require "../search/spotify"

EXAMPLE_CONFIG = <<-EOP
#{Style.dim "exampleconfig.yml"}
#{Style.dim "===="}
#{Style.blue "search_terms"}: #{Style.green "\"lyrics\""}
#{Style.blue "binary_directory"}: #{Style.green "~/.irs/bin"}
#{Style.blue "music_directory"}: #{Style.green "~/Music"}
#{Style.blue "filename_pattern"}: #{Style.green "\"{track_number} - {title}\""}
#{Style.blue "directory_pattern"}: #{Style.green "\"{artist}/{album}\""}
#{Style.blue "client_key"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
#{Style.blue "client_secret"}: #{Style.green "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}
#{Style.blue "single_folder_playlist"}: 
  #{Style.blue "enabled"}: #{Style.green "true"}
  #{Style.blue "retain_playlist_order"}: #{Style.green "true"}
  #{Style.blue "unify_into_album"}: #{Style.green "false"}
#{Style.dim "===="}
EOP

module Config
  extend self

  @@arguments = [
    "search_terms",
    "binary_directory",
    "music_directory",
    "filename_pattern",
    "directory_pattern",
    "client_key",
    "client_secret",
    "single_folder_playlist: enabled",
    "single_folder_playlist: retain_playlist_order",
    "single_folder_playlist: unify_into_album",
  ]

  @@conf = YAML.parse("")
  begin
    @@conf = YAML.parse(File.read(ENV["IRS_CONFIG_LOCATION"]))
  rescue
    puts Style.red "Before anything else, define the environment variable IRS_CONFIG_LOCATION pointing to a .yml file like this one."
    puts EXAMPLE_CONFIG
    puts Style.bold "See https://github.com/cooperhammond/irs for more information on the config file"
    exit 1
  end

  def search_terms : String
    return @@conf["search_terms"].to_s
  end

  def binary_location : String
    path = @@conf["binary_directory"].to_s
    return Path[path].expand(home: true).to_s
  end

  def music_directory : String
    path = @@conf["music_directory"].to_s
    return Path[path].expand(home: true).to_s
  end
  
  def filename_pattern : String
    return @@conf["filename_pattern"].to_s
  end
  
  def directory_pattern : String
    return @@conf["directory_pattern"].to_s
  end

  def client_key : String
    return @@conf["client_key"].to_s
  end

  def client_secret : String
    return @@conf["client_secret"].to_s
  end

  def single_folder_playlist? : Bool
    return @@conf["single_folder_playlist"]["enabled"].as_bool
  end

  def retain_playlist_order? : Bool
    return @@conf["single_folder_playlist"]["retain_playlist_order"].as_bool
  end

  def unify_into_album? : Bool
    return @@conf["single_folder_playlist"]["unify_into_album"].as_bool
  end

  def check_necessities
    missing_configs = [] of String
    @@arguments.each do |argument|
      if !check_conf(argument)
        missing_configs.push(argument)
      end
    end
    if missing_configs.size > 0
      puts Style.red("You are missing the following key(s) in your YAML config file:")
      missing_configs.each do |config|
        puts "  " + config
      end
      puts "\nHere's an example of what your config should look like:"
      puts EXAMPLE_CONFIG
      puts Style.bold "See https://github.com/cooperhammond/irs for more information on the config file"
      exit 1
    end
    spotify = SpotifySearcher.new
    spotify.authorize(self.client_key, self.client_secret)
    if !spotify.authorized?
      puts Style.red("There's something wrong with your client key and/or client secret")
      puts "Get your keys from https://developer.spotify.com/dashboard, and enter them in your config file"
      puts "Your config file is at #{ENV["IRS_CONFIG_LOCATION"]}"
      puts EXAMPLE_CONFIG
      puts Style.bold "See https://github.com/cooperhammond/irs for more information on the config file"
      exit 1
    end
  end

  private def check_conf(key : String) : YAML::Any?
    if key.includes?(": ")
      args = key.split(": ")
      if @@conf[args[0]]?
        return @@conf[args[0]][args[1]]?
      else
        return @@conf[args[0]]?
      end
    else
      return @@conf[key]?
    end
  end
end


================================================
FILE: src/bottle/pattern.cr
================================================
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


================================================
FILE: src/bottle/styles.cr
================================================
require "colorize"

class Style
  def self.bold(txt)
    txt.colorize.mode(:bold).to_s
  end

  def self.dim(txt)
    txt.colorize.mode(:dim).to_s
  end

  def self.blue(txt)
    txt.colorize(:light_blue).to_s
  end

  def self.green(txt)
    txt.colorize(:light_green).to_s
  end

  def self.red(txt)
    txt.colorize(:light_red).to_s
  end
end


================================================
FILE: src/bottle/version.cr
================================================
module IRS
  VERSION = "1.4.0"
end


================================================
FILE: src/glue/album.cr
================================================
require "../bottle/config"

require "./mapper"
require "./song"
require "./list"

class Album < SpotifyList
  @home_music_directory = Config.music_directory

  # Uses the `spotify_searcher` defined in parent `SpotifyList` to find the
  # correct metadata of the list
  def find_it : JSON::Any
    album = @spotify_searcher.find_item("album", {
      "name"   => @list_name.as(String),
      "artist" => @list_author.as(String),
    })
    if album
      return album.as(JSON::Any)
    else
      puts "No album was found by that name and artist."
      exit 1
    end
  end

  # Will define specific metadata that may not be included in the raw return
  # of spotify's album json. Moves the title of the album and the album art
  # to the json of the single song
  def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any
    album_metadata = parse_to_json(%(
      {
        "name": "#{list["name"]}",
        "images": [{"url": "#{list["images"][0]["url"]}"}]
      }
    ))

    prepped_data = TrackMapper.from_json(datum.to_json)
    prepped_data.album = album_metadata

    data = parse_to_json(prepped_data.to_json)

    return data
  end

  private def organize(song : Song)
    song.organize_it()
  end
end


================================================
FILE: src/glue/list.cr
================================================
require "json"

require "../search/spotify"
require "../search/youtube"

require "../interact/ripper"
require "../interact/tagger"

require "./song"

# A parent class for downloading albums and playlists from spotify
abstract class SpotifyList
  @spotify_searcher = SpotifySearcher.new
  @file_names = [] of String

  @outputs : Hash(String, Array(String)) = {
    "searching" => [
      Style.bold("Searching for %l by %a ... \r"),
      Style.green("+ ") + Style.bold("%l by %a                                 \n")
    ],
    "url" => [
      Style.bold("When prompted for a URL, provide a youtube URL or press enter to scrape for one\n")
    ]
  }

  def initialize(@list_name : String, @list_author : String?)
  end

  # Finds the list, and downloads all of the songs using the `Song` class
  def grab_it(flags = {} of String => String)
    ask_url = flags["url"]?
    ask_skip = flags["ask_skip"]?
    is_playlist = flags["playlist"]?
  
    if !@spotify_searcher.authorized?
      raise("Need to call provide_client_keys on Album or Playlist class.")
    end

    if ask_url
      outputter("url", 0)
    end

    outputter("searching", 0)
    list = find_it()
    outputter("searching", 1)
    contents = list["tracks"]["items"].as_a

    i = 0
    contents.each do |datum|
      i += 1
      if datum["track"]?
        datum = datum["track"]
      end

      data = organize_song_metadata(list, datum)

      s_name = data["name"].to_s
      s_artist = data["artists"][0]["name"].to_s

      song = Song.new(s_name, s_artist)
      song.provide_spotify(@spotify_searcher)
      song.provide_metadata(data)

      puts Style.bold("[#{i}/#{contents.size}]")

      unless ask_skip && skip?(s_name, s_artist, is_playlist)
        song.grab_it(flags: flags)
        organize(song)
      else
        puts "Skipping..."
      end
    end
  end

  # Will authorize the class associated `SpotifySearcher`
  def provide_client_keys(client_key : String, client_secret : String)
    @spotify_searcher.authorize(client_key, client_secret)
  end

  private def skip?(name, artist, is_playlist)
    print "Skip #{Style.blue name}" +
      (is_playlist ? " (by #{Style.green artist})": "") + "? "
    response = gets
    return response && response.lstrip.downcase.starts_with? "y"
  end

  private def outputter(key : String, index : Int32)
    text = @outputs[key][index]
      .gsub("%l", @list_name)
      .gsub("%a", @list_author)
    print text
  end

  # Defined in subclasses, will return the appropriate information or call an
  # error if the info is not found and exit
  abstract def find_it : JSON::Any

  # If there's a need to organize the individual song data so that the `Song`
  # class can better handle it, this function will be defined in the subclass
  private abstract def organize_song_metadata(list : JSON::Any,
                                              datum : JSON::Any) : JSON::Any

  # Will define the specific type of organization for a list of songs.
  # Needed because most people want albums sorted by artist, but playlists all
  # in one folder
  private abstract def organize(song : Song)
end


================================================
FILE: src/glue/mapper.cr
================================================
require "json"
require "json_mapping"

class PlaylistExtensionMapper
  JSON.mapping(
    tracks: {
      type:   PlaylistTracksMapper,
      setter: true,
    },
    id: String,
    images: JSON::Any,
    name: String,
    owner: JSON::Any,
    type: String
  )
end

class PlaylistTracksMapper
  JSON.mapping(
    items: {
      type:   Array(JSON::Any),
      setter: true,
    },
    total: Int32
  )
end

class TrackMapper
  JSON.mapping(
    album: {
      type:    JSON::Any,
      nilable: true,
      setter:  true,
    },
    artists: {
      type: Array(JSON::Any),
      setter: true  
    },
    disc_number: {
      type: Int32,
      setter: true
    },
    id: String,
    name: String,
    track_number: {
      type: Int32,
      setter: true
    },
    duration_ms: Int32,
    type: String,
    uri: String
  )
end

def parse_to_json(string_json : String) : JSON::Any
  return JSON.parse(string_json)
end


================================================
FILE: src/glue/playlist.cr
================================================
require "json"

require "../bottle/config"

require "./song"
require "./list"
require "./mapper"

class Playlist < SpotifyList
  @song_index = 1
  @home_music_directory = Config.music_directory
  @playlist : JSON::Any?

  # Uses the `spotify_searcher` defined in parent `SpotifyList` to find the
  # correct metadata of the list
  def find_it : JSON::Any
    @playlist = @spotify_searcher.find_item("playlist", {
      "name"     => @list_name.as(String),
      "username" => @list_author.as(String),
    })
    if @playlist
      return @playlist.as(JSON::Any)
    else
      puts "No playlists were found by that name and user."
      exit 1
    end
  end

  # Will define specific metadata that may not be included in the raw return
  # of spotify's album json. Moves the title of the album and the album art
  # to the json of the single song
  def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any
    data = datum

    if Config.retain_playlist_order?
      track = TrackMapper.from_json(data.to_json)
      track.track_number = @song_index
      track.disc_number = 1
      data = JSON.parse(track.to_json)
    end

    if Config.unify_into_album?
      track = TrackMapper.from_json(data.to_json)
      track.album = JSON.parse(%({
        "name": "#{list["name"]}",
        "images": [{"url": "#{list["images"][0]["url"]}"}]
      }))
      track.artists.push(JSON.parse(%({
        "name": "#{list["owner"]["display_name"]}",
        "owner": true
      })))
      data = JSON.parse(track.to_json)
    end

    @song_index += 1

    return data
  end

  private def organize(song : Song)
    if Config.single_folder_playlist?
      path = Path[@home_music_directory].expand(home: true)
      path = path / @playlist.as(JSON::Any)["name"].to_s
        .gsub(/[\/]/, "").gsub("  ", " ")
      strpath = path.to_s
      if !File.directory?(strpath)
        FileUtils.mkdir_p(strpath)
      end
      safe_filename = song.filename.gsub(/[\/]/, "").gsub("  ", " ")
      FileUtils.cp("./" + song.filename, (path / safe_filename).to_s)
      FileUtils.rm("./" + song.filename)
    else
      song.organize_it()
    end
  end
end


================================================
FILE: src/glue/song.cr
================================================
require "../search/spotify"
require "../search/youtube"

require "../interact/ripper"
require "../interact/tagger"

require "../bottle/config"
require "../bottle/pattern"
require "../bottle/styles"

class Song
  @spotify_searcher = SpotifySearcher.new
  @client_id = ""
  @client_secret = ""

  @metadata : JSON::Any?
  getter filename = ""
  @artist = ""
  @album = ""

  @outputs : Hash(String, Array(String)) = {
    "intro" => [Style.bold("[%s by %a]\n")],
    "metadata" => [
      "  Searching for metadata ...\r",
      Style.green("  + ") + Style.dim("Metadata found                  \n")
    ],
    "url" => [
      "  Searching for URL ...\r",
      Style.green("  + ") + Style.dim("URL found                       \n"),
      "  Validating URL ...\r",
      Style.green("  + ") + Style.dim("URL validated                   \n"),
      "  URL?: "
    ],
    "download" => [
      "  Downloading video:\n",
      Style.green("\r  + ") + Style.dim("Converted to mp3              \n")
    ],
    "albumart" => [
      "  Downloading album art ...\r",
      Style.green("  + ") + Style.dim("Album art downloaded            \n")
    ],
    "tagging" => [
      "  Attaching metadata ...\r",
      Style.green("  + ") + Style.dim("Metadata attached               \n")
    ],
    "finished" => [
      Style.green("  + ") + "Finished!\n"
    ]
  }

  def initialize(@song_name : String, @artist_name : String)
  end

  # Find, downloads, and tags the mp3 song that this class represents.
  # Optionally takes a youtube URL to download from
  #
  # ```
  # Song.new("Bohemian Rhapsody", "Queen").grab_it
  # ```
  def grab_it(url : (String | Nil) = nil, flags = {} of String => String)
    passed_url : (String | Nil) = flags["url"]?
    passed_file : (String | Nil) = flags["apply_file"]?
    select_link = flags["select"]?

    outputter("intro", 0)

    if !@spotify_searcher.authorized? && !@metadata
      if @client_id != "" && @client_secret != ""
        @spotify_searcher.authorize(@client_id, @client_secret)
      else
        raise("Need to call either `provide_metadata`, `provide_spotify`, " +
              "or `provide_client_keys` so that Spotify can be interfaced with.")
      end
    end

    if !@metadata
      outputter("metadata", 0)
      @metadata = @spotify_searcher.find_item("track", {
        "name"   => @song_name,
        "artist" => @artist_name,
      })

      if !@metadata
        raise("There was no metadata found on Spotify for " +
              %("#{@song_name}" by "#{@artist_name}". ) +
              "Check your input and try again.")
      end
      outputter("metadata", 1)
    end

    data = @metadata.as(JSON::Any)
    @song_name = data["name"].as_s
    @artist_name = data["artists"][0]["name"].as_s
    @filename = "#{Pattern.parse(Config.filename_pattern, data)}.mp3"

    if passed_file
      puts Style.green("  +") + Style.dim(" Moving file: ") + passed_file
      File.rename(passed_file, @filename)
    else
      if passed_url
        if passed_url.strip != ""
          url = passed_url
        else
          outputter("url", 4)
          url = gets
          if !url.nil? && url.strip == ""
            url = nil
          end
        end
      end

      if !url
        outputter("url", 0)
        url = Youtube.find_url(data, flags: flags)
        if !url
          raise("There was no url found on youtube for " +
                %("#{@song_name}" by "#{@artist_name}. ) +
                "Check your input and try again.")
        end
        outputter("url", 1)
      else
        outputter("url", 2)
        url = Youtube.validate_url(url)
        if !url
          raise("The url is an invalid youtube URL " +
                "Check the URL and try again")
        end
        outputter("url", 3)
      end

      outputter("download", 0)
      Ripper.download_mp3(url.as(String), @filename)
      outputter("download", 1)
    end

    outputter("albumart", 0)
    temp_albumart_filename = ".tempalbumart.jpg"
    HTTP::Client.get(data["album"]["images"][0]["url"].as_s) do |response|
      File.write(temp_albumart_filename, response.body_io)
    end
    outputter("albumart", 0)

    # check if song's metadata has been modded in playlist, update artist accordingly
    if data["artists"][-1]["owner"]?
      @artist = data["artists"][-1]["name"].as_s
    else
      @artist = data["artists"][0]["name"].as_s
    end
    @album = data["album"]["name"].as_s

    tagger = Tags.new(@filename)
    tagger.add_album_art(temp_albumart_filename)
    tagger.add_text_tag("title", data["name"].as_s)
    tagger.add_text_tag("artist", @artist)

    if !@album.empty?
      tagger.add_text_tag("album", @album)
    end

    if genre = @spotify_searcher.find_genre(data["artists"][0]["id"].as_s)
      tagger.add_text_tag("genre", genre)
    end

    tagger.add_text_tag("track", data["track_number"].to_s)
    tagger.add_text_tag("disc", data["disc_number"].to_s)

    outputter("tagging", 0)
    tagger.save
    File.delete(temp_albumart_filename)
    outputter("tagging", 1)

    outputter("finished", 0)
  end

  # Will organize the song into the user's provided music directory
  # in the user's provided structure
  # Must be called AFTER the song has been downloaded.
  #
  # ```
  # s = Song.new("Bohemian Rhapsody", "Queen").grab_it
  # s.organize_it()
  # # With
  # # directory_pattern = "{artist}/{album}"
  # # filename_pattern = "{track_number} - {title}"
  # # Mp3 will be moved to
  # # /home/cooper/Music/Queen/A Night At The Opera/1 - Bohemian Rhapsody.mp3
  # ```
  def organize_it()
    path = Path[Config.music_directory].expand(home: true)
    Pattern.parse(Config.directory_pattern, @metadata.as(JSON::Any)).split('/').each do |dir|
      path = path / dir.gsub(/[\/]/, "").gsub("  ", " ")
    end
    strpath = path.to_s
    if !File.directory?(strpath)
      FileUtils.mkdir_p(strpath)
    end
    safe_filename = @filename.gsub(/[\/]/, "").gsub("  ", " ")
    FileUtils.cp("./" + @filename, (path / safe_filename).to_s)
    FileUtils.rm("./" + @filename)
  end

  # Provide metadata so that it doesn't have to find it. Useful for overwriting
  # metadata. Must be called if provide_client_keys and provide_spotify are not
  # called.
  #
  # ```
  # Song.new(...).provide_metadata(...).grab_it
  # ```
  def provide_metadata(metadata : JSON::Any) : self
    @metadata = metadata
    return self
  end

  # Provide an already authenticated `SpotifySearcher` class. Useful to avoid
  # authenticating over and over again. Must be called if provide_metadata and
  # provide_client_keys are not called.
  #
  # ```
  # Song.new(...).provide_spotify(SpotifySearcher.new
  #   .authenticate("XXXXXXXXXX", "XXXXXXXXXXX")).grab_it
  # ```
  def provide_spotify(spotify : SpotifySearcher) : self
    @spotify_searcher = spotify
    return self
  end

  # Provide spotify client keys. Must be called if provide_metadata and
  # provide_spotify are not called.
  #
  # ```
  # Song.new(...).provide_client_keys("XXXXXXXXXX", "XXXXXXXXX").grab_it
  # ```
  def provide_client_keys(client_id : String, client_secret : String) : self
    @client_id = client_id
    @client_secret = client_secret
    return self
  end

  private def outputter(key : String, index : Int32)
    text = @outputs[key][index]
      .gsub("%s", @song_name)
      .gsub("%a", @artist_name)
    print text
  end
end


================================================
FILE: src/interact/future.cr
================================================
# copy and pasted from crystal 0.33.1
# https://github.com/crystal-lang/crystal/blob/18e76172444c7bd07f58bf360bc21981b667668d/src/concurrent/future.cr#L138


# :nodoc:
class Concurrent::Future(R)
  enum State
    Idle
    Delayed
    Running
    Completed
    Canceled
  end

  @value : R?
  @error : Exception?
  @delay : Float64

  def initialize(run_immediately = true, delay = 0.0, &@block : -> R)
    @state = State::Idle
    @value = nil
    @error = nil
    @channel = Channel(Nil).new
    @delay = delay.to_f
    @cancel_msg = nil

    spawn_compute if run_immediately
  end

  def get
    wait
    value_or_raise
  end

  def success?
    completed? && !@error
  end

  def failure?
    completed? && @error
  end

  def canceled?
    @state == State::Canceled
  end

  def completed?
    @state == State::Completed
  end

  def running?
    @state == State::Running
  end

  def delayed?
    @state == State::Delayed
  end

  def idle?
    @state == State::Idle
  end

  def cancel(msg = "Future canceled, you reached the [End of Time]")
    return if @state >= State::Completed
    @state = State::Canceled
    @cancel_msg = msg
    @channel.close
    nil
  end

  private def compute
    return if @state >= State::Delayed
    run_compute
  end

  private def spawn_compute
    return if @state >= State::Delayed

    @state = @delay > 0 ? State::Delayed : State::Running

    spawn { run_compute }
  end

  private def run_compute
    delay = @delay

    if delay > 0
      sleep delay
      return if @state >= State::Canceled
      @state = State::Running
    end

    begin
      @value = @block.call
    rescue ex
      @error = ex
    ensure
      @channel.close
      @state = State::Completed
    end
  end

  private def wait
    return if @state >= State::Completed
    compute
    @channel.receive?
  end

  private def value_or_raise
    raise Exception.new(@cancel_msg) if @state == State::Canceled

    value = @value
    if value.is_a?(R)
      value
    elsif error = @error
      raise error
    else
      raise "compiler bug"
    end
  end
end

# Spawns a `Fiber` to compute *&block* in the background after *delay* has elapsed.
# Access to get is synchronized between fibers.  *&block* is only called once.
# May be canceled before *&block* is called by calling `cancel`.
# ```
# d = delay(1) { Process.kill(Process.pid) }
# long_operation
# d.cancel
# ```
def delay(delay, &block : -> _)
  Concurrent::Future.new delay: delay, &block
end

# Spawns a `Fiber` to compute *&block* in the background.
# Access to get is synchronized between fibers.  *&block* is only called once.
# ```
# f = future { http_request }
# ... other actions ...
# f.get #=> String
# ```
def future(&exp : -> _)
  Concurrent::Future.new &exp
end

# Conditionally spawns a `Fiber` to run *&block* in the background.
# Access to get is synchronized between fibers.  *&block* is only called once.
# *&block* doesn't run by default, only when `get` is called.
# ```
# l = lazy { expensive_computation }
# spawn { maybe_use_computation(l) }
# spawn { maybe_use_computation(l) }
# ```
def lazy(&block : -> _)
  Concurrent::Future.new run_immediately: false, &block
end



================================================
FILE: src/interact/logger.cr
================================================
require "./future"

class Logger
  @done_signal = "---DONE---"

  @command : String

  # *command* is the bash command that you want to run and capture the output
  # of. *@log_name* is the name of the log file you want to temporarily create.
  # *@sleept* is the time you want to wait before rechecking if the command has
  # started yet, probably something you don't want to worry about
  def initialize(command : String, @log_name : String, @sleept = 0.01)
    # Have the command output its information to a log and after the command is
    # finished, append an end signal to the document
    @command = "#{command} > #{@log_name} "            # standard output to log
    @command += "2> #{@log_name} && "                  # errors to log
    @command += "echo #{@done_signal} >> #{@log_name}" #
  end

  # Run @command in the background and pipe its output to the log file, with
  # something constantly monitoring the log file and yielding each new line to
  # the block call. Useful for changing the output of binaries you don't have
  # much control over.
  # Note that the created temp log will be deleted unless the command fails
  # its exit or .start is called with delete_file: false
  #
  # ```
  # l = Logger.new(".temp.log", %(echo "CIA spying" && sleep 2 && echo "new veggie tales season"))
  # l.start do |output, index|
  #   case output
  #   when "CIA spying"
  #     puts "i sleep"
  #   when .includes?("veggie tales")
  #     puts "real shit"
  #   end
  # end
  # ```
  def start(delete_file = true, &block) : Bool
    # Delete the log if it already exists
    File.delete(@log_name) if File.exists?(@log_name)

    # Run the command in the background
    called = future {
      system(@command)
    }

    # Wait for the log file to be written to
    while !File.exists?(@log_name)
      sleep @sleept
    end

    log = File.open(@log_name)
    log_content = read_file(log)
    index = 0

    while true
      temp_content = read_file(log)

      # make sure that there is new data
      if temp_content.size > 0 && log_content != temp_content
        log_content = temp_content

        # break the loop if the command has completed
        break if log_content[0] == @done_signal

        # give the line and index to the block
        yield log_content[0], index
        index += 1
      end
    end

    status = called.get
    if status == true && delete_file == true
      log.delete
    end

    return called.get
  end

  # Reads each line of the file into an Array of Strings
  private def read_file(file : IO) : Array(String)
    content = [] of String

    file.each_line do |line|
      content.push(line)
    end

    return content
  end
end


================================================
FILE: src/interact/ripper.cr
================================================
require "./logger"
require "../bottle/config"
require "../bottle/styles"

module Ripper
  extend self

  BIN_LOC = Path[Config.binary_location]

  # Downloads the video from the given *video_url* using the youtube-dl binary
  # Will create any directories that don't exist specified in *output_filename*
  #
  # ```
  # Ripper.download_mp3("https://youtube.com/watch?v=0xnciFWAqa0",
  #   "Queen/A Night At The Opera/Bohemian Rhapsody.mp3")
  # ```
  def download_mp3(video_url : String, output_filename : String)
    ydl_loc = BIN_LOC.join("youtube-dl")

    # remove the extension that will be added on by ydl
    output_filename = output_filename.split(".")[..-2].join(".")

    options = {
      "--output" => %("#{output_filename}.%(ext)s"), # auto-add correct ext
      # "--quiet" => "",
      "--verbose"         => "",
      "--ffmpeg-location" => BIN_LOC,
      "--extract-audio"   => "",
      "--audio-format"    => "mp3",
      "--audio-quality"   => "0",
    }

    command = ydl_loc.to_s + " " + video_url
    options.keys.each do |option|
      command += " #{option} #{options[option]}"
    end

    l = Logger.new(command, ".ripper.log")
    o = RipperOutputCensor.new

    return l.start do |line, index|
      o.censor_output(line, index)
    end
  end

  # An internal class that will keep track of what to output to the user or
  # what should be hidden.
  private class RipperOutputCensor
    @dl_status_index = 0

    def censor_output(line : String, index : Int32)
      case line
      when .includes? "[download]"
        if @dl_status_index != 0
          print "\e[1A"
          print "\e[0K\r"
        end
        puts line.sub("[download]", "   ")
        @dl_status_index += 1

        if line.includes? "100%"
          print "  Converting to mp3 ..."
        end
      end
    end
  end
end


================================================
FILE: src/interact/tagger.cr
================================================
require "../bottle/config"

# Uses FFMPEG binary to add metadata to mp3 files
# ```
# t = Tags.new("bohem rap.mp3")
# t.add_album_art("a night at the opera album cover.jpg")
# t.add_text_tag("title", "Bohemian Rhapsody")
# t.save
# ```
class Tags
  # TODO: export this path to a config file
  @BIN_LOC = Config.binary_location
  @query_args = [] of String

  # initialize the class with an already created MP3
  def initialize(@filename : String)
    if !File.exists?(@filename)
      raise "MP3 not found at location: #{@filename}"
    end

    @query_args.push(%(-i "#{@filename}"))
  end

  # Add album art to the mp3. Album art must be added BEFORE text tags are.
  # Check the usage above to see a working example.
  def add_album_art(image_location : String) : Nil
    if !File.exists?(image_location)
      raise "Image file not found at location: #{image_location}"
    end

    @query_args.push(%(-i "#{image_location}"))
    @query_args.push("-map 0:0 -map 1:0")
    @query_args.push("-c copy")
    @query_args.push("-id3v2_version 3")
    @query_args.push(%(-metadata:s:v title="Album cover"))
    @query_args.push(%(-metadata:s:v comment="Cover (front)"))
    @query_args.push(%(-metadata:s:v title="Album cover"))
  end

  # Add a text tag to the mp3. If you want to see what text tags are supported,
  # check out: https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata
  def add_text_tag(key : String, value : String) : Nil
    @query_args.push(%(-metadata #{key}="#{value}"))
  end

  # Run the necessary commands to attach album art to the mp3
  def save : Nil
    @query_args.push(%("_#{@filename}"))
    command = @BIN_LOC + "/ffmpeg " + @query_args.join(" ")

    l = Logger.new(command, ".tagger.log")
    l.start { |line, start| }

    File.delete(@filename)
    File.rename("_" + @filename, @filename)
  end
end

# a = Tags.new("test.mp3")
# a.add_text_tag("title", "Warwick Avenue")
# a.add_album_art("file.png")
# a.save()


================================================
FILE: src/irs.cr
================================================
require "./bottle/cli"

def main
  cli = CLI.new(ARGV)
  cli.act_on_args
end

main()


================================================
FILE: src/search/ranking.cr
================================================
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

================================================
FILE: src/search/spotify.cr
================================================
require "http"
require "json"
require "base64"

require "../glue/mapper"

class SpotifySearcher
  @root_url = Path["https://api.spotify.com/v1/"]

  @access_header : (HTTP::Headers | Nil) = nil
  @authorized = false

  # Saves an access token for future program use with spotify using client IDs.
  # Specs defined on spotify's developer api:
  # https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow
  #
  # ```
  # SpotifySearcher.new.authorize("XXXXXXXXXX", "XXXXXXXXXX")
  # ```
  def authorize(client_id : String, client_secret : String) : self
    auth_url = "https://accounts.spotify.com/api/token"

    headers = HTTP::Headers{
      "Authorization" => "Basic " +
                         Base64.strict_encode "#{client_id}:#{client_secret}",
    }

    payload = "grant_type=client_credentials"

    response = HTTP::Client.post(auth_url, headers: headers, form: payload)
    if response.status_code != 200
      @authorized = false
      return self
    end

    access_token = JSON.parse(response.body)["access_token"]

    @access_header = HTTP::Headers{
      "Authorization" => "Bearer #{access_token}",
    }

    @authorized = true

    return self
  end

  # Check if the class is authorized or not
  def authorized? : Bool
    return @authorized
  end

  # Searches spotify with the specified parameters for the specified items
  #
  # ```
  # spotify_searcher.find_item("track", {
  #   "artist" => "Queen",
  #   "track" => "Bohemian Rhapsody"
  # })
  # => {track metadata}
  # ```
  def find_item(item_type : String, item_parameters : Hash, offset = 0,
                limit = 20) : JSON::Any?
    query = generate_query(item_type, item_parameters)

    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)
    error_check(response)

    items = JSON.parse(response.body)[item_type + "s"]["items"].as_a

    points = rank_items(items, item_parameters)

    to_return = nil

    begin
      # this means no points were assigned so don't return the "best guess"
      if points[0][0] <= 0
        to_return = nil
      else
        to_return = get_item(item_type, items[points[0][1]]["id"].to_s)
      end
    rescue IndexError
      to_return = nil
    end

    # if this triggers, it means that a playlist has failed to be found, so
    # the search will be bootstrapped into find_user_playlist
    if to_return == nil && item_type == "playlist"
      return find_user_playlist(
        item_parameters["username"],
        item_parameters["name"]
      )
    end

    return to_return
  end

  # Grabs a users playlists and searches through it for the specified playlist
  #
  # ```
  # spotify_searcher.find_user_playlist("prakkillian", "the little man")
  # => {playlist metadata}
  # ```
  def find_user_playlist(username : String, name : String, offset = 0,
                         limit = 20) : JSON::Any?
    url = "users/#{username}/playlists?limit=#{limit}&offset=#{offset}"
    url = @root_url.join(url).to_s

    response = HTTP::Client.get(url, headers: @access_header)
    error_check(response)
    body = JSON.parse(response.body)

    items = body["items"]
    points = [] of Array(Int32)

    items.as_a.each_index do |i|
      points.push([points_compare(items[i]["name"].to_s, name), i])
    end
    points.sort! { |a, b| b[0] <=> a[0] }

    begin
      if points[0][0] < 3
        return find_user_playlist(username, name, offset + limit, limit)
      else
        return get_item("playlist", items[points[0][1]]["id"].to_s)
      end
    rescue IndexError
      return nil
    end
  end

  # Get the complete metadata of an item based off of its id
  #
  # ```
  # SpotifySearcher.new.authorize(...).get_item("artist", "1dfeR4HaWDbWqFHLkxsg1d")
  # ```
  def get_item(item_type : String, id : String, offset = 0,
               limit = 100) : JSON::Any
    if item_type == "playlist"
      return get_playlist(id, offset, limit)
    end

    url = "#{item_type}s/#{id}?limit=#{limit}&offset=#{offset}"
    url = @root_url.join(url).to_s

    response = HTTP::Client.get(url, headers: @access_header)
    error_check(response)

    body = JSON.parse(response.body)

    return body
  end

  # The only way this method differs from `get_item` is that it makes sure to
  # insert ALL tracks from the playlist into the `JSON::Any`
  #
  # ```
  # SpotifySearcher.new.authorize(...).get_playlist("122Fc9gVuSZoksEjKEx7L0")
  # ```
  def get_playlist(id, offset = 0, limit = 100) : JSON::Any
    url = "playlists/#{id}?limit=#{limit}&offset=#{offset}"
    url = @root_url.join(url).to_s

    response = HTTP::Client.get(url, headers: @access_header)
    error_check(response)
    body = JSON.parse(response.body)
    parent = PlaylistExtensionMapper.from_json(response.body)

    more_tracks = body["tracks"]["total"].as_i > offset + limit
    if more_tracks
      return playlist_extension(parent, id, offset = offset + limit)
    end

    return body
  end

  # This method exists to loop through spotify API requests and combine all
  # tracks that may not be captured by the limit of 100.
  private def playlist_extension(parent : PlaylistExtensionMapper,
                                 id : String, offset = 0, limit = 100) : JSON::Any
    url = "playlists/#{id}/tracks?limit=#{limit}&offset=#{offset}"
    url = @root_url.join(url).to_s

    response = HTTP::Client.get(url, headers: @access_header)
    error_check(response)
    body = JSON.parse(response.body)
    new_tracks = PlaylistTracksMapper.from_json(response.body)

    new_tracks.items.each do |track|
      parent.tracks.items.push(track)
    end

    more_tracks = body["total"].as_i > offset + limit
    if more_tracks
      return playlist_extension(parent, id, offset = offset + limit)
    end

    return JSON.parse(parent.to_json)
  end

  # Find the genre of an artist based off of their id
  #
  # ```
  # SpotifySearcher.new.authorize(...).find_genre("1dfeR4HaWDbWqFHLkxsg1d")
  # ```
  def find_genre(id : String) : String | Nil
    genre = get_item("artist", id)["genres"]

    if genre.as_a.empty?
      return nil
    end

    genre = genre[0].to_s
    genre = genre.split(" ").map { |x| x.capitalize }.join(" ")

    return genre
  end

  # Checks for errors in HTTP requests and raises one if found
  private def error_check(response : HTTP::Client::Response) : Nil
    if response.status_code != 200
      raise("There was an error with your request.\n" +
            "Status code: #{response.status_code}\n" +
            "Response: \n#{response.body}")
    end
  end

  # Generates url to run a GET request against to the Spotify open API
  # Returns a `String.`
  private def generate_query(item_type : String, item_parameters : Hash) : String
    query = ""

    # parameter keys to exclude in the api request. These values will be put
    # in, just not their keys.
    query_exclude = ["username"]

    item_parameters.keys.each do |k|
      # This will map album and track names from the name key to the query
      if k == "name"
        # will remove the "name:<title>" param from the query
        if item_type == "playlist"
          query += item_parameters[k] + "+"
        else
          query += as_field(item_type, item_parameters[k])
        end

        # check if the key is to be excluded
      elsif query_exclude.includes?(k)
        next

        # if it's none of the above, treat it normally
        # NOTE: playlist names will be inserted into the query normally, without
        # a parameter.
      else
        query += as_field(k, item_parameters[k])
      end
    end

    return URI.encode(query.rchop("+"))
  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 as_field(key, value) : String
    return "#{key}:#{value}+"
  end

  # Ranks the given items based off of the info from parameters.
  # Meant to find the item that the user desires.
  # Returns an `Array` of `Array(Int32)` or [[3, 1], [...], ...]
  private def rank_items(items : Array,
                         parameters : Hash) : Array(Array(Int32))
    points = [] of Array(Int32)
    index = 0

    items.each do |item|
      pts = 0

      # Think about whether this following logic is worth having in one method.
      # Is it nice to have a single method that handles it all or having a few
      # methods for each of the item types? (track, album, playlist)
      parameters.keys.each do |k|
        val = parameters[k]

        # The key to compare to for artist
        if k == "artist"
          pts += points_compare(item["artists"][0]["name"].to_s, val)
        end

        # The key to compare to for playlists
        if k == "username"
          pts_to_add = points_compare(item["owner"]["display_name"].to_s, val)
          pts += pts_to_add
          pts += -10 if pts_to_add == 0
        end

        # The key regardless of whether item is track, album,or playlist
        if k == "name"
          pts += points_compare(item["name"].to_s, val)
        end
      end

      points.push([pts, index])
      index += 1
    end

    points.sort! { |a, b| b[0] <=> a[0] }

    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 the strings are the exact same, return 3 pts.
  # If *item1* includes *item2*, return 1 pt.
  # Else, return 0 pts.
  private def points_compare(item1 : String, item2 : String) : Int32
    item1 = item1.downcase.gsub(/[^a-z0-9]/, "")
    item2 = item2.downcase.gsub(/[^a-z0-9]/, "")

    if item1 == item2
      return 3
    elsif item1.includes?(item2)
      return 1
    else
      return 0
    end
  end

end

# puts SpotifySearcher.new()
#   .authorize("XXXXXXXXXXXXXXX",
#              "XXXXXXXXXXXXXXX")
#   .find_item("playlist", {
#     "name" => "Brain Food",
#     "username" => "spotify"
#     # "name " => "A Night At The Opera",
#     # "artist" => "Queen"
#     # "track" => "Bohemian Rhapsody",
#     # "artist" => "Queen"
#   })


================================================
FILE: src/search/youtube.cr
================================================
require "http"
require "xml"
require "json"
require "uri"

require "./ranking"

require "../bottle/config"
require "../bottle/styles"


module Youtube
  extend self

  VALID_LINK_CLASSES = [
    "yt-simple-endpoint style-scope ytd-video-renderer",
    "yt-uix-tile-link yt-ui-ellipsis yt-ui-ellipsis-2 yt-uix-sessionlink      spf-link ",
  ]

  # Note that VID_VALUE_CLASS, VID_METADATA_CLASS, and YT_METADATA_CLASS are found in ranking.cr

  # Finds a youtube url based off of the given information.
  # The query to youtube is constructed like this:
  #   "<song_name> <artist_name> <search terms>"
  # If *download_first* is provided, the first link found will be downloaded.
  # If *select_link* is provided, a menu of options will be shown for the user to choose their poison
  #
  # ```
  # Youtube.find_url("Bohemian Rhapsody", "Queen")
  # => "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
  # ```
  def find_url(spotify_metadata : JSON::Any,
               flags = {} of String => String) : String?

    search_terms = Config.search_terms

    select_link = flags["select"]?

    song_name = spotify_metadata["name"].as_s
    artist_name = spotify_metadata["artists"][0]["name"].as_s

    human_query = "#{song_name} #{artist_name} #{search_terms.strip}"
    params = HTTP::Params.encode({"search_query" => human_query})

    response = HTTP::Client.get("https://www.youtube.com/results?#{params}")

    yt_metadata = get_yt_search_metadata(response.body)

    if yt_metadata.size == 0
      puts "There were no results for this query on youtube: \"#{human_query}\""
      return nil
    end

    root = "https://youtube.com"
    ranked = Ranker.rank_videos(spotify_metadata, yt_metadata, human_query)

    if select_link
      return root + select_link_menu(spotify_metadata, yt_metadata)
    end

    begin
      puts Style.dim("  Video: ") + yt_metadata[ranked[0]["index"]]["title"]
      return root + yt_metadata[ranked[0]["index"]]["href"]
    rescue IndexError
      return nil
    end

    exit 1
  end

  # Presents a menu with song info for the user to choose which url they want to download
  private def select_link_menu(spotify_metadata : JSON::Any,
                               yt_metadata : YT_METADATA_CLASS) : String
    puts Style.dim("  Spotify info: ") +
         Style.bold("\"" + spotify_metadata["name"].to_s) + "\" by \"" +
         Style.bold(spotify_metadata["artists"][0]["name"].to_s + "\"") +
         " @ " + Style.blue((spotify_metadata["duration_ms"].as_i / 1000).to_i.to_s) + "s"
    puts "  Choose video to download:"
    index = 1
    yt_metadata.each do |vid|
      print "    " + Style.bold(index.to_s + " ")
      puts "\"" + vid["title"] + "\" @ " + Style.blue((vid["duration_ms"].to_i / 1000).to_i.to_s) + "s"
      index += 1
      if index > 5
        break
      end
    end

    input = 0
    while true # not between 1 and 5
      begin
        print Style.bold("  > ")
        input = gets.not_nil!.chomp.to_i
        if input < 6 && input > 0
          break
        end
      rescue
        puts Style.red("  Invalid input, try again.")
      end
    end

    return yt_metadata[input-1]["href"]

  end

  # Finds valid video links from a `HTTP::Client.get` request
  # Returns an `Array` of `NODES_CLASS` containing additional metadata from Youtube
  private def get_yt_search_metadata(response_body : String) : YT_METADATA_CLASS
    yt_initial_data : JSON::Any = JSON.parse("{}")

    response_body.each_line do |line|
      # timestamp 11/8/2020:
      # youtube's html page has a line previous to this literally with 'scraper_data_begin' as a comment
      if line.includes?("var ytInitialData")
        # Extract JSON data from line
        data = line.split(" = ")[2].delete(';')
        dataEnd = (data.index("</script>") || 0) - 1

        begin
          yt_initial_data = JSON.parse(data[0..dataEnd])
        rescue
          break
        end
      end
    end

    if yt_initial_data == JSON.parse("{}")
      puts "Youtube has changed the way it organizes its webpage, submit a bug"
      puts "saying it has done so on https://github.com/cooperhammond/irs"
      exit(1)
    end

    # where the vid metadata lives
    yt_initial_data = yt_initial_data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"]["sectionListRenderer"]["contents"]

    video_metadata = [] of VID_METADATA_CLASS

    i = 0
    while true
      begin
        # video title
        raw_metadata = yt_initial_data[0]["itemSectionRenderer"]["contents"][i]["videoRenderer"]

        metadata = {} of String => VID_VALUE_CLASS

        metadata["title"] = raw_metadata["title"]["runs"][0]["text"].as_s
        metadata["href"] = raw_metadata["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s
        timestamp = raw_metadata["lengthText"]["simpleText"].as_s
        metadata["timestamp"] = timestamp
        metadata["duration_ms"] = ((timestamp.split(":")[0].to_i * 60 +
                               timestamp.split(":")[1].to_i) * 1000).to_s


        video_metadata.push(metadata)
      rescue IndexError
        break
      rescue Exception
      end
      i += 1
    end

    return video_metadata
  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
Download .txt
gitextract_npef2uk3/

├── .editorconfig
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── shard.yml
├── spec/
│   ├── irs_spec.cr
│   └── spec_helper.cr
└── src/
    ├── bottle/
    │   ├── cli.cr
    │   ├── config.cr
    │   ├── pattern.cr
    │   ├── styles.cr
    │   └── version.cr
    ├── glue/
    │   ├── album.cr
    │   ├── list.cr
    │   ├── mapper.cr
    │   ├── playlist.cr
    │   └── song.cr
    ├── interact/
    │   ├── future.cr
    │   ├── logger.cr
    │   ├── ripper.cr
    │   └── tagger.cr
    ├── irs.cr
    └── search/
        ├── ranking.cr
        ├── spotify.cr
        └── youtube.cr
Condensed preview — 26 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (74K chars).
[
  {
    "path": ".editorconfig",
    "chars": 150,
    "preview": "root = true\n\n[*.cr]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\ntr"
  },
  {
    "path": ".gitignore",
    "chars": 111,
    "preview": "/docs/\n/lib/\n/bin/\n/logs/\n/.shards/\n/Music/\n*.dwarf\n\n*.mp3\n*.webm*\n.ripper.log\nffmpeg\nffprobe\nyoutube-dl\n*.temp"
  },
  {
    "path": ".travis.yml",
    "chars": 168,
    "preview": "language: crystal\n\n# Uncomment the following if you'd like Travis to run specs and check code formatting\n# script:\n#   -"
  },
  {
    "path": "LICENSE",
    "chars": 1081,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2020 Cooper Hammond\n\nPermission is hereby granted, free of charge, to any person ob"
  },
  {
    "path": "README.md",
    "chars": 9010,
    "preview": "# irs: The Ironic Repositioning System\n\n[![made-with-crystal](https://img.shields.io/badge/Made%20with-Crystal-1f425f.sv"
  },
  {
    "path": "shard.yml",
    "chars": 253,
    "preview": "name: irs\nversion: 1.4.0\n\nauthors:\n  - Cooper Hammond <kepoorh@gmail.com>\n\ntargets:\n  irs:\n    main: src/irs.cr\n\nlicense"
  },
  {
    "path": "spec/irs_spec.cr",
    "chars": 852,
    "preview": "require \"./spec_helper\"\n\ndescribe CLI do\n  # TODO: Write tests\n\n  it \"can show help\" do\n    run_CLI_with_args([\"--help\"]"
  },
  {
    "path": "spec/spec_helper.cr",
    "chars": 170,
    "preview": "require \"spec\"\n\n# https://github.com/mosop/stdio\n\nrequire \"../src/bottle/cli\"\n\ndef run_CLI_with_args(argv : Array(String"
  },
  {
    "path": "src/bottle/cli.cr",
    "chars": 6003,
    "preview": "require \"ydl_binaries\"\n\nrequire \"./config\"\nrequire \"./styles\"\nrequire \"./version\"\n\nrequire \"../glue/song\"\nrequire \"../gl"
  },
  {
    "path": "src/bottle/config.cr",
    "chars": 4006,
    "preview": "require \"yaml\"\n\nrequire \"./styles\"\n\nrequire \"../search/spotify\"\n\nEXAMPLE_CONFIG = <<-EOP\n#{Style.dim \"exampleconfig.yml\""
  },
  {
    "path": "src/bottle/pattern.cr",
    "chars": 945,
    "preview": "module Pattern\n  extend self\n\n  def parse(formatString : String, metadata : JSON::Any)\n    formatted : String = formatSt"
  },
  {
    "path": "src/bottle/styles.cr",
    "chars": 346,
    "preview": "require \"colorize\"\n\nclass Style\n  def self.bold(txt)\n    txt.colorize.mode(:bold).to_s\n  end\n\n  def self.dim(txt)\n    tx"
  },
  {
    "path": "src/bottle/version.cr",
    "chars": 35,
    "preview": "module IRS\n  VERSION = \"1.4.0\"\nend\n"
  },
  {
    "path": "src/glue/album.cr",
    "chars": 1234,
    "preview": "require \"../bottle/config\"\n\nrequire \"./mapper\"\nrequire \"./song\"\nrequire \"./list\"\n\nclass Album < SpotifyList\n  @home_musi"
  },
  {
    "path": "src/glue/list.cr",
    "chars": 3125,
    "preview": "require \"json\"\n\nrequire \"../search/spotify\"\nrequire \"../search/youtube\"\n\nrequire \"../interact/ripper\"\nrequire \"../intera"
  },
  {
    "path": "src/glue/mapper.cr",
    "chars": 922,
    "preview": "require \"json\"\nrequire \"json_mapping\"\n\nclass PlaylistExtensionMapper\n  JSON.mapping(\n    tracks: {\n      type:   Playlis"
  },
  {
    "path": "src/glue/playlist.cr",
    "chars": 2155,
    "preview": "require \"json\"\n\nrequire \"../bottle/config\"\n\nrequire \"./song\"\nrequire \"./list\"\nrequire \"./mapper\"\n\nclass Playlist < Spoti"
  },
  {
    "path": "src/glue/song.cr",
    "chars": 7366,
    "preview": "require \"../search/spotify\"\nrequire \"../search/youtube\"\n\nrequire \"../interact/ripper\"\nrequire \"../interact/tagger\"\n\nrequ"
  },
  {
    "path": "src/interact/future.cr",
    "chars": 3170,
    "preview": "# copy and pasted from crystal 0.33.1\n# https://github.com/crystal-lang/crystal/blob/18e76172444c7bd07f58bf360bc21981b66"
  },
  {
    "path": "src/interact/logger.cr",
    "chars": 2685,
    "preview": "require \"./future\"\n\nclass Logger\n  @done_signal = \"---DONE---\"\n\n  @command : String\n\n  # *command* is the bash command t"
  },
  {
    "path": "src/interact/ripper.cr",
    "chars": 1825,
    "preview": "require \"./logger\"\nrequire \"../bottle/config\"\nrequire \"../bottle/styles\"\n\nmodule Ripper\n  extend self\n\n  BIN_LOC = Path["
  },
  {
    "path": "src/interact/tagger.cr",
    "chars": 1952,
    "preview": "require \"../bottle/config\"\n\n# Uses FFMPEG binary to add metadata to mp3 files\n# ```\n# t = Tags.new(\"bohem rap.mp3\")\n# t."
  },
  {
    "path": "src/irs.cr",
    "chars": 85,
    "preview": "require \"./bottle/cli\"\n\ndef main\n  cli = CLI.new(ARGV)\n  cli.act_on_args\nend\n\nmain()\n"
  },
  {
    "path": "src/search/ranking.cr",
    "chars": 4564,
    "preview": "alias VID_VALUE_CLASS = String\nalias VID_METADATA_CLASS = Hash(String, VID_VALUE_CLASS)\nalias YT_METADATA_CLASS = Array("
  },
  {
    "path": "src/search/spotify.cr",
    "chars": 10277,
    "preview": "require \"http\"\nrequire \"json\"\nrequire \"base64\"\n\nrequire \"../glue/mapper\"\n\nclass SpotifySearcher\n  @root_url = Path[\"http"
  },
  {
    "path": "src/search/youtube.cr",
    "chars": 6089,
    "preview": "require \"http\"\nrequire \"xml\"\nrequire \"json\"\nrequire \"uri\"\n\nrequire \"./ranking\"\n\nrequire \"../bottle/config\"\nrequire \"../b"
  }
]

About this extraction

This page contains the full source code of the cooperhammond/irs GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 26 files (67.0 KB), approximately 18.8k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!