terminal_player 0.0.7
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.
- checksums.yaml +7 -0
- data/.gitignore +25 -0
- data/.rspec +2 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +35 -0
- data/LICENSE +0 -0
- data/README.md +134 -0
- data/Rakefile +5 -0
- data/Vagrantfile +119 -0
- data/bin/fish_functions/di.fish +3 -0
- data/bin/fish_functions/soma.fish +3 -0
- data/bin/fish_functions/spot.fish +3 -0
- data/bin/fish_functions/tp.fish +3 -0
- data/bin/terminal_player +79 -0
- data/bootstrap.sh +33 -0
- data/lib/terminal_player.rb +218 -0
- data/lib/terminal_player/di.rb +3 -0
- data/lib/terminal_player/di/audioaddict.rb +17 -0
- data/lib/terminal_player/di/di.rb +22 -0
- data/lib/terminal_player/mplayer.rb +43 -0
- data/lib/terminal_player/play_history.rb +14 -0
- data/lib/terminal_player/site.rb +94 -0
- data/lib/terminal_player/soma.rb +18 -0
- data/lib/terminal_player/spotiphy.rb +35 -0
- data/lib/terminal_player/spotiphy/spotiphy_player.rb +204 -0
- data/spec/lib/terminal_player/di/audioaddict_spec.rb +27 -0
- data/spec/lib/terminal_player/site_spec.rb +36 -0
- data/spec/lib/terminal_player_spec.rb +138 -0
- data/spec/spec_helper.rb +17 -0
- data/spotify_appkey.key +0 -0
- data/terminal_player.gemspec +26 -0
- metadata +137 -0
@@ -0,0 +1,204 @@
|
|
1
|
+
require 'spotify'
|
2
|
+
require 'plaything'
|
3
|
+
|
4
|
+
class SpotiphyPlayer
|
5
|
+
include Observable
|
6
|
+
|
7
|
+
def initialize(options)
|
8
|
+
@options = options
|
9
|
+
|
10
|
+
@plaything = Plaything.new
|
11
|
+
setup_session_callbacks
|
12
|
+
|
13
|
+
# HACK not sure this is copacetic
|
14
|
+
path = File.expand_path File.dirname(__FILE__)
|
15
|
+
path = "#{path}/../../../"
|
16
|
+
appkey = IO.read("#{path}spotify_appkey.key", encoding: "BINARY")
|
17
|
+
config = Spotify::SessionConfig.new({
|
18
|
+
api_version: Spotify::API_VERSION.to_i,
|
19
|
+
application_key: appkey,
|
20
|
+
cache_location: "#{path}.spotify/",
|
21
|
+
settings_location: "#{path}.spotify/",
|
22
|
+
tracefile: "#{path}spotify_tracefile.txt",
|
23
|
+
user_agent: "terminal_player gem",
|
24
|
+
callbacks: Spotify::SessionCallbacks.new($session_callbacks),
|
25
|
+
})
|
26
|
+
@session = create_session(config)
|
27
|
+
|
28
|
+
login(get_env("SPOTIFY_USERNAME"), get_env("SPOTIFY_PASSWORD"))
|
29
|
+
end
|
30
|
+
|
31
|
+
def play
|
32
|
+
u = @options[:url]
|
33
|
+
if u[':track:']
|
34
|
+
play_track_from_uri u
|
35
|
+
elsif u[':playlist:']
|
36
|
+
play_playlist u
|
37
|
+
elsif u[':album:']
|
38
|
+
play_album u
|
39
|
+
else
|
40
|
+
puts "unsupported URI #{u}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def next
|
45
|
+
$end_of_track = true
|
46
|
+
end
|
47
|
+
|
48
|
+
def get_track(uri)
|
49
|
+
link = Spotify.link_create_from_string(uri)
|
50
|
+
track = Spotify.link_as_track(link)
|
51
|
+
track
|
52
|
+
end
|
53
|
+
|
54
|
+
def play_track_from_uri(uri)
|
55
|
+
track = get_track(uri)
|
56
|
+
play_track track
|
57
|
+
end
|
58
|
+
|
59
|
+
def play_track(track)
|
60
|
+
wait_for_track_to_load track
|
61
|
+
artist = Spotify.track_artist(track, 0)
|
62
|
+
notify "SPOTTY #{Spotify.track_name(track)} - #{Spotify.artist_name(artist)}"
|
63
|
+
begin
|
64
|
+
play_track_raw track
|
65
|
+
wait_for_track_to_end
|
66
|
+
rescue => e
|
67
|
+
puts "play_track: error playing track: #{e}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def play_playlist(uri)
|
72
|
+
link = Spotify.link_create_from_string(uri)
|
73
|
+
plist = Spotify.playlist_create(@session, link)
|
74
|
+
poll(@session) { Spotify.playlist_is_loaded(plist) }
|
75
|
+
num_tracks = Spotify.playlist_num_tracks(plist)
|
76
|
+
# TODO this should reset channel
|
77
|
+
# puts "\nPlaying #{Spotify.playlist_name(plist)}, #{num_tracks} tracks, " +
|
78
|
+
# "#{Spotify.playlist_num_subscribers(plist)} subscribers"
|
79
|
+
0.upto(num_tracks - 1) do |i|
|
80
|
+
track = Spotify.playlist_track(plist, i)
|
81
|
+
play_track track
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def play_album(uri)
|
86
|
+
link = Spotify.link_create_from_string(uri)
|
87
|
+
browser = Spotify.albumbrowse_create(@session, Spotify.link_as_album(link), proc { }, nil)
|
88
|
+
poll(@session) { Spotify.albumbrowse_is_loaded(browser) }
|
89
|
+
# album = Spotify.albumbrowse_album(browser)
|
90
|
+
num_tracks = Spotify.albumbrowse_num_tracks(browser)
|
91
|
+
# TODO this should reset channel
|
92
|
+
# puts "\nPlaying #{Spotify.album_name(album)} (#{Spotify.album_year(album)}), #{num_tracks} tracks"
|
93
|
+
0.upto(num_tracks - 1) do |i|
|
94
|
+
track = Spotify.albumbrowse_track(browser, i)
|
95
|
+
play_track track
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def play_track_raw(track)
|
100
|
+
Spotify.try(:session_player_play, @session, false)
|
101
|
+
Spotify.try(:session_player_load, @session, track)
|
102
|
+
Spotify.try(:session_player_play, @session, true)
|
103
|
+
end
|
104
|
+
|
105
|
+
def wait_for_track_to_end
|
106
|
+
poll(@session) { $end_of_track }
|
107
|
+
$end_of_track = false
|
108
|
+
end
|
109
|
+
|
110
|
+
def wait_for_track_to_load(track)
|
111
|
+
poll(@session) { Spotify.track_is_loaded(track) }
|
112
|
+
end
|
113
|
+
|
114
|
+
private
|
115
|
+
|
116
|
+
def notify(message)
|
117
|
+
changed
|
118
|
+
notify_observers(Time.now, message)
|
119
|
+
end
|
120
|
+
|
121
|
+
def login(u, p)
|
122
|
+
Spotify.session_login(@session, u, p, false, nil)
|
123
|
+
poll(@session) { Spotify.session_connectionstate(@session) == :logged_in }
|
124
|
+
end
|
125
|
+
|
126
|
+
def poll(session)
|
127
|
+
until yield
|
128
|
+
FFI::MemoryPointer.new(:int) do |ptr|
|
129
|
+
Spotify.session_process_events(session, ptr)
|
130
|
+
end
|
131
|
+
sleep(0.1)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def create_session(config)
|
136
|
+
FFI::MemoryPointer.new(Spotify::Session) do |ptr|
|
137
|
+
Spotify.try(:session_create, config, ptr)
|
138
|
+
return Spotify::Session.new(ptr.read_pointer)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def setup_session_callbacks
|
143
|
+
# these must remain global. i think.
|
144
|
+
$session_callbacks = {
|
145
|
+
log_message: proc do |session, message|
|
146
|
+
#$logger.info("session (log message)") { message }
|
147
|
+
end,
|
148
|
+
|
149
|
+
logged_in: proc do |session, error|
|
150
|
+
#$logger.debug("session (logged in)") { Spotify::Error.explain(error) }
|
151
|
+
end,
|
152
|
+
|
153
|
+
logged_out: proc do |session|
|
154
|
+
#$logger.debug("session (logged out)") { "logged out!" }
|
155
|
+
end,
|
156
|
+
|
157
|
+
streaming_error: proc do |session, error|
|
158
|
+
#$logger.error("session (player)") { "streaming error %s" % Spotify::Error.explain(error) }
|
159
|
+
end,
|
160
|
+
|
161
|
+
start_playback: proc do |session|
|
162
|
+
#$logger.debug("session (player)") { "start playback" }
|
163
|
+
@plaything.play
|
164
|
+
end,
|
165
|
+
|
166
|
+
stop_playback: proc do |session|
|
167
|
+
#$logger.debug("session (player)") { "stop playback" }
|
168
|
+
@plaything.stop
|
169
|
+
end,
|
170
|
+
|
171
|
+
get_audio_buffer_stats: proc do |session, stats|
|
172
|
+
stats[:samples] = @plaything.queue_size
|
173
|
+
stats[:stutter] = @plaything.drops
|
174
|
+
#$logger.debug("session (player)") { "queue size [#{stats[:samples]}, #{stats[:stutter]}]" }
|
175
|
+
end,
|
176
|
+
|
177
|
+
music_delivery: proc do |session, format, frames, num_frames|
|
178
|
+
if num_frames == 0
|
179
|
+
#$logger.debug("session (player)") { "music delivery audio discontuity" }
|
180
|
+
@plaything.stop
|
181
|
+
0
|
182
|
+
else
|
183
|
+
frames = FrameReader.new(format[:channels], format[:sample_type], num_frames, frames)
|
184
|
+
consumed_frames = @plaything.stream(frames, format.to_h)
|
185
|
+
#$logger.debug("session (player)") { "music delivery #{consumed_frames} of #{num_frames}" }
|
186
|
+
consumed_frames
|
187
|
+
end
|
188
|
+
end,
|
189
|
+
|
190
|
+
end_of_track: proc do |session|
|
191
|
+
$end_of_track = true
|
192
|
+
#$logger.debug("session (player)") { "end of track" }
|
193
|
+
@plaything.stop
|
194
|
+
end,
|
195
|
+
}
|
196
|
+
end
|
197
|
+
|
198
|
+
def get_env(name)
|
199
|
+
ENV.fetch(name) do
|
200
|
+
raise "set the #{name} environment variable"
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'terminal_player/di'
|
3
|
+
|
4
|
+
describe AudioAddict do
|
5
|
+
class DummyClass
|
6
|
+
end
|
7
|
+
|
8
|
+
before(:each) do
|
9
|
+
@dummy = DummyClass.new
|
10
|
+
@dummy.extend(AudioAddict)
|
11
|
+
end
|
12
|
+
|
13
|
+
it "returns a good song list" do
|
14
|
+
songs = @dummy.get_recently_played_list(15) # 15 is breaks
|
15
|
+
songs.length.should be > 0
|
16
|
+
s = songs[0]
|
17
|
+
s.should.respond_to? :channel_id
|
18
|
+
s['channel_id'].should be 15
|
19
|
+
|
20
|
+
s.should.respond_to? :duration
|
21
|
+
s.should.respond_to? :length
|
22
|
+
s.should.respond_to? :started
|
23
|
+
s.should.respond_to? :title
|
24
|
+
s.should.respond_to? :track
|
25
|
+
s.should.respond_to? :votes
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'terminal_player'
|
3
|
+
|
4
|
+
describe Site do
|
5
|
+
it "bombs with no url" do
|
6
|
+
options = {cache: 999, cache_min: 12, url: ''}
|
7
|
+
lambda { Site.new(options, "di") }.should raise_error RuntimeError
|
8
|
+
end
|
9
|
+
|
10
|
+
it "sets up properly for di.fm" do
|
11
|
+
options = {cache: 999, cache_min: 12, url: 'http://www.di.fm/breaks.pls'}
|
12
|
+
s = Site.new(options, 'di')
|
13
|
+
s.name.should == 'di'
|
14
|
+
s.current_channel.should == 'breaks'
|
15
|
+
s.player.class.should be Mplayer
|
16
|
+
s.is_mplayer.should be true
|
17
|
+
end
|
18
|
+
|
19
|
+
it "should return appropriate types for spotify URIs" do
|
20
|
+
# use di because firing up spotify without username/password bombs
|
21
|
+
options = {cache: 999, cache_min: 12, url: 'http://www.di.fm/breaks.pls'}
|
22
|
+
s = Site.new(options, 'di')
|
23
|
+
|
24
|
+
output = s.send(:spotify_type, 'spotify:album:5PFwYpKSHE3Zab4YTrgyv2')
|
25
|
+
output.should == 'album'
|
26
|
+
|
27
|
+
output = s.send(:spotify_type, 'spotify:playlist:5PFwYpKSHE3Zab4YTrgyv2')
|
28
|
+
output.should == 'playlist'
|
29
|
+
|
30
|
+
output = s.send(:spotify_type, 'spotify:track:5PFwYpKSHE3Zab4YTrgyv2')
|
31
|
+
output.should == 'track'
|
32
|
+
|
33
|
+
output = s.send(:spotify_type, 'spotify:unknown:5PFwYpKSHE3Zab4YTrgyv2')
|
34
|
+
output.should == 'spotify:unknown:5PFwYpKSHE3Zab4YTrgyv2'
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'terminal_player'
|
3
|
+
|
4
|
+
def capture_stdout(&block)
|
5
|
+
original_stdout = $stdout
|
6
|
+
$stdout = fake = StringIO.new
|
7
|
+
begin
|
8
|
+
yield
|
9
|
+
ensure
|
10
|
+
$stdout = original_stdout
|
11
|
+
end
|
12
|
+
fake.string
|
13
|
+
end
|
14
|
+
|
15
|
+
describe TerminalPlayer do
|
16
|
+
it "sets up properly for di.fm" do
|
17
|
+
options = { url: 'http://www.di.fm/' }
|
18
|
+
tp = TerminalPlayer.new(options)
|
19
|
+
s = tp.instance_variable_get(:@site)
|
20
|
+
s.class.should be DI
|
21
|
+
s.instance_variable_get(:@name).should == 'di-lo'
|
22
|
+
end
|
23
|
+
|
24
|
+
it "sets up properly for somafm.com" do
|
25
|
+
options = { url: 'http://somafm.com/' }
|
26
|
+
tp = TerminalPlayer.new(options)
|
27
|
+
tp.instance_variable_get(:@site).class.should be Soma
|
28
|
+
end
|
29
|
+
|
30
|
+
it "sets up properly for spotify" do
|
31
|
+
options = { url: 'spotify:blah:blah' }
|
32
|
+
tp = TerminalPlayer.new(options)
|
33
|
+
tp.instance_variable_get(:@site).class.should be Spotiphy
|
34
|
+
end
|
35
|
+
|
36
|
+
it "bombs with a bad site" do
|
37
|
+
options = { url: 'unknown' }
|
38
|
+
lambda { TerminalPlayer.new(options) }.should raise_error
|
39
|
+
end
|
40
|
+
|
41
|
+
it "lists di channels" do
|
42
|
+
options = { url: 'http://www.di.fm/channels.pls' }
|
43
|
+
output = capture_stdout do
|
44
|
+
lambda { TerminalPlayer.new(options) }.should raise_error SystemExit
|
45
|
+
end
|
46
|
+
output.should include 'ambient'
|
47
|
+
output.should include 'breaks'
|
48
|
+
output.should include 'vocaltrance'
|
49
|
+
end
|
50
|
+
|
51
|
+
it "lists soma channels" do
|
52
|
+
options = { url: 'http://somafm.com/channels.pls' }
|
53
|
+
output = capture_stdout do
|
54
|
+
lambda { TerminalPlayer.new(options) }.should raise_error SystemExit
|
55
|
+
end
|
56
|
+
output.should include 'doomed'
|
57
|
+
output.should include 'lush64'
|
58
|
+
output.should include 'u80s130'
|
59
|
+
end
|
60
|
+
|
61
|
+
it "displays songs" do
|
62
|
+
options = {
|
63
|
+
url: 'http://www.di.fm/premium_high/breaks.pls?abc123',
|
64
|
+
play_history_path: ''
|
65
|
+
}
|
66
|
+
tp = TerminalPlayer.new(options)
|
67
|
+
tp.instance_variable_get(:@site).class.should be DI
|
68
|
+
tp.instance_variable_get(:@site).instance_variable_get(:@name).should == 'di-hi'
|
69
|
+
tp.instance_variable_get(:@site).instance_variable_get(:@current_channel).should == 'breaks'
|
70
|
+
|
71
|
+
t = Time.now
|
72
|
+
output = capture_stdout do
|
73
|
+
songs = ['one', 'two', 'three']
|
74
|
+
tp.update(t, songs)
|
75
|
+
end
|
76
|
+
output.should include t.strftime("%H:%M:%S")
|
77
|
+
output.should include '[di-hi/breaks]'
|
78
|
+
output.should include 'three'
|
79
|
+
end
|
80
|
+
|
81
|
+
it "doesn't display duplicate songs" do
|
82
|
+
options = {
|
83
|
+
url: 'http://www.di.fm/premium_high/breaks.pls?abc123',
|
84
|
+
play_history_path: ''
|
85
|
+
}
|
86
|
+
tp = TerminalPlayer.new(options)
|
87
|
+
t = Time.now
|
88
|
+
songs = ['one', 'two', 'three']
|
89
|
+
output = capture_stdout do
|
90
|
+
tp.update(t, songs)
|
91
|
+
end
|
92
|
+
output.scan(/three/).length.should == 1
|
93
|
+
|
94
|
+
# add a dupe
|
95
|
+
songs << 'three'
|
96
|
+
output += capture_stdout do
|
97
|
+
tp.update(t, songs)
|
98
|
+
end
|
99
|
+
output.scan(/three/).length.should == 1
|
100
|
+
end
|
101
|
+
|
102
|
+
it "cleans song titles" do
|
103
|
+
options = { url: 'http://somafm.com/' }
|
104
|
+
tp = TerminalPlayer.new(options)
|
105
|
+
ab = 'ali baba and the forty theieves'
|
106
|
+
output = tp.send(:cleanup, ab)
|
107
|
+
output.should == ab.gsub(/ /, '+')
|
108
|
+
end
|
109
|
+
|
110
|
+
it "removes common crud in song titles" do
|
111
|
+
options = { url: 'http://somafm.com/' }
|
112
|
+
tp = TerminalPlayer.new(options)
|
113
|
+
# Feat.
|
114
|
+
ab = 'ali baba Feat. the forty theieves'
|
115
|
+
output = tp.send(:cleanup, ab)
|
116
|
+
output.should == 'ali+baba+the+forty+theieves'
|
117
|
+
|
118
|
+
# Many, but not all, non-word characters
|
119
|
+
ab = "?ali \tbaba , th\ne .forty thei^eves&*"
|
120
|
+
output = tp.send(:cleanup, ab)
|
121
|
+
output.should == 'ali+baba+the+.forty+theieves'
|
122
|
+
end
|
123
|
+
|
124
|
+
it "removes stuff in parenthesis from song titles" do
|
125
|
+
options = { url: 'http://somafm.com/' }
|
126
|
+
tp = TerminalPlayer.new(options)
|
127
|
+
|
128
|
+
# anything in parenthesis
|
129
|
+
ab = 'ali baba (original mix) - forty thieves'
|
130
|
+
output = tp.send(:cleanup, ab)
|
131
|
+
output.should == 'ali+baba+forty+thieves'
|
132
|
+
|
133
|
+
# ... even partially
|
134
|
+
ab = 'ali baba (ori'
|
135
|
+
output = tp.send(:cleanup, ab)
|
136
|
+
output.should == 'ali+baba'
|
137
|
+
end
|
138
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# This file was generated by the `rspec --init` command. Conventionally, all
|
2
|
+
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
|
3
|
+
# Require this file using `require "spec_helper"` to ensure that it is only
|
4
|
+
# loaded once.
|
5
|
+
#
|
6
|
+
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
|
7
|
+
RSpec.configure do |config|
|
8
|
+
config.treat_symbols_as_metadata_keys_with_true_values = true
|
9
|
+
config.run_all_when_everything_filtered = true
|
10
|
+
config.filter_run :focus
|
11
|
+
|
12
|
+
# Run specs in random order to surface order dependencies. If you find an
|
13
|
+
# order dependency and want to debug it, you can fix the order by providing
|
14
|
+
# the seed, which is printed after each run.
|
15
|
+
# --seed 1234
|
16
|
+
config.order = 'random'
|
17
|
+
end
|
data/spotify_appkey.key
ADDED
Binary file
|
@@ -0,0 +1,26 @@
|
|
1
|
+
Gem::Specification.new do |gem|
|
2
|
+
gem.authors = ['Shane Thomas']
|
3
|
+
gem.email = ['shane@devshane.com']
|
4
|
+
gem.homepage = 'https://github.com/devshane/terminal-player'
|
5
|
+
|
6
|
+
gem.summary = 'A minimalistic terminal-based player for di.fm, somafm.com, and Spotify.'
|
7
|
+
gem.description = 'Terminal player is a minimalistic terminal-based player for di.fm, somafm.com, and Spotify.'
|
8
|
+
|
9
|
+
gem.files = `git ls-files`.split($\)
|
10
|
+
gem.executables = ['terminal_player']
|
11
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
12
|
+
|
13
|
+
gem.name = 'terminal_player'
|
14
|
+
gem.version = '0.0.7'
|
15
|
+
gem.date = '2013-12-15'
|
16
|
+
gem.licenses = ['MIT']
|
17
|
+
|
18
|
+
gem.require_paths = ['lib']
|
19
|
+
|
20
|
+
gem.required_ruby_version = '>= 1.9.3'
|
21
|
+
|
22
|
+
gem.add_runtime_dependency('rake', '~> 10.1.0')
|
23
|
+
gem.add_runtime_dependency('rspec', '~> 2.14.1')
|
24
|
+
gem.add_runtime_dependency('spotify', '~> 12.5.3')
|
25
|
+
gem.add_runtime_dependency('plaything', '~> 1.1.1')
|
26
|
+
end
|