├── LICENSE
├── README.md
└── radi.sh
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 uru (https://twitter.com/uru_2)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # radish
2 | [NHKラジオ らじる★らじる](https://www.nhk.or.jp/radio/) / [radiko](http://radiko.jp/) / [ListenRadio](http://listenradio.jp/) / [渋谷のラジオ](https://shiburadi.com/) で現在配信中の番組を保存するシェルスクリプトです。なお配信形式と同じフォーマットで保存するため、別形式へのエンコードは行いません。
3 |
4 |
5 | # ※重要なお知らせ
6 |
7 | **2025-05-16を最後に更新停止します。**
8 |
9 | 作者自身も長らく利用しておらずここで一旦区切りをつけることにしました。
10 | 今までご愛顧いただき、誠にありがとうございました。
11 |
12 | 特にradikoライブ配信のタイムラグが大きくなっている(放送局の財布事情が厳しいためオーディオアドを導入するのはいいですがあまりにラグが酷い)割には不定期に発生する対処のモチベーションを保つことができなくなっていました。
13 | (余程の理由がない限りタイムフリー保存のほうが速いですし、タイムラグもライブ配信と比べて遙かに小さいです)
14 |
15 | またここ最近は音声配信という括りで番組のPodcast等への公開や各種プラットフォーム側でのタイムシフト機能の実装により、わざわざ実放送時間にリアルタイム保存する意義は薄れていっていることも理由の一つです。
16 |
17 | オープンソースですので修正・機能追加などはforkしてぜひ公開してください。
18 |
19 |
20 | ## 必要なもの
21 | - curl
22 | - libxml2 (xmllintのみ使用)
23 | - jq
24 | - FFmpeg (3.x以降 要AAC,HLSサポート)
25 |
26 |
27 | ## 使い方
28 | ```
29 | $ ./radi.sh [options]
30 | ```
31 |
32 | | 引数 | 必須 |説明 |備考 |
33 | |:-|:-:|:-|:-|
34 | |-t _SITE TYPE_|○|録音対象サイト|nhk: NHK らじる★らじる
radiko: radiko
lisradi: ListenRadio
shiburadi: 渋谷のラジオ
35 | |-s _STATION ID_|△|放送局ID|`-l` オプションで表示されるID
渋谷のラジオは指定不要|
36 | |-d _MINUTE_|○|録音時間(分)||
37 | |-i _MAIL_||ラジコプレミアム ログインメールアドレス|環境変数 `RADIKO_MAIL` でも指定可能|
38 | |-p _PASSWORD_||ラジコプレミアム ログインパスワード|環境変数 `RADIKO_PASSWORD` でも指定可能|
39 | |-o _PATH_||出力パス|未指定の場合カレントディレクトリに `放送局ID_年月日時分秒.(m4a or mp3)` というファイルを作成
拡張子がない場合または配信側の形式と異なる場合には拡張子を自動補完します|
40 | |-l||放送局ID/名称表示|結果は300行以上になります、また取得は(割と)重いです|
41 |
42 |
43 | ## 実行例
44 | ```
45 | NHK らじる★らじる
46 | $ ./radi.sh -t nhk -s tokyo-fm -d 31 -o "/hoge/foo.m4a"
47 | ```
48 |
49 | ```
50 | radikoエリア内の局
51 | $ ./radi.sh -t radiko -s LFR -d 21 -o "/hoge/$(date "+%Y-%m-%d") テレフォン人生相談.m4a"
52 | ```
53 |
54 | ```
55 | radikoエリア外の局 (ラジコプレミアム)
56 | $ ./radi.sh -t radiko -s HBC -d 31 -o "/hoge/foo.m4a" -i "foo@example.com" -p "password"
57 | ```
58 |
59 | ```
60 | radikoエリア外の局 (ラジコプレミアム 環境変数からログイン情報設定)
61 | $ export RADIKO_MAIL="foo@example.com"
62 | $ export RADIKO_PASSWORD="password"
63 | $ ./radi.sh -t radiko -s HBC -d 31 -o "/hoge/foo.m4a"
64 | ```
65 |
66 | ```
67 | ListenRadio
68 | $ ./radi.sh -t lisradi -s 30058 -d 30 -o "/hoge/foo.m4a"
69 | ```
70 |
71 | ```
72 | 渋谷のラジオ
73 | $ ./radi.sh -t shiburadi -d 30 -o "/hoge/foo.mp3"
74 | ```
75 |
76 |
77 | ## 注意点
78 |
79 | 録音手法については2019/5/25時点での調査結果であり、対象サイトの仕様変更等で利用できなくなる可能性もありますのであらかじめご了承ください。
80 | また渋谷のラジオの録音時にではffmpegから "Application provided invalid, non monotonically increasing dts to muxer in stream" というメッセージが吐き出されるのですが、音声は聴けるようなのでとりあえずそのままにしています。
81 |
82 |
83 | ## 動作確認環境
84 | - Ubuntu 18.04.2 LTS
85 | - curl 7.58.0
86 | - xmllint using libxml version 20904
87 | - jq 1.5-1-a5b5cbe
88 | - ffmpeg 4.1.3-0york1~18.04
89 | - FreeBSD 12.0-RELEASE
90 | - curl 7.65.0
91 | - xmllint using libxml version 20908
92 | - jq 1.6
93 | - ffmpeg 4.1.3
94 |
95 |
96 | ## 作った人
97 | うる。 ([@uru_2](https://twitter.com/uru_2))
98 |
99 |
100 | ## ライセンス
101 | [MIT License](LICENSE)
102 |
--------------------------------------------------------------------------------
/radi.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #
3 | # Japan internet radio live streaming recoder
4 | # Copyright (C) 2019 uru (https://twitter.com/uru_2)
5 | # License is MIT (see LICENSE file)
6 | set -u
7 |
8 | #######################################
9 | # Show usage
10 | # Arguments:
11 | # None
12 | # Returns:
13 | # None
14 | #######################################
15 | show_usage() {
16 | cat << _EOT_
17 | Usage: $(basename "$0") [options]
18 | Options:
19 | -t TYPE Record type
20 | nhk: NHK Radidu
21 | radiko: radiko
22 | lisradi: ListenRadio
23 | shiburadi: Shibuya no Radio
24 | -s STATION ID Station ID
25 | -d MINUTE Record minute(s)
26 | -o FILEPATH Output file path
27 | -i ADDRESS login mail address (radiko only)
28 | -p PASSWORD login password (radiko only)
29 | -l Show all station ID list
30 | _EOT_
31 | }
32 |
33 | #######################################
34 | # Show all station ID and name
35 | # Arguments:
36 | # None
37 | # Returns:
38 | # None
39 | #######################################
40 | show_all_stations() {
41 | # Radiru
42 | echo "Record type: nhk"
43 | list=$(curl --silent "https://www.nhk.or.jp/radio/config/config_v5.8.0_radiru_and.xml")
44 | cnt=$(echo "${list}" | xmllint --xpath "count(/radiru_config/area)" - 2> /dev/null)
45 | for i in $(awk "BEGIN { for (i = 1; i <= ${cnt}; i++) { print i } }"); do
46 | echo " $(echo "${list}" | xmllint --xpath "concat(string((/radiru_config/area)[${i}]/@id), '-r1: ', string((/radiru_config/area)[${i}]/@name), ' R1')" - 2> /dev/null)"
47 | echo " $(echo "${list}" | xmllint --xpath "concat(string((/radiru_config/area)[${i}]/@id), '-fm: ', string((/radiru_config/area)[${i}]/@name), ' FM')" - 2> /dev/null)"
48 | done
49 | echo " r2: R2"
50 | echo ""
51 |
52 | # radiko
53 | echo "Record type: radiko"
54 | list=$(curl --silent "https://radiko.jp/v3/station/region/full.xml")
55 | cnt=$(echo "${list}" | xmllint --xpath "count(/region/stations/station)" - 2> /dev/null)
56 | for i in $(awk "BEGIN { for (i = 1; i <= ${cnt}; i++) { print i } }"); do
57 | echo " $(echo "${list}" | xmllint --xpath "concat((/region/stations/station)[${i}]/id/text(), ': ', (/region/stations/station)[${i}]/name/text())" - 2> /dev/null)"
58 | done
59 | echo ""
60 |
61 | # ListenRadio
62 | echo "Record site type: lisradi"
63 | curl --silent "http://listenradio.jp/service/channel.aspx" | jq -r '.Channel[] | " " + (.ChannelId | tostring) + ": " + .ChannelName' 2> /dev/null
64 | echo ""
65 |
66 | # Shibuya no Radio
67 | echo "Record type: shiburadi"
68 | echo " None"
69 | echo ""
70 | }
71 |
72 | #######################################
73 | # Radiko Login
74 | # Arguments:
75 | # Mail address
76 | # Password
77 | # Returns:
78 | # 0: Success
79 | # 1: Failed
80 | #######################################
81 | login_radiko() {
82 | mail=$1
83 | password=$2
84 |
85 | # Login
86 | login_json=$(curl \
87 | --silent \
88 | --request POST \
89 | --data-urlencode "mail=${mail}" \
90 | --data-urlencode "pass=${password}" \
91 | --output - \
92 | "https://radiko.jp/ap/member/webapi/member/login")
93 |
94 | # Extract login result
95 | radiko_session=$(echo "${login_json}" | jq -r ".radiko_session")
96 | areafree=$(echo "${login_json}" | jq -r ".areafree")
97 |
98 | # Check login
99 | if [ -z "${radiko_session}" ] || [ "${areafree}" != "1" ]; then
100 | return 1
101 | fi
102 |
103 | echo "${radiko_session}"
104 | return 0
105 | }
106 |
107 | #######################################
108 | # Radiko Logout
109 | # Arguments:
110 | # radiko Session
111 | # Returns:
112 | # None
113 | #######################################
114 | logout_radiko() {
115 | radiko_session=$1
116 |
117 | # Logout
118 | curl \
119 | --silent \
120 | --request POST \
121 | --data-urlencode "radiko_session=${radiko_session}" \
122 | --output /dev/null \
123 | "https://radiko.jp/v4/api/member/logout"
124 | }
125 |
126 | #######################################
127 | # Authorize radiko
128 | # Arguments:
129 | # radiko Session
130 | # Returns:
131 | # 0: Success
132 | # 1: Failed
133 | #######################################
134 | radiko_authorize() {
135 | radiko_session=$1
136 |
137 | # Define authorize key value (from https://radiko.jp/apps/js/playerCommon.js)
138 | RADIKO_AUTHKEY_VALUE="bcd151073c03b352e1ef2fd66c32209da9ca0afa"
139 |
140 | # Authorize 1
141 | auth1_res=$(curl \
142 | --silent \
143 | --header "X-Radiko-App: pc_html5" \
144 | --header "X-Radiko-App-Version: 0.0.1" \
145 | --header "X-Radiko-Device: pc" \
146 | --header "X-Radiko-User: dummy_user" \
147 | --dump-header - \
148 | --output /dev/null \
149 | "https://radiko.jp/v2/api/auth1")
150 |
151 | # Get partial key
152 | authtoken=$(echo "${auth1_res}" | awk 'tolower($0) ~/^x-radiko-authtoken: / {print substr($0,21,length($0)-21)}')
153 | keyoffset=$(echo "${auth1_res}" | awk 'tolower($0) ~/^x-radiko-keyoffset: / {print substr($0,21,length($0)-21)}')
154 | keylength=$(echo "${auth1_res}" | awk 'tolower($0) ~/^x-radiko-keylength: / {print substr($0,21,length($0)-21)}')
155 | if [ -z "${authtoken}" ] || [ -z "${keyoffset}" ] || [ -z "${keylength}" ]; then
156 | return 1
157 | fi
158 | partialkey=$(echo "${RADIKO_AUTHKEY_VALUE}" | dd bs=1 "skip=${keyoffset}" "count=${keylength}" 2> /dev/null | base64)
159 |
160 | # Authorize 2
161 | auth2_url_param=""
162 | if [ -n "${radiko_session}" ]; then
163 | auth2_url_param="?radiko_session=${radiko_session}"
164 | fi
165 | curl \
166 | --silent \
167 | --header "X-Radiko-Device: pc" \
168 | --header "X-Radiko-User: dummy_user" \
169 | --header "X-Radiko-AuthToken: ${authtoken}" \
170 | --header "X-Radiko-PartialKey: ${partialkey}" \
171 | --output /dev/null \
172 | "https://radiko.jp/v2/api/auth2${auth2_url_param}"
173 | ret=$?
174 | if [ ${ret} -ne 0 ]; then
175 | return 1
176 | fi
177 |
178 | echo "${authtoken}"
179 | return 0
180 | }
181 |
182 | #######################################
183 | # Get NHK Radiru HLS streaming URI
184 | # Arguments:
185 | # Station ID
186 | # Returns:
187 | # None
188 | #######################################
189 | get_hls_uri_nhk() {
190 | station_id=$1
191 |
192 | if [ "${station_id}" = "r2" ]; then
193 | # R2
194 | curl --silent "https://www.nhk.or.jp/radio/config/config_v5.8.0_radiru_and.xml" | xmllint --xpath "string(/radiru_config/config[@key='url_stream_r2']/value[1]/@text)" - 2> /dev/null
195 | else
196 | # Split area and channel
197 | area="$(echo "${station_id}" | cut -d '-' -f 1)"
198 | channel="$(echo "${station_id}" | cut -d '-' -f 2)"
199 | curl --silent "https://www.nhk.or.jp/radio/config/config_v5.8.0_radiru_and.xml" | xmllint --xpath "string(/radiru_config/area[@id='${area}']/config[@key='url_stream_${channel}']/value[1]/@text)" - 2> /dev/null
200 | fi
201 | }
202 |
203 | #######################################
204 | # Get radiko HLS streaming URI
205 | # Arguments:
206 | # Station ID
207 | # radiko login status
208 | # Returns:
209 | # None
210 | #######################################
211 | get_hls_uri_radiko() {
212 | station_id=$1
213 | radiko_login_status=$2
214 |
215 | areafree="0"
216 | if [ "${radiko_login_status}" = "1" ]; then
217 | areafree="1"
218 | fi
219 |
220 | uri=$(curl --silent "https://radiko.jp/v3/station/stream/pc_html5/${station_id}.xml" | xmllint --xpath "/urls/url[@timefree='0' and @areafree='${areafree}'][playlist_create_url[not(contains(text(),'_definst_'))]][2]/playlist_create_url/text()" - | sed 's/\&/\&/g' 2> /dev/null)
221 | echo "${uri}?station_id=${station_id}&l=15&type=c&lsid="
222 | }
223 |
224 | #######################################
225 | # Get ListenRadio HLS streaming URI
226 | # Arguments:
227 | # Station ID
228 | # Returns:
229 | # None
230 | #######################################
231 | get_hls_uri_lisradi() {
232 | station_id=$1
233 |
234 | curl --silent "http://listenradio.jp/service/channel.aspx" | jq -r ".Channel[] | select(.ChannelId == ${station_id}) | .ChannelHls" 2> /dev/null
235 | }
236 |
237 | #######################################
238 | # Get Shibuya no Radio HLS streaming URI
239 | # Arguments:
240 | # None
241 | # Returns:
242 | # None
243 | #######################################
244 | get_hls_uri_shiburadi() {
245 | curl --silent "https://shibuyanoradio.info/infoapi/?ver=1.1" | jq -r ".basicinfo.hls_playback" 2> /dev/null
246 | }
247 |
248 | #######################################
249 | # Format time text
250 | # Arguments:
251 | # Time minute
252 | # Returns:
253 | # None
254 | #######################################
255 | format_time() {
256 | minute=$1
257 |
258 | hour=$((minute / 60))
259 | minute=$((minute % 60))
260 |
261 | printf "%02d:%02d:%02d" "${hour}" "${minute}" "0"
262 | }
263 |
264 |
265 | ##### Main routine start #####
266 |
267 | # Argument none?
268 | if [ $# -lt 1 ]; then
269 | show_usage
270 | exit 1
271 | fi
272 |
273 | # Parse argument
274 | type=""
275 | station_id=""
276 | duration=0
277 | output=""
278 | login_id=""
279 | login_password=""
280 | while getopts t:s:d:o:i:p:l option; do
281 | case "${option}" in
282 | t)
283 | type="${OPTARG}"
284 | ;;
285 | s)
286 | station_id="${OPTARG}"
287 | ;;
288 | d)
289 | duration="${OPTARG}"
290 | ;;
291 | o)
292 | output="${OPTARG}"
293 | ;;
294 | i)
295 | login_id="${OPTARG}"
296 | ;;
297 | p)
298 | login_password="${OPTARG}"
299 | ;;
300 | l)
301 | show_all_stations
302 | exit 0
303 | ;;
304 | \?)
305 | show_usage
306 | exit 1
307 | ;;
308 | esac
309 | done
310 |
311 | # Set value from ENV
312 | if [ "${type}" = "radiko" ]; then
313 | if [ -z "${login_id}" ]; then
314 | env | grep -q -E "^RADIKO_MAIL="
315 | ret=$?
316 | if [ ${ret} -eq 0 ]; then
317 | login_id="${RADIKO_MAIL}"
318 | fi
319 | fi
320 | if [ -z "${login_password}" ]; then
321 | env | grep -q -E "^RADIKO_PASSWORD="
322 | ret=$?
323 | if [ ${ret} -eq 0 ]; then
324 | login_password="${RADIKO_PASSWORD}"
325 | fi
326 | fi
327 | fi
328 |
329 | # Check argument parameter
330 | if [ -z "${type}" ]; then
331 | # -t value is empty
332 | echo "Require \"Type\"" >&2
333 | exit 1
334 | fi
335 | echo "${duration}" | grep -q -E "^[0-9]+$"
336 | ret=$?
337 | if [ ${ret} -ne 0 ]; then
338 | # -d value is invalid
339 | echo "Invalid \"Record minute\"" >&2
340 | exit 1
341 | fi
342 | if [ "${type}" = "shiburadi" ]; then
343 | station_id="shiburadi"
344 | else
345 | if [ -z "${station_id}" ]; then
346 | # -s value is empty
347 | echo "Require \"Station ID\"" >&2
348 | exit 1
349 | fi
350 | fi
351 |
352 | # Generate default file path
353 | file_ext="m4a"
354 | if [ "${type}" = "shiburadi" ]; then
355 | file_ext="mp3"
356 | fi
357 | if [ -z "${output}" ]; then
358 | output="${station_id}_$(date +%Y%m%d%H%M%S).${file_ext}"
359 | else
360 | # Fix file path extension
361 | echo "${output}" | grep -q -E "\\.${file_ext}$"
362 | ret=$?
363 | if [ ${ret} -ne 0 ]; then
364 | output="${output}.${file_ext}"
365 | fi
366 | fi
367 |
368 | playlist_uri=""
369 | radiko_authtoken=""
370 |
371 | # Record type processes
372 | if [ "${type}" = "nhk" ]; then
373 | # NHK
374 | playlist_uri=$(get_hls_uri_nhk "${station_id}")
375 | elif [ "${type}" = "lisradi" ]; then
376 | # ListenRadio
377 | playlist_uri=$(get_hls_uri_lisradi "${station_id}")
378 | elif [ "${type}" = "shiburadi" ]; then
379 | # Shibuya no Radio
380 | playlist_uri=$(get_hls_uri_shiburadi)
381 | elif [ "${type}" = "radiko" ]; then
382 | # radiko
383 | radiko_session=""
384 | radiko_login_status="0"
385 |
386 | # Login radiko premium
387 | if [ -n "${login_id}" ]; then
388 | radiko_session=$(login_radiko "${login_id}" "${login_password}")
389 | ret=$?
390 | if [ ${ret} -ne 0 ]; then
391 | echo "Cannot login radiko premium" >&2
392 | exit 1
393 | fi
394 |
395 | # Register radiko logout handler
396 | trap "logout_radiko ""${radiko_session}""" EXIT HUP INT QUIT TERM
397 |
398 | radiko_login_status="1"
399 | fi
400 |
401 | # Authorize
402 | radiko_authtoken=$(radiko_authorize "${radiko_session}")
403 | ret=$?
404 | if [ ${ret} -ne 0 ]; then
405 | echo "radiko authorize failed" >&2
406 | exit 1
407 | fi
408 |
409 | playlist_uri=$(get_hls_uri_radiko "${station_id}" "${radiko_login_status}")
410 | fi
411 | if [ -z "${playlist_uri}" ]; then
412 | echo "Cannot get playlist URI" >&2
413 | exit 1
414 | fi
415 |
416 | # Record
417 | if [ "${type}" = "radiko" ]; then
418 | ffmpeg \
419 | -loglevel error \
420 | -fflags +discardcorrupt \
421 | -headers "X-Radiko-Authtoken: ${radiko_authtoken}" \
422 | -i "${playlist_uri}" \
423 | -acodec copy \
424 | -vn \
425 | -bsf:a aac_adtstoasc \
426 | -y \
427 | -t "$(format_time "${duration}")" \
428 | "${output}"
429 | elif [ "${type}" = "shiburadi" ]; then
430 | ffmpeg \
431 | -loglevel error \
432 | -fflags +discardcorrupt \
433 | -i "${playlist_uri}" \
434 | -acodec copy \
435 | -vn \
436 | -y \
437 | -t "$(format_time "${duration}")" \
438 | "${output}"
439 | else
440 | ffmpeg \
441 | -loglevel error \
442 | -fflags +discardcorrupt \
443 | -i "${playlist_uri}" \
444 | -acodec copy \
445 | -vn \
446 | -bsf:a aac_adtstoasc \
447 | -y \
448 | -t "$(format_time "${duration}")" \
449 | "${output}"
450 | fi
451 | ret=$?
452 | if [ ${ret} -ne 0 ]; then
453 | echo "Record failed" >&2
454 | exit 1
455 | fi
456 |
457 | # Finish
458 | exit 0
459 | ##### Main routine end #####
460 |
--------------------------------------------------------------------------------