somadic 0.0.1
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 +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
|