[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*.cr]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\ntrim_trailing_whitespace = true\n"
  },
  {
    "path": ".gitignore",
    "content": "/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",
    "content": "language: crystal\n\n# Uncomment the following if you'd like Travis to run specs and check code formatting\n# script:\n#   - crystal spec\n#   - crystal tool format --check\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2020 Cooper Hammond\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# irs: The Ironic Repositioning System\n\n[![made-with-crystal](https://img.shields.io/badge/Made%20with-Crystal-1f425f.svg?style=flat-square)](https://crystal-lang.org/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](https://github.com/cooperhammond/irs/blob/master/LICENSE)\n[![Say Thanks](https://img.shields.io/badge/say-thanks-ff69b4.svg?style=flat-square)](https://saythanks.io/to/kepoorh%40gmail.com)\n\n> A music scraper that understands your metadata needs.\n\n`irs` is a command-line application that downloads audio and metadata in order\nto package an mp3 with both. Extensible, the user can download individual \nsongs, entire albums, or playlists from Spotify.\n\n<p align=\"center\">\n    <img src=\"https://i.imgur.com/7QTM6rD.png\" height=\"400\" title=\"#1F816D\" />\n</p>\n<p align=\"center\"\n\n[![forthebadge](https://forthebadge.com/images/badges/compatibility-betamax.svg)](https://forthebadge.com)\n[![forthebadge](https://forthebadge.com/images/badges/ages-18.svg)](https://forthebadge.com)\n[![forthebadge](https://forthebadge.com/images/badges/built-by-codebabes.svg)](https://forthebadge.com)\n</p>\n\n---\n\n## Table of Contents\n\n- [Usage](#usage)\n    - [Demo](#demo)\n- [Installation](#installation)\n    - [Pre-built](#pre-built)\n    - [From source](#from-source)\n    - [Set up](#setup)\n- [Config](#config)\n- [How it works](#how-it-works)\n- [Contributing](#contributing)\n\n\n## Usage\n\n```\n~ $ irs -h\n\nUsage: irs [--help] [--version] [--install]\n           [-s <song> -a <artist>]\n           [-A <album> -a <artist>]\n           [-p <playlist> -a <username>]\n\nArguments:\n    -h, --help                  Show this help message and exit\n    -v, --version               Show the program version and exit\n    -i, --install               Download binaries to config location\n    -c, --config                Show config file location\n    -a, --artist <artist>       Specify artist name for downloading\n    -s, --song <song>           Specify song name to download\n    -A, --album <album>         Specify the album name to download\n    -p, --playlist <playlist>   Specify the playlist name to download\n    -u, --url <url>             Specify the youtube url to download from (for single songs only)\n    -g, --give-url              Specify the youtube url sources while downloading (for albums or playlists only only)\n\nExamples:\n    $ irs --song \"Bohemian Rhapsody\" --artist \"Queen\"\n    # => downloads the song \"Bohemian Rhapsody\" by \"Queen\"\n    $ irs --album \"Demon Days\" --artist \"Gorillaz\"\n    # => downloads the album \"Demon Days\" by \"Gorillaz\"\n    $ irs --playlist \"a different drummer\" --artist \"prakkillian\"\n    # => downloads the playlist \"a different drummer\" by the user prakkillian\n```\n\n### Demo\n\n[![asciicast](https://asciinema.org/a/332793.svg)](https://asciinema.org/a/332793)\n\n## Installation\n\n### Pre-built \n\nJust download the latest release for your platform \n[here](https://github.com/cooperhammond/irs/releases).\n\nNote 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.\n\n### From Source\n\nIf you're one of those cool people who compiles from source\n\n1. Install crystal-lang \n    ([`https://crystal-lang.org/install/`](https://crystal-lang.org/install/))\n1. Clone it (`git clone https://github.com/cooperhammond/irs`)\n1. CD it (`cd irs`)\n1. Build it (`shards build`)\n\n### Setup\n\n1. Create a `.yaml` config file somewhere on your system (usually `~/.irs/`)\n1. Copy the following into it\n    ```yaml\n    binary_directory: ~/.irs/bin\n    music_directory: ~/Music\n    filename_pattern: \"{track_number} - {title}\"\n    directory_pattern: \"{artist}/{album}\"\n    client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\n    client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\n    single_folder_playlist:\n        enabled: true\n        retain_playlist_order: true\n        unify_into_album: false\n    ```\n1. Set the environment variable `IRS_CONFIG_LOCATION` pointing to that file\n1. Go to [`https://developer.spotify.com/dashboard/`](https://developer.spotify.com/dashboard/)\n1. Log in or create an account\n1. Click `CREATE A CLIENT ID`\n1. Enter all necessary info, true or false, continue\n1. Find your client key and client secret\n1. Copy each respectively into the X's in your config file\n1. Run `irs --install` and answer the prompts!\n\nYou should be good to go! Run the file from your command line to get more help on\nusage or keep reading!\n\n# Config\n\nYou may have noticed that there's a config file with more than a few options. \nHere's what they do:\n```yaml\nbinary_directory: ~/.irs/bin\nmusic_directory: ~/Music\nsearch_terms: \"lyrics\"\nfilename_pattern: \"{track_number} - {title}\"\ndirectory_pattern: \"{artist}/{album}\"\nclient_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\nclient_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\nsingle_folder_playlist:\n    enabled: true\n    retain_playlist_order: true\n    unify_into_album: false\n```\n - `binary_directory`: a path specifying where the downloaded binaries should\n    be placed\n - `music_directory`: a path specifying where downloaded mp3s should be placed.\n - `search_terms`: additional search terms to plug into youtube, which can be\n    potentially useful for not grabbing erroneous audio.\n - `filename_pattern`: a pattern for the output filename of the mp3\n - `directory_pattern`: a pattern for the folder structure your mp3s are saved in\n - `client_key`: a client key from your spotify API application\n - `client_secret`: a client secret key from your spotify API application\n - `single_folder_playlist/enabled`: if set to true, all mp3s from a downloaded\n    playlist will be placed in the same folder.\n - `single_folder_playlist/retain_playlist_order`: if set to true, the track \n    numbers of the mp3s of the playlist will be overwritten to correspond to\n    their place in the playlist\n - `single_folder_playlist/unify_into_album`: if set to true, will overwrite\n    the album name and album image of the mp3 with the title of your playlist\n    and the image for your playlist respectively\n\n\nIn a pattern following keywords will be replaced:\n\n| Keyword | Replacement | Example |\n| :----: | :----: | :----: |\n| `{artist}` | Artist Name | Queen |\n| `{title}` | Track Title | Bohemian Rhapsody |\n| `{album}` | Album Name | Stone Cold Classics |\n| `{track_number}` | Track Number | 9 |\n| `{total_tracks}` | Total Tracks in Album | 14 |\n| `{disc_number}` | Disc Number | 1 |\n| `{day}` | Release Day | 01 |\n| `{month}` | Release Month | 01 |\n| `{year}` | Release Year | 2006 |\n| `{id}` | Spotify ID | 6l8GvAyoUZwWDgF1e4822w |\n\nBeware OS-restrictions when naming your mp3s.\n\nPattern Examples:\n```yaml\nmusic_directory: ~/Music\nfilename_pattern: \"{track_number} - {title}\"\ndirectory_pattern: \"{artist}/{album}\"\n```\nOutputs: `~/Music/Queen/Stone Cold Classics/9 - Bohemian Rhapsody.mp3`\n<br><br>\n```yaml\nmusic_directory: ~/Music\nfilename_pattern: \"{artist} - {title}\"\ndirectory_pattern: \"\"\n```\nOutputs: `~/Music/Queen - Bohemian Rhapsody.mp3`\n<br><br>\n```yaml\nmusic_directory: ~/Music\nfilename_pattern: \"{track_number} of {total_tracks} - {title}\"\ndirectory_pattern: \"{year}/{artist}/{album}\"\n```\nOutputs: `~/Music/2006/Queen/Stone Cold Classics/9 of 14 - Bohemian Rhapsody.mp3`\n<br><br>\n```yaml\nmusic_directory: ~/Music\nfilename_pattern: \"{track_number}. {title}\"\ndirectory_pattern: \"irs/{artist} - {album}\"\n```\nOutputs: `~/Music/irs/Queen - Stone Cold Classics/9. Bohemian Rhapsody.mp3`\n<br>\n\n\n## How it works\n\n**At it's core** `irs` downloads individual songs. It does this by interfacing\nwith the Spotify API, grabbing metadata, and then searching Youtube for a video\ncontaining the song's audio. It will download the video using \n[`youtube-dl`](https://github.com/ytdl-org/youtube-dl), extract the audio using\n[`ffmpeg`](https://ffmpeg.org/), and then pack the audio and metadata together\ninto an MP3.\n\nFrom the core, it has been extended to download the index of albums and \nplaylists through the spotify API, and then iteratively use the method above\nfor downloading each song.\n\nIt used to be in python, but\n1. I wasn't a fan of python's limited ability to distribute standalone binaries\n1. It was a charlie foxtrot of code that I made when I was little and I wanted\n    to refine it\n1. `crystal-lang` made some promises and I was interested in seeing how well it\n    did (verdict: if you're building high-level tools you want to run quickly \n    and distribute, it's perfect)\n\n\n## Contributing\n\nAny and all contributions are welcome. If you think of a cool feature, send a \nPR or shoot me an [email](mailto:kepoorh@gmail.com). If you think something \ncould be implemented better, _please_ shoot me an email. If you like what I'm\ndoing here, _pretty please_ shoot me an email.\n\n1. Fork it (<https://github.com/your-github-user/irs/fork>)\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create a new Pull Request\n"
  },
  {
    "path": "shard.yml",
    "content": "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: MIT\n\ndependencies:\n  ydl_binaries:\n    github: cooperhammond/ydl-binaries\n  json_mapping:\n    github: crystal-lang/json_mapping.cr\n"
  },
  {
    "path": "spec/irs_spec.cr",
    "content": "require \"./spec_helper\"\n\ndescribe CLI do\n  # TODO: Write tests\n\n  it \"can show help\" do\n    run_CLI_with_args([\"--help\"])\n  end\n\n  it \"can show version\" do\n    run_CLI_with_args([\"--version\"])\n  end\n\n  # !!TODO: make a long and short version of the test suite\n  # TODO: makes so this doesn't need user input\n  it \"can install ytdl and ffmpeg binaries\" do\n    # run_CLI_with_args([\"--install\"])\n  end\n\n  it \"can show config file loc\" do\n    run_CLI_with_args([\"--config\"])\n  end\n\n  it \"can download a single song\" do\n    run_CLI_with_args([\"--song\", \"Bohemian Rhapsody\", \"--artist\", \"Queen\"])\n  end\n\n  it \"can download an album\" do\n    run_CLI_with_args([\"--artist\", \"Arctic Monkeys\", \"--album\", \"Da Frame 2R / Matador\"])\n  end\n\n  it \"can download a playlist\" do\n    run_CLI_with_args([\"--artist\", \"prakkillian\", \"--playlist\", \"IRS Testing\"])\n  end\nend\n"
  },
  {
    "path": "spec/spec_helper.cr",
    "content": "require \"spec\"\n\n# https://github.com/mosop/stdio\n\nrequire \"../src/bottle/cli\"\n\ndef run_CLI_with_args(argv : Array(String))\n    cli = CLI.new(argv)\n    cli.act_on_args\nend"
  },
  {
    "path": "src/bottle/cli.cr",
    "content": "require \"ydl_binaries\"\n\nrequire \"./config\"\nrequire \"./styles\"\nrequire \"./version\"\n\nrequire \"../glue/song\"\nrequire \"../glue/album\"\nrequire \"../glue/playlist\"\n\nclass CLI\n  # layout:\n  # [[shortflag, longflag], key, type]\n  @options = [\n    [[\"-h\", \"--help\"], \"help\", \"bool\"],\n    [[\"-v\", \"--version\"], \"version\", \"bool\"],\n    [[\"-i\", \"--install\"], \"install\", \"bool\"],\n    [[\"-c\", \"--config\"], \"config\", \"bool\"],\n    [[\"-a\", \"--artist\"], \"artist\", \"string\"],\n    [[\"-s\", \"--song\"], \"song\", \"string\"],\n    [[\"-A\", \"--album\"], \"album\", \"string\"],\n    [[\"-p\", \"--playlist\"], \"playlist\", \"string\"],\n    [[\"-u\", \"--url\"], \"url\", \"string\"],\n    [[\"-S\", \"--select\"], \"select\", \"bool\"],\n    [[\"--ask-skip\"], \"ask_skip\", \"bool\"],\n    [[\"--apply\"], \"apply_file\", \"string\"]\n  ]\n\n  @args : Hash(String, String)\n\n  def initialize(argv : Array(String))\n    @args = parse_args(argv)\n  end\n\n  def version\n    puts \"irs v#{IRS::VERSION}\"\n  end\n\n  def help\n    msg = <<-EOP\n    #{Style.bold \"Usage: irs [--help] [--version] [--install]\"}\n    #{Style.bold \"           [-s <song> -a <artist>]\"}\n    #{Style.bold \"           [-A <album> -a <artist>]\"}\n    #{Style.bold \"           [-p <playlist> -a <username>]\"}\n\n    #{Style.bold \"Arguments:\"}\n        #{Style.blue \"-h, --help\"}                  Show this help message and exit\n        #{Style.blue \"-v, --version\"}               Show the program version and exit\n        #{Style.blue \"-i, --install\"}               Download binaries to config location\n        #{Style.blue \"-c, --config\"}                Show config file location\n        #{Style.blue \"-a, --artist <artist>\"}       Specify artist name for downloading\n        #{Style.blue \"-s, --song <song>\"}           Specify song name to download\n        #{Style.blue \"-A, --album <album>\"}         Specify the album name to download\n        #{Style.blue \"-p, --playlist <playlist>\"}   Specify the playlist name to download\n        #{Style.blue \"-u, --url <url>\"}             Specify the youtube url to download from\n        #{Style.blue \"                 \"}           (for albums and playlists, the command-line\n        #{Style.blue \"                 \"}           argument is ignored, and it should be '')\n        #{Style.blue \"-S, --select\"}                Use a menu to choose each song's video source\n        #{Style.blue \"--ask-skip\"}                  Before every playlist/album song, ask to skip\n        #{Style.blue \"--apply <file>\"}              Apply metadata to a existing file\n\n    #{Style.bold \"Examples:\"}\n        $ #{Style.green %(irs --song \"Bohemian Rhapsody\" --artist \"Queen\")}\n        #{Style.dim %(# => downloads the song \"Bohemian Rhapsody\" by \"Queen\")}\n        $ #{Style.green %(irs --album \"Demon Days\" --artist \"Gorillaz\")}\n        #{Style.dim %(# => downloads the album \"Demon Days\" by \"Gorillaz\")}\n        $ #{Style.green %(irs --playlist \"a different drummer\" --artist \"prakkillian\")}\n        #{Style.dim %(# => downloads the playlist \"a different drummer\" by the user prakkillian)}\n\n    #{Style.bold \"This project is licensed under the MIT license.\"}\n    #{Style.bold \"Project page: <https://github.com/cooperhammond/irs>\"}\n    EOP\n\n    puts msg\n  end\n\n  def act_on_args\n    Config.check_necessities\n\n    if @args[\"help\"]? || @args.keys.size == 0\n      help\n\n    elsif @args[\"version\"]?\n      version\n\n    elsif @args[\"install\"]?\n      YdlBinaries.get_both(Config.binary_location)\n\n    elsif @args[\"config\"]?\n      puts ENV[\"IRS_CONFIG_LOCATION\"]?\n\n    elsif @args[\"song\"]? && @args[\"artist\"]?\n      s = Song.new(@args[\"song\"], @args[\"artist\"])\n      s.provide_client_keys(Config.client_key, Config.client_secret)\n      s.grab_it(flags: @args)\n      s.organize_it()\n\n    elsif @args[\"album\"]? && @args[\"artist\"]?\n      a = Album.new(@args[\"album\"], @args[\"artist\"])\n      a.provide_client_keys(Config.client_key, Config.client_secret)\n      a.grab_it(flags: @args)\n\n    elsif @args[\"playlist\"]? && @args[\"artist\"]?\n      p = Playlist.new(@args[\"playlist\"], @args[\"artist\"])\n      p.provide_client_keys(Config.client_key, Config.client_secret)\n      p.grab_it(flags: @args)\n\n    else\n      puts Style.red(\"Those arguments don't do anything when used that way.\")\n      puts \"Type `irs -h` to see usage.\"\n    end\n  end\n\n  private def parse_args(argv : Array(String)) : Hash(String, String)\n    arguments = {} of String => String\n\n    i = 0\n    current_key = \"\"\n    pass_next_arg = false\n    argv.each do |arg|\n      # If the previous arg was an arg flag, this is an arg, so pass it\n      if pass_next_arg\n        pass_next_arg = false\n        i += 1\n        next\n      end\n\n      flag = [] of Array(String) | String\n      valid_flag = false\n\n      @options.each do |option|\n        if option[0].includes?(arg)\n          flag = option\n          valid_flag = true\n          break\n        end\n      end\n\n      # ensure the flag is actually defined\n      if !valid_flag\n        arg_error argv, i, %(\"#{arg}\" is an invalid flag or argument.)\n      end\n\n      # ensure there's an argument if the program needs one\n      if flag[2] == \"string\" && i + 1 >= argv.size\n        arg_error argv, i, %(\"#{arg}\" needs an argument.)\n      end\n\n      key = flag[1].as(String)\n      if flag[2] == \"string\"\n        arguments[key] = argv[i + 1]\n        pass_next_arg = true\n      elsif flag[2] == \"bool\"\n        arguments[key] = \"true\"\n      end\n\n      i += 1\n    end\n\n    return arguments\n  end\n\n  private def arg_error(argv : Array(String), arg : Int32, msg : String) : Nil\n    precursor = \"irs\"\n\n    precursor += \" \" if arg != 0\n\n    if arg == 0\n      start = [] of String\n    else\n      start = argv[..arg - 1]\n    end\n    last = argv[arg + 1..]\n\n    distance = (precursor + start.join(\" \")).size\n\n    print Style.dim(precursor + start.join(\" \"))\n    print Style.bold(Style.red(\" \" + argv[arg]).to_s)\n    puts Style.dim (\" \" + last.join(\" \"))\n\n    (0..distance).each do |i|\n      print \" \"\n    end\n    puts \"^\"\n\n    puts Style.red(Style.bold(msg).to_s)\n    puts \"Type `irs -h` to see usage.\"\n    exit 1\n  end\nend\n"
  },
  {
    "path": "src/bottle/config.cr",
    "content": "require \"yaml\"\n\nrequire \"./styles\"\n\nrequire \"../search/spotify\"\n\nEXAMPLE_CONFIG = <<-EOP\n#{Style.dim \"exampleconfig.yml\"}\n#{Style.dim \"====\"}\n#{Style.blue \"search_terms\"}: #{Style.green \"\\\"lyrics\\\"\"}\n#{Style.blue \"binary_directory\"}: #{Style.green \"~/.irs/bin\"}\n#{Style.blue \"music_directory\"}: #{Style.green \"~/Music\"}\n#{Style.blue \"filename_pattern\"}: #{Style.green \"\\\"{track_number} - {title}\\\"\"}\n#{Style.blue \"directory_pattern\"}: #{Style.green \"\\\"{artist}/{album}\\\"\"}\n#{Style.blue \"client_key\"}: #{Style.green \"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\"}\n#{Style.blue \"client_secret\"}: #{Style.green \"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\"}\n#{Style.blue \"single_folder_playlist\"}: \n  #{Style.blue \"enabled\"}: #{Style.green \"true\"}\n  #{Style.blue \"retain_playlist_order\"}: #{Style.green \"true\"}\n  #{Style.blue \"unify_into_album\"}: #{Style.green \"false\"}\n#{Style.dim \"====\"}\nEOP\n\nmodule Config\n  extend self\n\n  @@arguments = [\n    \"search_terms\",\n    \"binary_directory\",\n    \"music_directory\",\n    \"filename_pattern\",\n    \"directory_pattern\",\n    \"client_key\",\n    \"client_secret\",\n    \"single_folder_playlist: enabled\",\n    \"single_folder_playlist: retain_playlist_order\",\n    \"single_folder_playlist: unify_into_album\",\n  ]\n\n  @@conf = YAML.parse(\"\")\n  begin\n    @@conf = YAML.parse(File.read(ENV[\"IRS_CONFIG_LOCATION\"]))\n  rescue\n    puts Style.red \"Before anything else, define the environment variable IRS_CONFIG_LOCATION pointing to a .yml file like this one.\"\n    puts EXAMPLE_CONFIG\n    puts Style.bold \"See https://github.com/cooperhammond/irs for more information on the config file\"\n    exit 1\n  end\n\n  def search_terms : String\n    return @@conf[\"search_terms\"].to_s\n  end\n\n  def binary_location : String\n    path = @@conf[\"binary_directory\"].to_s\n    return Path[path].expand(home: true).to_s\n  end\n\n  def music_directory : String\n    path = @@conf[\"music_directory\"].to_s\n    return Path[path].expand(home: true).to_s\n  end\n  \n  def filename_pattern : String\n    return @@conf[\"filename_pattern\"].to_s\n  end\n  \n  def directory_pattern : String\n    return @@conf[\"directory_pattern\"].to_s\n  end\n\n  def client_key : String\n    return @@conf[\"client_key\"].to_s\n  end\n\n  def client_secret : String\n    return @@conf[\"client_secret\"].to_s\n  end\n\n  def single_folder_playlist? : Bool\n    return @@conf[\"single_folder_playlist\"][\"enabled\"].as_bool\n  end\n\n  def retain_playlist_order? : Bool\n    return @@conf[\"single_folder_playlist\"][\"retain_playlist_order\"].as_bool\n  end\n\n  def unify_into_album? : Bool\n    return @@conf[\"single_folder_playlist\"][\"unify_into_album\"].as_bool\n  end\n\n  def check_necessities\n    missing_configs = [] of String\n    @@arguments.each do |argument|\n      if !check_conf(argument)\n        missing_configs.push(argument)\n      end\n    end\n    if missing_configs.size > 0\n      puts Style.red(\"You are missing the following key(s) in your YAML config file:\")\n      missing_configs.each do |config|\n        puts \"  \" + config\n      end\n      puts \"\\nHere's an example of what your config should look like:\"\n      puts EXAMPLE_CONFIG\n      puts Style.bold \"See https://github.com/cooperhammond/irs for more information on the config file\"\n      exit 1\n    end\n    spotify = SpotifySearcher.new\n    spotify.authorize(self.client_key, self.client_secret)\n    if !spotify.authorized?\n      puts Style.red(\"There's something wrong with your client key and/or client secret\")\n      puts \"Get your keys from https://developer.spotify.com/dashboard, and enter them in your config file\"\n      puts \"Your config file is at #{ENV[\"IRS_CONFIG_LOCATION\"]}\"\n      puts EXAMPLE_CONFIG\n      puts Style.bold \"See https://github.com/cooperhammond/irs for more information on the config file\"\n      exit 1\n    end\n  end\n\n  private def check_conf(key : String) : YAML::Any?\n    if key.includes?(\": \")\n      args = key.split(\": \")\n      if @@conf[args[0]]?\n        return @@conf[args[0]][args[1]]?\n      else\n        return @@conf[args[0]]?\n      end\n    else\n      return @@conf[key]?\n    end\n  end\nend\n"
  },
  {
    "path": "src/bottle/pattern.cr",
    "content": "module Pattern\n  extend self\n\n  def parse(formatString : String, metadata : JSON::Any)\n    formatted : String = formatString\n\n    date : Array(String) = (metadata[\"album\"]? || JSON.parse(\"{}\"))[\"release_date\"]?.to_s.split('-')\n\n    keys : Hash(String, String) = {\n      \"artist\" => ((metadata.dig?(\"artists\") || JSON.parse(\"{}\"))[0]? || JSON.parse(\"{}\"))[\"name\"]?.to_s,\n      \"title\" => metadata[\"name\"]?.to_s,\n      \"album\" => (metadata[\"album\"]? || JSON.parse(\"{}\"))[\"name\"]?.to_s,\n      \"track_number\" => metadata[\"track_number\"]?.to_s,\n      \"disc_number\" => metadata[\"disc_number\"]?.to_s,\n      \"total_tracks\" => (metadata[\"album\"]? || JSON.parse(\"{}\"))[\"total_tracks\"]?.to_s,\n      \"year\" => date[0]?.to_s,\n      \"month\" => date[1]?.to_s,\n      \"day\" => date[2]?.to_s,\n      \"id\" => metadata[\"id\"]?.to_s\n    }\n\n    keys.each do |pair|\n      formatted = formatted.gsub(\"{#{pair[0]}}\", pair[1] || \"\")\n    end\n\n    return formatted\n  end\nend\n"
  },
  {
    "path": "src/bottle/styles.cr",
    "content": "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    txt.colorize.mode(:dim).to_s\n  end\n\n  def self.blue(txt)\n    txt.colorize(:light_blue).to_s\n  end\n\n  def self.green(txt)\n    txt.colorize(:light_green).to_s\n  end\n\n  def self.red(txt)\n    txt.colorize(:light_red).to_s\n  end\nend\n"
  },
  {
    "path": "src/bottle/version.cr",
    "content": "module IRS\n  VERSION = \"1.4.0\"\nend\n"
  },
  {
    "path": "src/glue/album.cr",
    "content": "require \"../bottle/config\"\n\nrequire \"./mapper\"\nrequire \"./song\"\nrequire \"./list\"\n\nclass Album < SpotifyList\n  @home_music_directory = Config.music_directory\n\n  # Uses the `spotify_searcher` defined in parent `SpotifyList` to find the\n  # correct metadata of the list\n  def find_it : JSON::Any\n    album = @spotify_searcher.find_item(\"album\", {\n      \"name\"   => @list_name.as(String),\n      \"artist\" => @list_author.as(String),\n    })\n    if album\n      return album.as(JSON::Any)\n    else\n      puts \"No album was found by that name and artist.\"\n      exit 1\n    end\n  end\n\n  # Will define specific metadata that may not be included in the raw return\n  # of spotify's album json. Moves the title of the album and the album art\n  # to the json of the single song\n  def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any\n    album_metadata = parse_to_json(%(\n      {\n        \"name\": \"#{list[\"name\"]}\",\n        \"images\": [{\"url\": \"#{list[\"images\"][0][\"url\"]}\"}]\n      }\n    ))\n\n    prepped_data = TrackMapper.from_json(datum.to_json)\n    prepped_data.album = album_metadata\n\n    data = parse_to_json(prepped_data.to_json)\n\n    return data\n  end\n\n  private def organize(song : Song)\n    song.organize_it()\n  end\nend\n"
  },
  {
    "path": "src/glue/list.cr",
    "content": "require \"json\"\n\nrequire \"../search/spotify\"\nrequire \"../search/youtube\"\n\nrequire \"../interact/ripper\"\nrequire \"../interact/tagger\"\n\nrequire \"./song\"\n\n# A parent class for downloading albums and playlists from spotify\nabstract class SpotifyList\n  @spotify_searcher = SpotifySearcher.new\n  @file_names = [] of String\n\n  @outputs : Hash(String, Array(String)) = {\n    \"searching\" => [\n      Style.bold(\"Searching for %l by %a ... \\r\"),\n      Style.green(\"+ \") + Style.bold(\"%l by %a                                 \\n\")\n    ],\n    \"url\" => [\n      Style.bold(\"When prompted for a URL, provide a youtube URL or press enter to scrape for one\\n\")\n    ]\n  }\n\n  def initialize(@list_name : String, @list_author : String?)\n  end\n\n  # Finds the list, and downloads all of the songs using the `Song` class\n  def grab_it(flags = {} of String => String)\n    ask_url = flags[\"url\"]?\n    ask_skip = flags[\"ask_skip\"]?\n    is_playlist = flags[\"playlist\"]?\n  \n    if !@spotify_searcher.authorized?\n      raise(\"Need to call provide_client_keys on Album or Playlist class.\")\n    end\n\n    if ask_url\n      outputter(\"url\", 0)\n    end\n\n    outputter(\"searching\", 0)\n    list = find_it()\n    outputter(\"searching\", 1)\n    contents = list[\"tracks\"][\"items\"].as_a\n\n    i = 0\n    contents.each do |datum|\n      i += 1\n      if datum[\"track\"]?\n        datum = datum[\"track\"]\n      end\n\n      data = organize_song_metadata(list, datum)\n\n      s_name = data[\"name\"].to_s\n      s_artist = data[\"artists\"][0][\"name\"].to_s\n\n      song = Song.new(s_name, s_artist)\n      song.provide_spotify(@spotify_searcher)\n      song.provide_metadata(data)\n\n      puts Style.bold(\"[#{i}/#{contents.size}]\")\n\n      unless ask_skip && skip?(s_name, s_artist, is_playlist)\n        song.grab_it(flags: flags)\n        organize(song)\n      else\n        puts \"Skipping...\"\n      end\n    end\n  end\n\n  # Will authorize the class associated `SpotifySearcher`\n  def provide_client_keys(client_key : String, client_secret : String)\n    @spotify_searcher.authorize(client_key, client_secret)\n  end\n\n  private def skip?(name, artist, is_playlist)\n    print \"Skip #{Style.blue name}\" +\n      (is_playlist ? \" (by #{Style.green artist})\": \"\") + \"? \"\n    response = gets\n    return response && response.lstrip.downcase.starts_with? \"y\"\n  end\n\n  private def outputter(key : String, index : Int32)\n    text = @outputs[key][index]\n      .gsub(\"%l\", @list_name)\n      .gsub(\"%a\", @list_author)\n    print text\n  end\n\n  # Defined in subclasses, will return the appropriate information or call an\n  # error if the info is not found and exit\n  abstract def find_it : JSON::Any\n\n  # If there's a need to organize the individual song data so that the `Song`\n  # class can better handle it, this function will be defined in the subclass\n  private abstract def organize_song_metadata(list : JSON::Any,\n                                              datum : JSON::Any) : JSON::Any\n\n  # Will define the specific type of organization for a list of songs.\n  # Needed because most people want albums sorted by artist, but playlists all\n  # in one folder\n  private abstract def organize(song : Song)\nend\n"
  },
  {
    "path": "src/glue/mapper.cr",
    "content": "require \"json\"\nrequire \"json_mapping\"\n\nclass PlaylistExtensionMapper\n  JSON.mapping(\n    tracks: {\n      type:   PlaylistTracksMapper,\n      setter: true,\n    },\n    id: String,\n    images: JSON::Any,\n    name: String,\n    owner: JSON::Any,\n    type: String\n  )\nend\n\nclass PlaylistTracksMapper\n  JSON.mapping(\n    items: {\n      type:   Array(JSON::Any),\n      setter: true,\n    },\n    total: Int32\n  )\nend\n\nclass TrackMapper\n  JSON.mapping(\n    album: {\n      type:    JSON::Any,\n      nilable: true,\n      setter:  true,\n    },\n    artists: {\n      type: Array(JSON::Any),\n      setter: true  \n    },\n    disc_number: {\n      type: Int32,\n      setter: true\n    },\n    id: String,\n    name: String,\n    track_number: {\n      type: Int32,\n      setter: true\n    },\n    duration_ms: Int32,\n    type: String,\n    uri: String\n  )\nend\n\ndef parse_to_json(string_json : String) : JSON::Any\n  return JSON.parse(string_json)\nend\n"
  },
  {
    "path": "src/glue/playlist.cr",
    "content": "require \"json\"\n\nrequire \"../bottle/config\"\n\nrequire \"./song\"\nrequire \"./list\"\nrequire \"./mapper\"\n\nclass Playlist < SpotifyList\n  @song_index = 1\n  @home_music_directory = Config.music_directory\n  @playlist : JSON::Any?\n\n  # Uses the `spotify_searcher` defined in parent `SpotifyList` to find the\n  # correct metadata of the list\n  def find_it : JSON::Any\n    @playlist = @spotify_searcher.find_item(\"playlist\", {\n      \"name\"     => @list_name.as(String),\n      \"username\" => @list_author.as(String),\n    })\n    if @playlist\n      return @playlist.as(JSON::Any)\n    else\n      puts \"No playlists were found by that name and user.\"\n      exit 1\n    end\n  end\n\n  # Will define specific metadata that may not be included in the raw return\n  # of spotify's album json. Moves the title of the album and the album art\n  # to the json of the single song\n  def organize_song_metadata(list : JSON::Any, datum : JSON::Any) : JSON::Any\n    data = datum\n\n    if Config.retain_playlist_order?\n      track = TrackMapper.from_json(data.to_json)\n      track.track_number = @song_index\n      track.disc_number = 1\n      data = JSON.parse(track.to_json)\n    end\n\n    if Config.unify_into_album?\n      track = TrackMapper.from_json(data.to_json)\n      track.album = JSON.parse(%({\n        \"name\": \"#{list[\"name\"]}\",\n        \"images\": [{\"url\": \"#{list[\"images\"][0][\"url\"]}\"}]\n      }))\n      track.artists.push(JSON.parse(%({\n        \"name\": \"#{list[\"owner\"][\"display_name\"]}\",\n        \"owner\": true\n      })))\n      data = JSON.parse(track.to_json)\n    end\n\n    @song_index += 1\n\n    return data\n  end\n\n  private def organize(song : Song)\n    if Config.single_folder_playlist?\n      path = Path[@home_music_directory].expand(home: true)\n      path = path / @playlist.as(JSON::Any)[\"name\"].to_s\n        .gsub(/[\\/]/, \"\").gsub(\"  \", \" \")\n      strpath = path.to_s\n      if !File.directory?(strpath)\n        FileUtils.mkdir_p(strpath)\n      end\n      safe_filename = song.filename.gsub(/[\\/]/, \"\").gsub(\"  \", \" \")\n      FileUtils.cp(\"./\" + song.filename, (path / safe_filename).to_s)\n      FileUtils.rm(\"./\" + song.filename)\n    else\n      song.organize_it()\n    end\n  end\nend\n"
  },
  {
    "path": "src/glue/song.cr",
    "content": "require \"../search/spotify\"\nrequire \"../search/youtube\"\n\nrequire \"../interact/ripper\"\nrequire \"../interact/tagger\"\n\nrequire \"../bottle/config\"\nrequire \"../bottle/pattern\"\nrequire \"../bottle/styles\"\n\nclass Song\n  @spotify_searcher = SpotifySearcher.new\n  @client_id = \"\"\n  @client_secret = \"\"\n\n  @metadata : JSON::Any?\n  getter filename = \"\"\n  @artist = \"\"\n  @album = \"\"\n\n  @outputs : Hash(String, Array(String)) = {\n    \"intro\" => [Style.bold(\"[%s by %a]\\n\")],\n    \"metadata\" => [\n      \"  Searching for metadata ...\\r\",\n      Style.green(\"  + \") + Style.dim(\"Metadata found                  \\n\")\n    ],\n    \"url\" => [\n      \"  Searching for URL ...\\r\",\n      Style.green(\"  + \") + Style.dim(\"URL found                       \\n\"),\n      \"  Validating URL ...\\r\",\n      Style.green(\"  + \") + Style.dim(\"URL validated                   \\n\"),\n      \"  URL?: \"\n    ],\n    \"download\" => [\n      \"  Downloading video:\\n\",\n      Style.green(\"\\r  + \") + Style.dim(\"Converted to mp3              \\n\")\n    ],\n    \"albumart\" => [\n      \"  Downloading album art ...\\r\",\n      Style.green(\"  + \") + Style.dim(\"Album art downloaded            \\n\")\n    ],\n    \"tagging\" => [\n      \"  Attaching metadata ...\\r\",\n      Style.green(\"  + \") + Style.dim(\"Metadata attached               \\n\")\n    ],\n    \"finished\" => [\n      Style.green(\"  + \") + \"Finished!\\n\"\n    ]\n  }\n\n  def initialize(@song_name : String, @artist_name : String)\n  end\n\n  # Find, downloads, and tags the mp3 song that this class represents.\n  # Optionally takes a youtube URL to download from\n  #\n  # ```\n  # Song.new(\"Bohemian Rhapsody\", \"Queen\").grab_it\n  # ```\n  def grab_it(url : (String | Nil) = nil, flags = {} of String => String)\n    passed_url : (String | Nil) = flags[\"url\"]?\n    passed_file : (String | Nil) = flags[\"apply_file\"]?\n    select_link = flags[\"select\"]?\n\n    outputter(\"intro\", 0)\n\n    if !@spotify_searcher.authorized? && !@metadata\n      if @client_id != \"\" && @client_secret != \"\"\n        @spotify_searcher.authorize(@client_id, @client_secret)\n      else\n        raise(\"Need to call either `provide_metadata`, `provide_spotify`, \" +\n              \"or `provide_client_keys` so that Spotify can be interfaced with.\")\n      end\n    end\n\n    if !@metadata\n      outputter(\"metadata\", 0)\n      @metadata = @spotify_searcher.find_item(\"track\", {\n        \"name\"   => @song_name,\n        \"artist\" => @artist_name,\n      })\n\n      if !@metadata\n        raise(\"There was no metadata found on Spotify for \" +\n              %(\"#{@song_name}\" by \"#{@artist_name}\". ) +\n              \"Check your input and try again.\")\n      end\n      outputter(\"metadata\", 1)\n    end\n\n    data = @metadata.as(JSON::Any)\n    @song_name = data[\"name\"].as_s\n    @artist_name = data[\"artists\"][0][\"name\"].as_s\n    @filename = \"#{Pattern.parse(Config.filename_pattern, data)}.mp3\"\n\n    if passed_file\n      puts Style.green(\"  +\") + Style.dim(\" Moving file: \") + passed_file\n      File.rename(passed_file, @filename)\n    else\n      if passed_url\n        if passed_url.strip != \"\"\n          url = passed_url\n        else\n          outputter(\"url\", 4)\n          url = gets\n          if !url.nil? && url.strip == \"\"\n            url = nil\n          end\n        end\n      end\n\n      if !url\n        outputter(\"url\", 0)\n        url = Youtube.find_url(data, flags: flags)\n        if !url\n          raise(\"There was no url found on youtube for \" +\n                %(\"#{@song_name}\" by \"#{@artist_name}. ) +\n                \"Check your input and try again.\")\n        end\n        outputter(\"url\", 1)\n      else\n        outputter(\"url\", 2)\n        url = Youtube.validate_url(url)\n        if !url\n          raise(\"The url is an invalid youtube URL \" +\n                \"Check the URL and try again\")\n        end\n        outputter(\"url\", 3)\n      end\n\n      outputter(\"download\", 0)\n      Ripper.download_mp3(url.as(String), @filename)\n      outputter(\"download\", 1)\n    end\n\n    outputter(\"albumart\", 0)\n    temp_albumart_filename = \".tempalbumart.jpg\"\n    HTTP::Client.get(data[\"album\"][\"images\"][0][\"url\"].as_s) do |response|\n      File.write(temp_albumart_filename, response.body_io)\n    end\n    outputter(\"albumart\", 0)\n\n    # check if song's metadata has been modded in playlist, update artist accordingly\n    if data[\"artists\"][-1][\"owner\"]?\n      @artist = data[\"artists\"][-1][\"name\"].as_s\n    else\n      @artist = data[\"artists\"][0][\"name\"].as_s\n    end\n    @album = data[\"album\"][\"name\"].as_s\n\n    tagger = Tags.new(@filename)\n    tagger.add_album_art(temp_albumart_filename)\n    tagger.add_text_tag(\"title\", data[\"name\"].as_s)\n    tagger.add_text_tag(\"artist\", @artist)\n\n    if !@album.empty?\n      tagger.add_text_tag(\"album\", @album)\n    end\n\n    if genre = @spotify_searcher.find_genre(data[\"artists\"][0][\"id\"].as_s)\n      tagger.add_text_tag(\"genre\", genre)\n    end\n\n    tagger.add_text_tag(\"track\", data[\"track_number\"].to_s)\n    tagger.add_text_tag(\"disc\", data[\"disc_number\"].to_s)\n\n    outputter(\"tagging\", 0)\n    tagger.save\n    File.delete(temp_albumart_filename)\n    outputter(\"tagging\", 1)\n\n    outputter(\"finished\", 0)\n  end\n\n  # Will organize the song into the user's provided music directory\n  # in the user's provided structure\n  # Must be called AFTER the song has been downloaded.\n  #\n  # ```\n  # s = Song.new(\"Bohemian Rhapsody\", \"Queen\").grab_it\n  # s.organize_it()\n  # # With\n  # # directory_pattern = \"{artist}/{album}\"\n  # # filename_pattern = \"{track_number} - {title}\"\n  # # Mp3 will be moved to\n  # # /home/cooper/Music/Queen/A Night At The Opera/1 - Bohemian Rhapsody.mp3\n  # ```\n  def organize_it()\n    path = Path[Config.music_directory].expand(home: true)\n    Pattern.parse(Config.directory_pattern, @metadata.as(JSON::Any)).split('/').each do |dir|\n      path = path / dir.gsub(/[\\/]/, \"\").gsub(\"  \", \" \")\n    end\n    strpath = path.to_s\n    if !File.directory?(strpath)\n      FileUtils.mkdir_p(strpath)\n    end\n    safe_filename = @filename.gsub(/[\\/]/, \"\").gsub(\"  \", \" \")\n    FileUtils.cp(\"./\" + @filename, (path / safe_filename).to_s)\n    FileUtils.rm(\"./\" + @filename)\n  end\n\n  # Provide metadata so that it doesn't have to find it. Useful for overwriting\n  # metadata. Must be called if provide_client_keys and provide_spotify are not\n  # called.\n  #\n  # ```\n  # Song.new(...).provide_metadata(...).grab_it\n  # ```\n  def provide_metadata(metadata : JSON::Any) : self\n    @metadata = metadata\n    return self\n  end\n\n  # Provide an already authenticated `SpotifySearcher` class. Useful to avoid\n  # authenticating over and over again. Must be called if provide_metadata and\n  # provide_client_keys are not called.\n  #\n  # ```\n  # Song.new(...).provide_spotify(SpotifySearcher.new\n  #   .authenticate(\"XXXXXXXXXX\", \"XXXXXXXXXXX\")).grab_it\n  # ```\n  def provide_spotify(spotify : SpotifySearcher) : self\n    @spotify_searcher = spotify\n    return self\n  end\n\n  # Provide spotify client keys. Must be called if provide_metadata and\n  # provide_spotify are not called.\n  #\n  # ```\n  # Song.new(...).provide_client_keys(\"XXXXXXXXXX\", \"XXXXXXXXX\").grab_it\n  # ```\n  def provide_client_keys(client_id : String, client_secret : String) : self\n    @client_id = client_id\n    @client_secret = client_secret\n    return self\n  end\n\n  private def outputter(key : String, index : Int32)\n    text = @outputs[key][index]\n      .gsub(\"%s\", @song_name)\n      .gsub(\"%a\", @artist_name)\n    print text\n  end\nend\n"
  },
  {
    "path": "src/interact/future.cr",
    "content": "# copy and pasted from crystal 0.33.1\n# https://github.com/crystal-lang/crystal/blob/18e76172444c7bd07f58bf360bc21981b667668d/src/concurrent/future.cr#L138\n\n\n# :nodoc:\nclass Concurrent::Future(R)\n  enum State\n    Idle\n    Delayed\n    Running\n    Completed\n    Canceled\n  end\n\n  @value : R?\n  @error : Exception?\n  @delay : Float64\n\n  def initialize(run_immediately = true, delay = 0.0, &@block : -> R)\n    @state = State::Idle\n    @value = nil\n    @error = nil\n    @channel = Channel(Nil).new\n    @delay = delay.to_f\n    @cancel_msg = nil\n\n    spawn_compute if run_immediately\n  end\n\n  def get\n    wait\n    value_or_raise\n  end\n\n  def success?\n    completed? && !@error\n  end\n\n  def failure?\n    completed? && @error\n  end\n\n  def canceled?\n    @state == State::Canceled\n  end\n\n  def completed?\n    @state == State::Completed\n  end\n\n  def running?\n    @state == State::Running\n  end\n\n  def delayed?\n    @state == State::Delayed\n  end\n\n  def idle?\n    @state == State::Idle\n  end\n\n  def cancel(msg = \"Future canceled, you reached the [End of Time]\")\n    return if @state >= State::Completed\n    @state = State::Canceled\n    @cancel_msg = msg\n    @channel.close\n    nil\n  end\n\n  private def compute\n    return if @state >= State::Delayed\n    run_compute\n  end\n\n  private def spawn_compute\n    return if @state >= State::Delayed\n\n    @state = @delay > 0 ? State::Delayed : State::Running\n\n    spawn { run_compute }\n  end\n\n  private def run_compute\n    delay = @delay\n\n    if delay > 0\n      sleep delay\n      return if @state >= State::Canceled\n      @state = State::Running\n    end\n\n    begin\n      @value = @block.call\n    rescue ex\n      @error = ex\n    ensure\n      @channel.close\n      @state = State::Completed\n    end\n  end\n\n  private def wait\n    return if @state >= State::Completed\n    compute\n    @channel.receive?\n  end\n\n  private def value_or_raise\n    raise Exception.new(@cancel_msg) if @state == State::Canceled\n\n    value = @value\n    if value.is_a?(R)\n      value\n    elsif error = @error\n      raise error\n    else\n      raise \"compiler bug\"\n    end\n  end\nend\n\n# Spawns a `Fiber` to compute *&block* in the background after *delay* has elapsed.\n# Access to get is synchronized between fibers.  *&block* is only called once.\n# May be canceled before *&block* is called by calling `cancel`.\n# ```\n# d = delay(1) { Process.kill(Process.pid) }\n# long_operation\n# d.cancel\n# ```\ndef delay(delay, &block : -> _)\n  Concurrent::Future.new delay: delay, &block\nend\n\n# Spawns a `Fiber` to compute *&block* in the background.\n# Access to get is synchronized between fibers.  *&block* is only called once.\n# ```\n# f = future { http_request }\n# ... other actions ...\n# f.get #=> String\n# ```\ndef future(&exp : -> _)\n  Concurrent::Future.new &exp\nend\n\n# Conditionally spawns a `Fiber` to run *&block* in the background.\n# Access to get is synchronized between fibers.  *&block* is only called once.\n# *&block* doesn't run by default, only when `get` is called.\n# ```\n# l = lazy { expensive_computation }\n# spawn { maybe_use_computation(l) }\n# spawn { maybe_use_computation(l) }\n# ```\ndef lazy(&block : -> _)\n  Concurrent::Future.new run_immediately: false, &block\nend\n\n"
  },
  {
    "path": "src/interact/logger.cr",
    "content": "require \"./future\"\n\nclass Logger\n  @done_signal = \"---DONE---\"\n\n  @command : String\n\n  # *command* is the bash command that you want to run and capture the output\n  # of. *@log_name* is the name of the log file you want to temporarily create.\n  # *@sleept* is the time you want to wait before rechecking if the command has\n  # started yet, probably something you don't want to worry about\n  def initialize(command : String, @log_name : String, @sleept = 0.01)\n    # Have the command output its information to a log and after the command is\n    # finished, append an end signal to the document\n    @command = \"#{command} > #{@log_name} \"            # standard output to log\n    @command += \"2> #{@log_name} && \"                  # errors to log\n    @command += \"echo #{@done_signal} >> #{@log_name}\" #\n  end\n\n  # Run @command in the background and pipe its output to the log file, with\n  # something constantly monitoring the log file and yielding each new line to\n  # the block call. Useful for changing the output of binaries you don't have\n  # much control over.\n  # Note that the created temp log will be deleted unless the command fails\n  # its exit or .start is called with delete_file: false\n  #\n  # ```\n  # l = Logger.new(\".temp.log\", %(echo \"CIA spying\" && sleep 2 && echo \"new veggie tales season\"))\n  # l.start do |output, index|\n  #   case output\n  #   when \"CIA spying\"\n  #     puts \"i sleep\"\n  #   when .includes?(\"veggie tales\")\n  #     puts \"real shit\"\n  #   end\n  # end\n  # ```\n  def start(delete_file = true, &block) : Bool\n    # Delete the log if it already exists\n    File.delete(@log_name) if File.exists?(@log_name)\n\n    # Run the command in the background\n    called = future {\n      system(@command)\n    }\n\n    # Wait for the log file to be written to\n    while !File.exists?(@log_name)\n      sleep @sleept\n    end\n\n    log = File.open(@log_name)\n    log_content = read_file(log)\n    index = 0\n\n    while true\n      temp_content = read_file(log)\n\n      # make sure that there is new data\n      if temp_content.size > 0 && log_content != temp_content\n        log_content = temp_content\n\n        # break the loop if the command has completed\n        break if log_content[0] == @done_signal\n\n        # give the line and index to the block\n        yield log_content[0], index\n        index += 1\n      end\n    end\n\n    status = called.get\n    if status == true && delete_file == true\n      log.delete\n    end\n\n    return called.get\n  end\n\n  # Reads each line of the file into an Array of Strings\n  private def read_file(file : IO) : Array(String)\n    content = [] of String\n\n    file.each_line do |line|\n      content.push(line)\n    end\n\n    return content\n  end\nend\n"
  },
  {
    "path": "src/interact/ripper.cr",
    "content": "require \"./logger\"\nrequire \"../bottle/config\"\nrequire \"../bottle/styles\"\n\nmodule Ripper\n  extend self\n\n  BIN_LOC = Path[Config.binary_location]\n\n  # Downloads the video from the given *video_url* using the youtube-dl binary\n  # Will create any directories that don't exist specified in *output_filename*\n  #\n  # ```\n  # Ripper.download_mp3(\"https://youtube.com/watch?v=0xnciFWAqa0\",\n  #   \"Queen/A Night At The Opera/Bohemian Rhapsody.mp3\")\n  # ```\n  def download_mp3(video_url : String, output_filename : String)\n    ydl_loc = BIN_LOC.join(\"youtube-dl\")\n\n    # remove the extension that will be added on by ydl\n    output_filename = output_filename.split(\".\")[..-2].join(\".\")\n\n    options = {\n      \"--output\" => %(\"#{output_filename}.%(ext)s\"), # auto-add correct ext\n      # \"--quiet\" => \"\",\n      \"--verbose\"         => \"\",\n      \"--ffmpeg-location\" => BIN_LOC,\n      \"--extract-audio\"   => \"\",\n      \"--audio-format\"    => \"mp3\",\n      \"--audio-quality\"   => \"0\",\n    }\n\n    command = ydl_loc.to_s + \" \" + video_url\n    options.keys.each do |option|\n      command += \" #{option} #{options[option]}\"\n    end\n\n    l = Logger.new(command, \".ripper.log\")\n    o = RipperOutputCensor.new\n\n    return l.start do |line, index|\n      o.censor_output(line, index)\n    end\n  end\n\n  # An internal class that will keep track of what to output to the user or\n  # what should be hidden.\n  private class RipperOutputCensor\n    @dl_status_index = 0\n\n    def censor_output(line : String, index : Int32)\n      case line\n      when .includes? \"[download]\"\n        if @dl_status_index != 0\n          print \"\\e[1A\"\n          print \"\\e[0K\\r\"\n        end\n        puts line.sub(\"[download]\", \"   \")\n        @dl_status_index += 1\n\n        if line.includes? \"100%\"\n          print \"  Converting to mp3 ...\"\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "src/interact/tagger.cr",
    "content": "require \"../bottle/config\"\n\n# Uses FFMPEG binary to add metadata to mp3 files\n# ```\n# t = Tags.new(\"bohem rap.mp3\")\n# t.add_album_art(\"a night at the opera album cover.jpg\")\n# t.add_text_tag(\"title\", \"Bohemian Rhapsody\")\n# t.save\n# ```\nclass Tags\n  # TODO: export this path to a config file\n  @BIN_LOC = Config.binary_location\n  @query_args = [] of String\n\n  # initialize the class with an already created MP3\n  def initialize(@filename : String)\n    if !File.exists?(@filename)\n      raise \"MP3 not found at location: #{@filename}\"\n    end\n\n    @query_args.push(%(-i \"#{@filename}\"))\n  end\n\n  # Add album art to the mp3. Album art must be added BEFORE text tags are.\n  # Check the usage above to see a working example.\n  def add_album_art(image_location : String) : Nil\n    if !File.exists?(image_location)\n      raise \"Image file not found at location: #{image_location}\"\n    end\n\n    @query_args.push(%(-i \"#{image_location}\"))\n    @query_args.push(\"-map 0:0 -map 1:0\")\n    @query_args.push(\"-c copy\")\n    @query_args.push(\"-id3v2_version 3\")\n    @query_args.push(%(-metadata:s:v title=\"Album cover\"))\n    @query_args.push(%(-metadata:s:v comment=\"Cover (front)\"))\n    @query_args.push(%(-metadata:s:v title=\"Album cover\"))\n  end\n\n  # Add a text tag to the mp3. If you want to see what text tags are supported,\n  # check out: https://wiki.multimedia.cx/index.php?title=FFmpeg_Metadata\n  def add_text_tag(key : String, value : String) : Nil\n    @query_args.push(%(-metadata #{key}=\"#{value}\"))\n  end\n\n  # Run the necessary commands to attach album art to the mp3\n  def save : Nil\n    @query_args.push(%(\"_#{@filename}\"))\n    command = @BIN_LOC + \"/ffmpeg \" + @query_args.join(\" \")\n\n    l = Logger.new(command, \".tagger.log\")\n    l.start { |line, start| }\n\n    File.delete(@filename)\n    File.rename(\"_\" + @filename, @filename)\n  end\nend\n\n# a = Tags.new(\"test.mp3\")\n# a.add_text_tag(\"title\", \"Warwick Avenue\")\n# a.add_album_art(\"file.png\")\n# a.save()\n"
  },
  {
    "path": "src/irs.cr",
    "content": "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",
    "content": "alias VID_VALUE_CLASS = String\nalias VID_METADATA_CLASS = Hash(String, VID_VALUE_CLASS)\nalias YT_METADATA_CLASS = Array(VID_METADATA_CLASS)\n\nmodule Ranker\n  extend self\n\n  GARBAGE_PHRASES = [\n    \"cover\", \"album\", \"live\", \"clean\", \"version\", \"full\", \"full album\", \"row\",\n    \"at\", \"@\", \"session\", \"how to\", \"npr music\", \"reimagined\", \"version\",\n    \"trailer\"\n  ]\n\n  GOLDEN_PHRASES = [\n    \"official video\", \"official music video\",\n  ]\n\n  # Will rank videos according to their title and the user input, returns a sorted array of hashes\n  # of the points a song was assigned and its original index\n  # *spotify_metadata* is the metadate (from spotify) of the song that you want\n  # *yt_metadata* is an array of hashes with metadata scraped from the youtube search result page\n  # *query* is the query that you submitted to youtube for the results you now have\n  # ```\n  # Ranker.rank_videos(spotify_metadata, yt_metadata, query)\n  # => [\n  #      {\"points\" => x, \"index\" => x},\n  #      ...\n  #    ]\n  # ```\n  # \"index\" corresponds to the original index of the song in yt_metadata\n  def rank_videos(spotify_metadata : JSON::Any, yt_metadata : YT_METADATA_CLASS,\n                  query : String) : Array(Hash(String, Int32))\n    points = [] of Hash(String, Int32)\n    index = 0\n\n    actual_song_name = spotify_metadata[\"name\"].as_s\n    actual_artist_name = spotify_metadata[\"artists\"][0][\"name\"].as_s\n\n    yt_metadata.each do |vid|\n      pts = 0\n\n      pts += points_string_compare(actual_song_name, vid[\"title\"])\n      pts += points_string_compare(actual_artist_name, vid[\"title\"])\n      pts += count_buzzphrases(query, vid[\"title\"])\n      pts += compare_timestamps(spotify_metadata, vid)\n\n      points.push({\n        \"points\" => pts,\n        \"index\"  => index,\n      })\n      index += 1\n    end\n\n    # Sort first by points and then by original index of the song\n    points.sort! { |a, b|\n      if b[\"points\"] == a[\"points\"]\n        a[\"index\"] <=> b[\"index\"]\n      else\n        b[\"points\"] <=> a[\"points\"]\n      end\n    }\n\n    return points\n  end\n\n  # SINGULAR COMPONENT OF RANKING ALGORITHM\n  private def compare_timestamps(spotify_metadata : JSON::Any, node : VID_METADATA_CLASS) : Int32\n    # puts spotify_metadata.to_pretty_json()\n    actual_time = spotify_metadata[\"duration_ms\"].as_i\n    vid_time = node[\"duration_ms\"].to_i\n\n    difference = (actual_time - vid_time).abs \n\n    # puts \"actual: #{actual_time}, vid: #{vid_time}\"\n    # puts \"\\tdiff: #{difference}\"\n    # puts \"\\ttitle: #{node[\"title\"]}\"\n\n    if difference <= 1000\n      return 3\n    elsif difference <= 2000\n      return 2\n    elsif difference <= 5000\n      return 1\n    else \n      return 0\n    end\n  end\n\n  # SINGULAR COMPONENT OF RANKING ALGORITHM\n  # Returns an `Int` based off the number of points worth assigning to the\n  # matchiness of the string. First the strings are downcased and then all\n  # nonalphanumeric characters are stripped.\n  # If *item1* includes *item2*, return 3 pts.\n  # If after the items have been blanked, *item1* includes *item2*,\n  #   return 1 pts.\n  # Else, return 0 pts.\n  private def points_string_compare(item1 : String, item2 : String) : Int32\n    if item2.includes?(item1)\n      return 3\n    end\n\n    item1 = item1.downcase.gsub(/[^a-z0-9]/, \"\")\n    item2 = item2.downcase.gsub(/[^a-z0-9]/, \"\")\n\n    if item2.includes?(item1)\n      return 1\n    else\n      return 0\n    end\n  end\n\n  # SINGULAR COMPONENT OF RANKING ALGORITHM\n  # Checks if there are any phrases in the title of the video that would\n  # indicate audio having what we want.\n  # *video_name* is the title of the video, and *query* is what the user the\n  # program searched for. *query* is needed in order to make sure we're not\n  # subtracting points from something that's naturally in the title\n  private def count_buzzphrases(query : String, video_name : String) : Int32\n    good_phrases = 0\n    bad_phrases = 0\n\n    GOLDEN_PHRASES.each do |gold_phrase|\n      gold_phrase = gold_phrase.downcase.gsub(/[^a-z0-9]/, \"\")\n\n      if query.downcase.gsub(/[^a-z0-9]/, \"\").includes?(gold_phrase)\n        next\n      elsif video_name.downcase.gsub(/[^a-z0-9]/, \"\").includes?(gold_phrase)\n        good_phrases += 1\n      end\n    end\n\n    GARBAGE_PHRASES.each do |garbage_phrase|\n      garbage_phrase = garbage_phrase.downcase.gsub(/[^a-z0-9]/, \"\")\n\n      if query.downcase.gsub(/[^a-z0-9]/, \"\").includes?(garbage_phrase)\n        next\n      elsif video_name.downcase.gsub(/[^a-z0-9]/, \"\").includes?(garbage_phrase)\n        bad_phrases += 1\n      end\n    end\n\n    return good_phrases - bad_phrases\n  end\nend"
  },
  {
    "path": "src/search/spotify.cr",
    "content": "require \"http\"\nrequire \"json\"\nrequire \"base64\"\n\nrequire \"../glue/mapper\"\n\nclass SpotifySearcher\n  @root_url = Path[\"https://api.spotify.com/v1/\"]\n\n  @access_header : (HTTP::Headers | Nil) = nil\n  @authorized = false\n\n  # Saves an access token for future program use with spotify using client IDs.\n  # Specs defined on spotify's developer api:\n  # https://developer.spotify.com/documentation/general/guides/authorization-guide/#client-credentials-flow\n  #\n  # ```\n  # SpotifySearcher.new.authorize(\"XXXXXXXXXX\", \"XXXXXXXXXX\")\n  # ```\n  def authorize(client_id : String, client_secret : String) : self\n    auth_url = \"https://accounts.spotify.com/api/token\"\n\n    headers = HTTP::Headers{\n      \"Authorization\" => \"Basic \" +\n                         Base64.strict_encode \"#{client_id}:#{client_secret}\",\n    }\n\n    payload = \"grant_type=client_credentials\"\n\n    response = HTTP::Client.post(auth_url, headers: headers, form: payload)\n    if response.status_code != 200\n      @authorized = false\n      return self\n    end\n\n    access_token = JSON.parse(response.body)[\"access_token\"]\n\n    @access_header = HTTP::Headers{\n      \"Authorization\" => \"Bearer #{access_token}\",\n    }\n\n    @authorized = true\n\n    return self\n  end\n\n  # Check if the class is authorized or not\n  def authorized? : Bool\n    return @authorized\n  end\n\n  # Searches spotify with the specified parameters for the specified items\n  #\n  # ```\n  # spotify_searcher.find_item(\"track\", {\n  #   \"artist\" => \"Queen\",\n  #   \"track\" => \"Bohemian Rhapsody\"\n  # })\n  # => {track metadata}\n  # ```\n  def find_item(item_type : String, item_parameters : Hash, offset = 0,\n                limit = 20) : JSON::Any?\n    query = generate_query(item_type, item_parameters)\n\n    url = \"search?q=#{query}&type=#{item_type}&limit=#{limit}&offset=#{offset}\"\n    url = @root_url.join(url).to_s\n\n    response = HTTP::Client.get(url, headers: @access_header)\n    error_check(response)\n\n    items = JSON.parse(response.body)[item_type + \"s\"][\"items\"].as_a\n\n    points = rank_items(items, item_parameters)\n\n    to_return = nil\n\n    begin\n      # this means no points were assigned so don't return the \"best guess\"\n      if points[0][0] <= 0\n        to_return = nil\n      else\n        to_return = get_item(item_type, items[points[0][1]][\"id\"].to_s)\n      end\n    rescue IndexError\n      to_return = nil\n    end\n\n    # if this triggers, it means that a playlist has failed to be found, so\n    # the search will be bootstrapped into find_user_playlist\n    if to_return == nil && item_type == \"playlist\"\n      return find_user_playlist(\n        item_parameters[\"username\"],\n        item_parameters[\"name\"]\n      )\n    end\n\n    return to_return\n  end\n\n  # Grabs a users playlists and searches through it for the specified playlist\n  #\n  # ```\n  # spotify_searcher.find_user_playlist(\"prakkillian\", \"the little man\")\n  # => {playlist metadata}\n  # ```\n  def find_user_playlist(username : String, name : String, offset = 0,\n                         limit = 20) : JSON::Any?\n    url = \"users/#{username}/playlists?limit=#{limit}&offset=#{offset}\"\n    url = @root_url.join(url).to_s\n\n    response = HTTP::Client.get(url, headers: @access_header)\n    error_check(response)\n    body = JSON.parse(response.body)\n\n    items = body[\"items\"]\n    points = [] of Array(Int32)\n\n    items.as_a.each_index do |i|\n      points.push([points_compare(items[i][\"name\"].to_s, name), i])\n    end\n    points.sort! { |a, b| b[0] <=> a[0] }\n\n    begin\n      if points[0][0] < 3\n        return find_user_playlist(username, name, offset + limit, limit)\n      else\n        return get_item(\"playlist\", items[points[0][1]][\"id\"].to_s)\n      end\n    rescue IndexError\n      return nil\n    end\n  end\n\n  # Get the complete metadata of an item based off of its id\n  #\n  # ```\n  # SpotifySearcher.new.authorize(...).get_item(\"artist\", \"1dfeR4HaWDbWqFHLkxsg1d\")\n  # ```\n  def get_item(item_type : String, id : String, offset = 0,\n               limit = 100) : JSON::Any\n    if item_type == \"playlist\"\n      return get_playlist(id, offset, limit)\n    end\n\n    url = \"#{item_type}s/#{id}?limit=#{limit}&offset=#{offset}\"\n    url = @root_url.join(url).to_s\n\n    response = HTTP::Client.get(url, headers: @access_header)\n    error_check(response)\n\n    body = JSON.parse(response.body)\n\n    return body\n  end\n\n  # The only way this method differs from `get_item` is that it makes sure to\n  # insert ALL tracks from the playlist into the `JSON::Any`\n  #\n  # ```\n  # SpotifySearcher.new.authorize(...).get_playlist(\"122Fc9gVuSZoksEjKEx7L0\")\n  # ```\n  def get_playlist(id, offset = 0, limit = 100) : JSON::Any\n    url = \"playlists/#{id}?limit=#{limit}&offset=#{offset}\"\n    url = @root_url.join(url).to_s\n\n    response = HTTP::Client.get(url, headers: @access_header)\n    error_check(response)\n    body = JSON.parse(response.body)\n    parent = PlaylistExtensionMapper.from_json(response.body)\n\n    more_tracks = body[\"tracks\"][\"total\"].as_i > offset + limit\n    if more_tracks\n      return playlist_extension(parent, id, offset = offset + limit)\n    end\n\n    return body\n  end\n\n  # This method exists to loop through spotify API requests and combine all\n  # tracks that may not be captured by the limit of 100.\n  private def playlist_extension(parent : PlaylistExtensionMapper,\n                                 id : String, offset = 0, limit = 100) : JSON::Any\n    url = \"playlists/#{id}/tracks?limit=#{limit}&offset=#{offset}\"\n    url = @root_url.join(url).to_s\n\n    response = HTTP::Client.get(url, headers: @access_header)\n    error_check(response)\n    body = JSON.parse(response.body)\n    new_tracks = PlaylistTracksMapper.from_json(response.body)\n\n    new_tracks.items.each do |track|\n      parent.tracks.items.push(track)\n    end\n\n    more_tracks = body[\"total\"].as_i > offset + limit\n    if more_tracks\n      return playlist_extension(parent, id, offset = offset + limit)\n    end\n\n    return JSON.parse(parent.to_json)\n  end\n\n  # Find the genre of an artist based off of their id\n  #\n  # ```\n  # SpotifySearcher.new.authorize(...).find_genre(\"1dfeR4HaWDbWqFHLkxsg1d\")\n  # ```\n  def find_genre(id : String) : String | Nil\n    genre = get_item(\"artist\", id)[\"genres\"]\n\n    if genre.as_a.empty?\n      return nil\n    end\n\n    genre = genre[0].to_s\n    genre = genre.split(\" \").map { |x| x.capitalize }.join(\" \")\n\n    return genre\n  end\n\n  # Checks for errors in HTTP requests and raises one if found\n  private def error_check(response : HTTP::Client::Response) : Nil\n    if response.status_code != 200\n      raise(\"There was an error with your request.\\n\" +\n            \"Status code: #{response.status_code}\\n\" +\n            \"Response: \\n#{response.body}\")\n    end\n  end\n\n  # Generates url to run a GET request against to the Spotify open API\n  # Returns a `String.`\n  private def generate_query(item_type : String, item_parameters : Hash) : String\n    query = \"\"\n\n    # parameter keys to exclude in the api request. These values will be put\n    # in, just not their keys.\n    query_exclude = [\"username\"]\n\n    item_parameters.keys.each do |k|\n      # This will map album and track names from the name key to the query\n      if k == \"name\"\n        # will remove the \"name:<title>\" param from the query\n        if item_type == \"playlist\"\n          query += item_parameters[k] + \"+\"\n        else\n          query += as_field(item_type, item_parameters[k])\n        end\n\n        # check if the key is to be excluded\n      elsif query_exclude.includes?(k)\n        next\n\n        # if it's none of the above, treat it normally\n        # NOTE: playlist names will be inserted into the query normally, without\n        # a parameter.\n      else\n        query += as_field(k, item_parameters[k])\n      end\n    end\n\n    return URI.encode(query.rchop(\"+\"))\n  end\n\n  # Returns a `String` encoded for the spotify api\n  #\n  # ```\n  # query_encode(\"album\", \"A Night At The Opera\")\n  # => \"album:A Night At The Opera+\"\n  # ```\n  private def as_field(key, value) : String\n    return \"#{key}:#{value}+\"\n  end\n\n  # Ranks the given items based off of the info from parameters.\n  # Meant to find the item that the user desires.\n  # Returns an `Array` of `Array(Int32)` or [[3, 1], [...], ...]\n  private def rank_items(items : Array,\n                         parameters : Hash) : Array(Array(Int32))\n    points = [] of Array(Int32)\n    index = 0\n\n    items.each do |item|\n      pts = 0\n\n      # Think about whether this following logic is worth having in one method.\n      # Is it nice to have a single method that handles it all or having a few\n      # methods for each of the item types? (track, album, playlist)\n      parameters.keys.each do |k|\n        val = parameters[k]\n\n        # The key to compare to for artist\n        if k == \"artist\"\n          pts += points_compare(item[\"artists\"][0][\"name\"].to_s, val)\n        end\n\n        # The key to compare to for playlists\n        if k == \"username\"\n          pts_to_add = points_compare(item[\"owner\"][\"display_name\"].to_s, val)\n          pts += pts_to_add\n          pts += -10 if pts_to_add == 0\n        end\n\n        # The key regardless of whether item is track, album,or playlist\n        if k == \"name\"\n          pts += points_compare(item[\"name\"].to_s, val)\n        end\n      end\n\n      points.push([pts, index])\n      index += 1\n    end\n\n    points.sort! { |a, b| b[0] <=> a[0] }\n\n    return points\n  end\n\n  # Returns an `Int` based off the number of points worth assigning to the\n  # matchiness of the string. First the strings are downcased and then all\n  # nonalphanumeric characters are stripped.\n  # If the strings are the exact same, return 3 pts.\n  # If *item1* includes *item2*, return 1 pt.\n  # Else, return 0 pts.\n  private def points_compare(item1 : String, item2 : String) : Int32\n    item1 = item1.downcase.gsub(/[^a-z0-9]/, \"\")\n    item2 = item2.downcase.gsub(/[^a-z0-9]/, \"\")\n\n    if item1 == item2\n      return 3\n    elsif item1.includes?(item2)\n      return 1\n    else\n      return 0\n    end\n  end\n\nend\n\n# puts SpotifySearcher.new()\n#   .authorize(\"XXXXXXXXXXXXXXX\",\n#              \"XXXXXXXXXXXXXXX\")\n#   .find_item(\"playlist\", {\n#     \"name\" => \"Brain Food\",\n#     \"username\" => \"spotify\"\n#     # \"name \" => \"A Night At The Opera\",\n#     # \"artist\" => \"Queen\"\n#     # \"track\" => \"Bohemian Rhapsody\",\n#     # \"artist\" => \"Queen\"\n#   })\n"
  },
  {
    "path": "src/search/youtube.cr",
    "content": "require \"http\"\nrequire \"xml\"\nrequire \"json\"\nrequire \"uri\"\n\nrequire \"./ranking\"\n\nrequire \"../bottle/config\"\nrequire \"../bottle/styles\"\n\n\nmodule Youtube\n  extend self\n\n  VALID_LINK_CLASSES = [\n    \"yt-simple-endpoint style-scope ytd-video-renderer\",\n    \"yt-uix-tile-link yt-ui-ellipsis yt-ui-ellipsis-2 yt-uix-sessionlink      spf-link \",\n  ]\n\n  # Note that VID_VALUE_CLASS, VID_METADATA_CLASS, and YT_METADATA_CLASS are found in ranking.cr\n\n  # Finds a youtube url based off of the given information.\n  # The query to youtube is constructed like this:\n  #   \"<song_name> <artist_name> <search terms>\"\n  # If *download_first* is provided, the first link found will be downloaded.\n  # If *select_link* is provided, a menu of options will be shown for the user to choose their poison\n  #\n  # ```\n  # Youtube.find_url(\"Bohemian Rhapsody\", \"Queen\")\n  # => \"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"\n  # ```\n  def find_url(spotify_metadata : JSON::Any,\n               flags = {} of String => String) : String?\n\n    search_terms = Config.search_terms\n\n    select_link = flags[\"select\"]?\n\n    song_name = spotify_metadata[\"name\"].as_s\n    artist_name = spotify_metadata[\"artists\"][0][\"name\"].as_s\n\n    human_query = \"#{song_name} #{artist_name} #{search_terms.strip}\"\n    params = HTTP::Params.encode({\"search_query\" => human_query})\n\n    response = HTTP::Client.get(\"https://www.youtube.com/results?#{params}\")\n\n    yt_metadata = get_yt_search_metadata(response.body)\n\n    if yt_metadata.size == 0\n      puts \"There were no results for this query on youtube: \\\"#{human_query}\\\"\"\n      return nil\n    end\n\n    root = \"https://youtube.com\"\n    ranked = Ranker.rank_videos(spotify_metadata, yt_metadata, human_query)\n\n    if select_link\n      return root + select_link_menu(spotify_metadata, yt_metadata)\n    end\n\n    begin\n      puts Style.dim(\"  Video: \") + yt_metadata[ranked[0][\"index\"]][\"title\"]\n      return root + yt_metadata[ranked[0][\"index\"]][\"href\"]\n    rescue IndexError\n      return nil\n    end\n\n    exit 1\n  end\n\n  # Presents a menu with song info for the user to choose which url they want to download\n  private def select_link_menu(spotify_metadata : JSON::Any,\n                               yt_metadata : YT_METADATA_CLASS) : String\n    puts Style.dim(\"  Spotify info: \") +\n         Style.bold(\"\\\"\" + spotify_metadata[\"name\"].to_s) + \"\\\" by \\\"\" +\n         Style.bold(spotify_metadata[\"artists\"][0][\"name\"].to_s + \"\\\"\") +\n         \" @ \" + Style.blue((spotify_metadata[\"duration_ms\"].as_i / 1000).to_i.to_s) + \"s\"\n    puts \"  Choose video to download:\"\n    index = 1\n    yt_metadata.each do |vid|\n      print \"    \" + Style.bold(index.to_s + \" \")\n      puts \"\\\"\" + vid[\"title\"] + \"\\\" @ \" + Style.blue((vid[\"duration_ms\"].to_i / 1000).to_i.to_s) + \"s\"\n      index += 1\n      if index > 5\n        break\n      end\n    end\n\n    input = 0\n    while true # not between 1 and 5\n      begin\n        print Style.bold(\"  > \")\n        input = gets.not_nil!.chomp.to_i\n        if input < 6 && input > 0\n          break\n        end\n      rescue\n        puts Style.red(\"  Invalid input, try again.\")\n      end\n    end\n\n    return yt_metadata[input-1][\"href\"]\n\n  end\n\n  # Finds valid video links from a `HTTP::Client.get` request\n  # Returns an `Array` of `NODES_CLASS` containing additional metadata from Youtube\n  private def get_yt_search_metadata(response_body : String) : YT_METADATA_CLASS\n    yt_initial_data : JSON::Any = JSON.parse(\"{}\")\n\n    response_body.each_line do |line|\n      # timestamp 11/8/2020:\n      # youtube's html page has a line previous to this literally with 'scraper_data_begin' as a comment\n      if line.includes?(\"var ytInitialData\")\n        # Extract JSON data from line\n        data = line.split(\" = \")[2].delete(';')\n        dataEnd = (data.index(\"</script>\") || 0) - 1\n\n        begin\n          yt_initial_data = JSON.parse(data[0..dataEnd])\n        rescue\n          break\n        end\n      end\n    end\n\n    if yt_initial_data == JSON.parse(\"{}\")\n      puts \"Youtube has changed the way it organizes its webpage, submit a bug\"\n      puts \"saying it has done so on https://github.com/cooperhammond/irs\"\n      exit(1)\n    end\n\n    # where the vid metadata lives\n    yt_initial_data = yt_initial_data[\"contents\"][\"twoColumnSearchResultsRenderer\"][\"primaryContents\"][\"sectionListRenderer\"][\"contents\"]\n\n    video_metadata = [] of VID_METADATA_CLASS\n\n    i = 0\n    while true\n      begin\n        # video title\n        raw_metadata = yt_initial_data[0][\"itemSectionRenderer\"][\"contents\"][i][\"videoRenderer\"]\n\n        metadata = {} of String => VID_VALUE_CLASS\n\n        metadata[\"title\"] = raw_metadata[\"title\"][\"runs\"][0][\"text\"].as_s\n        metadata[\"href\"] = raw_metadata[\"navigationEndpoint\"][\"commandMetadata\"][\"webCommandMetadata\"][\"url\"].as_s\n        timestamp = raw_metadata[\"lengthText\"][\"simpleText\"].as_s\n        metadata[\"timestamp\"] = timestamp\n        metadata[\"duration_ms\"] = ((timestamp.split(\":\")[0].to_i * 60 +\n                               timestamp.split(\":\")[1].to_i) * 1000).to_s\n\n\n        video_metadata.push(metadata)\n      rescue IndexError\n        break\n      rescue Exception\n      end\n      i += 1\n    end\n\n    return video_metadata\n  end\n\n  # Returns as a valid URL if possible\n  #\n  # ```\n  # Youtube.validate_url(\"https://www.youtube.com/watch?v=NOTANACTUALVIDEOID\")\n  # => nil\n  # ```\n  def validate_url(url : String) : String | Nil\n    uri = URI.parse url\n    return nil if !uri\n\n    query = uri.query\n    return nil if !query\n\n    # find the video ID\n    vID = nil\n    query.split('&').each do |q|\n      if q.starts_with?(\"v=\")\n        vID = q[2..-1]\n      end\n    end\n    return nil if !vID\n\n    url = \"https://www.youtube.com/watch?v=#{vID}\"\n\n    # this is an internal endpoint to validate the video ID\n    params = HTTP::Params.encode({\"format\" => \"json\", \"url\" => url})\n    response = HTTP::Client.get \"https://www.youtube.com/oembed?#{params}\"\n    return nil unless response.success?\n\n    res_json = JSON.parse(response.body)\n    title = res_json[\"title\"].as_s\n    puts Style.dim(\"  Video: \") + title\n\n    return url\n  end\nend\n"
  }
]