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.
@@ -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,3 @@
1
+ require 'terminal_player/site'
2
+ require 'terminal_player/di/audioaddict'
3
+ require 'terminal_player/di/di'
@@ -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