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,218 @@
|
|
1
|
+
require 'observer'
|
2
|
+
require 'open-uri'
|
3
|
+
|
4
|
+
require 'terminal_player/site'
|
5
|
+
require 'terminal_player/mplayer'
|
6
|
+
require 'terminal_player/play_history'
|
7
|
+
require 'terminal_player/di'
|
8
|
+
require 'terminal_player/soma'
|
9
|
+
require 'terminal_player/spotiphy'
|
10
|
+
|
11
|
+
class TerminalPlayer
|
12
|
+
def initialize(options)
|
13
|
+
@last_log = ''
|
14
|
+
@last_di_fetch = 0
|
15
|
+
@recent_songs = []
|
16
|
+
@stop_updating = false
|
17
|
+
|
18
|
+
@options = options
|
19
|
+
if @options[:url]['di.fm']
|
20
|
+
@site = DI.new(@options)
|
21
|
+
elsif @options[:url]['somafm.com']
|
22
|
+
@site = Soma.new(@options)
|
23
|
+
elsif @options[:url][':']
|
24
|
+
@site = Spotiphy.new(@options)
|
25
|
+
else
|
26
|
+
fail "no url"
|
27
|
+
end
|
28
|
+
|
29
|
+
if @options[:url]['channels.pls']
|
30
|
+
list_channels
|
31
|
+
puts "\n\n"
|
32
|
+
exit
|
33
|
+
end
|
34
|
+
|
35
|
+
@site.add_observer(self)
|
36
|
+
end
|
37
|
+
|
38
|
+
def keypress_handler
|
39
|
+
Thread.new do
|
40
|
+
loop do
|
41
|
+
begin
|
42
|
+
state = `stty -g`
|
43
|
+
`stty raw -echo -icanon isig`
|
44
|
+
str = STDIN.getc
|
45
|
+
ensure
|
46
|
+
`stty #{state}`
|
47
|
+
end
|
48
|
+
|
49
|
+
ch = str.chr
|
50
|
+
case ch
|
51
|
+
when 'c'
|
52
|
+
@stop_updating = true
|
53
|
+
list_channels
|
54
|
+
@stop_updating = false
|
55
|
+
update(Time.now, @site.songs, true, true) unless @site.is_di_plus
|
56
|
+
when 'n'
|
57
|
+
@site.player.next if @site.is_spotify
|
58
|
+
when 'r'
|
59
|
+
update(Time.now, @site.songs, true, true)
|
60
|
+
when 's'
|
61
|
+
if @options[:spotify_search]
|
62
|
+
s = cleanup(@site.songs.last)
|
63
|
+
`open "spotify:search:#{s}"`
|
64
|
+
end
|
65
|
+
when 'S'
|
66
|
+
google @site.songs.last
|
67
|
+
when '9', '0' # volume
|
68
|
+
@site.player.write ch if @site.is_mplayer?
|
69
|
+
when ' ' # pause/resume
|
70
|
+
@site.player.write ch if @site.is_mplayer?
|
71
|
+
end
|
72
|
+
sleep 0.2
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def refresh_display
|
78
|
+
Thread.new do
|
79
|
+
loop do
|
80
|
+
unless @site.songs.nil?
|
81
|
+
update(Time.now, @site.songs, true, true)
|
82
|
+
end
|
83
|
+
sleep 1
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def google(s)
|
89
|
+
`open "https://www.google.com/search?safe=off&q=#{s}"`
|
90
|
+
end
|
91
|
+
|
92
|
+
def play
|
93
|
+
refresh_display if @site.is_di_plus
|
94
|
+
keypress_handler
|
95
|
+
@site.play
|
96
|
+
end
|
97
|
+
|
98
|
+
def update(time, songs, force=false, is_refresh=false)
|
99
|
+
return if @stop_updating
|
100
|
+
@stop_updating = true
|
101
|
+
begin
|
102
|
+
if @last_log != songs.last || force
|
103
|
+
unless songs.last.nil?
|
104
|
+
@last_log = song = songs.last
|
105
|
+
cols = `tput cols`.to_i
|
106
|
+
preamble = "[#{@site.name}/#{@site.current_channel}]"
|
107
|
+
if @site.class == DI && @site.is_di_plus
|
108
|
+
extras = get_di_info
|
109
|
+
else
|
110
|
+
extras = time.strftime('%H:%M:%S')
|
111
|
+
end
|
112
|
+
while (1 + preamble.length + extras.length + song.length) > cols
|
113
|
+
song = song[0..-2]
|
114
|
+
end
|
115
|
+
spaces = ' ' * (cols - song.length - preamble.length - extras.length - 1)
|
116
|
+
song = "#{song}#{spaces}#{extras}"
|
117
|
+
print "\n" unless is_refresh
|
118
|
+
print "#{preamble} #{song}\r"
|
119
|
+
unless force || @options[:play_history_path].empty?
|
120
|
+
PlayHistory.write @options[:play_history_path],
|
121
|
+
"#{time.strftime("%H:%M:%S")} #{preamble} #{songs.last}"
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
rescue => e
|
126
|
+
write "update error: #{e}"
|
127
|
+
end
|
128
|
+
@stop_updating = false
|
129
|
+
end
|
130
|
+
|
131
|
+
def list_channels
|
132
|
+
puts "\n\n"
|
133
|
+
channels = @site.get_channels
|
134
|
+
chans = channels.map { |c| c[:name] }.join("\n")
|
135
|
+
if `which column`.empty?
|
136
|
+
puts chans
|
137
|
+
else
|
138
|
+
puts `echo "#{chans}" | column`
|
139
|
+
end
|
140
|
+
puts "\n"
|
141
|
+
end
|
142
|
+
|
143
|
+
private
|
144
|
+
|
145
|
+
def get_di_info
|
146
|
+
chid = 0
|
147
|
+
info = ''
|
148
|
+
|
149
|
+
@channels ||= @site.get_channels
|
150
|
+
@channels.each do |c|
|
151
|
+
if c[:name] == @site.current_channel
|
152
|
+
chid = c[:id].to_i
|
153
|
+
break
|
154
|
+
end
|
155
|
+
end
|
156
|
+
if chid > 0
|
157
|
+
retries = 0
|
158
|
+
status = ""
|
159
|
+
if Time.now.to_i - @last_di_fetch > 60
|
160
|
+
@last_di_fetch = Time.now.to_i
|
161
|
+
@recent_songs = @site.get_recently_played_list(chid) if chid > 0
|
162
|
+
status = "* "
|
163
|
+
end
|
164
|
+
loop do
|
165
|
+
s = @recent_songs.first
|
166
|
+
next if s.nil?
|
167
|
+
break if retries > 4
|
168
|
+
if s['track'][@site.songs.last.strip]
|
169
|
+
info = " #{status}" \
|
170
|
+
"#{format_secs(Time.now.to_i - s['started'].to_i)} > " \
|
171
|
+
"#{format_secs(s['duration'])} : " \
|
172
|
+
"+#{"%-2d" % s['votes']['up']} -#{"%-2d" % s['votes']['down']}"
|
173
|
+
break
|
174
|
+
else
|
175
|
+
if retries >= 4
|
176
|
+
# might be a station break
|
177
|
+
break
|
178
|
+
end
|
179
|
+
sleep (retries + 1) * 1.5
|
180
|
+
@recent_songs = @site.get_recently_played_list(chid)
|
181
|
+
end
|
182
|
+
retries += 1
|
183
|
+
end
|
184
|
+
end
|
185
|
+
info
|
186
|
+
end
|
187
|
+
|
188
|
+
def format_secs(seconds)
|
189
|
+
secs = seconds.abs
|
190
|
+
hours = 0
|
191
|
+
if secs > 3600
|
192
|
+
hours = secs / 3600
|
193
|
+
secs -= 3600 * hours
|
194
|
+
end
|
195
|
+
mins = secs / 60
|
196
|
+
secs = secs % 60
|
197
|
+
h = hours > 0 ? "#{"%d" % hours}:" : ""
|
198
|
+
"#{h}#{"%02d" % mins}:#{"%02d" % secs}"
|
199
|
+
end
|
200
|
+
|
201
|
+
def cleanup(song)
|
202
|
+
s = song.gsub(/[Ff]eat\./, '')
|
203
|
+
s.gsub!(/\(.*?\)/, '')
|
204
|
+
s.gsub!(/\(.*/, '')
|
205
|
+
s.gsub!(/-/, ' ')
|
206
|
+
s.gsub!(/[^A-Za-z \.]/, '')
|
207
|
+
|
208
|
+
s.strip!
|
209
|
+
|
210
|
+
s.gsub!(/ /, '+')
|
211
|
+
s.gsub!(/\++/, '+')
|
212
|
+
s
|
213
|
+
end
|
214
|
+
|
215
|
+
def write(message)
|
216
|
+
print "\n<terminal_player-debug> #{message}\r\n"
|
217
|
+
end
|
218
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module AudioAddict
|
4
|
+
def get_recently_played_list(channel_id)
|
5
|
+
rp = []
|
6
|
+
|
7
|
+
url = "http://api.audioaddict.com/v1/di/track_history/channel/" \
|
8
|
+
"#{channel_id}.jsonp?callback=_AudioAddict_TrackHistory_Channel"
|
9
|
+
|
10
|
+
f = open(url)
|
11
|
+
page = f.read
|
12
|
+
data = JSON.parse(page[page.index("(") + 1..-3])
|
13
|
+
|
14
|
+
data.each { |d| rp << d if d['title'] }
|
15
|
+
rp
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class DI < Site
|
2
|
+
include AudioAddict
|
3
|
+
|
4
|
+
def initialize(options)
|
5
|
+
p = !options[:url]['premium'].nil?
|
6
|
+
super(options, p ? 'di-hi' : 'di-lo')
|
7
|
+
end
|
8
|
+
|
9
|
+
def get_channels
|
10
|
+
@channels = []
|
11
|
+
f = open('http://www.di.fm')
|
12
|
+
page = f.read
|
13
|
+
chan_ids = page.scan(/data-channel-id="(\d+)"/).flatten
|
14
|
+
chans = page.scan(/data-tunein-url="http:\/\/www.di.fm\/(.*?)"/).flatten
|
15
|
+
zipped = chan_ids.zip(chans)
|
16
|
+
zipped.each do |z|
|
17
|
+
@channels << {id: z[0], name: z[1]}
|
18
|
+
end
|
19
|
+
@channels.sort_by! {|k, _| k[:name]}
|
20
|
+
@channels.uniq! {|k, _| k[:name]}
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
class Mplayer
|
2
|
+
include Observable
|
3
|
+
|
4
|
+
def initialize(options)
|
5
|
+
@options = options
|
6
|
+
# assumes options has :cache, :cache_min, :url
|
7
|
+
end
|
8
|
+
|
9
|
+
def play
|
10
|
+
return if @options[:stub] # HACK
|
11
|
+
|
12
|
+
@player_thread = Thread.new do
|
13
|
+
player = "mplayer -quiet -cache #{@options[:cache]} " \
|
14
|
+
"-cache-min #{@options[:cache_min]} " \
|
15
|
+
"-playlist \"#{@options[:url]}\" 2>&1"
|
16
|
+
notify "starting player (cache #{@options[:cache]}, min #{@options[:cache_min]})..."
|
17
|
+
@player_pipe = IO.popen(player, "r+")
|
18
|
+
loop do
|
19
|
+
line = @player_pipe.readline.chomp
|
20
|
+
if line['Starting playback']
|
21
|
+
notify line
|
22
|
+
elsif line['ICY']
|
23
|
+
notify line
|
24
|
+
elsif line['Cache empty']
|
25
|
+
notify line
|
26
|
+
elsif line['Cache fill']
|
27
|
+
notify "filling cache..."
|
28
|
+
end
|
29
|
+
end
|
30
|
+
@player_pipe.close
|
31
|
+
end
|
32
|
+
@player_thread.join
|
33
|
+
end
|
34
|
+
|
35
|
+
def write(char)
|
36
|
+
@player_pipe.write(char)
|
37
|
+
end
|
38
|
+
|
39
|
+
def notify(message)
|
40
|
+
changed
|
41
|
+
notify_observers(Time.now, message)
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
class PlayHistory
|
5
|
+
def self.write(path, message)
|
6
|
+
FileUtils.mkdir_p(path)
|
7
|
+
logger = Logger.new("#{path}/play_history.log", 'daily')
|
8
|
+
logger.formatter = proc do |severity, datetime, progname, msg|
|
9
|
+
"#{msg}\n"
|
10
|
+
end
|
11
|
+
logger.info(message)
|
12
|
+
logger.close
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'observer'
|
2
|
+
|
3
|
+
class Site
|
4
|
+
include Observable
|
5
|
+
|
6
|
+
attr_accessor :songs, :player
|
7
|
+
attr_reader :name, :current_channel, :channels, :is_spotify, :is_di_plus, :is_mplayer
|
8
|
+
|
9
|
+
def initialize(options, name)
|
10
|
+
@name = name
|
11
|
+
@songs = []
|
12
|
+
@channels = []
|
13
|
+
|
14
|
+
options[:cache] || options[:cache] = 512
|
15
|
+
options[:cache_min] || options[:cache_min] = 30
|
16
|
+
options[:url] || options[:url] = ''
|
17
|
+
|
18
|
+
if options[:url].nil? || options[:url].empty?
|
19
|
+
fail "no :url in the options hash sent to Site"
|
20
|
+
end
|
21
|
+
|
22
|
+
@is_di_plus = options[:di_plus]
|
23
|
+
|
24
|
+
@is_spotify = !options[:url]['spotify:'].nil?
|
25
|
+
if @is_spotify
|
26
|
+
@current_channel = spotify_type(options[:url])
|
27
|
+
else
|
28
|
+
@current_channel = options[:url].split('/').last
|
29
|
+
@current_channel = @current_channel[0..@current_channel.index('.') - 1]
|
30
|
+
end
|
31
|
+
|
32
|
+
if @is_spotify
|
33
|
+
@is_mplayer = false
|
34
|
+
@player = SpotiphyPlayer.new(options)
|
35
|
+
else
|
36
|
+
@is_mplayer = true
|
37
|
+
@player = Mplayer.new({cache: options[:cache],
|
38
|
+
cache_min: options[:cache_min],
|
39
|
+
url: options[:url]})
|
40
|
+
end
|
41
|
+
PlayerMessageObserver.new(self, @player)
|
42
|
+
end
|
43
|
+
|
44
|
+
def play
|
45
|
+
@player.play
|
46
|
+
end
|
47
|
+
|
48
|
+
def song_changed
|
49
|
+
changed
|
50
|
+
notify_observers(Time.now, @songs)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def spotify_type(uri)
|
56
|
+
return 'playlist' if uri[':playlist:']
|
57
|
+
return 'album' if uri[':album:']
|
58
|
+
return 'track' if uri[':track:']
|
59
|
+
return uri
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
class Site::Observer
|
64
|
+
def initialize(site, player)
|
65
|
+
@site = site
|
66
|
+
player.add_observer(self)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
class PlayerMessageObserver < Site::Observer
|
71
|
+
@channels = []
|
72
|
+
|
73
|
+
def update(time, message)
|
74
|
+
if message['ICY']
|
75
|
+
begin
|
76
|
+
m = message.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
|
77
|
+
song = m[/StreamTitle='(.*?)';/, 1]
|
78
|
+
rescue => e
|
79
|
+
write "error '#{e}' with ICY message: #{m}"
|
80
|
+
end
|
81
|
+
@site.songs << song
|
82
|
+
@site.song_changed
|
83
|
+
elsif message['SPOTTY']
|
84
|
+
@site.songs << message.gsub(/SPOTTY /, '')
|
85
|
+
@site.song_changed
|
86
|
+
elsif message['Cache ']
|
87
|
+
write message
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def write(message)
|
92
|
+
print "\n<site-debug> #{message}\r"
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class Soma < Site
|
2
|
+
def initialize(options)
|
3
|
+
super(options, "soma")
|
4
|
+
end
|
5
|
+
|
6
|
+
def get_channels
|
7
|
+
@channels = []
|
8
|
+
f = open('http://somafm.com/listen')
|
9
|
+
page = f.read
|
10
|
+
chans = page.scan(/\/play\/(.*?)"/).flatten
|
11
|
+
chans.each do |c|
|
12
|
+
next if c['fw/']
|
13
|
+
@channels << {id: 0, name: c}
|
14
|
+
end
|
15
|
+
@channels.sort_by! {|k, _| k[:name]}
|
16
|
+
@channels.uniq! {|k, _| k[:name]}
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'terminal_player/spotiphy/spotiphy_player'
|
2
|
+
|
3
|
+
class Spotiphy < Site
|
4
|
+
def initialize(options)
|
5
|
+
super(options, "spotify")
|
6
|
+
end
|
7
|
+
|
8
|
+
def get_channels
|
9
|
+
# TODO maybe playlists?
|
10
|
+
@channels = [{id: 0, name: "There is no channel support for spotify yet."}]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class FrameReader
|
15
|
+
include Enumerable
|
16
|
+
|
17
|
+
def initialize(channels, sample_type, frames_count, frames_ptr)
|
18
|
+
@channels = channels
|
19
|
+
@sample_type = sample_type
|
20
|
+
@size = frames_count * @channels
|
21
|
+
@pointer = FFI::Pointer.new(@sample_type, frames_ptr)
|
22
|
+
end
|
23
|
+
|
24
|
+
attr_reader :size
|
25
|
+
|
26
|
+
def each
|
27
|
+
return enum_for(__method__) unless block_given?
|
28
|
+
|
29
|
+
ffi_read = :"read_#{@sample_type}"
|
30
|
+
|
31
|
+
(0...size).each do |index|
|
32
|
+
yield @pointer[index].public_send(ffi_read)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|