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