somadic 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.rspec +2 -0
- data/Gemfile +4 -0
- data/Guardfile +6 -0
- data/LICENSE.txt +22 -0
- data/README.md +84 -0
- data/Rakefile +1 -0
- data/bin/somadic +424 -0
- data/lib/somadic.rb +12 -0
- data/lib/somadic/audio_addict.rb +26 -0
- data/lib/somadic/base_channel.rb +53 -0
- data/lib/somadic/channel/di.rb +83 -0
- data/lib/somadic/channel/soma.rb +91 -0
- data/lib/somadic/logger.rb +33 -0
- data/lib/somadic/mplayer.rb +84 -0
- data/lib/somadic/version.rb +3 -0
- data/presets/chill.yaml +6 -0
- data/presets/lounge.yaml +4 -0
- data/somadic.gemspec +31 -0
- data/spec/lib/somadic/audio_addict_spec.rb +12 -0
- data/spec/lib/somadic/base_channel_spec.rb +5 -0
- data/spec/lib/somadic/channel/di_spec.rb +34 -0
- data/spec/lib/somadic/channel/soma_spec.rb +27 -0
- data/spec/lib/somadic/logger_spec.rb +9 -0
- data/spec/lib/somadic/mplayer_spec.rb +26 -0
- data/spec/lib/somadic_spec.rb +5 -0
- data/spec/spec_helper.rb +20 -0
- metadata +220 -0
data/lib/somadic.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'mono_logger'
|
2
|
+
require 'observer'
|
3
|
+
require 'open-uri'
|
4
|
+
require 'json'
|
5
|
+
require 'api_cache'
|
6
|
+
|
7
|
+
module Somadic
|
8
|
+
# Your code goes here...
|
9
|
+
end
|
10
|
+
|
11
|
+
Dir[File.join(__dir__, 'somadic', '*.rb')].each { |f| require f }
|
12
|
+
Dir[File.join(__dir__, 'somadic', 'channel', '*.rb')].each { |f| require f }
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Somadic
|
2
|
+
class AudioAddict
|
3
|
+
def initialize(channel_id)
|
4
|
+
@url = "http://api.audioaddict.com/v1/di/track_history/channel/" \
|
5
|
+
"#{channel_id}.jsonp?callback=_AudioAddict_TrackHistory_Channel"
|
6
|
+
end
|
7
|
+
|
8
|
+
def refresh_playlist
|
9
|
+
f = open(@url)
|
10
|
+
page = f.read
|
11
|
+
data = JSON.parse(page[page.index("(") + 1..-3])
|
12
|
+
|
13
|
+
symbolized_data = []
|
14
|
+
data.each { |d| symbolized_data << symbolize_keys(d) }
|
15
|
+
@songs = symbolized_data.keep_if { |d| d[:title] }
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def symbolize_keys(hash)
|
21
|
+
sym_hash = {}
|
22
|
+
hash.each { |k, v| sym_hash[k.to_sym] = v.is_a?(Hash) ? symbolize_keys(v) : v }
|
23
|
+
sym_hash
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Somadic
|
2
|
+
class BaseChannel
|
3
|
+
attr_reader :channels, :song
|
4
|
+
|
5
|
+
API_TIMEOUT = 60
|
6
|
+
ONE_DAY = 86400
|
7
|
+
|
8
|
+
def initialize(options)
|
9
|
+
@url = options[:url]
|
10
|
+
playlist = @url.split('/').last
|
11
|
+
name = playlist[0..playlist.index('.pls') - 1]
|
12
|
+
@channel = find_channel(name)
|
13
|
+
|
14
|
+
@mp = Mplayer.new(options)
|
15
|
+
@mp.add_observer(self)
|
16
|
+
@listeners = options[:listeners]
|
17
|
+
end
|
18
|
+
|
19
|
+
# Let's go already.
|
20
|
+
def start
|
21
|
+
Somadic::Logger.debug('BaseChannel#start')
|
22
|
+
@mp.start
|
23
|
+
rescue => e
|
24
|
+
Somadic::Logger.error("BaseChannel#start error: #{e}")
|
25
|
+
end
|
26
|
+
|
27
|
+
# Enough already.
|
28
|
+
def stop
|
29
|
+
Somadic::Logger.debug('BaseChannel#stop')
|
30
|
+
@mp.stop
|
31
|
+
end
|
32
|
+
|
33
|
+
def stopped?
|
34
|
+
@mp.stopped?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Observer callback, and also one of the simplest displays possible.
|
38
|
+
def update(time, song)
|
39
|
+
Somadic::Logger.debug("BaseChannel#update: #{time} - #{song}")
|
40
|
+
songs = [{ 'started' => Time.now.to_i - 1,
|
41
|
+
'duration' => 1,
|
42
|
+
'track' => song,
|
43
|
+
'votes' => { 'up' => 0, 'down' => 0 } }]
|
44
|
+
@listeners.each do |l|
|
45
|
+
l.update(@channel, songs) if l.respond_to?(:update)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def find_channel(name)
|
50
|
+
raise NotImplementedError
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Somadic
|
2
|
+
module Channel
|
3
|
+
class DI < Somadic::BaseChannel
|
4
|
+
def initialize(options)
|
5
|
+
url = if options[:premium_id]
|
6
|
+
"http://listen.di.fm/premium_high/#{options[:channel]}.pls?#{options[:premium_id]}"
|
7
|
+
else
|
8
|
+
"http://listen.di.fm/public3/#{options[:channel]}.pls"
|
9
|
+
end
|
10
|
+
@channels = load_channels
|
11
|
+
super(options.merge({ url: url }))
|
12
|
+
end
|
13
|
+
|
14
|
+
# Overrides BaseChannel
|
15
|
+
def find_channel(name)
|
16
|
+
Somadic::Logger.debug("DI#find_channel(#{name})")
|
17
|
+
@channels.each do |c|
|
18
|
+
return c if c[:name] == name
|
19
|
+
end
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
|
23
|
+
# Observer callback.
|
24
|
+
#
|
25
|
+
# TODO: time isn't used, song isn't required
|
26
|
+
def update(time, song)
|
27
|
+
@song = song if song
|
28
|
+
aa = Somadic::AudioAddict.new(@channel[:id])
|
29
|
+
songs = aa.refresh_playlist
|
30
|
+
if songs.first[:track] != @song
|
31
|
+
# try again
|
32
|
+
songs = poll_for_song
|
33
|
+
end
|
34
|
+
@listeners.each do |l|
|
35
|
+
Somadic::Logger.debug("DI#update: updating listener #{l}")
|
36
|
+
l.update(@channel, songs) if l.respond_to?(:update)
|
37
|
+
end
|
38
|
+
rescue => e
|
39
|
+
Somadic::Logger.error("DI#update: error #{e}")
|
40
|
+
end
|
41
|
+
|
42
|
+
# Overrides BaseChannel.
|
43
|
+
def stop
|
44
|
+
Somadic::Logger.debug('DI#stop')
|
45
|
+
@mp.stop
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def poll_for_song
|
51
|
+
aa = Somadic::AudioAddict.new(@channel[:id])
|
52
|
+
songs = aa.refresh_playlist
|
53
|
+
one_minute_from_now = Time.now + 1
|
54
|
+
while songs.first[:track] != @song
|
55
|
+
Somadic::Logger.debug("DI#poll_for_song: #{songs.first[:track]} != #{@song}")
|
56
|
+
break if Time.now > one_minute_from_now
|
57
|
+
sleep one_minute_from_now - Time.now < 15 ? 2 : 5
|
58
|
+
songs = aa.refresh_playlist
|
59
|
+
end
|
60
|
+
songs
|
61
|
+
end
|
62
|
+
|
63
|
+
# Loads the channel list.
|
64
|
+
def load_channels
|
65
|
+
APICache.logger = Somadic::Logger
|
66
|
+
APICache.get('di_fm_channel_list', cache: ONE_DAY, timeout: API_TIMEOUT) do
|
67
|
+
Somadic::Logger.debug('DI#load_channels')
|
68
|
+
channels = []
|
69
|
+
f = open('http://www.di.fm')
|
70
|
+
page = f.read
|
71
|
+
chan_ids = page.scan(/data-channel-id="(\d+)"/).flatten
|
72
|
+
chans = page.scan(/data-tunein-url="http:\/\/www.di.fm\/(.*?)"/).flatten
|
73
|
+
zipped = chan_ids.zip(chans)
|
74
|
+
zipped.each do |z|
|
75
|
+
channels << {id: z[0], name: z[1]}
|
76
|
+
end
|
77
|
+
channels.sort_by! {|k, _| k[:name]}
|
78
|
+
channels.uniq! {|k, _| k[:name]}
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module Somadic
|
2
|
+
module Channel
|
3
|
+
class Soma < Somadic::BaseChannel
|
4
|
+
def initialize(options)
|
5
|
+
@options = options
|
6
|
+
@channels = load_channels
|
7
|
+
super(options.merge({ url: "http://somafm.com/#{options[:channel]}.pls" }))
|
8
|
+
end
|
9
|
+
|
10
|
+
# Overrides BaseChannel
|
11
|
+
def find_channel(name)
|
12
|
+
Somadic::Logger.debug("Soma#find_channel(#{name})")
|
13
|
+
{ id: 0, name: name }
|
14
|
+
end
|
15
|
+
|
16
|
+
# Observer callback.
|
17
|
+
def update(time, song)
|
18
|
+
@song = song if song
|
19
|
+
songs = refresh_playlist
|
20
|
+
channel = { id: 0, name: @options[:channel] }
|
21
|
+
@listeners.each do |l|
|
22
|
+
l.update(channel, songs) if l.respond_to?(:update)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def load_channels
|
29
|
+
APICache.logger = Somadic::Logger
|
30
|
+
APICache.get('soma_fm_chanel_list', cache: ONE_DAY, timeout: API_TIMEOUT) do
|
31
|
+
Somadic::Logger.debug('Soma#load_channels')
|
32
|
+
channels = []
|
33
|
+
f = open('http://somafm.com/listen')
|
34
|
+
page = f.read
|
35
|
+
chans = page.scan(/\/play\/(.*?)"/).flatten
|
36
|
+
chans.each do |c|
|
37
|
+
unless c.start_with?('fw/') || c.gsub(/\d+$/, '') != c
|
38
|
+
channels << {id: 0, name: c}
|
39
|
+
end
|
40
|
+
end
|
41
|
+
channels.sort_by! {|k, _| k[:name]}
|
42
|
+
channels.uniq! {|k, _| k[:name]}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def refresh_playlist
|
47
|
+
# soma
|
48
|
+
c = @options[:channel].gsub(/(130|64|48|32)$/, '')
|
49
|
+
url = "http://somafm.com/#{c}/songhistory.html"
|
50
|
+
|
51
|
+
f = open(url)
|
52
|
+
page = f.read
|
53
|
+
page.gsub!("\n", "")
|
54
|
+
|
55
|
+
playlist = page.scan(/<!-- line \d+ -->.*?<tr>.*?<td>(.*?)<\/td>.*?<td>(<a.*?)<\/td>.*?<td>(.*?)<\/td>.*?<td>(.*?)<\/td>/)
|
56
|
+
songs = []
|
57
|
+
@next_load = Time.at(1)
|
58
|
+
playlist.each do |song|
|
59
|
+
if @next_load == Time.at(1)
|
60
|
+
@next_load = Time.now + 30
|
61
|
+
end
|
62
|
+
next if song[3].scan(/<a.*?>(.*?)<\/a>/).empty?
|
63
|
+
|
64
|
+
d = {}
|
65
|
+
song[0] = song[0][0..song[0].index('&')-1]if song[0]['&'] # clean hh:mm:ss (Now)
|
66
|
+
|
67
|
+
pt = Time.parse(song[0])
|
68
|
+
local = Chronic.parse(pt.to_s.gsub(/-\d+$/, '-0700'))
|
69
|
+
d[:started] = local.to_i
|
70
|
+
|
71
|
+
d[:votes] = {up: 0, down: 0}
|
72
|
+
d[:duration] = 0
|
73
|
+
d[:artist] = strip_a(song[1])
|
74
|
+
d[:title] = song[2]
|
75
|
+
d[:track] = "#{d[:artist]} - #{d[:title]}"
|
76
|
+
album = strip_a(song[3])
|
77
|
+
d[:title] += "- #{strip_a(song[3])}" unless album.empty?
|
78
|
+
songs << d
|
79
|
+
end
|
80
|
+
songs
|
81
|
+
end
|
82
|
+
|
83
|
+
# Removes anchor tags from `s`.
|
84
|
+
def strip_a(s)
|
85
|
+
results = s.scan(/<a.*?>(.*?)<\/a>/)
|
86
|
+
return [] if results.empty?
|
87
|
+
results[0][0]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
module Somadic
|
4
|
+
class Logger
|
5
|
+
LOG_PATH = "#{ENV['HOME']}/.somadic/"
|
6
|
+
LOG_FILE = 'somadic.log'
|
7
|
+
|
8
|
+
def self.debug(msg)
|
9
|
+
instance.debug(msg)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.info(msg)
|
13
|
+
instance.info(msg)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.error(msg)
|
17
|
+
instance.error(msg)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.warn(msg)
|
21
|
+
instance.warn(msg)
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.instance
|
25
|
+
::FileUtils.mkdir_p(LOG_PATH) unless File.directory?(LOG_PATH)
|
26
|
+
l = MonoLogger.new(File.join(LOG_PATH, LOG_FILE), 'daily')
|
27
|
+
l.formatter = proc do |severity, datetime, _, msg|
|
28
|
+
"[#{severity}] #{datetime}: #{msg}\n"
|
29
|
+
end
|
30
|
+
l
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
module Somadic
|
2
|
+
class Mplayer
|
3
|
+
include Observable
|
4
|
+
|
5
|
+
MPLAYER = 'mplayer'
|
6
|
+
|
7
|
+
attr_accessor :url, :cache, :cache_min
|
8
|
+
|
9
|
+
# Sets up a new instance of Mplayer.
|
10
|
+
#
|
11
|
+
# Valid options are:
|
12
|
+
#
|
13
|
+
# :cache - how much memory in kBytes to use when precaching
|
14
|
+
# a file or URL
|
15
|
+
# :cache_min - playback will start when the cache has been filled up
|
16
|
+
# to `cache_min` of the total.
|
17
|
+
#
|
18
|
+
# See the mplayer man page for more.
|
19
|
+
def initialize(options)
|
20
|
+
@url = options[:url]
|
21
|
+
@cache = options[:cache]
|
22
|
+
@cache_min =options[:cache_min]
|
23
|
+
@stopped = true
|
24
|
+
end
|
25
|
+
|
26
|
+
# Starts mplayer on a new thread.
|
27
|
+
def start
|
28
|
+
@stopped = false
|
29
|
+
@player_thread = Thread.new do
|
30
|
+
cmd = command
|
31
|
+
Somadic::Logger.debug("Mplayer#start: popen #{cmd}")
|
32
|
+
pipe = IO.popen(cmd, 'r+')
|
33
|
+
loop do
|
34
|
+
line = pipe.readline.chomp
|
35
|
+
if line['Starting playback']
|
36
|
+
Somadic::Logger.debug("Mplayer#pipe: #{line}")
|
37
|
+
elsif line.start_with?('ICY ')
|
38
|
+
begin
|
39
|
+
Somadic::Logger.debug("Mplayer#pipe: #{line}")
|
40
|
+
_, v = line.split(';')[0].split('=')
|
41
|
+
song = v[1..-2]
|
42
|
+
rescue Exception => e
|
43
|
+
Somadic::Logger.debug("unicode fuckup: #{e}")
|
44
|
+
end
|
45
|
+
notify(song)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
pipe.close
|
49
|
+
end
|
50
|
+
rescue => e
|
51
|
+
Somadic::Logger.error("Mplayer#start: error #{e}")
|
52
|
+
end
|
53
|
+
|
54
|
+
# Stops mplayer.
|
55
|
+
def stop
|
56
|
+
Somadic::Logger.debug("Mplayer#stop")
|
57
|
+
@stopped = true
|
58
|
+
`killall mplayer`
|
59
|
+
end
|
60
|
+
|
61
|
+
def stopped?
|
62
|
+
@stopped
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
# Builds the command line for launching mplayer.
|
68
|
+
def command
|
69
|
+
cmd = MPLAYER
|
70
|
+
cmd = "#{cmd} -cache #{@cache}" if @cache
|
71
|
+
cmd = "#{cmd} -cache-min #{@cache_min}" if @cache_min
|
72
|
+
cmd = "#{cmd} -playlist #{@url}"
|
73
|
+
cmd = "#{cmd} 2>&1"
|
74
|
+
cmd
|
75
|
+
end
|
76
|
+
|
77
|
+
# Tell everybody who cares that something happened.
|
78
|
+
def notify(message)
|
79
|
+
Somadic::Logger.debug("Mplayer#notify(#{message})")
|
80
|
+
changed
|
81
|
+
notify_observers(Time.now, message)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
data/presets/chill.yaml
ADDED
data/presets/lounge.yaml
ADDED
data/somadic.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'somadic/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'somadic'
|
8
|
+
spec.version = Somadic::VERSION
|
9
|
+
spec.authors = ['Shane Thomas']
|
10
|
+
spec.email = ['shane@devshane.com']
|
11
|
+
spec.summary = %q{Somadic is a terminal-based player for somafm.com and DI.fm}
|
12
|
+
spec.description = %q{Somadic is a terminal-based player for somafm.com and DI.fm.}
|
13
|
+
spec.homepage = 'https://github.com/devshane/somadic'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_runtime_dependency 'mono_logger', '~> 1.1'
|
22
|
+
spec.add_runtime_dependency 'curses', '~> 1.0'
|
23
|
+
spec.add_runtime_dependency 'progress_bar', '~> 1.0'
|
24
|
+
spec.add_runtime_dependency 'api_cache', '~> 0.3'
|
25
|
+
spec.add_runtime_dependency 'chronic', '~> 0.10'
|
26
|
+
spec.add_development_dependency 'bundler', '~> 1.5'
|
27
|
+
spec.add_development_dependency 'rake', '~> 10.3'
|
28
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
29
|
+
spec.add_development_dependency 'guard-rspec', '~> 4.3'
|
30
|
+
spec.add_development_dependency 'pry', '~> 0.10'
|
31
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Somadic::AudioAddict do
|
4
|
+
it 'can refresh a playlist' do
|
5
|
+
aa = Somadic::AudioAddict.new(4)
|
6
|
+
songs = aa.refresh_playlist
|
7
|
+
expect(songs.count).to be > 0
|
8
|
+
s = songs.first
|
9
|
+
expect(s[:title].length).to be > 0
|
10
|
+
expect(s[:votes].length).to eql 4
|
11
|
+
end
|
12
|
+
end
|