.
+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.
diff --git a/README.md b/README.md
old mode 100644
new mode 100755
index 7818c0f..8945327
--- a/README.md
+++ b/README.md
@@ -1,85 +1,177 @@
-# Ironic Redistribution System
+# irs: The Ironic Repositioning System
-[![License: GNU](https://img.shields.io/badge/license-gnu-yellow.svg?style=flat-square)](http://www.gnu.org/licenses/gpl.html)
-[![Stars](https://img.shields.io/github/stars/kepoorhampond/irs.svg?style=flat-square)](https://github.com/kepoorhampond/irs/stargazers)
-[![Say Thanks](https://img.shields.io/badge/say-thanks-ff69b4.svg?style=flat-square)](https://saythanks.io/to/kepoorhampond)
-[![PyPI](https://img.shields.io/badge/pypi-irs-blue.svg?style=flat-square)](https://pypi.python.org/pypi/irs)
+[![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 downloader that understands your metadata needs.
+> A music scraper that understands your metadata needs.
-A tool to download your music with metadata. It uses [Spotify](https://www.spotify.com/) for finding metadata and [Youtube](https://www.youtube.com/) for the actual audio source. You will need to have some Spotify tokens, the instructions to set them up are [here](https://github.com/kepoorhampond/irs#spotify-tokens).
+`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.
-Works with Python 2 and 3.
+
+
+
+
+
+---
+
+## 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
-## Install and Setup
```
-$ sudo pip install irs
-$ irs --setup
+~ $ irs -h
+
+Usage: irs [--help] [--version] [--install]
+ [-s -a ]
+ [-A -a ]
+ [-p -a ]
+
+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 Specify artist name for downloading
+ -s, --song Specify song name to download
+ -A, --album Specify the album name to download
+ -p, --playlist Specify the playlist name to download
+
+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
```
-**You will need to have some Spotify tokens, the instructions to set them up are [here](https://github.com/kepoorhampond/irs#spotify-tokens).**
+### Demo
+[![asciicast](https://asciinema.org/a/332793.svg)](https://asciinema.org/a/332793)
-## Demo and Usages
+## Installation
-The usages can be found with the `-h` or `--help` flag:
+### Pre-built
+
+Just download the latest release for your platform
+[here](https://github.com/cooperhammond/irs/releases).
+
+### 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
+ 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
+client_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+client_secret: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+single_folder_playlist:
+ enabled: true
+ retain_playlist_order: true
+ unify_into_album: false
```
-usage: irs [-h] [-S] [-a ARTIST] [-s SONG] [-A ALBUM] [-p PLAYLIST]
- [-u USERNAME] [-o ORGANIZATION]
+ - `binary_directory`: a path specifying where the downloaded binaries should
+ be placed
+ - `music_directory`: a path specifying where downloaded mp3s should be placed.
+ Note that there will be more structure created inside that folder, usually
+ in the format of `music-dir>artist-name>album-name>track`
+ - `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
-optional arguments:
- -h, --help show this help message and exit
- -S, --setup Run this by itself to setup config files and folder
- for irs and download the ffmpeg binaries
- -a ARTIST, --artist ARTIST
- Specify artist name. Must be used with -s/--song or
- -A/--album
- -s SONG, --song SONG Specify song name. Must be used with -a/--artist
- -A ALBUM, --album ALBUM
- Specify album name. Can be used by itself.
- -p PLAYLIST, --playlist PLAYLIST
- Specify playlist name. Must be used with -A/--album
- -u USERNAME, --username USERNAME
- Specify user name for playlist. Must be used with
- -A/--album
- -o ORGANIZATION, --organization ORGANIZATION
- Specify type of organization for list. Used when
- downloading spotify playlist/album
-```
+## How it works
-So all of these are valid commands:
-```
-$ irs -a "Brandon Flowers" -s "Lonely Town"
-$ irs -u "spotify" -p "Brain Food"
-$ irs -A "Suicide Squad: The Album"
-```
-But these are not:
-```
-$ irs -s "Bohemian Rhapsody"
-$ irs -p "Best Nirvana"
-```
+**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.
-## Spotify Tokens
+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.
-To download metadata through spotify, you'll want to head to their Dev Apps page, [here](https://developer.spotify.com/my-applications/). After doing that you'll want to create a new app. Name it whatever you want and then once you've done that, find the `Client ID` and `Client Secret` keys. You'll want to take those keys and paste them into your system's environment variables as `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`, correspondingly. VoilĂ ! You can now download metadata with IRS!
+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)
-## Metadata
-Currently, the program attaches the following metadata to the downloaded files:
- - Title
- - Artist
- - Album
- - Album Art
- - Genre
- - Track Number
- - Disc Number
+## Contributing
-## Wishlist
+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.
- - [x] Full album downloading
- - [x] Album art metadata correctly displayed
- - [x] Spotify playlist downloading
- - [ ] Comment metadata
- - [ ] Compilation metadata
- - [ ] GUI/Console interactive version - *in progress*
- - [ ] Lyric metadata
+1. Fork it ()
+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
\ No newline at end of file
diff --git a/irs/__init__.py b/irs/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/irs/cli/__init__.py b/irs/cli/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/irs/cli/cli.py b/irs/cli/cli.py
deleted file mode 100644
index 8079625..0000000
--- a/irs/cli/cli.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import argparse
-
-from ..glue.song import Song
-from ..glue.album import Album
-from ..glue.playlist import Playlist
-from ..install.setup import set_it_up
-from .config_parser import parse_config
-
-def main():
- """The main cli method. Parses arguments from the command line."""
-
- parser = argparse.ArgumentParser()
-
- parser.add_argument("-S", "--setup", dest="setup", action='store_true',
- help="Run this by itself to setup config files "
- "and folder for irs and download the ffmpeg binaries")
-
- parser.add_argument("-a", "--artist", dest="artist",
- help="Specify artist name. Must be used with -s/--song or -A/--album")
-
- parser.add_argument("-s", "--song", dest="song",
- help="Specify song name. Must be used with -a/--artist")
-
- parser.add_argument("-A", "--album", dest="album",
- help="Specify album name. Can be used by itself.")
-
- parser.add_argument("-p", "--playlist", dest="playlist",
- help="Specify playlist name. Must be used with -A/--album")
-
- parser.add_argument("-u", "--username", dest="username",
- help="Specify user name for playlist. Must be used with -A/--album")
-
- parser.add_argument("-o", "--organization", dest="organization",
- default="single-folder", help="Specify type of organization for list."
- " Used when downloading spotify playlist/album")
-
- args = parser.parse_args()
-
- if args.setup:
- set_it_up()
-
- parse_config()
-
- if args.song and args.artist: # single song
- Song(args.song, args.artist).grab_it()
- elif args.album and args.artist: # album with an artist
- Album(args.album, args.artist).grab_it()
- elif args.album: # album without artist
- Album(args.album).grab_it()
- elif args.playlist and args.username: # playlist
- Playlist(args.playlist, args.username, args.organization).grab_it()
diff --git a/irs/cli/config_parser.py b/irs/cli/config_parser.py
deleted file mode 100644
index 6104591..0000000
--- a/irs/cli/config_parser.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import os
-import sys
-
-import yaml
-
-
-def parse_config():
- """Parses config using environment variables."""
-
- home = os.environ.get("HOME") or os.path.expanduser("~/")
-
- check_for_and_set("irs_config_dir", home + "/.irs", ".irs/")
-
- check_for = [home + "/.irs/config.yml", home + "/.irs/bin/ffmpeg",
- home + "/.irs/bin/ffprobe"]
-
- for path in check_for:
- if not os.path.exists(path):
- print("There's no config set up. Set up a configuration folder by "
- "running `irs --setup`")
- sys.exit(1)
-
- config = {}
-
- with open(os.environ["irs_config_dir"] + "/config.yml", "r") as stream:
- try:
- config = yaml.safe_load(stream)
- except yaml.YAMLError as exc:
- print(exc)
-
- check_for_and_set("SPOTIFY_CLIENT_ID", config.get(
- "SPOTIFY_KEYS").get("CLIENT_ID"), None)
- check_for_and_set("SPOTIFY_CLIENT_SECRET", config.get(
- "SPOTIFY_KEYS").get("CLIENT_SECRET"), None)
-
- check_for_and_set("irs_music_dir", os.path.expanduser(config.get("music_directory")),
- home + "/Music")
- check_for_and_set("irs_ffmpeg_dir", os.environ["irs_config_dir"] + "/bin", None)
-
-
-def check_for_and_set(key, val, else_):
- """Checks for an environment variable and if it doesn't exist, then set it
- equal to the val given.
- :param key: string, key to check for existence
- :param val: value to replace key value with if it doesn't exists
- :param else_: if val doesn't exist, use else_ instead
- """
- if not os.environ.get(key):
- if key:
- os.environ[key] = val
- else:
- os.environ[key] = else_
diff --git a/irs/glue/__init__.py b/irs/glue/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/irs/glue/album.py b/irs/glue/album.py
deleted file mode 100644
index 8f6af7a..0000000
--- a/irs/glue/album.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from .list import SpotifyList
-
-class Album(SpotifyList):
- """A class for downloading albums as a whole."""
-
- def _SpotifyList__find_it(self):
- album = self.spotify_searcher.find_album(
- self.list_title,
- self.list_author
- )
- return album
-
- def _SpotifyList__set_organization(self, song_index, song):
- pass
\ No newline at end of file
diff --git a/irs/glue/list.py b/irs/glue/list.py
deleted file mode 100644
index b7f9a7b..0000000
--- a/irs/glue/list.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import sys
-import abc
-
-from ..search import spotify
-from .song import Song
-
-class SpotifyList(object):
- """A parent class for downloading spotify albums and playlists"""
-
- def __init__(self, list_title, list_author=None):
- self.spotify_searcher = spotify.SpotifySearcher().authorize()
- self.list_title = list_title
- self.list_author = list_author
- self.file_names = []
-
- def grab_it(self):
- """Downloads the songs!
- """
- spotify_list = self.__find_it()
- list_contents = spotify_list["tracks"]["items"]
-
- for index, s in enumerate(list_contents):
- # if it's a playlist, get the actual track, not the metadata of
- # the playlist
- if s.get("track"):
- s = s["track"]
-
- song = Song(s["name"], s["artists"][0]["name"])
- song.provide_spotify(self.spotify_searcher)
- song.provide_metadata(self.spotify_searcher.song(s["uri"]))
- song.get_relevant_tags()
- self.__set_organization(index, song)
- song.grab_it()
-
- # These following functions are named weird b/c PEP8 and python are in
- # conflict. An error gets raised when private methods
- # (prefix = __ due to PEP8) are overriden by child class with the same
- # name b/c python is dumb with how it concatenates class names and method
- # names with underscores
-
- def __find_it(self):
- """Finds the list and return it"""
- raise NotImplementedError("Must override __find_it with"
- "_SpotifyList__find_it")
-
- def __set_organization(self, song_index, song):
- """Post processing method for a single song
- :param song: Song class
- """
- raise NotImplementedError("Must override __post_process with"
- "_SpotifyList__set_organization")
\ No newline at end of file
diff --git a/irs/glue/playlist.py b/irs/glue/playlist.py
deleted file mode 100644
index 1c2695a..0000000
--- a/irs/glue/playlist.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import os
-
-from .list import SpotifyList
-
-class Playlist(SpotifyList):
- """A class for downloading albums as a whole.
- The majority of specs for methods can be found in the SpotifyList file"""
-
- def __init__(self, playlist_name, username, organization="single-folder"):
- """
- :param playlist_name: a string, the name of the playlist
- :param username: a string, the username of the creator of the playlist
- :param organization: a string, following options:
- "single-folder": All of the songs downloaded will be put into a
- single folder. root-music-dir>playlist-name
- "standard": All of the songs downloaded will be
- organized by root-music-dir>artist>album
- """
- super(Playlist, self).__init__(playlist_name, username)
- self.organization = organization
-
- def _SpotifyList__find_it(self):
- playlist = self.spotify_searcher.find_playlist(
- self.list_title,
- self.list_author
- )
- return playlist
-
- def _SpotifyList__set_organization(self, song_index, song):
- if self.organization == "standard":
- song.set_standard_organization()
- elif self.organization == "single-folder":
- # reindex the file names in order to keep them in alphabetical order
- song.provide_new_file_name("{} - {}.mp3".format(
- song_index + 1, song.tags["title"]
- ))
- song.provide_new_location(os.path.join(
- os.getcwd(), self.list_title
- ))
\ No newline at end of file
diff --git a/irs/glue/song.py b/irs/glue/song.py
deleted file mode 100644
index 1486a2f..0000000
--- a/irs/glue/song.py
+++ /dev/null
@@ -1,193 +0,0 @@
-import os
-import sys
-import errno
-import string
-
-from ..search import spotify, youtube
-from ..interact import ripper, tagger
-
-
-class Song(object):
- """A grabber for a single song. Unless provided, gets metadata, finds url,
- downloads, converts, tags, and moves it."""
- def __init__(self, song_title, artist_name):
- self.song_title = song_title
- self.artist_name = artist_name
-
- self.spotify_searcher = None
- self.spotify_authenticated = False
-
- self.metadata = None
- self.tags = {}
- self.parsed_tags = False
-
- self.file_name = song_title + ".mp3"
- self.end_file_name = None
-
- self.current_location = os.getcwd()
- self.end_location = None
-
- def grab_it(self, post_process=True):
- """The main method to call for this class. Unless provided, grabs
- metadata, finds the url, downloads the video, converts it, and
- tags it.
- :param post_process: boolean,
- :rtype: a string, the file_name
- """
-
- self.metadata = self.__parse_data()
-
- self.tags = self.get_relevant_tags()
-
- print("'{}' by {}:".format(self.tags["title"], self.tags["artist"]))
-
- print("Searching youtube ...")
- song_url = youtube.find_url(self.tags["title"], self.tags["artist"])
-
- if self.metadata:
- self.file_name = '{} - {}.mp3'.format(
- self.tags["tracknumber"],
- self.tags["title"]
- )
-
- print("Downloading ...")
- ripper.rip_from_url(song_url, self.file_name)
- print("Converting to mp3 ...") # TODO: add this into a hook for ydl
-
- print("Tagging ...")
- song_tags = tagger.Tagger(self.file_name)
-
- for tag in self.tags:
- if tag is "albumart":
- song_tags.add_album_art(self.tags[tag])
- else:
- song_tags.add_tag(tag, self.tags[tag])
-
- if post_process:
- self.__organize()
-
- def provide_spotify(self, spotify_searcher):
- """This function will set this class's spotify searcher to the one
- provided to prevent the need to authenticate twice
- :param spotify_searcher: an instance of
- irs.searcher.spotify.SpotifySearcher, the spotify searcher to use
- :rtype: self class
- """
- self.spotify_searcher = spotify_searcher
- self.spotify_authenticated = True
- return self
-
- def provide_metadata(self, metadata):
- """Provides metadata for the song so searches don't have to be
- performed twice. If this is called with new metadata,
- other metadata won't be searched.
- :param metadata: a dict, the new metadata from a spotipy track search
- :rtype: self class
- """
- self.metadata = metadata
- return self
-
- def provide_tag(self, key, value):
- """Provides tags for the song. Tags will still be parsed, but will not
- overwrite these provided tags.
- :param: a dict, the tags that will overwrite the metadata provided tags
- :rtype: self class
- """
- self.tags[key] = value
- return self
-
- def provide_new_location(self, new_loc):
- """Provides a new, non-default location for the song.
- :param new_loc: a string, the path of the new location WITHOUT filename
- :rtype: self class
- """
- self.end_location = new_loc
- return self
-
- def provide_new_file_name(self, new_name):
- """Provides a new file name for the song file. DOESNT append .mp3
- :param new_name: string
- :rtype: self class
- """
- self.end_file_name = new_name
- return self
-
- def set_standard_organization(self):
- """Sets standard organization for the file, which is
- root-music-dir>artist-folder>album-folder>song
- """
- if not self.parsed_tags:
- self.tags = self.get_relevant_tags()
- self.end_location = os.path.join(
- os.environ.get("irs_music_dir"), self.tags["artist"],
- self.tags["album"]
- )
- self.end_file_name = "{} - {}.mp3".format(
- self.tags["tracknumber"], self.tags["title"]
- )
-
- def get_relevant_tags(self):
- """Sorts relevant info from the spotipy metadata. Merges with any
- provided tags from provide_tags method.
- :rtype: a dict, parsed tags
- """
- # TODO: come up with fallback solution if there's no metadata found
- # follows this pattern:
- # if this does not exist:
- # set the thing that doesn't exist to a
- # specific value from the metadata dict
- tags = self.tags
- metadata = self.metadata
-
- if not tags.get("title"):
- tags["title"] = metadata["name"]
- if not tags.get("artist"):
- tags["artist"] = metadata["artists"][0]["name"]
- if not tags.get("album"):
- tags["album"] = metadata["album"]["name"]
- if not tags.get("tracknumber"):
- tags["tracknumber"] = str(metadata["track_number"])
- if not tags.get("albumart"):
- tags["albumart"] = metadata["album"]["images"][0]["url"]
- if not tags.get("genre") and self.spotify_searcher:
- tags["genre"] = string.capwords(self.spotify_searcher.artist(
- metadata["artists"][0]["uri"])["genres"][0])
-
- self.tags = tags
- return self.tags
-
- def __organize(self):
- """Based off of self.current_location, self.end_location, and self.
- file_name, this function creates folders for the end location and moves
- the file there.
- """
- if not self.end_location:
- self.set_standard_organization()
-
- if not os.path.exists(self.end_location):
- # try loop to prevent against race conditions with os.path.exists
- # and os.makedirs
- try:
- os.makedirs(self.end_location)
- except OSError as exc:
- if exc.errno != errno.EEXIST:
- raise
-
- os.rename(
- self.current_location + "/" + self.file_name,
- self.end_location + "/" + self.end_file_name,
- )
-
- def __parse_data(self):
- """If a spotify searcher has not been provided, create one."""
- if not self.spotify_authenticated and not self.metadata:
- self.spotify_searcher = spotify.SpotifySearcher().authorize()
- self.spotify_authenticated = True
-
- """If metadata has not been provided, search for it."""
- if not self.metadata:
- self.metadata = self.spotify_searcher.find_song(
- self.song_title, self.artist_name
- )
-
- return self.metadata
diff --git a/irs/interact/__init__.py b/irs/interact/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/irs/interact/ripper.py b/irs/interact/ripper.py
deleted file mode 100644
index b6dd57e..0000000
--- a/irs/interact/ripper.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import os
-import sys
-import glob
-import shutil
-
-import youtube_dl
-
-
-def rip_from_url(video_url, output_name):
- """This method downloads a video, converts it to an MP3 and renames it
- :param video_url: a string, youtube url of the video you want to download
- :param output_name: a string, the name of the output file
- """
-
- ydl_opts = {
- 'format': 'bestaudio/best',
- 'postprocessors': [{
- 'key': 'FFmpegExtractAudio',
- 'preferredcodec': 'mp3',
- 'preferredquality': '192',
- }],
- 'logger': _DownloadLogger(),
- 'progress_hooks': [_download_hook],
- 'output': "tmp_file",
- 'prefer-ffmpeg': True,
- 'ffmpeg_location': os.environ["irs_ffmpeg_dir"],
- }
-
- with youtube_dl.YoutubeDL(ydl_opts) as ydl:
- ydl.download([video_url])
-
- for f in glob.glob("./*%s*" % video_url.split("/watch?v=")[-1]):
- shutil.move(f, output_name)
-
-
-class _DownloadLogger(object):
- def debug(self, msg):
- pass
-
- def warning(self, msg):
- pass
-
- def error(self, msg):
- print(msg)
-
-
-# TODO: update the download log
-def _download_hook(d):
- if d['status'] == 'finished':
- print("Done!")
\ No newline at end of file
diff --git a/irs/interact/tagger.py b/irs/interact/tagger.py
deleted file mode 100644
index 26a6409..0000000
--- a/irs/interact/tagger.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import sys
-
-if sys.version_info[0] >= 3:
- from urllib.request import urlopen
-elif sys.version_info[0] < 3:
- from urllib import quote_plus, quote
- from urllib import urlopen
-
-from mutagen.mp3 import EasyMP3
-from mutagen.easyid3 import EasyID3, EasyID3KeyError
-from mutagen.id3 import APIC, ID3
-
-
-class Tagger(object):
- """Attaches ID3 tags to MP3 files."""
-
- def __init__(self, location):
- """Initializes the class and generates ID3 tags for the mp3
- :param location: a string, the location of the mp3 that you want ID3
- tags on
- """
- EasyID3.RegisterTextKey("comment", "COMM")
- self.location = location
- self.mp3 = EasyID3(self.location)
-
- def add_tag(self, tag, data):
- """Adds a tag to the mp3 file you specified in __init__ and saves it
- :param tag: a string, the name of the tag you want to add to the mp3
- valid tag names:
- "title", "artist", "album", "genre", "tracknumber" (string),
- "discnumber" (string),
- "compilation" ("1" for true, "" for false)
- :param data: a string, the data that you want to attach to the mp3
- under the specified tag name
- """
- # For valid tags: `EasyID3.valid_keys.keys()`
- self.mp3[tag] = data
- self.mp3.save()
-
- def read_tag(self, tag):
- """Tries to read a tag from the initialized mp3 file
- :param tag: a string, the name of the tag you want to read
- :rtype: an array with a string inside. The string inside the array is
- the data you're requesting. If there's no tag associated or no data
- attached with your requested tag, a blank array will be returned.
- """
- try:
- return self.mp3[tag]
- except EasyID3KeyError or KeyError:
- return []
-
- def add_album_art(self, image_url):
- """Adds album art to the initialized mp3 file
- :param image_url: a string, the url of the image you want to attach to
- the mp3
- """
- mp3 = EasyMP3(self.location, ID3=ID3)
- mp3.tags.add(
- APIC(
- encoding = 3,
- mime = 'image/png',
- type = 3,
- desc = 'cover',
- data = urlopen(image_url).read()
- )
- )
- mp3.save()
\ No newline at end of file
diff --git a/irs/search/__init__.py b/irs/search/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/irs/search/spotify.py b/irs/search/spotify.py
deleted file mode 100644
index 742ea9f..0000000
--- a/irs/search/spotify.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import os
-import sys
-import re
-
-import spotipy
-from spotipy.oauth2 import SpotifyClientCredentials
-
-
-class SpotifySearcher(object):
- """Searches spotify for song, album, and playlist metadata."""
-
- def authorize(self, client_id=None, client_secret=None):
- """Authorizes this class with spotify using client ids
- :rtype: returns self class
- """
-
- # TODO: remove these when you finish config files
- if not client_id:
- client_id = os.environ["SPOTIFY_CLIENT_ID"]
- if not client_secret:
- client_secret = os.environ["SPOTIFY_CLIENT_SECRET"]
-
- try:
- creds = SpotifyClientCredentials(client_id, client_secret)
- self.authorized = True
- self.spotify = spotipy.Spotify(client_credentials_manager=creds)
- except Exception:
- self.authorized = False
- self.spotify = spotipy.Spotify()
-
- return self
-
- def find_song(self, song_title, artist_name, limit=50, offset=0):
- """Searches spotify for a song and grabs its metadata
- :param song_title: a string, the title of the song you're looking for
- :param artist_name: a string, the artist of the above song
- :rtype: a dictionary of metadata about the song
- """
- songs = self.spotify.search(q=song_title, type="track")["tracks"]
-
- for song in songs["items"]:
- if _simplify(song_title) in _simplify(song["name"]) and \
- _simplify(artist_name) in _simplify(song["artists"][0]["name"]):
- return song
-
- if songs['next']:
- return self.find_song(song_title, artist_name,
- offset=offset + limit)
- else:
- print("There were no songs found by that name with that artist.")
- sys.exit(1)
-
- def find_album(self, album_title, artist_name=None, limit=50, offset=0):
- """Searches spotify for an album and grabs its contents and metadata
- :param album_title: a string, the title of the album
- :param artist_name: a string, the name of the artist of the album
- :rtype: a dictionary of metadata about the album
- """
- query = album_title
- if artist_name:
- query += " " + artist_name
- albums = self.spotify.search(q=query, type="album")['albums']
-
- for album in albums['items']:
- if _simplify(album_title) in _simplify(album["name"]):
- return self.spotify.album(album['uri'])
-
- if albums['next']:
- return self.find_album(album_title, artist_name,
- offset=offset + limit)
- else:
- print("There were no albums found by that name with that artist.")
- sys.exit(1)
-
- def find_playlist(self, playlist_title, username, limit=50, offset=0):
- """Searches spotify for a playlist and grabs its contents and metadata
- :param playlist_title: a string, the title of the playlist
- :param username: a string, the username of the playlist creator/owner
- :rtype: a dictionary of metadata about the playlist
- """
- playlists = []
- playlists = self.spotify.user_playlists(username, limit, offset)
-
- for playlist in playlists['items']:
- if _simplify(playlist_title) in _simplify(playlist['name']):
- return self.spotify.user_playlist(username, playlist['id'])
-
- if playlists['next']:
- return self.find_playlist(playlist_title, username,
- offset=offset + limit)
- else:
- print("There were no playlists by that name found.")
- sys.exit(1)
-
- def artist(self, artist_uri):
- """Gets artist metadata from uri
- :param artist_uri: the spotify uri for the artist
- :rtype: a dict of info about the artist
- """
- return self.spotify.artist(artist_uri)
-
- def song(self, song_uri):
- """Gets song metadata from uri
- :param song_uri: the spotify uri for the artist
- :rtype: a dict of info about the artist
- """
- return self.spotify.track(song_uri)
-
-
-
-# TODO: export this function to a utilities file
-def _simplify(string):
- """Lowercases and strips all non alphanumeric characters from the string
- :param string: a string to be modified
- :rtype: the modified string
- """
- if type(string) == bytes:
- string = string.decode()
- return re.sub(r'[^a-zA-Z0-9]+', '', string.lower())
\ No newline at end of file
diff --git a/irs/search/youtube.py b/irs/search/youtube.py
deleted file mode 100644
index 3cecc33..0000000
--- a/irs/search/youtube.py
+++ /dev/null
@@ -1,191 +0,0 @@
-import sys
-import re
-
-if sys.version_info[0] >= 3:
- from urllib.parse import urlencode
- from urllib.request import urlopen
-elif sys.version_info[0] < 3:
- from urllib import urlencode
- from urllib import urlopen
-else:
- print("Must be using Python 2 or 3")
- sys.exit(1)
-
-from bs4 import BeautifulSoup
-
-
-def find_url(song_title, artist_name, search_terms=None, caught_by_google=False, download_first=False):
- """Finds the youtube video url for the requested song. The youtube
- query is constructed like this:
- " "
- so plugging in "Bohemian Rhapsody", "Queen", and "lyrics" would end
- up with a search for "Bohemian Rhapsody Queen lyrics" on youtube
- :param-required song: song name
- :param-required artist: artist name
- :param search_terms: any additional search terms you may want to
- add to the search query
- :param caught_by_google: a boolean, if not false or none, turns on
- the captcha catcher
- :param download_first: a boolean, if true, downloads first video
- that youtube returns
- :rtype: A string of the youtube url for the song
- """
-
- query = artist_name + " " + song_title
- if search_terms:
- query += " " + search_terms
-
- encoded_query = urlencode({"search_query": query})
-
- url = "http://www.youtube.com/results?" + encoded_query
-
- soup = _get_url_data(url, caught_by_google)
-
- # if you want to inspect the html being requested
- # print(soup.prettify())
- # with open("index.html", "wb") as f:
- # f.write(soup.prettify().encode('utf-8'))
-
- # Each of the tags in the results list have the following relevant
- # attributes:
- # "title": the title of the youtube video
- # "href": the youtube video code, namely the X's of
- # https://www.youtube.com/watch?v=XXXXXXXXXXX
- # "class": the classes of the link, used to identify the youtube title
- results = _find_links(soup)
-
- best_guess = None
- total_tries_counter = 0
-
- if len(results) <= 0:
- raise Exception('There were no search results for "{}"'.format(query))
-
- if download_first == True:
- return "https://youtube.com" + results[0]["href"]
-
- scores = []
-
- for index, link in enumerate(results):
- scores.append([
- index,
- _score_song(song_title, artist_name, link["title"]),
- link["href"]
- ])
-
- # sort by the score of the song
- sorted(scores, key=lambda x: x[1])
-
- return "https://youtube.com" + results[scores[0][0]]["href"]
-
-
-def _score_song(song_title, artist_name, video_title):
- """Scores the likelihood of the song audio being in the video based off of
- the video title.
- :param song_title: a string, the title of the song that you're looking for
- :param video_title: a string, the title of the video you're analyzing
- :rtype: an integer, the score of the song
- """
- points = 0
-
- song_title = _simplify(song_title)
- artist_name = _simplify(artist_name)
- video_title = _simplify(video_title)
-
- if song_title in video_title:
- points += 3
-
- if artist_name in video_title:
- points += 3
-
- points -= _count_garbage_phrases(video_title, song_title)
-
- return points
-
-
-def _simplify(string):
- """Lowercases and strips all non alphanumeric characters from the string
- :param string: a string to be modified
- :rtype: the modified string
- """
- if type(string) == bytes:
- string = string.decode()
- return re.sub(r'[^a-zA-Z0-9]+', '', string.lower())
-
-
-def _count_garbage_phrases(video_title, song_title):
- """Checks if there are any phrases in the title of the video that would
- indicate it doesn't have the audio we want
- :param string: a string, the youtube video title
- :param title: a string, the actual title of the song we're looking for
- :rtype: an integer, of the number of bad phrases in the song
- """
-
- # Garbage phrases found through experiences of downloading the wrong song
- # TODO: add this into the config so the user can mess with it if they want
- garbage_phrases = (
- "cover album live clean rare version full full album row at "
- "@ session how to npr music reimagined hr version"
- ).split(" ")
-
- bad_phrases = 0
-
- for gphrase in garbage_phrases:
- # make sure we're not invalidating part of the title of the song
- if gphrase in song_title.lower():
- continue
-
- # check if the garbage phrase is not in the video title
- if gphrase in video_title.lower():
- bad_phrases += 1
-
- return bad_phrases
-
-
-def _find_links(soup):
- """Finds youtube video links in the html soup
- :param soup: a BeautifulSoup(...) element
- :rtype: returns a list of valid youtube video links
- """
- return list(filter(None, map(_find_link, soup.find_all("a"))))
-
-
-def _find_link(link):
- """Tests html tags to see if they contain a youtube video link.
- Should be used only with the find_links function in a map func.
- :param link: accepts an element from BeautifulSoup(...).find_all(...)
- :rtype: returns the link if it's an actual video link, otherwise, None
- """
- try:
- class_to_check = str(" ".join(link["class"]))
- except KeyError:
- return
-
- # these classes are found by inspecting the html soup of a youtube search.
- valid_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 ")
- ]
-
- try:
- # Make sure it's not a playlist
- if "&list=" in link["href"]:
- return
-
- for valid_class in valid_classes:
- if valid_class in class_to_check:
- return link
- except KeyError:
- pass
-
-
-# TODO: build in the captcha cheater if the user is "caught" by google
-def _get_url_data(url, caught_by_google):
- """Gets parsed html from the specified url
- :param url: A string, the url to request and parse.
- :param caught_by_google: A boolean, will open and use the captcha
- cheat to get around google's captcha.
- :rtype: A BeautifulSoup class
- """
- html_content = urlopen(url).read()
- return BeautifulSoup(html_content, 'html.parser')
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 79bc678..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,5 +0,0 @@
-[bdist_wheel]
-# This flag says that the code is written to work on both Python 2 and Python
-# 3. If at all possible, it is good practice to do this. If you cannot, you
-# will need to generate wheels for each Python version that you support.
-universal=1
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 05a66af..0000000
--- a/setup.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from setuptools import setup
-
-setup(
- name = 'irs',
- version = '7.0.2',
- description = 'A music downloader that gets metadata too.',
- url = 'https://github.com/kepoorhampond/irs',
- author = 'Kepoor Hampond',
- author_email = 'kepoorh@gmail.com',
- license = 'GPL',
- packages = ['irs', 'irs.search', 'irs.interact', 'irs.glue',
- 'irs.install', 'irs.cli'],
- install_requires = [
- 'bs4', # HTML parsing
- 'mutagen', # MP3 tags
- 'argparse', # CLI arg parsing
- 'spotipy', # Interfacing w/ Spotify API
- 'ydl-binaries', # Downloading ffmpeg/ffprobe binaries
- 'pyyaml', # Config files done simply
- 'youtube-dl' # Download youtube videos
- ],
- entry_points = {
- 'console_scripts': ['irs = irs.cli.cli:main'],
- },
-)
diff --git a/shard.lock b/shard.lock
new file mode 100755
index 0000000..fefdfa4
--- /dev/null
+++ b/shard.lock
@@ -0,0 +1,6 @@
+version: 1.0
+shards:
+ ydl_binaries:
+ github: cooperhammond/ydl-binaries
+ commit: c82e3937fee20fd076b1c73e24b2d0205e2cf0da
+
diff --git a/shard.yml b/shard.yml
new file mode 100755
index 0000000..9782be4
--- /dev/null
+++ b/shard.yml
@@ -0,0 +1,15 @@
+name: irs
+version: 1.0.0
+
+authors:
+ - Cooper Hammond
+
+targets:
+ irs:
+ main: src/irs.cr
+
+license: MIT
+
+dependencies:
+ ydl_binaries:
+ github: cooperhammond/ydl-binaries
\ No newline at end of file
diff --git a/spec/irs_spec.cr b/spec/irs_spec.cr
new file mode 100755
index 0000000..9562b57
--- /dev/null
+++ b/spec/irs_spec.cr
@@ -0,0 +1,9 @@
+require "./spec_helper"
+
+describe Irs do
+ # TODO: Write tests
+
+ it "works" do
+ false.should eq(true)
+ end
+end
diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr
new file mode 100755
index 0000000..5c3c333
--- /dev/null
+++ b/spec/spec_helper.cr
@@ -0,0 +1,2 @@
+require "spec"
+require "../src/irs"
diff --git a/src/bottle/cli.cr b/src/bottle/cli.cr
new file mode 100755
index 0000000..2cbf378
--- /dev/null
+++ b/src/bottle/cli.cr
@@ -0,0 +1,179 @@
+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"],
+ ]
+
+ @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 -a ]"}
+ #{Style.bold " [-A -a ]"}
+ #{Style.bold " [-p -a ]"}
+
+ #{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 "} Specify artist name for downloading
+ #{Style.blue "-s, --song "} Specify song name to download
+ #{Style.blue "-A, --album "} Specify the album name to download
+ #{Style.blue "-p, --playlist "} Specify the playlist name to download
+
+ #{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: "}
+ EOP
+
+ puts msg
+ end
+
+ def act_on_args
+ Config.check_necessities
+
+ if @args["help"]? || @args.keys.size == 0
+ help
+ exit
+ elsif @args["version"]?
+ version
+ exit
+ elsif @args["install"]?
+ YdlBinaries.get_both(Config.binary_location)
+ exit
+ elsif @args["config"]?
+ puts ENV["IRS_CONFIG_LOCATION"]?
+ exit
+ 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
+ s.organize_it(Config.music_directory)
+ exit
+ 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
+ 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
+ else
+ puts Style.red("Those arguments don't do anything when used that way.")
+ puts "Type `irs -h` to see usage."
+ exit 1
+ 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
diff --git a/src/bottle/config.cr b/src/bottle/config.cr
new file mode 100755
index 0000000..027086f
--- /dev/null
+++ b/src/bottle/config.cr
@@ -0,0 +1,115 @@
+require "yaml"
+
+require "./styles"
+
+require "../search/spotify"
+
+EXAMPLE_CONFIG = <<-EOP
+#{Style.dim "exampleconfig.yml"}
+#{Style.dim "===="}
+#{Style.blue "binary_directory"}: #{Style.green "~/.irs/bin"}
+#{Style.blue "music_directory"}: #{Style.green "~/Music"}
+#{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 = [
+ "binary_directory",
+ "music_directory",
+ "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 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 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
diff --git a/src/bottle/styles.cr b/src/bottle/styles.cr
new file mode 100755
index 0000000..f965378
--- /dev/null
+++ b/src/bottle/styles.cr
@@ -0,0 +1,23 @@
+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
diff --git a/src/bottle/version.cr b/src/bottle/version.cr
new file mode 100755
index 0000000..d9b75dc
--- /dev/null
+++ b/src/bottle/version.cr
@@ -0,0 +1,3 @@
+module IRS
+ VERSION = "0.1.0"
+end
diff --git a/src/glue/album.cr b/src/glue/album.cr
new file mode 100755
index 0000000..1f5e3ee
--- /dev/null
+++ b/src/glue/album.cr
@@ -0,0 +1,47 @@
+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
+ 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(@home_music_directory)
+ end
+end
diff --git a/src/glue/list.cr b/src/glue/list.cr
new file mode 100755
index 0000000..4d4d583
--- /dev/null
+++ b/src/glue/list.cr
@@ -0,0 +1,83 @@
+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")
+ ]
+ }
+
+ 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
+ if !@spotify_searcher.authorized?
+ raise("Need to call provide_client_keys on Album or Playlist class.")
+ end
+
+ outputter("searching", 0)
+ list = find_it()
+ outputter("searching", 1)
+ contents = list["tracks"]["items"].as_a
+
+ i = 0
+ contents.each do |datum|
+ if datum["track"]?
+ datum = datum["track"]
+ end
+
+ data = organize_song_metadata(list, datum)
+
+ song = Song.new(data["name"].to_s, data["artists"][0]["name"].to_s)
+ song.provide_spotify(@spotify_searcher)
+ song.provide_metadata(data)
+
+ puts Style.bold("[#{data["track_number"]}/#{contents.size}]")
+ song.grab_it
+
+ organize(song)
+
+ i += 1
+ 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 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
diff --git a/src/glue/mapper.cr b/src/glue/mapper.cr
new file mode 100755
index 0000000..5083a37
--- /dev/null
+++ b/src/glue/mapper.cr
@@ -0,0 +1,55 @@
+require "json"
+
+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
+ },
+ type: String,
+ uri: String
+ )
+end
+
+def parse_to_json(string_json : String) : JSON::Any
+ return JSON.parse(string_json)
+end
diff --git a/src/glue/playlist.cr b/src/glue/playlist.cr
new file mode 100755
index 0000000..0a8dd68
--- /dev/null
+++ b/src/glue/playlist.cr
@@ -0,0 +1,75 @@
+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
+ @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(" ", " ")
+ File.rename("./" + song.filename, (path / safe_filename).to_s)
+ else
+ song.organize_it(@home_music_directory)
+ end
+ end
+end
diff --git a/src/glue/song.cr b/src/glue/song.cr
new file mode 100755
index 0000000..e512ba9
--- /dev/null
+++ b/src/glue/song.cr
@@ -0,0 +1,195 @@
+require "../search/spotify"
+require "../search/youtube"
+
+require "../interact/ripper"
+require "../interact/tagger"
+
+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")
+ ],
+ "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.
+ #
+ # ```
+ # Song.new("Bohemian Rhapsody", "Queen").grab_it
+ # ```
+ def grab_it
+ 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)
+ @filename = data["track_number"].to_s + " - #{data["name"].to_s}.mp3"
+
+ outputter("url", 0)
+ url = Youtube.find_url(@song_name, @artist_name, search_terms: "lyrics")
+ 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)
+
+ outputter("download", 0)
+ Ripper.download_mp3(url.as(String), @filename)
+ outputter("download", 1)
+
+ outputter("albumart", 0)
+ temp_albumart_filename = ".tempalbumart.jpg"
+ HTTP::Client.get(data["album"]["images"][0]["url"].to_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"].to_s
+ else
+ @artist = data["artists"][0]["name"].to_s
+ end
+ @album = data["album"]["name"].to_s
+
+ tagger = Tags.new(@filename)
+ tagger.add_album_art(temp_albumart_filename)
+ tagger.add_text_tag("title", data["name"].to_s)
+ tagger.add_text_tag("artist", @artist)
+ tagger.add_text_tag("album", @album)
+ tagger.add_text_tag("genre",
+ @spotify_searcher.find_genre(data["artists"][0]["id"].to_s))
+ 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 as
+ # music_directory > artist_name > album_name > song
+ # Must be called AFTER the song has been downloaded.
+ #
+ # ```
+ # s = Song.new("Bohemian Rhapsody", "Queen").grab_it
+ # s.organize_it("/home/cooper/Music")
+ # # Will move the mp3 file to
+ # # /home/cooper/Music/Queen/A Night At The Opera/1 - Bohemian Rhapsody.mp3
+ # ```
+ def organize_it(music_directory : String)
+ path = Path[music_directory].expand(home: true)
+ path = path / @artist_name.gsub(/[\/]/, "").gsub(" ", " ")
+ path = path / @album.gsub(/[\/]/, "").gsub(" ", " ")
+ strpath = path.to_s
+ if !File.directory?(strpath)
+ FileUtils.mkdir_p(strpath)
+ end
+ safe_filename = @filename.gsub(/[\/]/, "").gsub(" ", " ")
+ File.rename("./" + @filename, (path / safe_filename).to_s)
+ 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
diff --git a/src/interact/logger.cr b/src/interact/logger.cr
new file mode 100755
index 0000000..3f21acd
--- /dev/null
+++ b/src/interact/logger.cr
@@ -0,0 +1,88 @@
+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
diff --git a/src/interact/ripper.cr b/src/interact/ripper.cr
new file mode 100755
index 0000000..5a5aa38
--- /dev/null
+++ b/src/interact/ripper.cr
@@ -0,0 +1,67 @@
+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
diff --git a/src/interact/tagger.cr b/src/interact/tagger.cr
new file mode 100755
index 0000000..3f4ab7e
--- /dev/null
+++ b/src/interact/tagger.cr
@@ -0,0 +1,62 @@
+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()
diff --git a/src/irs.cr b/src/irs.cr
new file mode 100755
index 0000000..f57fb4b
--- /dev/null
+++ b/src/irs.cr
@@ -0,0 +1,8 @@
+require "./bottle/cli"
+
+def main
+ cli = CLI.new(ARGV)
+ cli.act_on_args
+end
+
+main()
diff --git a/src/search/spotify.cr b/src/search/spotify.cr
new file mode 100755
index 0000000..33501e1
--- /dev/null
+++ b/src/search/spotify.cr
@@ -0,0 +1,345 @@
+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, offset, limit)
+
+ url = @root_url.join("search?q=#{query}").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
+ genre = get_item("artist", id)["genres"][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,
+ offset : Int32, limit : Int32) : 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:" param from the query
+ if item_type == "playlist"
+ query += item_parameters[k].gsub(" ", "+") + "+"
+ else
+ query += param_encode(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 += param_encode(k, item_parameters[k])
+ end
+ end
+
+ # extra api info
+ query += "&type=#{item_type}&limit=#{limit}&offset=#{offset}"
+
+ return query
+ 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
+
+ # Returns a `String` encoded for the spotify api
+ #
+ # ```
+ # query_encode("album", "A Night At The Opera")
+ # => "album:A+Night+At+The+Opera"
+ # ```
+ private def param_encode(key : String, value : String) : String
+ return key.gsub(" ", "+") + ":" + value.gsub(" ", "+") + "+"
+ end
+end
+
+# 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"
+# })
diff --git a/src/search/youtube.cr b/src/search/youtube.cr
new file mode 100755
index 0000000..5742852
--- /dev/null
+++ b/src/search/youtube.cr
@@ -0,0 +1,180 @@
+require "http"
+require "xml"
+
+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 ",
+ ]
+
+ GARBAGE_PHRASES = [
+ "cover", "album", "live", "clean", "version", "full", "full album", "row",
+ "at", "@", "session", "how to", "npr music", "reimagined", "hr version",
+ "trailer",
+ ]
+
+ GOLDEN_PHRASES = [
+ "official video", "official music video",
+ ]
+
+ # Finds a youtube url based off of the given information.
+ # The query to youtube is constructed like this:
+ # " "
+ # If *download_first* is provided, the first link found will be downloaded.
+ #
+ # ```
+ # Youtube.find_url("Bohemian Rhapsody", "Queen")
+ # => "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
+ # ```
+ def find_url(song_name : String, artist_name : String, search_terms = "",
+ download_first = false) : String?
+ query = (song_name + " " + artist_name + " " + search_terms).strip.gsub(" ", "+")
+
+ url = "https://www.youtube.com/results?search_query=" + query
+
+ response = HTTP::Client.get(url)
+
+ valid_nodes = get_video_link_nodes(response.body)
+
+ if valid_nodes.size == 0
+ puts "There were no results for that query."
+ return nil
+ end
+
+ root = "https://youtube.com"
+
+ return root + valid_nodes[0]["href"] if download_first
+
+ ranked = rank_videos(song_name, artist_name, query, valid_nodes)
+
+ begin
+ return root + valid_nodes[ranked[0]["index"]]["href"]
+ rescue IndexError
+ return nil
+ end
+ end
+
+ # Will rank videos according to their title and the user input
+ # Return:
+ # [
+ # {"points" => x, "index" => x},
+ # ...
+ # ]
+ private def rank_videos(song_name : String, artist_name : String,
+ query : String, nodes : Array(XML::Node)) : Array(Hash(String, Int32))
+ points = [] of Hash(String, Int32)
+ index = 0
+
+ nodes.each do |node|
+ pts = 0
+
+ pts += points_compare(song_name, node["title"])
+ pts += points_compare(artist_name, node["title"])
+ pts += count_buzzphrases(query, node["title"])
+
+ points.push({
+ "points" => pts,
+ "index" => index,
+ })
+ index += 1
+ 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
+
+ # Returns an `Int` based off the number of points worth assigning to the
+ # matchiness of the string. First the strings are downcased and then all
+ # nonalphanumeric characters are stripped.
+ # If *item1* includes *item2*, return 3 pts.
+ # If after the items have been blanked, *item1* includes *item2*,
+ # return 1 pts.
+ # Else, return 0 pts.
+ private def points_compare(item1 : String, item2 : String) : Int32
+ if item2.includes?(item1)
+ return 3
+ end
+
+ item1 = item1.downcase.gsub(/[^a-z0-9]/, "")
+ item2 = item2.downcase.gsub(/[^a-z0-9]/, "")
+
+ if item2.includes?(item1)
+ return 1
+ else
+ return 0
+ end
+ end
+
+ # Checks if there are any phrases in the title of the video that would
+ # indicate audio having what we want.
+ # *video_name* is the title of the video, and *query* is what the user the
+ # program searched for. *query* is needed in order to make sure we're not
+ # subtracting points from something that's naturally in the title
+ private def count_buzzphrases(query : String, video_name : String) : Int32
+ good_phrases = 0
+ bad_phrases = 0
+
+ GOLDEN_PHRASES.each do |gold_phrase|
+ gold_phrase = gold_phrase.downcase.gsub(/[^a-z0-9]/, "")
+
+ if query.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
+ next
+ elsif video_name.downcase.gsub(/[^a-z0-9]/, "").includes?(gold_phrase)
+ good_phrases += 1
+ end
+ end
+
+ 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
+
+ # Finds valid video links from a `HTTP::Client.get` request
+ # Returns an `Array` of `XML::Node`
+ private def get_video_link_nodes(doc : String) : Array(XML::Node)
+ nodes = XML.parse(doc).xpath_nodes("//a")
+ valid_nodes = [] of XML::Node
+
+ nodes.each do |node|
+ if video_link_node?(node)
+ valid_nodes.push(node)
+ end
+ end
+
+ return valid_nodes
+ end
+
+ # Tests if the provided `XML::Node` has a valid link to a video
+ # Returns a `Bool`
+ private def video_link_node?(node : XML::Node) : Bool
+ # If this passes, then the node links to a playlist, not a video
+ if node["href"]?
+ return false if node["href"].includes?("&list=")
+ end
+
+ VALID_LINK_CLASSES.each do |valid_class|
+ if node["class"]?
+ return true if node["class"].includes?(valid_class)
+ end
+ end
+ return false
+ end
+end