├── .gitignore ├── EXAMPLES.md ├── Gemfile ├── LICENSE ├── README.md ├── api ├── Makefile ├── server.cr └── shard.yml └── src ├── syfin.rb └── syfin ├── actions.rb ├── parser.rb └── requests.rb /.gitignore: -------------------------------------------------------------------------------- 1 | # Ruby Ignores 2 | # ------------ 3 | *.gem 4 | *.rbc 5 | /.config 6 | /pkg/ 7 | /test/tmp/ 8 | /test/version_tmp/ 9 | /tmp/ 10 | /.bundle/ 11 | /vendor/bundle 12 | Gemfile.lock 13 | 14 | # Crystal Ignores 15 | # --------------- 16 | /docs/ 17 | /lib/ 18 | /bin/ 19 | /.shards/ 20 | *.dwarf 21 | /shard.lock 22 | 23 | # Enviornment Ignores 24 | # ------------------- 25 | .replit 26 | .env 27 | .rbenv 28 | main.rb -------------------------------------------------------------------------------- /EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # Syfin Examples 2 | 3 | #### Help: 4 | 5 | General help command for `syfin`. 6 | 7 | ```shell 8 | syfin --help 9 | ``` 10 | 11 | #### Version: 12 | 13 | Show `syfin` and Ruby Version. 14 | 15 | ```shell 16 | syfin --version 17 | ``` 18 | 19 | #### Download a Song by URL: 20 | 21 | ```shell 22 | syfin open.spotify.com/track/2N5AA16n90ZDksRqGID3kM 23 | ``` 24 | 25 | #### Download a Song by ID: 26 | 27 | ```shell 28 | syfin --id tracks:2N5AA16n90ZDksRqGID3kM 29 | ``` 30 | 31 | #### Download *n* number of songs from an Album or Playlist: 32 | 33 | ```shell 34 | syfin --limit=1 https://open.spotify.com/album/6USIVqn1qiNAsRYtWo2CSa 35 | ``` 36 | 37 | #### Confirm each song in an Album or Playlist 38 | 39 | ```shell 40 | syfin --confirm https://open.spotify.com/album/6USIVqn1qiNAsRYtWo2CSa 41 | ``` 42 | 43 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # gem "rails" 8 | 9 | gem 'cli-ui', '~> 1.4' 10 | 11 | gem 'colorize', '~> 0.8.1' 12 | 13 | gem "httparty", "~> 0.18.1" 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 frissyn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # syfin 2 | 3 | **S**potify **f**rom **Y**ouTube **In**staller, or **syfin** (pronounced [`siphon`](https://www.google.com/search?q=siphon+pronunciation&safe=active&ssui=on)), is a Command Line Application that searches YouTube for any given Spotify track, album, or playlist, and installs the best matching MP3s. 4 | 5 | [*This is a pet project that I'm not completely focused on, it might be a while before this is usable. :)*] 6 | 7 | 8 | ### Installation 9 | 10 | TODO: Write installation instruction here. 11 | 12 | ### Usage 13 | 14 | TODO: Write usage instruction here. 15 | 16 | ### Examples 17 | 18 | You can find a comprehensive list of exmaples and snippets [`here`](https://github.com/frissyn/syfin/blob/master/EXAMPLES.md). 19 | 20 | ### Contributing 21 | 22 | 1. Fork the repository: [`Fork`](https://github.com/frissyn/syfin/fork) 23 | 2. Create your feature branch (`git checkout -b my-new-feature`) 24 | 3. Commit your changes (`git commit -am 'Add some feature'`) 25 | 4. Push to the branch (`git push origin my-new-feature`) 26 | 5. Create a new Pull Request! 🎉 27 | 28 | You can also re-create these steps with GitHub Desktop, Visual Studio Code, or whatever git version control UI you prefer. 29 | 30 | ### Developement 31 | 32 | ##### **CLI:** 33 | 34 | TODO: Write developement notes for CLI here. 35 | 36 | ##### **API:** 37 | 38 | TODO: Write developement notes for API here. 39 | -------------------------------------------------------------------------------- /api/Makefile: -------------------------------------------------------------------------------- 1 | export TARGET_FILE = server.cr 2 | export SOURCE_FILES = $(shell find *.cr) 3 | 4 | .PHONY: run 5 | run: ./server 6 | ./server 7 | rm server 8 | 9 | ./server: $(SOURCE_FILES) 10 | crystal build --no-debug --progress $(TARGET_FILE) -------------------------------------------------------------------------------- /api/server.cr: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require "http" 3 | require "json" 4 | require "kemal" 5 | require "base64" 6 | 7 | module YouTube 8 | # ... 9 | # ... 10 | end 11 | 12 | module Spotify 13 | Client = HTTP::Client.new(URI.parse("https://api.spotify.com")) 14 | CREDS = Base64.encode("#{ENV["sID"]}:#{ENV["sSECRET"]}").gsub("\n", "") 15 | end 16 | 17 | get "/" { next "=D" } 18 | 19 | get "/y/:res" do |env| 20 | payload = env.params.url.dup 21 | 22 | next URI.decode(payload["res"].to_s) 23 | end 24 | 25 | get "/s/:res/:id" do |env| 26 | payload = env.params.url.dup 27 | 28 | auth = JSON.parse( 29 | HTTP::Client.post( 30 | url: URI.parse("https://accounts.spotify.com/api/token"), 31 | headers: HTTP::Headers{"Authorization" => "Basic #{Spotify::CREDS}"}, 32 | form: {"grant_type" => "client_credentials"} 33 | ).body 34 | ) 35 | 36 | next Spotify::Client.get( 37 | path: "/v1/#{payload["res"]}/#{payload["id"]}", 38 | headers: HTTP::Headers{"Authorization" => "Bearer #{auth["access_token"].as_s}"} 39 | ).body 40 | end 41 | 42 | Kemal.config.port = 8080 43 | Kemal.config.host_binding = "0.0.0.0" 44 | 45 | Kemal.run -------------------------------------------------------------------------------- /api/shard.yml: -------------------------------------------------------------------------------- 1 | name: syfin-api 2 | version: 0.1.0 3 | 4 | authors: 5 | - frissyn 6 | 7 | dependencies: 8 | kemal: 9 | github: kemalcr/kemal 10 | 11 | license: MIT -------------------------------------------------------------------------------- /src/syfin.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'uri' 4 | require 'cli/ui' 5 | require 'colorize' 6 | 7 | require_relative 'syfin/actions' 8 | require_relative 'syfin/parser' 9 | require_relative 'syfin/requests' 10 | 11 | module Syfin 12 | UI = CLI::UI 13 | VERSION = "0.0.1a".freeze 14 | end 15 | 16 | Syfin::UI::StdoutRouter.enable 17 | Parser = Syfin::MainParser.new 18 | 19 | # Split main operations into "pipelines" 20 | # A pipline is defined as a script operation encased 21 | # in an exception handler for expected or common errors. 22 | 23 | # PARSING PIPLINE 24 | # --------------- 25 | begin 26 | args, opts = Parser.parse(ARGV, Syfin::VERSION).finalize() 27 | 28 | if opts[:is_uri] 29 | target = URI.parse(args[0]).path.split('/') 30 | args[0] = ["#{target[-2]}s", "#{target[-1]}"] 31 | else 32 | if /(tracks|playlists|albums):(.){22}/.match?(args[0]) 33 | args[0] = args[0].split(":") 34 | else 35 | Syfin::UI.puts("✗ ParseError: Invalid Spotify ID.".red, to: $stderr) 36 | exit(1) 37 | end 38 | end 39 | 40 | if opts[:verbose] 41 | puts("✓ Parsed Options!".green) 42 | end 43 | rescue OptionParser::ParseError => err 44 | Syfin::UI.puts("✗ ParseError: #{err.message}".red, to: $stderr) 45 | exit(1) 46 | rescue URI::InvalidURIError => err 47 | Syfin::UI.puts("✗ InvalidURIError: #{err.message}".red, to: $stderr) 48 | exit(1) 49 | end 50 | 51 | 52 | # SPOTIFY PIPELINE 53 | # ---------------- 54 | tries ||= 0 55 | target = args[0][0][0..-2] 56 | spins = Syfin::UI::SpinGroup.new 57 | 58 | spins.add("Fetching #{target}...".red) do |spn| 59 | begin 60 | res = Syfin::Spotify.get(args[0][0], args[0][1]) 61 | spn.update_title( 62 | "Found #{args[0][0][0..-2]} ".green + 63 | "\"#{res["name"]}\"! (Attempt #{tries + 1})".green 64 | ) 65 | rescue 66 | if (tries += 1) < 3 67 | retry 68 | else 69 | spn.update_title("✗ #{err.message}".red, to: $stderr) 70 | exit(1) 71 | end 72 | end 73 | end; spins.wait 74 | -------------------------------------------------------------------------------- /src/syfin/actions.rb: -------------------------------------------------------------------------------- 1 | module Syfin 2 | class Actions 3 | # ... 4 | # TODO: Add common functions that very based on opts 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /src/syfin/parser.rb: -------------------------------------------------------------------------------- 1 | require 'uri' 2 | require 'colorize' 3 | require 'optparse' 4 | 5 | module Syfin 6 | class MainParser 7 | def parse(args, version) 8 | @options = ScriptOptions.new 9 | 10 | @args = OptionParser.new do |pr| 11 | @options.def_options(pr, version) 12 | pr.parse!(args) 13 | end 14 | 15 | @options 16 | end 17 | 18 | class ScriptOptions 19 | attr_accessor \ 20 | :limit, 21 | :target, 22 | :is_uri, 23 | :verbose 24 | 25 | def initialize 26 | self.limit = -1 27 | self.is_uri = true 28 | self.verbose = false 29 | self.target = 'Syfins' 30 | end 31 | 32 | def finalize 33 | [ 34 | ARGV, 35 | { 36 | limit: -1, 37 | target: target, 38 | is_uri: is_uri, 39 | verbose: verbose 40 | } 41 | ] 42 | end 43 | 44 | def def_options(parser, _version) 45 | parser.banner = 'Usage: syfin [options]' 46 | parser.separator('') 47 | parser.separator('Options:') 48 | 49 | is_uri_option?(parser) 50 | target_option?(parser) 51 | verbose_option?(parser) 52 | set_limit_option?(parser) 53 | 54 | parser.on_tail('-h', '--help', 'Displays this message.') do 55 | puts parser 56 | exit(0) 57 | end 58 | 59 | parser.on_tail('--version', 'Displays current version.') do 60 | print "Syfin: #{VERSION}\nRuby: #{RUBY_VERSION}\n".green 61 | exit(0) 62 | end 63 | 64 | return true 65 | end 66 | 67 | def is_uri_option?(parser) 68 | parser.on('--id', 'Parse given value as ID, instead of URL.') do 69 | self.is_uri = false 70 | end 71 | end 72 | 73 | def verbose_option?(parser) 74 | parser.on('-v', '--verbose', 'Increase verbosity of output.') do 75 | self.verbose = true 76 | end 77 | end 78 | 79 | def target_option?(parser) 80 | parser.on('--target=TARGET', String, 'Target directory to download results.') do |v| 81 | self.target = v 82 | end 83 | end 84 | 85 | def set_limit_option?(parser) 86 | parser.on('--limit=LIMIT', Integer, 'Limit the number of songs to donwnload.') do |v| 87 | self.limit = v 88 | end 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /src/syfin/requests.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'httparty' 3 | 4 | module Syfin 5 | class Spotify 6 | @@BASE = "https://syfin-api.frissyn.repl.co" 7 | 8 | def Spotify.get(type, data) 9 | res = HTTParty.get("#{@@BASE}/s/#{type}/#{data}") 10 | 11 | return JSON.parse(res.body) 12 | end 13 | end 14 | end 15 | --------------------------------------------------------------------------------