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
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
|