rbtune 1.0
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 +17 -0
- data/Gemfile +4 -0
- data/LICENSE +8 -0
- data/README.md +152 -0
- data/Rakefile +2 -0
- data/bin/console +15 -0
- data/bin/rbplay +50 -0
- data/bin/setup +8 -0
- data/exe/rbtune +95 -0
- data/exe/timefree +108 -0
- data/lib/keychain.rb +47 -0
- data/lib/player/ffmpeg.rb +54 -0
- data/lib/player/mplayer.rb +66 -0
- data/lib/player/player.rb +29 -0
- data/lib/player/rtmpdump.rb +37 -0
- data/lib/rbtune.rb +8 -0
- data/lib/rbtune/agqr.rb +36 -0
- data/lib/rbtune/jcba.rb +32 -0
- data/lib/rbtune/listenradio.rb +45 -0
- data/lib/rbtune/radiko.rb +143 -0
- data/lib/rbtune/radiko_premium.rb +70 -0
- data/lib/rbtune/radio.rb +207 -0
- data/lib/rbtune/radiru.rb +52 -0
- data/lib/rbtune/simul.rb +93 -0
- data/lib/rbtune/station.rb +91 -0
- data/lib/rbtune/timefree.rb +51 -0
- data/lib/rbtune/version.rb +3 -0
- data/rbtune.gemspec +34 -0
- metadata +158 -0
@@ -0,0 +1,70 @@
|
|
1
|
+
#radiko.rb
|
2
|
+
# coding: utf-8
|
3
|
+
|
4
|
+
require "rbtune/radiko"
|
5
|
+
|
6
|
+
class RadikoPremium < Radiko
|
7
|
+
|
8
|
+
def self.set_authentication(kc, account)
|
9
|
+
begin
|
10
|
+
password = kc.query('radikoプレミアムのパスワードを入力してください', account)
|
11
|
+
radio = RadikoPremium.new
|
12
|
+
radio.login account, password
|
13
|
+
kc.set account, password
|
14
|
+
puts "Radikoプレミアムのアカウントが正しく登録されました"
|
15
|
+
|
16
|
+
rescue Radio::HTTPForbiddenException
|
17
|
+
$stderr.puts "アカウント情報が正しくありません"
|
18
|
+
|
19
|
+
rescue RuntimeError
|
20
|
+
$stderr.puts $!
|
21
|
+
|
22
|
+
ensure
|
23
|
+
radio && radio.close
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
|
28
|
+
def headers
|
29
|
+
{
|
30
|
+
'pragma' => 'no-cache',
|
31
|
+
'Cache-Control' => 'no-cache',
|
32
|
+
'Expires' => 'Thu, 01 Jan 1970 00:00:00 GMT',
|
33
|
+
'Accept-Language' => 'ja-jp',
|
34
|
+
'Accept-Encoding' => 'gzip, deflate',
|
35
|
+
'Accept' => 'application/json, text/javascript, */*; q=0.01',
|
36
|
+
'X-Requested-With' => 'XMLHttpRequest'
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
def login(account, password)
|
42
|
+
res = agent.post 'https://radiko.jp/ap/member/login/login', {
|
43
|
+
mail: account, pass: password
|
44
|
+
}
|
45
|
+
|
46
|
+
begin
|
47
|
+
params = []
|
48
|
+
referer = nil
|
49
|
+
@logged_in = true
|
50
|
+
agent.get 'https://radiko.jp/ap/member/webapi/member/login/check', params, referer, headers
|
51
|
+
rescue Mechanize::ResponseCodeError => ex
|
52
|
+
raise HTTPForbiddenException if ex.message.include?('400 => Net::HTTPBadRequest')
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
|
57
|
+
def close
|
58
|
+
params = []
|
59
|
+
referer = nil
|
60
|
+
agent.get 'https://radiko.jp/ap/member/webapi/member/logout', params, referer, headers
|
61
|
+
@logged_in = false
|
62
|
+
end
|
63
|
+
|
64
|
+
def stations_uri
|
65
|
+
"http://radiko.jp/v3/station/region/full.xml"
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
|
70
|
+
end
|
data/lib/rbtune/radio.rb
ADDED
@@ -0,0 +1,207 @@
|
|
1
|
+
# Radio仮想基底クラス
|
2
|
+
# Radiko, Radiru等で継承して使う
|
3
|
+
#
|
4
|
+
# radio.login account, password (RadikoPremium 等、必要な場合のみ)
|
5
|
+
# radio.open
|
6
|
+
# radio.tune channel
|
7
|
+
# radio.play または radio.record
|
8
|
+
# radio.close
|
9
|
+
|
10
|
+
require "date"
|
11
|
+
require "benchmark"
|
12
|
+
require "net/http"
|
13
|
+
require "rexml/document"
|
14
|
+
|
15
|
+
class Radio
|
16
|
+
attr_accessor :outdir
|
17
|
+
attr_reader :ext
|
18
|
+
attr_reader :area_id # for Radiko(Premium)
|
19
|
+
attr_reader :area_ja # for Radiko(Premium)
|
20
|
+
attr_reader :area_en # for Radiko(Premium)
|
21
|
+
|
22
|
+
def Radio.inherited(subclass)
|
23
|
+
@@bands ||= []
|
24
|
+
@@bands << subclass
|
25
|
+
end
|
26
|
+
|
27
|
+
|
28
|
+
def initialize
|
29
|
+
@outdir = '.'
|
30
|
+
@ext = 'm4a'
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
def self.db
|
35
|
+
@@db ||= Station::pstore_db
|
36
|
+
end
|
37
|
+
|
38
|
+
|
39
|
+
def self.stations
|
40
|
+
@stations ||= self.db.transaction(true) { self.db[name] }
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
def self.channels
|
45
|
+
@channels ||= self.stations.map {|st| [st.id, st.uri]}.to_h
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
def self.bands
|
50
|
+
@@bands
|
51
|
+
end
|
52
|
+
|
53
|
+
|
54
|
+
def self.search(channel)
|
55
|
+
radio_class, station = self.find(channel) || self.match(channel)
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
# Radioクラスのリストから、id と一致する放送局を探す
|
60
|
+
# return: [Radioクラス, 放送局] or nil
|
61
|
+
def self.find(id)
|
62
|
+
Radio.bands.each do |tuner|
|
63
|
+
if tuner.stations
|
64
|
+
station = tuner.stations.find {|station| station.id == id}
|
65
|
+
return [tuner, station] if station
|
66
|
+
end
|
67
|
+
end
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
# Radioクラスのリストから、name を含む放送局を探す
|
73
|
+
# return: [Radioクラス, 放送局] or nil
|
74
|
+
def self.match(name)
|
75
|
+
matcher = /#{name}/i
|
76
|
+
Radio.bands.each do |tuner|
|
77
|
+
next unless tuner.stations
|
78
|
+
found = tuner.stations.find { |station| station.name.match?(matcher) || station.ascii_name.match?(matcher) }
|
79
|
+
return [tuner, found] if found
|
80
|
+
end
|
81
|
+
nil
|
82
|
+
end
|
83
|
+
|
84
|
+
|
85
|
+
def agent
|
86
|
+
@agent ||= Mechanize.new
|
87
|
+
end
|
88
|
+
|
89
|
+
|
90
|
+
def create_player(uri)
|
91
|
+
# rtmpdumpのコマンドラインを生成する(playから呼ばれる)
|
92
|
+
end
|
93
|
+
|
94
|
+
|
95
|
+
def login(account=nil, password=nil)
|
96
|
+
# ラジオサービスにログイン
|
97
|
+
end
|
98
|
+
|
99
|
+
def open
|
100
|
+
end
|
101
|
+
|
102
|
+
def close
|
103
|
+
end
|
104
|
+
|
105
|
+
|
106
|
+
def tune(channel)
|
107
|
+
@channel = channel
|
108
|
+
end
|
109
|
+
|
110
|
+
|
111
|
+
def channel_to_uri
|
112
|
+
self.class::channels[@channel] || @channel
|
113
|
+
end
|
114
|
+
|
115
|
+
|
116
|
+
def record(filename, sec, quiet: false, dt: DateTime.now)
|
117
|
+
begin
|
118
|
+
uri = channel_to_uri
|
119
|
+
raise 'not tuned yet.' unless uri
|
120
|
+
|
121
|
+
puts "record: #{uri}"
|
122
|
+
# $stderr.puts "play: #{sec}, #{filename}, #{quiet}"
|
123
|
+
player = create_player uri
|
124
|
+
remain_sec = sec
|
125
|
+
rtime = 0
|
126
|
+
minimum_sec = 60 # 残り録音時間がこれ以下ならば、録音が中断してもやり直さない
|
127
|
+
datetimes = []
|
128
|
+
begin
|
129
|
+
rtime += Benchmark.realtime do
|
130
|
+
dt = datetime dt
|
131
|
+
datetimes << dt
|
132
|
+
tmpfile = make_tmpfile @channel, dt
|
133
|
+
player.rec tmpfile, remain_sec, quiet
|
134
|
+
end
|
135
|
+
remain_sec -= rtime
|
136
|
+
dt = DateTime.now
|
137
|
+
end while remain_sec >= minimum_sec
|
138
|
+
|
139
|
+
rescue Interrupt, Errno::EPIPE
|
140
|
+
# do nothing
|
141
|
+
|
142
|
+
ensure
|
143
|
+
# 最後にまとめて convert する
|
144
|
+
datetimes.each do |dt|
|
145
|
+
tmpfile = make_tmpfile @channel, dt
|
146
|
+
recfile = make_recfile(filename, dt)
|
147
|
+
convert tmpfile, recfile
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
|
153
|
+
def play
|
154
|
+
uri = channel_to_uri
|
155
|
+
raise 'not tuned yet.' unless uri
|
156
|
+
puts "play: #{uri}"
|
157
|
+
create_player(uri).play
|
158
|
+
end
|
159
|
+
|
160
|
+
|
161
|
+
def convert(tmpfile, recfile)
|
162
|
+
FileUtils.mv tmpfile, recfile
|
163
|
+
end
|
164
|
+
|
165
|
+
|
166
|
+
def convert_ffmpeg(tmpfile, recfile)
|
167
|
+
ffmpeg = FFMpeg.new
|
168
|
+
ffmpeg['loglevel'] = 'quiet'
|
169
|
+
ffmpeg['i'] = %Q("#{tmpfile}")
|
170
|
+
ffmpeg['b:a'] = '70k'
|
171
|
+
stdout, stderr, status = ffmpeg.rec recfile, nil
|
172
|
+
FileUtils.rm tmpfile if status.success?
|
173
|
+
end
|
174
|
+
|
175
|
+
|
176
|
+
def datetime(dt)
|
177
|
+
dt.to_s[0..15].gsub(/:/, '=')
|
178
|
+
end
|
179
|
+
|
180
|
+
|
181
|
+
def make_tmpfile(channel, datetime)
|
182
|
+
File.join outdir, "#{channel}.#{datetime}.#{$$}.#{ext}"
|
183
|
+
end
|
184
|
+
|
185
|
+
|
186
|
+
def out_ext
|
187
|
+
@out_ext || ext
|
188
|
+
end
|
189
|
+
|
190
|
+
def make_recfile(title, datetime)
|
191
|
+
File.join outdir, "#{title}.#{datetime}.#{out_ext}"
|
192
|
+
end
|
193
|
+
|
194
|
+
|
195
|
+
def fetch_stations
|
196
|
+
body = agent.get stations_uri
|
197
|
+
stations = parse_stations body
|
198
|
+
end
|
199
|
+
|
200
|
+
|
201
|
+
class HTTPBadRequestException < StandardError; end
|
202
|
+
class HTTPForbiddenException < StandardError; end
|
203
|
+
|
204
|
+
end
|
205
|
+
|
206
|
+
|
207
|
+
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# radiru.rb
|
2
|
+
# coding: utf-8
|
3
|
+
|
4
|
+
require "rbtune/radio"
|
5
|
+
require "player/ffmpeg"
|
6
|
+
require "fileutils"
|
7
|
+
|
8
|
+
class Radiru < Radio
|
9
|
+
|
10
|
+
|
11
|
+
def create_player(uri)
|
12
|
+
ffmpeg = FFMpeg.new
|
13
|
+
ffmpeg['i'] = uri # input stream
|
14
|
+
ffmpeg['acodec'] = 'copy' # acodecオプションはiオプションのあとに置かないとエラー
|
15
|
+
ffmpeg
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
def stations_uri
|
20
|
+
"https://www.nhk.or.jp/radio/config/config_web.xml"
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
def parse_stations(body)
|
25
|
+
stations = body.search '//data'
|
26
|
+
stationsjp = {
|
27
|
+
'r1' => "ラジオ第1",
|
28
|
+
'r2' => "ラジオ第2",
|
29
|
+
'fm' => "FM",
|
30
|
+
}
|
31
|
+
stations = stations.map do |station|
|
32
|
+
areajp = station.at('areajp').text
|
33
|
+
area = station.at('area').text
|
34
|
+
# 地区ごとに第1, 第2, FM を登録する
|
35
|
+
r1, r2, fm = %w(r1 r2 fm).map do |v|
|
36
|
+
hls = "#{v}hls"
|
37
|
+
id = "nhk#{v}-#{area}".upcase
|
38
|
+
uri = station.at(hls).text
|
39
|
+
name = "NHK#{stationsjp[v]}-#{areajp}"
|
40
|
+
Station.new(id, uri, name: name, ascii_name: id)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
stations.flatten!
|
44
|
+
# id: NHKR1, NHKR2, NHKFM を東京局に割り当てる
|
45
|
+
stations.find_all { |station| station.id =~ /-TOKYO/}.reverse.map do |station|
|
46
|
+
id = station.id.sub(/-TOKYO/, '')
|
47
|
+
stations.unshift Station.new(id, station.uri, name: station.name, ascii_name: id)
|
48
|
+
end
|
49
|
+
stations
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
data/lib/rbtune/simul.rb
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "rbtune/radio"
|
4
|
+
require "player/mplayer"
|
5
|
+
require "rbtune/station"
|
6
|
+
|
7
|
+
class Simul < Radio
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
super
|
11
|
+
@ext = 'asf'
|
12
|
+
@out_ext = 'm4a'
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
def channel_to_uri
|
17
|
+
ch = super
|
18
|
+
if ch.end_with?('.asx')
|
19
|
+
parse_asx ch
|
20
|
+
else
|
21
|
+
ch
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
def parse_asx(uri)
|
27
|
+
asx = Net::HTTP::get URI::parse(uri)
|
28
|
+
asx.force_encoding "Shift_JIS"
|
29
|
+
asx.encode! 'utf-8'
|
30
|
+
asx.downcase!
|
31
|
+
if asx.gsub!(%r(</ask>), '</asx>')
|
32
|
+
$stderr.puts 'fix!! </ask> to <asx>'
|
33
|
+
end
|
34
|
+
doc = REXML::Document.new(asx)
|
35
|
+
ref = doc.get_elements('//entry/ref')[0]
|
36
|
+
ref.attribute('href').value
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
def create_player(uri)
|
41
|
+
mplayer = Mplayer.new uri
|
42
|
+
mplayer.merge! 'benchmark' => '', 'vo' => 'null'
|
43
|
+
mplayer
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
def convert(tmpfile, recfile)
|
48
|
+
convert_ffmpeg(tmpfile, recfile)
|
49
|
+
end
|
50
|
+
|
51
|
+
|
52
|
+
def link_to_station_id(link)
|
53
|
+
link =~ %r{(/asx/([\w-]+).asx|nkansai.tv/(\w+)/?\Z|(flower|redswave|fm-tanba|darazfm|AmamiFM|comiten|fm-shimabara|fm-kitaq))}
|
54
|
+
id = ($2 || $3 || $4).sub(/fm[-_]/, 'fm').sub(/[-_]fm/, 'fm')
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
def stations_uri
|
59
|
+
'http://www.simulradio.info'
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
def parse_stations(body)
|
64
|
+
radioboxes = body / 'div.radiobox'
|
65
|
+
radioboxes.map do |station|
|
66
|
+
rows = station / 'tr'
|
67
|
+
cols = rows[1] / 'td'
|
68
|
+
ankers = station / 'a'
|
69
|
+
|
70
|
+
player = ankers.select {|a|
|
71
|
+
img = a.at 'img'
|
72
|
+
img && img['alt'] == '放送を聴く'
|
73
|
+
}
|
74
|
+
|
75
|
+
title = station.at('td > p > strong > a').text.strip
|
76
|
+
links0 = player.map! {|e| e['href']}
|
77
|
+
links = links0.select { |uri| uri =~ %r{\.asx\Z|nkansai.tv} }
|
78
|
+
if links.empty?
|
79
|
+
# $stderr.puts "#{title}: #{links0 * ', '}"
|
80
|
+
else
|
81
|
+
link = links[0]
|
82
|
+
id = link_to_station_id(link)
|
83
|
+
if id
|
84
|
+
Station.new(id, link, name: title, ascii_name: id)
|
85
|
+
else
|
86
|
+
$stderr.puts "!!! #{title}: #{link}"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end.compact
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require "pstore"
|
2
|
+
|
3
|
+
class Station
|
4
|
+
attr_reader :name
|
5
|
+
attr_reader :id
|
6
|
+
attr_reader :ascii_name
|
7
|
+
attr_reader :description
|
8
|
+
attr_reader :uri
|
9
|
+
|
10
|
+
def self.pstore_db
|
11
|
+
@db ||= PStore.new(File.join(ENV['HOME'], '.rbtune.db'))
|
12
|
+
end
|
13
|
+
|
14
|
+
|
15
|
+
def self.list_stations
|
16
|
+
Radio.bands.each do |radio|
|
17
|
+
name = radio.to_s
|
18
|
+
stations = radio.stations
|
19
|
+
if stations.nil? || stations.empty?
|
20
|
+
$stderr.puts "warning: #{name} に放送局が登録されていません。"
|
21
|
+
$stderr.puts " rbtune --fetch-stations を実行して、放送局情報を取得してください。"
|
22
|
+
else
|
23
|
+
puts "* #{name}"
|
24
|
+
stations.each { |station| puts " #{station}" }
|
25
|
+
puts ''
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
def self.fetch_stations
|
32
|
+
db = Station::pstore_db
|
33
|
+
Radio.bands.each do |radio_class|
|
34
|
+
begin
|
35
|
+
$stderr.puts ">>> fetching #{radio_class} stations..."
|
36
|
+
radio = radio_class.new
|
37
|
+
radio.open
|
38
|
+
stations = radio.fetch_stations
|
39
|
+
if stations.empty?
|
40
|
+
$stderr.puts " warning: no station found."
|
41
|
+
else
|
42
|
+
db.transaction { db[radio_class.to_s] = stations }
|
43
|
+
$stderr.puts " #{stations.size} stations fetched."
|
44
|
+
end
|
45
|
+
|
46
|
+
rescue SocketError
|
47
|
+
$stderr.puts $!
|
48
|
+
|
49
|
+
rescue Net::HTTPNotFound
|
50
|
+
$stderr.puts $!
|
51
|
+
|
52
|
+
rescue REXML::ParseException
|
53
|
+
$stderr.puts $!
|
54
|
+
|
55
|
+
rescue
|
56
|
+
# 例外を握りつぶしてすべてのクラスの放送局情報の取得を試みる
|
57
|
+
$stderr.puts $!
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
def initialize(id, uri, name: '', ascii_name: '', description: '')
|
64
|
+
@id = id.to_s.upcase
|
65
|
+
@uri = uri
|
66
|
+
@name = normalize_name name
|
67
|
+
@ascii_name = ascii_name
|
68
|
+
@description = description
|
69
|
+
end
|
70
|
+
|
71
|
+
|
72
|
+
def normalize_name(name)
|
73
|
+
# NHKの局名はnormalizeしない (NHKFM-* の-が取れてしまうから)
|
74
|
+
return name if name.include?('NHK')
|
75
|
+
name.strip
|
76
|
+
.sub(/[ -]?FM[ -]?/i, 'FM')
|
77
|
+
.sub(/fm|FM|エフエム|えふえむ/, 'FM')
|
78
|
+
.tr(' !@', ' !@')
|
79
|
+
end
|
80
|
+
|
81
|
+
def to_s
|
82
|
+
"#{id}:\t\t#{name}\t\t#{ascii_name}"
|
83
|
+
end
|
84
|
+
|
85
|
+
alias :inspect :to_s
|
86
|
+
|
87
|
+
def ==(other)
|
88
|
+
id == other.id
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|