spotify_cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ require 'dex/ui'
2
+ require 'io/console'
3
+
4
+ module Dex
5
+ module UI
6
+ module Terminal
7
+ def self.width
8
+ if console = IO.console
9
+ console.winsize[1]
10
+ else
11
+ 80
12
+ end
13
+ rescue Errno::EIO
14
+ 80
15
+ end
16
+ end
17
+ end
18
+ end
data/lib/dex/ui.rb ADDED
@@ -0,0 +1,83 @@
1
+ module Dex
2
+ module UI
3
+ autoload :ANSI, 'dex/ui/ansi'
4
+ autoload :Glyph, 'dex/ui/glyph'
5
+ autoload :Color, 'dex/ui/color'
6
+ autoload :Box, 'dex/ui/box'
7
+ autoload :Frame, 'dex/ui/frame'
8
+ autoload :Progress, 'dex/ui/progress'
9
+ autoload :Prompt, 'dex/ui/prompt'
10
+ autoload :Terminal, 'dex/ui/terminal'
11
+ autoload :Formatter, 'dex/ui/formatter'
12
+ autoload :Spinner, 'dex/ui/spinner'
13
+
14
+ # TODO: this, better
15
+ SpinGroup = Spinner::SpinGroup
16
+
17
+ # TODO: test
18
+ def self.glyph(handle)
19
+ Dex::UI::Glyph.lookup(handle)
20
+ end
21
+
22
+ # TODO: test
23
+ def self.resolve_color(input)
24
+ case input
25
+ when Symbol
26
+ Dex::UI::Color.lookup(input)
27
+ else
28
+ input
29
+ end
30
+ end
31
+
32
+ def self.confirm(question)
33
+ Dex::UI::Prompt.confirm(question)
34
+ end
35
+
36
+ def self.ask(question, **kwargs)
37
+ Dex::UI::Prompt.ask(question, **kwargs)
38
+ end
39
+
40
+ def self.resolve_text(input)
41
+ return input if input.nil?
42
+ Dex::UI::Formatter.new(input).format
43
+ end
44
+
45
+ def self.fmt(input, enable_color: true)
46
+ Dex::UI::Formatter.new(input).format(enable_color: enable_color)
47
+ end
48
+
49
+ def self.frame(*args, &block)
50
+ Dex::UI::Frame.open(*args, &block)
51
+ end
52
+
53
+ def self.spinner(*args, &block)
54
+ Dex::UI::Spinner.spin(*args, &block)
55
+ end
56
+
57
+ def self.with_frame_color(color, &block)
58
+ Dex::UI::Frame.with_frame_color_override(color, &block)
59
+ end
60
+
61
+ def self.log_output_to(path)
62
+ if Dex::UI::StdoutRouter.duplicate_output_to
63
+ raise "multiple logs not allowed"
64
+ end
65
+ Dex::UI::StdoutRouter.duplicate_output_to = File.open(path, 'w')
66
+ yield
67
+ ensure
68
+ f = Dex::UI::StdoutRouter.duplicate_output_to
69
+ f.close
70
+ Dex::UI::StdoutRouter.duplicate_output_to = nil
71
+ end
72
+
73
+ def self.raw
74
+ prev = Thread.current[:no_dexui_frame_inset]
75
+ Thread.current[:no_dexui_frame_inset] = true
76
+ yield
77
+ ensure
78
+ Thread.current[:no_dexui_frame_inset] = prev
79
+ end
80
+ end
81
+ end
82
+
83
+ require 'dex/ui/stdout_router'
@@ -0,0 +1,62 @@
1
+ module MethodAddedHook
2
+ private
3
+
4
+ def method_added(meth)
5
+ method_added_hook(meth)
6
+ super
7
+ end
8
+
9
+ def singleton_method_added(meth)
10
+ method_added_hook(meth)
11
+ super
12
+ end
13
+
14
+ def method_added_hook(meth)
15
+ @@__last_defined_doc__ ||= nil
16
+ return if !defined?(@@__last_defined_doc__) || @@__last_defined_doc__.nil?
17
+ @@__class_docs__ ||= {}
18
+ @@__class_docs__[self.to_s] ||= {}
19
+
20
+ @@__class_docs__[self.to_s][meth] = @@__last_defined_doc__
21
+ @@__last_defined_doc__ = nil
22
+ end
23
+ end
24
+
25
+ class Module
26
+ private
27
+ prepend MethodAddedHook
28
+
29
+ def doc(str, meth = nil)
30
+ return @@__class_docs__[self.to_s][meth] = str if meth
31
+ @@__last_defined_doc__ = str
32
+ end
33
+
34
+ def defdoc(str, meth, &block)
35
+ @@__class_docs__[self.to_s][meth] = str
36
+ define_method(meth, &block)
37
+ end
38
+ end
39
+
40
+ module Kernel
41
+ def get_doc(klass, meth)
42
+ docs = klass.class_variable_get(:@@__class_docs__)
43
+ docs[self.to_s][meth.to_sym] if docs && docs[self.to_s]
44
+ end
45
+ end
46
+
47
+
48
+ class String
49
+ # The following methods is taken from activesupport
50
+ #
51
+ # https://github.com/rails/rails/blob/d66e7835bea9505f7003e5038aa19b6ea95ceea1/activesupport/lib/active_support/core_ext/string/strip.rb
52
+ #
53
+ # All credit for this method goes to the original authors.
54
+ # The code is used under the MIT license.
55
+ #
56
+ # Strips indentation by removing the amount of leading whitespace in the least indented
57
+ # non-empty line in the whole string
58
+ #
59
+ def strip_heredoc
60
+ self.gsub(/^#{self.scan(/^[ \t]*(?=\S)/).min}/, "".freeze)
61
+ end
62
+ end
Binary file
@@ -0,0 +1,182 @@
1
+ require 'spotify_cli/app'
2
+ require 'helpers/doc'
3
+
4
+ module SpotifyCli
5
+ class Api
6
+ PLAY = "▶"
7
+ STOP = "◼"
8
+
9
+ SPOTIFY_SEARCH_API = "https://api.spotify.com/v1/search"
10
+
11
+ class << self
12
+ doc <<-EOF
13
+ Changes to the next song
14
+
15
+ {{bold:Usage:}}
16
+ {{command:spotify next}}
17
+ EOF
18
+ def next
19
+ puts "Playing next song"
20
+ SpotifyCli::App.next!
21
+ end
22
+
23
+ doc <<-EOF
24
+ Changes to the previous song
25
+
26
+ {{bold:Usage:}}
27
+ {{command:spotify previous}}
28
+ EOF
29
+ def previous
30
+ puts "Playing previous song"
31
+ SpotifyCli::App.prev!
32
+ end
33
+
34
+ doc <<-EOF
35
+ Sets the position in the song
36
+
37
+ {{bold:Usage:}}
38
+ {{command:spotify set_pos 60}}
39
+ EOF
40
+ def set_pos
41
+ puts "Setting position to #{ARGV[1]}"
42
+ SpotifyCli::App.set_pos!(ARGV[1])
43
+ end
44
+
45
+ doc <<-EOF
46
+ Replays the current song
47
+
48
+ {{bold:Usage:}}
49
+ {{command:spotify replay}}
50
+ EOF
51
+ def replay
52
+ puts "Restarting song"
53
+ SpotifyCli::App.replay!
54
+ end
55
+
56
+ doc <<-EOF
57
+ Play/Pause the current song, or play a specified artist,
58
+ track, album, or uri
59
+
60
+ {{bold:Usage:}}
61
+ {{command:spotify play artist [name]}}
62
+ {{command:spotify play track [name]}}
63
+ {{command:spotify play album [name]}}
64
+ {{command:spotify play uri [spotify uri]}}
65
+ EOF
66
+ def play_pause
67
+ args = ARGV[1..-1]
68
+
69
+ if args.empty?
70
+ # no specifying paremeter, this is a standard play/pause
71
+ SpotifyCli::App.play_pause!
72
+ status
73
+ return
74
+ end
75
+
76
+ arg = args.shift
77
+ type = arg == 'song' ? 'track' : arg
78
+
79
+ Dex::UI.frame("Searching for #{type}", timing: false) do
80
+ play_uri = case type
81
+ when 'album', 'artist', 'track'
82
+ results = search_and_play(type: type, query: args.join(' '))
83
+ results.first
84
+ when 'uri'
85
+ args.first
86
+ end
87
+ puts "Results found, playing"
88
+ SpotifyCli::App.play_uri!(play_uri)
89
+ sleep 0.05 # Give time for the app to switch otherwise status may be stale
90
+ end
91
+
92
+ status
93
+ end
94
+
95
+ doc <<-EOF
96
+ Pause/stop the current song
97
+
98
+ {{bold:Usage:}}
99
+ {{command:spotify pause}}
100
+ {{command:spotify stop}}
101
+ EOF
102
+ def pause
103
+ SpotifyCli::App.pause!
104
+ status
105
+ end
106
+
107
+ doc <<-EOF
108
+ Show the current song
109
+
110
+ {{bold:Usage:}}
111
+ {{command:spotify status}}
112
+ EOF
113
+ def status
114
+ stat = SpotifyCli::App.status
115
+
116
+ time = "#{stat[:position]} / #{stat[:duration]}"
117
+ state_sym = case stat[:state]
118
+ when 'playing'
119
+ PLAY
120
+ else
121
+ STOP
122
+ end
123
+ # 3 for padding around time, and symbol, and space for the symbol, 2 for frame
124
+ width = Dex::UI::Terminal.width - time.size - 5
125
+
126
+ Dex::UI.frame(stat[:track], timing: false) do
127
+ puts Dex::UI.resolve_text([
128
+ "{{bold:Artist:}} #{stat[:artist]}",
129
+ "{{bold:Album:}} #{stat[:album]}",
130
+ ].join("\n"))
131
+ puts [
132
+ Dex::UI::Progress.progress(stat[:percent_done], width),
133
+ state_sym,
134
+ time
135
+ ].join(' ')
136
+ end
137
+ end
138
+
139
+ doc <<-EOF
140
+ Display Help
141
+
142
+ {{bold:Usage:}}
143
+ {{command:spotify}}
144
+ {{command:spotify help}}
145
+ EOF
146
+ def help(mappings)
147
+ Dex::UI.frame('Spotify CLI', timing: false) do
148
+ puts "CLI interface for Spotify"
149
+ end
150
+
151
+ mappings.group_by { |_,v| v }.each do |k, v|
152
+ v.reject! { |mapping| mapping.first == k.to_s }
153
+ doc = get_doc(self.class, k.to_s).strip_heredoc
154
+
155
+ Dex::UI.frame(k, timing: false) do
156
+ puts puts Dex::UI.resolve_text(doc)
157
+ next if v.empty?
158
+ puts Dex::UI.resolve_text("{{bold:Aliases:}}")
159
+ v.each { |mapping| puts Dex::UI.resolve_text(" - {{info:#{mapping.first}}}") }
160
+ end
161
+ end
162
+ end
163
+
164
+ private
165
+
166
+ def search_and_play(args)
167
+ type = args[:type]
168
+ type2 = args[:type2] || type
169
+ query = args[:query]
170
+ limit = args[:limit] || 1
171
+ puts "Searching #{type}s for: #{query}";
172
+
173
+ curl_cmd = <<-EOF
174
+ curl -s -G #{SPOTIFY_SEARCH_API} --data-urlencode "q=#{query}" -d "type=#{type}&limit=#{limit}&offset=0" -H "Accept: application/json" \
175
+ | grep -E -o "spotify:#{type2}:[a-zA-Z0-9]+" -m #{limit}
176
+ EOF
177
+
178
+ `#{curl_cmd}`.strip.split("\n")
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,101 @@
1
+ module SpotifyCli
2
+ class App
3
+ def self.state
4
+ oascript('tell application "Spotify" to player state as string')
5
+ end
6
+
7
+ def self.status
8
+ artist = oascript('tell application "Spotify" to artist of current track as string')
9
+ album = oascript('tell application "Spotify" to album of current track as string')
10
+ track = oascript('tell application "Spotify" to name of current track as string')
11
+ duration = oascript(<<-EOF)
12
+ tell application "Spotify"
13
+ set durSec to (duration of current track / 1000) as text
14
+ set tM to (round (durSec / 60) rounding down) as text
15
+ if length of ((durSec mod 60 div 1) as text) is greater than 1 then
16
+ set tS to (durSec mod 60 div 1) as text
17
+ else
18
+ set tS to ("0" & (durSec mod 60 div 1)) as text
19
+ end if
20
+ set myTime to tM as text & ":" & tS as text
21
+ end tell
22
+ return myTime
23
+ EOF
24
+ position = oascript(<<-EOF)
25
+ tell application "Spotify"
26
+ set pos to player position
27
+ set nM to (round (pos / 60) rounding down) as text
28
+ if length of ((round (pos mod 60) rounding down) as text) is greater than 1 then
29
+ set nS to (round (pos mod 60) rounding down) as text
30
+ else
31
+ set nS to ("0" & (round (pos mod 60) rounding down)) as text
32
+ end if
33
+ set nowAt to nM as text & ":" & nS as text
34
+ end tell
35
+ return nowAt
36
+ EOF
37
+
38
+ {
39
+ state: state,
40
+ artist: artist,
41
+ album: album,
42
+ track: track,
43
+ duration: duration,
44
+ position: position,
45
+ percent_done: percent_done(position, duration)
46
+ }
47
+ end
48
+
49
+ def self.play_pause!
50
+ oascript('tell application "Spotify" to playpause')
51
+ end
52
+
53
+ def self.pause!
54
+ oascript('tell application "Spotify" to pause')
55
+ end
56
+
57
+ def self.play_uri!(uri)
58
+ oascript("tell application \"Spotify\" to play track \"#{uri}\"")
59
+ end
60
+
61
+ def self.next!
62
+ oascript('tell application "Spotify" to next track')
63
+ end
64
+
65
+ def self.set_pos!(pos)
66
+ oascript("tell application \"Spotify\" to set player position to #{pos}")
67
+ end
68
+
69
+ def self.previous!
70
+ oascript(<<-EOF)
71
+ tell application "Spotify"
72
+ set player position to 0
73
+ previous track
74
+ end tell
75
+ EOF
76
+ end
77
+
78
+ def self.replay!
79
+ oascript('tell application "Spotify" to set player position to 0')
80
+ end
81
+
82
+ def self.percent_done(position, duration)
83
+ seconds = ->(parts) do
84
+ acc = 0
85
+ multiplier = 1
86
+ while part = parts.shift
87
+ acc += part.to_f * multiplier
88
+ multiplier *= 60
89
+ end
90
+ acc
91
+ end
92
+ pos_parts = position.split(':').reverse
93
+ dur_parts = duration.split(':').reverse
94
+ seconds.call(pos_parts) / seconds.call(dur_parts)
95
+ end
96
+
97
+ def self.oascript(command)
98
+ `osascript -e '#{command}'`.strip
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,3 @@
1
+ module SpotifyCli
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,38 @@
1
+ require 'spotify_cli/version'
2
+ require 'dex/ui'
3
+ require 'spotify_cli/api'
4
+
5
+ module SpotifyCli
6
+ def self.call(args)
7
+ mappings = {
8
+ 'next' => :next,
9
+ 'n' => :next,
10
+ 'previous' => :previous,
11
+ 'pr' => :previous,
12
+ 'set_pos' => :set_pos,
13
+ 'pos' => :set_pos,
14
+ 'replay' => :replay,
15
+ 'rep' => :replay,
16
+ 'restart' => :replay,
17
+ 'pause' => :pause,
18
+ 'stop' => :pause,
19
+ 'play' => :play_pause,
20
+ 'p' => :play_pause,
21
+ 'play_pause' => :play_pause,
22
+ 'status' => :status,
23
+ 's' => :status,
24
+ 'help' => :help
25
+ }
26
+
27
+ if args.empty?
28
+ SpotifyCli::Api.status
29
+ else
30
+ mapping = mappings[args.first]
31
+ if mapping.nil? || mapping == :help
32
+ SpotifyCli::Api.help(mappings)
33
+ else
34
+ SpotifyCli::Api.send(mapping)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'spotify_cli/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "spotify_cli"
8
+ spec.version = SpotifyCli::VERSION
9
+ spec.authors = ["Julian Nadeau"]
10
+ spec.email = ["julian@jnadeau.ca"]
11
+ spec.license = "MIT"
12
+
13
+ spec.summary = "Spotify Application wrapper for control via command line"
14
+ spec.description = "Allow control of Spotify using a pretty UI interface. Intentionally simple."
15
+ spec.homepage = "https://github.com/jules2689/spotify_cli"
16
+
17
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
19
+ if spec.respond_to?(:metadata)
20
+ spec.metadata['allowed_push_host'] = "https://rubygems.org"
21
+ else
22
+ raise "RubyGems 2.0 or newer is required to protect against " \
23
+ "public gem pushes."
24
+ end
25
+
26
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
27
+ f.match(%r{^(test|spec|features)/})
28
+ end
29
+ spec.bindir = "bin"
30
+ spec.executables = ['spotify']
31
+ spec.require_paths = ["lib"]
32
+
33
+ spec.add_development_dependency "bundler", "~> 1.14"
34
+ spec.add_development_dependency "rake", "~> 10.0"
35
+ spec.add_development_dependency "minitest", "~> 5.0"
36
+ end
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spotify_cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julian Nadeau
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-05-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.14'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.14'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ description: Allow control of Spotify using a pretty UI interface. Intentionally simple.
56
+ email:
57
+ - julian@jnadeau.ca
58
+ executables:
59
+ - spotify
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".DS_Store"
64
+ - ".gitignore"
65
+ - ".travis.yml"
66
+ - CODE_OF_CONDUCT.md
67
+ - Gemfile
68
+ - README.md
69
+ - Rakefile
70
+ - bin/console
71
+ - bin/setup
72
+ - bin/spotify
73
+ - lib/dex/ui.rb
74
+ - lib/dex/ui/README.md
75
+ - lib/dex/ui/ansi.rb
76
+ - lib/dex/ui/box.rb
77
+ - lib/dex/ui/color.rb
78
+ - lib/dex/ui/formatter.rb
79
+ - lib/dex/ui/frame.rb
80
+ - lib/dex/ui/glyph.rb
81
+ - lib/dex/ui/progress.rb
82
+ - lib/dex/ui/prompt.rb
83
+ - lib/dex/ui/spinner.rb
84
+ - lib/dex/ui/stdout_router.rb
85
+ - lib/dex/ui/terminal.rb
86
+ - lib/helpers/doc.rb
87
+ - lib/spotify_cli.rb
88
+ - lib/spotify_cli/.DS_Store
89
+ - lib/spotify_cli/api.rb
90
+ - lib/spotify_cli/app.rb
91
+ - lib/spotify_cli/version.rb
92
+ - spotify_cli.gemspec
93
+ homepage: https://github.com/jules2689/spotify_cli
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ allowed_push_host: https://rubygems.org
98
+ post_install_message:
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubyforge_project:
114
+ rubygems_version: 2.5.1
115
+ signing_key:
116
+ specification_version: 4
117
+ summary: Spotify Application wrapper for control via command line
118
+ test_files: []