rbtune 1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/lib/keychain.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
# Pit のフロントエンド。
|
2
|
+
# key に uri を指定すると、accountでアカウントとパスワードのセットを得られる。
|
3
|
+
# key が空の場合、queryによってアカウントとパスワードのセットを登録できる。
|
4
|
+
# パスワードの難読化はごく甘く、単にBase64でエンコードしているのみ。
|
5
|
+
|
6
|
+
require "pit"
|
7
|
+
require "base64"
|
8
|
+
require 'io/console'
|
9
|
+
|
10
|
+
class KeyChain
|
11
|
+
attr_reader :key
|
12
|
+
|
13
|
+
def initialize(key)
|
14
|
+
@key = key
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
def account
|
19
|
+
radiko = Pit.get(key)
|
20
|
+
if radiko.has_key? :account and radiko.has_key? :password
|
21
|
+
[ radiko[:account], Base64.decode64(radiko[:password]) ]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
def set(account, password)
|
27
|
+
Pit.set(key, data: { account: account, password: Base64.encode64(password)})
|
28
|
+
[account, password]
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
def query(prompt, account)
|
33
|
+
old_account, old_password = account()
|
34
|
+
puts prompt
|
35
|
+
print ' password : '
|
36
|
+
STDOUT.flush
|
37
|
+
new_password = STDIN.noecho(&:gets).chomp
|
38
|
+
puts
|
39
|
+
case
|
40
|
+
when new_password.empty?
|
41
|
+
raise "空のパスワードは無効です (何も変更されません)"
|
42
|
+
when new_password == old_password
|
43
|
+
raise "旧パスワードと同じパスワードです"
|
44
|
+
end
|
45
|
+
new_password
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# ffmpeg.rb
|
2
|
+
require "timeout"
|
3
|
+
require "open3"
|
4
|
+
require "player/mplayer"
|
5
|
+
|
6
|
+
class FFMpeg < Player
|
7
|
+
def initialize
|
8
|
+
self['loglevel'] = 'warning'
|
9
|
+
self['n'] = '' # do not overwrite
|
10
|
+
@mplayer = Mplayer.new('-')
|
11
|
+
end
|
12
|
+
|
13
|
+
|
14
|
+
def command
|
15
|
+
'ffmpeg'
|
16
|
+
end
|
17
|
+
|
18
|
+
def options
|
19
|
+
map{ |k,v| "-#{k} #{v}"}*' '
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
def to_s
|
24
|
+
# novideoオプション -vn は、この位置でないと機能しない
|
25
|
+
%Q(#{command} #{options} -vn #{@output})
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
def play
|
30
|
+
self['f'] = 'mpegts'
|
31
|
+
@output = 'pipe:1'
|
32
|
+
cmd = "#{to_s} | #{@mplayer}"
|
33
|
+
$stderr.puts 'play: '+cmd
|
34
|
+
`#{cmd}`
|
35
|
+
raise $? unless $?.success?
|
36
|
+
end
|
37
|
+
|
38
|
+
|
39
|
+
def rec(file, sec, quiet = true)
|
40
|
+
self['t'] = sec if sec
|
41
|
+
if quiet
|
42
|
+
@output = file
|
43
|
+
cmd = to_s
|
44
|
+
else
|
45
|
+
self['f'] = 'mpegts'
|
46
|
+
@output = 'pipe:1'
|
47
|
+
cmd = "#{to_s} | tee #{file} | #{@mplayer}"
|
48
|
+
end
|
49
|
+
|
50
|
+
puts "rec: #{cmd}"
|
51
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# mplayer.rb
|
2
|
+
require "timeout"
|
3
|
+
require "open3"
|
4
|
+
require "player/player"
|
5
|
+
|
6
|
+
class Mplayer < Player
|
7
|
+
|
8
|
+
def initialize(url)
|
9
|
+
@url=url
|
10
|
+
self['cache'] = 64
|
11
|
+
self['cache-min'] = 16
|
12
|
+
self['quiet'] = '' # 画面表示をしない
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
def command
|
17
|
+
'mplayer'
|
18
|
+
end
|
19
|
+
|
20
|
+
def options
|
21
|
+
map{ |k,v| "-#{k} #{v}"}*' '
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
def to_s
|
26
|
+
%Q(#{command} #{@url} #{options} )
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
def play
|
31
|
+
puts to_s
|
32
|
+
`#{to_s}`
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
def rec(file, sec, quiet = true)
|
37
|
+
self['dumpstream'] = ''
|
38
|
+
self['dumpfile'] = file
|
39
|
+
if quiet
|
40
|
+
self['nosound'] = '' # 音声を再生しない
|
41
|
+
end
|
42
|
+
|
43
|
+
puts "rec (#{sec}s): #{to_s}"
|
44
|
+
stdin, stdout, stderr, wait_thread = Open3.popen3(to_s)
|
45
|
+
dsec = -1
|
46
|
+
psec = dsec
|
47
|
+
wait = 0
|
48
|
+
i = 0
|
49
|
+
WAIT_LIMIT = 3
|
50
|
+
while dsec <= sec
|
51
|
+
dsec = duration(file)
|
52
|
+
if dsec == psec
|
53
|
+
wait += 1
|
54
|
+
break if wait > WAIT_LIMIT
|
55
|
+
else
|
56
|
+
wait = 0
|
57
|
+
psec = dsec
|
58
|
+
end
|
59
|
+
p [i, dsec, psec, wait] if wait > 0
|
60
|
+
i += 1
|
61
|
+
sleep 1
|
62
|
+
end
|
63
|
+
stdin.write 'q'
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# Player 仮想クラス
|
2
|
+
# Mplayer等で継承して使う
|
3
|
+
|
4
|
+
class Player < Hash
|
5
|
+
|
6
|
+
def command; end
|
7
|
+
def play; end
|
8
|
+
def rec(file, sec, quiet = true); end
|
9
|
+
|
10
|
+
|
11
|
+
def options
|
12
|
+
map{ |k,v| "--#{k} #{v}"}*' '
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
def to_s
|
17
|
+
%Q(#{command} #{options} )
|
18
|
+
end
|
19
|
+
|
20
|
+
|
21
|
+
# 音声ファイル file の長さ(sec)を返す
|
22
|
+
def duration(file)
|
23
|
+
stdout, stderr, status = Open3.capture3("ffprobe #{file}")
|
24
|
+
stderr =~ /Duration: (\d{2}):(\d{2}):(\d{2}.\d{2})/m
|
25
|
+
hour, min, sec = [$1, $2, $3].map(&:to_f)
|
26
|
+
hour*60*60 + min*60 + sec
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# rtmpdump.rb
|
2
|
+
require "player/player"
|
3
|
+
|
4
|
+
|
5
|
+
class RtmpDump < Player
|
6
|
+
def initialize
|
7
|
+
self['live'] = ''
|
8
|
+
self['quiet'] = ''
|
9
|
+
end
|
10
|
+
|
11
|
+
|
12
|
+
def command
|
13
|
+
'rtmpdump'
|
14
|
+
end
|
15
|
+
|
16
|
+
|
17
|
+
def play
|
18
|
+
# puts "play: #{to_s} | mplayer -"
|
19
|
+
`#{to_s} | mplayer -`
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
def rec(file, sec, quiet = true)
|
24
|
+
self['stop'] = sec
|
25
|
+
|
26
|
+
if quiet
|
27
|
+
self['flv'] = file
|
28
|
+
puts "rec: #{to_s}"
|
29
|
+
`#{to_s}`
|
30
|
+
else
|
31
|
+
puts "rec: #{to_s}"
|
32
|
+
`#{to_s} | tee #{file} | mplayer -`
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
end
|
data/lib/rbtune.rb
ADDED
data/lib/rbtune/agqr.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# 文化放送 超A&G+ を受信する
|
2
|
+
|
3
|
+
require "rbtune/radio"
|
4
|
+
require "player/rtmpdump"
|
5
|
+
require "player/ffmpeg"
|
6
|
+
require "fileutils"
|
7
|
+
|
8
|
+
class Agqr < Radio
|
9
|
+
def initialize
|
10
|
+
@ext = 'flv'
|
11
|
+
@out_ext = 'm4a'
|
12
|
+
end
|
13
|
+
|
14
|
+
def fetch_stations
|
15
|
+
uri = "rtmp://fms-base1.mitene.ad.jp/agqr/aandg1"
|
16
|
+
[Station.new('AGQR', uri, name: '超A&G+', ascii_name: 'aandg1')]
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
def create_player(uri)
|
21
|
+
rtmpdump = RtmpDump.new
|
22
|
+
rtmpdump['rtmp'] = uri
|
23
|
+
rtmpdump
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
def convert(tmpfile, recfile)
|
28
|
+
ffmpeg = FFMpeg.new
|
29
|
+
ffmpeg['loglevel'] = 'quiet'
|
30
|
+
ffmpeg['i'] = %Q("#{tmpfile}")
|
31
|
+
ffmpeg['acodec'] = 'copy'
|
32
|
+
stdout, stderr, status = ffmpeg.rec recfile, nil
|
33
|
+
FileUtils.rm tmpfile if status.success?
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
data/lib/rbtune/jcba.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
=begin
|
3
|
+
JCBAサイマルラジオを受信する
|
4
|
+
=end
|
5
|
+
require "rbtune/listenradio"
|
6
|
+
require "rbtune/station"
|
7
|
+
|
8
|
+
|
9
|
+
class Jcba < ListenRadio
|
10
|
+
|
11
|
+
def stations_uri
|
12
|
+
'https://www.jcbasimul.com'
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
def parse_stations(body)
|
17
|
+
radioboxes = body / 'div.areaList ul li'
|
18
|
+
stations = radioboxes.map do |station|
|
19
|
+
h3 = station.at 'h3'
|
20
|
+
rplayer = station.at 'div.rplayer'
|
21
|
+
text = station.at 'div.text'
|
22
|
+
|
23
|
+
id = rplayer['id']
|
24
|
+
uri = "http://musicbird-hls.leanstream.co/musicbird/#{id}.stream/playlist.m3u8"
|
25
|
+
name = h3.text.sub(%r( / .*), '')
|
26
|
+
desc = text.text
|
27
|
+
# puts "(#{h3.text}) (#{rplayer['id']}) (#{text.text})"
|
28
|
+
Station.new(id, uri, name: name, description: desc)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
=begin
|
3
|
+
リスラジまたはJCBAサイマルラジオを受信する
|
4
|
+
=end
|
5
|
+
|
6
|
+
|
7
|
+
require "rbtune/radio"
|
8
|
+
require "player/ffmpeg"
|
9
|
+
require "json"
|
10
|
+
|
11
|
+
|
12
|
+
class ListenRadio < Radio
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
super
|
16
|
+
@ext = 'mp4'
|
17
|
+
end
|
18
|
+
|
19
|
+
def create_player(uri)
|
20
|
+
player = FFMpeg.new
|
21
|
+
player['i'] = uri # input stream
|
22
|
+
player['acodec'] = 'copy' # acodecオプションはiオプションのあとに置かないとエラー
|
23
|
+
player
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
def stations_uri
|
28
|
+
'http://listenradio.jp/service/channel.aspx'
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
def parse_stations(body)
|
33
|
+
json = JSON[body.body, symbolize_names: true]
|
34
|
+
stations = json[:Channel].map do |station|
|
35
|
+
name = station[:ChannelName]
|
36
|
+
id = station[:ChannelId]
|
37
|
+
desc = station[:ChannelDetail]
|
38
|
+
uri = station[:ChannelHls]
|
39
|
+
# puts "'#{name}' <#{uri}> #{desc}"
|
40
|
+
Station.new(id, uri, name: name, description: desc)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
end
|
@@ -0,0 +1,143 @@
|
|
1
|
+
#radiko.rb
|
2
|
+
# coding: utf-8
|
3
|
+
|
4
|
+
require "mechanize"
|
5
|
+
require "rbtune/radio"
|
6
|
+
require "player/rtmpdump"
|
7
|
+
require 'swf_ruby'
|
8
|
+
|
9
|
+
|
10
|
+
class Radiko < Radio
|
11
|
+
attr_reader :authtoken
|
12
|
+
|
13
|
+
|
14
|
+
def playerurl
|
15
|
+
"http://radiko.jp/apps/js/flash/myplayer-release.swf"
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
def playerfile
|
20
|
+
"player.swf"
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
def keyfile
|
25
|
+
"authkey.png"
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
# get_auth2 の返り値により @area_id, @area_ja, @area_en が設定される
|
30
|
+
def open
|
31
|
+
unless File.exists? playerfile
|
32
|
+
$stderr.puts 'fetching player...'
|
33
|
+
fetch_file playerurl, playerfile
|
34
|
+
end
|
35
|
+
unless File.exists? keyfile
|
36
|
+
swfextract playerfile, 12, keyfile
|
37
|
+
end
|
38
|
+
|
39
|
+
@authtoken, partialkey = authenticate1 'https://radiko.jp/v2/api/auth1_fms'
|
40
|
+
@area_id, @area_ja, @area_en = authenticate2 'https://radiko.jp/v2/api/auth2_fms', authtoken, partialkey
|
41
|
+
puts "area: #{area_id} (#{area_ja}: #{area_en})"
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
def channel_to_uri
|
46
|
+
xml = agent.get "http://radiko.jp/v2/station/stream/#{@channel}.xml"
|
47
|
+
xml.at('//url/item').text
|
48
|
+
end
|
49
|
+
|
50
|
+
|
51
|
+
def create_player(uri)
|
52
|
+
rtmpdump = RtmpDump.new
|
53
|
+
rtmpdump['rtmp'] = uri
|
54
|
+
rtmpdump['swfVfy'] = playerurl
|
55
|
+
rtmpdump['conn'] = %Q(S:"" --conn S:"" --conn S:"" --conn S:#{authtoken})
|
56
|
+
rtmpdump
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
def authenticate1(url)
|
61
|
+
res = agent.post url, {}, {
|
62
|
+
'pragma' => 'no-cache',
|
63
|
+
'X-Radiko-App' => 'pc_ts',
|
64
|
+
'X-Radiko-App-Version' => '4.0.0',
|
65
|
+
'X-Radiko-User' => 'test-stream',
|
66
|
+
'X-Radiko-Device' => 'pc',
|
67
|
+
}
|
68
|
+
s = res.body
|
69
|
+
s.sub! /\r\n\r\n.*/m, ''
|
70
|
+
arr = s.split(/\r\n/).map{|s| s.split('=')}.flatten
|
71
|
+
auth1 = Hash[*arr]
|
72
|
+
authtoken = auth1['X-Radiko-AuthToken'] || auth1['X-RADIKO-AUTHTOKEN']
|
73
|
+
offset = auth1['X-Radiko-KeyOffset'].to_i
|
74
|
+
length = auth1['X-Radiko-KeyLength'].to_i
|
75
|
+
partialkey = read_partialkey keyfile, offset, length
|
76
|
+
[authtoken, partialkey]
|
77
|
+
end
|
78
|
+
|
79
|
+
|
80
|
+
def read_partialkey(file, offset, length)
|
81
|
+
key = File.open(file, "rb") { |io| io.read(offset + length) }
|
82
|
+
Base64.encode64(key[offset,length]).chomp
|
83
|
+
end
|
84
|
+
|
85
|
+
|
86
|
+
# return: area info
|
87
|
+
def authenticate2(url, authtoken, partialkey)
|
88
|
+
# pp [url, authtoken, partialkey]
|
89
|
+
agent.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
90
|
+
res = agent.post url, {}, {
|
91
|
+
'pragma' => 'no-cache',
|
92
|
+
'X-Radiko-App' => 'pc_ts',
|
93
|
+
'X-Radiko-App-Version' => '4.0.0',
|
94
|
+
'X-Radiko-User' => 'test-stream',
|
95
|
+
'X-Radiko-Device' => 'pc',
|
96
|
+
'X-Radiko-Authtoken' => authtoken,
|
97
|
+
'X-Radiko-Partialkey' => partialkey,
|
98
|
+
}
|
99
|
+
body = res.body
|
100
|
+
body.force_encoding 'utf-8'
|
101
|
+
body.split(',').map(&:strip)
|
102
|
+
end
|
103
|
+
|
104
|
+
|
105
|
+
def stations_uri
|
106
|
+
"http://radiko.jp/v3/station/list/#{area_id}.xml"
|
107
|
+
end
|
108
|
+
|
109
|
+
|
110
|
+
def parse_stations(body)
|
111
|
+
stations = body.search '//station'
|
112
|
+
stations.map do |station|
|
113
|
+
id = station.at('id').text
|
114
|
+
name = station.at('name').text
|
115
|
+
uri = id
|
116
|
+
ascii_name = station.at('ascii_name').text
|
117
|
+
Station.new(id, uri, name: name, ascii_name: ascii_name)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
|
122
|
+
def fetch_file(url, file=nil)
|
123
|
+
content = agent.get_file(url)
|
124
|
+
File.open(file, "wb") { |fout| fout.write content } if file
|
125
|
+
content
|
126
|
+
end
|
127
|
+
|
128
|
+
|
129
|
+
def swfextract(swffile, character_id, out_file)
|
130
|
+
swf = SwfRuby::SwfDumper.new
|
131
|
+
swf.open(swffile)
|
132
|
+
swf.tags.each_with_index do |tag, i|
|
133
|
+
tag = swf.tags[i]
|
134
|
+
if tag.character_id && tag.character_id == character_id
|
135
|
+
offset = swf.tags_addresses[i]
|
136
|
+
len = tag.length
|
137
|
+
File.open(out_file, 'wb') { |out| out.print tag.data[6..-1] }
|
138
|
+
break
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
end
|