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