├── .gitignore ├── Circle-icons-radio-blue-48.png ├── Circle-icons-radio-red-48.png ├── Circle-icons-radio-red.svg ├── Circle-icons-radio.png ├── Circle-icons-radio.svg ├── LICENSE ├── README.md ├── _locales ├── en │ └── messages.json ├── ja │ └── messages.json └── zh_CN │ └── messages.json ├── background.js ├── manifest.jsonc.tmpl ├── modules ├── auth.js ├── constants.js ├── recording.js ├── rules.js ├── static.js ├── timeshift.js └── util.js ├── package.py ├── pages ├── offscreen.html ├── offscreen.js ├── options.html ├── options.js ├── popup.html └── popup.js ├── response ├── area-JP1.html ├── area-JP10.html ├── area-JP11.html ├── area-JP12.html ├── area-JP13.html ├── area-JP14.html ├── area-JP15.html ├── area-JP16.html ├── area-JP17.html ├── area-JP18.html ├── area-JP19.html ├── area-JP2.html ├── area-JP20.html ├── area-JP21.html ├── area-JP22.html ├── area-JP23.html ├── area-JP24.html ├── area-JP25.html ├── area-JP26.html ├── area-JP27.html ├── area-JP28.html ├── area-JP29.html ├── area-JP3.html ├── area-JP30.html ├── area-JP31.html ├── area-JP32.html ├── area-JP33.html ├── area-JP34.html ├── area-JP35.html ├── area-JP36.html ├── area-JP37.html ├── area-JP38.html ├── area-JP39.html ├── area-JP4.html ├── area-JP40.html ├── area-JP41.html ├── area-JP42.html ├── area-JP43.html ├── area-JP44.html ├── area-JP45.html ├── area-JP46.html ├── area-JP47.html ├── area-JP5.html ├── area-JP6.html ├── area-JP7.html ├── area-JP8.html ├── area-JP9.html ├── auth2-JP1.html ├── auth2-JP10.html ├── auth2-JP11.html ├── auth2-JP12.html ├── auth2-JP13.html ├── auth2-JP14.html ├── auth2-JP15.html ├── auth2-JP16.html ├── auth2-JP17.html ├── auth2-JP18.html ├── auth2-JP19.html ├── auth2-JP2.html ├── auth2-JP20.html ├── auth2-JP21.html ├── auth2-JP22.html ├── auth2-JP23.html ├── auth2-JP24.html ├── auth2-JP25.html ├── auth2-JP26.html ├── auth2-JP27.html ├── auth2-JP28.html ├── auth2-JP29.html ├── auth2-JP3.html ├── auth2-JP30.html ├── auth2-JP31.html ├── auth2-JP32.html ├── auth2-JP33.html ├── auth2-JP34.html ├── auth2-JP35.html ├── auth2-JP36.html ├── auth2-JP37.html ├── auth2-JP38.html ├── auth2-JP39.html ├── auth2-JP4.html ├── auth2-JP40.html ├── auth2-JP41.html ├── auth2-JP42.html ├── auth2-JP43.html ├── auth2-JP44.html ├── auth2-JP45.html ├── auth2-JP46.html ├── auth2-JP47.html ├── auth2-JP5.html ├── auth2-JP6.html ├── auth2-JP7.html ├── auth2-JP8.html └── auth2-JP9.html └── ui ├── common_start.js ├── inspect_start.js ├── mobile.css ├── mobile_start.js ├── recochoku_header_caution.css ├── share_redirect.js ├── share_redirect_inject.js ├── tver_playable_mobile.css └── tver_playable_ua_inspect.js /.gitignore: -------------------------------------------------------------------------------- 1 | manifest.json 2 | *.zip 3 | -------------------------------------------------------------------------------- /Circle-icons-radio-blue-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackyzy823/rajiko/e6ca0fe9983cd4a268dbc274139eedfc45388d71/Circle-icons-radio-blue-48.png -------------------------------------------------------------------------------- /Circle-icons-radio-red-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackyzy823/rajiko/e6ca0fe9983cd4a268dbc274139eedfc45388d71/Circle-icons-radio-red-48.png -------------------------------------------------------------------------------- /Circle-icons-radio-red.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Circle-icons-radio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jackyzy823/rajiko/e6ca0fe9983cd4a268dbc274139eedfc45388d71/Circle-icons-radio.png -------------------------------------------------------------------------------- /Circle-icons-radio.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rajiko 2 | ==================== 3 | 4 | Why I do this: 5 | ------------------- 6 | An overseas fan of Kalafina wanted to listen to the radio program 'Kalafina倶楽部' which was ended a few days after this extension have been developed. 7 | 8 | To **celebrate the reunion of Kalafina** at the end of 2024, I finally made Rajiko to support Manifest V3. 9 | 10 | How to use: 11 | ------------------- 12 | 1. Install it from [Chrome webstore](https://chrome.google.com/webstore/detail/rajiko/ejcfdikabeebbgbopoagpabbdokepnff) or [Firefox addons](https://addons.mozilla.org/firefox/addon/rajiko/). Firefox for Android is also supported. 13 | 2. Do nothing or change default area by clicking icon which only affects live area. 14 | 3. Recording live or download timeshift by clicking icon. 15 | 4. Click icon or click pause button to stop recording. 16 | 5. If you have a timefree30 plan (or double plan), you could download timefree30 timeshift after logged in. (Note: for Firefox private mode, you need to login in normal mode too) 17 | 18 | 19 | Sponsor me! 20 | ------------------- 21 | [!["Ko-fi sponsor"](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/jackyzy823) 22 | 23 | You could sponsor me via [Ko-fi](https://ko-fi.com/jackyzy823) (Also the link is available in the popup menu of Rajiko)! Thank you! 24 | Note: For japanese or japan resident, Please don't sponsor unless you've subscribed a Radiko's [premium plan](https://radiko.jp/premium)! 25 | 26 | 27 | Permission Details: 28 | ------------------- 29 | 1. activeTab : to get tab's url to decide whether auto refresh or alert or which radio to record. 30 | 2. cookies : for force setting radiko.jp's current location. 31 | 3. storage : for storing your location configuration. 32 | 4. webRequest : modify request to pass the authentication. 33 | 5. webRequestBlocking : modify request to pass the authentication. 34 | 6. \*://\*.radiko.jp/\* : the only site we aimed at. 35 | 7. declartiveContent : [TODO] for showing icon only on radiko pages.But firefox does not support this api.When firefox supports this api,tab permission will not be required. 36 | 8. downloads : for downloading recored audio. 37 | 9. \*://\*.smartstream.ne.jp/\* : the site where audio stored. 38 | 10. unlimitedStorage : for recording radio. 39 | 11. declarativeNetRequest: modify request to pass the authentication. 40 | 12. \*://\*.nhk.jp/\* : (bonus) support nhk radio. 41 | 13. \*://\*.nhk.or.jp/\* : (bonus) support nhk radio. 42 | 14. \*://\*.tver.jp/\* : (deprecated) (bonus) support tver. 43 | 15. \*://edge.api.brightcove.com/\* : (deprecated) (bonus) tver streaming url. 44 | 16. scripting: for fixing tver not working on linux. 45 | 17. offscreen: (Chrome only)for creating auido blob url to download. 46 | 18. \*://\*.radiko-cf.com/\*": the site where audio stored. 47 | 19. webRequestBlocking: (Firefox only) modify request to pass the authentication. 48 | 20. webRequestFilterResponse: (Firefox only) modify response to pass the area checkk authentication. 49 | 21. \*://\*.recochoku.jp/\* : (bonus) support recochoku.jp. 50 | 51 | What's new: 52 | ---------- 53 | + version 3.2025.10 - **ring your bell** 54 | 55 | Update for special radio station: MAJAL 56 | 57 | + version 3.2025.9 - **I have a dream** 58 | 59 | Fix live recording bug when getting from_time. 60 | 61 | + version 3.2025.8 - **sandpiper** 62 | 63 | Split nhkradio and tver options 64 | 65 | + version 3.2025.7 - **seventh heaven** 66 | 67 | Fix firefox option permission request 68 | 69 | Remove TVer geo-bypass support due to media endpoint changed (but reserve the fix for TVer under linux and TVer width under Android) 70 | 71 | + version 3.2025.6 - **serenato** 72 | 73 | Thanks to the help of a sponsor, implemented timeshift30 download. Note: You need login Radiko premium account with timeshift30 plan or double plan! For firefox private mode user: you need to login Radiko premium account in normal mode to download timeshift30. (This is a firefox defect.) 74 | 75 | 76 | + version 3.2025.5 - **ARIA** 77 | 78 | Improve mobile usability 79 | 80 | Support recochoku.jp 81 | 82 | 83 | + version 3.2025.3 - **sprinter** 84 | 85 | Implement MV3 on Firefox 86 | 87 | + version 3.2025.2 - **fairytale** 88 | 89 | Re-implement: areafree without switching area. 90 | 91 | Re-implement: download timeshift. 92 | 93 | Re-implement: recording. 94 | 95 | + version 3.2025.1 - **oblivious** 96 | 97 | Relauch with MV3 support 98 | 99 | Version name scheme: manifest-version.year.revision 100 | 101 | Codename scheme: a song from Kalafina 102 | 103 | Support NHK Radio, TVer 104 | 105 | + version 0.3.6.1 106 | 107 | update manifest key for Firefox for Android 108 | 109 | + version 0.3.6 110 | 111 | major improvement on Firefox for Android 112 | 113 | + version 0.3.5 114 | 115 | update css in popup to fix issue in chrome. Many Thanks to @fireattack. 116 | 117 | upadte new radio-area map 118 | 119 | sync radiko android version code 120 | 121 | + version 0.3.4 122 | 123 | fix matching when urls have query param 124 | 125 | + version 0.3.3 126 | 127 | fix bug on chrome by calling storage API before DOMContentLoaded 128 | 129 | + version 0.3.2 130 | 131 | fix area check bypass 132 | 133 | upadte new radio-area map 134 | 135 | + version 0.3.1 136 | 137 | fix m3u8 endpoints 138 | 139 | fix aac urls endpoints 140 | 141 | fix drag issue in some cases. 142 | 143 | + version 0.3.0 144 | 145 | improve timezone handle. 146 | 147 | upadte new radio-area map 148 | 149 | sync radiko android version code 150 | 151 | + version 0.2.9 152 | 153 | update css in popup to fix issue in chrome 94 (close #8). Many Thanks to @andykamezou for his css advise. 154 | 155 | hijack ajax to avoid #!out issue (replace old method) 156 | 157 | upadte new radio-area map 158 | 159 | sync radiko android version code 160 | 161 | + version 0.2.7.2 162 | 163 | since chrome do not count hash in history. revert v0.2.7. 164 | 165 | + version 0.2.7.1 166 | 167 | make page history correct via history.back 168 | 169 | + version 0.2.7 170 | 171 | bypass unclosable dialog 172 | 173 | + version 0.2.6 174 | 175 | make share page bypass geoblock (fix #1) 176 | 177 | sync radiko android version code to 7.3.7 178 | 179 | + version 0.2.5.8 180 | 181 | upadte new radio-area map for エフエム佐賀 エフエム徳島 182 | 183 | sync radiko android version code to 7.2.9 184 | 185 | + version 0.2.5.7 186 | 187 | upadte new radio-area map for エフエム秋田、Rhythm Station エフエム山形、FM岡山、エフエム山陰、エフエム宮崎 188 | 189 | change some radio's name 190 | 191 | + version 0.2.5.6 192 | 193 | upadte new radio-area map for HI-SIX(エフエム高知) 194 | 195 | sync radiko android version code to 7.2.0 196 | 197 | + version 0.2.5.5 198 | 199 | fix compatible problem for Chrome 72 - 200 | 201 | + version 0.2.5.4 202 | 203 | fix cors issue for Chrome 76 + 204 | 205 | sync radiko android version code to 7.1.1 206 | 207 | + version 0.2.5.3 208 | 209 | remove alert to avoid stuck in Chrome on Windows 210 | 211 | sync radiko android version code to 7.0.6 212 | 213 | + version 0.2.5.2 214 | 215 | make extension work under incognito mode 216 | 217 | sync radiko android version code to 6.4.4 218 | 219 | fix download blob file problem in new version firefox 220 | 221 | + version 0.2.5.1 222 | 223 | dirty fix for live recording issue caused by radiko using new `rpaa` api for stream. (May encounter unexpected problem.Issues are welcomed.) 224 | 225 | + version 0.2.5 226 | 227 | fix error caused by radiko new type api requestheader (X-Radiko-AreaId). 228 | 229 | solved a problem caused by CORS and Disk cache. 230 | 231 | + version 0.2.4.1 232 | 233 | upadte new radio-area map for FMFUKUI(FM福井) 234 | 235 | + version 0.2.4 236 | 237 | sync radiko android version code to 6.4.0 238 | 239 | resolve 5s problem in some mediaplayer. Now aac are concated without id3 metadata. 240 | 241 | + version 0.2.3 242 | 243 | fix time display in ballon when dragging in timefree, fix dragging in different timezone (Don't know if this fix works) 244 | 245 | update gps info from radiko android DEVELOPER_MODE 246 | 247 | + version 0.2.2 248 | 249 | fix timefree bypass logic. 250 | 251 | + version 0.2.1 252 | 253 | Now, you can use areafree(エリアフリー) and timefree(タイムフリー) as premium(プレミアム会員) freely without any operation. 254 | 255 | For switching to other area in timefree(タイムフリー) page, only click 地域変更 button in timefree(タイムフリー) page. 256 | 257 | The "3 hours a day" limitation of timefree(タイムフリー) has been unblocked.You can listen no matter how long now. And also you can download timefree(タイムフリー) program. 258 | 259 | "Choose Area" is only needed in displaying area in live(ライブ). 260 | 261 | If there's any bug or problem ,please try to disable and then enable or reinstall it.If this does not help , please tell me via review page or github issue. 262 | 263 | Update to the newest radio table [20180412]. 264 | 265 | + version 0.1.4.1 266 | 267 | bug fix: fix cookie error caused by different storage.local key name. 268 | 269 | continously improve mobile ui 270 | 271 | improve extension icon ui when recording 272 | 273 | 274 | + version 0.1.4 275 | 276 | change to responsive ui in firefox android ! 277 | 278 | fix gps info mistake 279 | 280 | adjust to correct japan timezone via moment-timezone 281 | 282 | + version 0.1.3 283 | 284 | experimentally support recording radio. [Caution: this would cause slowing down popup page and increasing cpu usage if recording too long. No more than 30 minutes is recommended.] 285 | 286 | + version 0.1.2 287 | 288 | fix bug in firefox 289 | 290 | + version 0.1.1 291 | 292 | support firefox for android 293 | 294 | + version 0.1 295 | 296 | initial version 297 | 298 | Suppport List: 299 | ------------------ 300 | + firefox latest 301 | 302 | + chromium like browser latest 303 | 304 | + firefox for android latest 305 | 306 | + kiwi browser for android 307 | 308 | + Edge browser for android (UI modification seems not work) 309 | 310 | Known Issue: 311 | --------------- 312 | + The timeshift program can not be played after downloading without force refresh ,becuase of a problem of xhr access-control with disk cache see :https://lists.w3.org/Archives/Public/www-archive/2017Aug/0000.html (solved by add Access-Control-Allow-Origin in response via extension or see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin CORS and caching ->set Vary: Origin in response?) 313 | + Failed to download recorded on firefox nightly and firefox for android nightly 314 | + Drag issue with timezone in timefree mode. (1.Always play the start part Wherever you drag 2.Cannot drag over time after localtime now) .The second problem may be caused by radiko which only aims atjapan user not handling timezone problem . (solved) 315 | + Timefree only plays 5 seconds. (Don't know whether this is my issue or radiko's ,update :solved) 316 | 317 | 318 | Technical Details: 319 | ------------------ 320 | 1. How it works? 321 | 322 | The authentication of pc(html5) version radkio validates user's location via ip address. 323 | 324 | However the android version of radkio validates user via geolocation provided by GPS(if possible),not via user's ip. 325 | 326 | So why don't we use the authentication method of android version in pc to bypass ip check? 327 | 328 | The authentication includes two step: 329 | 1. auth1 330 | 331 | request : platform_info , user_id 332 | 333 | response : a token to be valid, full_key_offset ,partial_key_length 334 | 335 | 2. auth2 336 | 337 | request: token ,platform_info ,user_id, a parital key generated by full key and offset , connection type (in android), gps location(in android) 338 | 339 | response: Your location (and your token is valid for only this location) / OUT 340 | 341 | In the pc version,the full key is simplely placed in the javascript code in `apps/js/playerCommon.js` : 342 | 343 | ```javascript 344 | player = new RadikoJSPlayer($audio[0], 'pc_html5', 'bcd151073c03b352e1ef2fd66c32209da9ca0afa' /*full key*/ ... 345 | ``` 346 | However the android version's full key is protected by native dynamic librarys.Obviously the key is much longer than that in pc version. 347 | 348 | 2. But how do you generate the partialkey/how do you get fullkey? 349 | 350 | By reversing android dynamic library,You can get the fullkey from .data segment after bypassing the root check ,emulator check and lots of anti-debugging tricks and waiting for itself to repair the .data segment. 351 | 352 | ABOUT AAC 353 | ------------ 354 | 1. HLS(HTTP Live Streaming) using Packed Audio see : https://tools.ietf.org/html/draft-pantos-http-live-streaming-23 which is ID3 tag + audio sample(AAC_ADTS,MP3,AC3) 355 | 2. About com.apple.streaming.transportStreamTimestamp ? Could i use this to sort?(yes) PTS -> (stamp2 - stamp1) / (90*1000.0) https://blog.csdn.net/qq_32430349/article/details/50218317 356 | 3. Drop all ID3 tag? see id3 in hls :https://helpx.adobe.com/adobe-media-server/dev/timed-metadata-hls-hds-streams.html 357 | 4. ID3 header -> size PRIV Frame header (PRIV size flag) -> identi end with \x00 64bit data (31bit 0 and 33bit data bigendian) 358 | frame header see http://id3.org/id3v2.4.0-structure priv see http://id3.org/id3v2.4.0-frames 359 | 360 | TODO 361 | ------------ 362 | 0. Using ffmpeg.js (based on Emscripten:an LLVM-to-JavaScript compiler) concating ts segments to avoid 5s problem in mediaplayer.Note:size is about 13MB. (depercated : just drop id3 tags and simplely concat adts strem) 363 | 1. Fake request headers more similarly (such as remove cookies and set accept,user agent,and etc) to avoid detection (partially done) 364 | due to the limitation of extension , cannot captialize some header's key 365 | 2. Automatic switch location , no need for manually choice. (consider not supporting) 366 | 3. Add recording function? (find solution on firefox -> webRequest.filterResponseData() and localstorge/chrome.storage -> downloads.download URL.createObjectURL(BlobObject), chrome may use xhr to save data , double trafic?) 367 | the right way to download data uri 368 | https://stackoverflow.com/questions/40269862/save-data-uri-as-file-using-downloads-download-api/40279050 369 | 370 | how to merge? (src site use hls.js to play m3u8 and aac) 371 | seems that directly concat is enough 372 | 373 | 4. Force Firefox android load web page,not app download page.(done) 374 | 5. consider generate different extension in different browser 375 | 376 | https://stackoverflow.com/questions/45911251/what-is-the-best-way-to-create-a-cross-browser-gmail-extension 377 | https://www.smashingmagazine.com/2017/04/browser-extension-edge-chrome-firefox-opera-brave-vivaldi/ 378 | 6. modify firefox android page to responsive page. (partially done) 379 | 7. break the time limitation of timeshift and be able to download timeshift content (done) 380 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_app_description": { 3 | "message": "Rajiko is a tool for unblocking geolocation restriction of radiko.jp! Enjoy the radio of Tokyo, Aomori, etc. from Osaka, Nagasaki, and even overseas!" 4 | }, 5 | "confirm_button": { 6 | "message": "Confirm" 7 | }, 8 | "popup_title": { 9 | "message": "Choose Area - Rajiko" 10 | }, 11 | "record_button_to_start": { 12 | "message": "Record $RADIONAME$", 13 | "placeholders": { 14 | "radioname": { 15 | "content": "$1", 16 | "example": "bayfm78" 17 | } 18 | } 19 | }, 20 | "record_button_to_stop": { 21 | "message": "Stop recording" 22 | }, 23 | "record_button_to_prepare": { 24 | "message": "Prepare to record $RADIONAME$", 25 | "placeholders": { 26 | "radioname": { 27 | "content": "$1", 28 | "example": "bayfm78" 29 | } 30 | } 31 | }, 32 | "timeshift_button": { 33 | "message": "Download Timeshift" 34 | }, 35 | "firefox_tf30_incognito": { 36 | "message": "Due to the limitation of Firefox, you need to login Radiko member in normal mode as well to download timefree30 program under private mode." 37 | } 38 | } -------------------------------------------------------------------------------- /_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_app_description": { 3 | "message": "Rajikoはradiko.jpのジオロケーション制限を解除するツールです! 大阪、長崎、そして海外から東京、青森などのラジオをお楽しみください!" 4 | }, 5 | "confirm_button": { 6 | "message": "確認する" 7 | }, 8 | "popup_title": { 9 | "message": "エリアを選択してください - Rajiko" 10 | }, 11 | "record_button_to_start": { 12 | "message": "$RADIONAME$を録音する", 13 | "placeholders": { 14 | "radioname": { 15 | "content": "$1", 16 | "example": "bayfm78" 17 | } 18 | } 19 | }, 20 | "record_button_to_stop": { 21 | "message": "録音を停止する" 22 | }, 23 | "record_button_to_prepare": { 24 | "message": "$RADIONAME$ の録音を準備する", 25 | "placeholders": { 26 | "radioname": { 27 | "content": "$1", 28 | "example": "bayfm78" 29 | } 30 | } 31 | }, 32 | "timeshift_button": { 33 | "message": "タイムシフト ダウンロード" 34 | }, 35 | "firefox_tf30_incognito": { 36 | "message": "Firefox の制限により、プライベート モードでタイムフリー30をダウンロードするには、通常モードでもRadikoメンバーにログインする必要があります。" 37 | } 38 | } -------------------------------------------------------------------------------- /_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_app_description": { 3 | "message": "Rajiko是一个解除radiko.jp地区限制的工具。能从大阪、长崎,甚至海外听到东京、青森等地的电台。" 4 | }, 5 | "confirm_button": { 6 | "message": "确认" 7 | }, 8 | "popup_title": { 9 | "message": "选择地区 - Rajiko" 10 | }, 11 | "record_button_to_start": { 12 | "message": "录音 $RADIONAME$", 13 | "placeholders": { 14 | "radioname": { 15 | "content": "$1", 16 | "example": "bayfm78" 17 | } 18 | } 19 | }, 20 | "record_button_to_stop": { 21 | "message": "停止录音" 22 | }, 23 | "record_button_to_prepare": { 24 | "message": "准备录音 $RADIONAME$ ", 25 | "placeholders": { 26 | "radioname": { 27 | "content": "$1", 28 | "example": "bayfm78" 29 | } 30 | } 31 | }, 32 | "timeshift_button": { 33 | "message": "下载タイムシフト" 34 | }, 35 | "firefox_tf30_incognito": { 36 | "message": "由于Firefox的限制,您需要在普通模式下也登录 Radiko 会员才能在隐私模式下下载 timeshift30。" 37 | } 38 | } -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | import { APP_VERSION_MAP, APP_KEY_MAP, IGNORELIST } from "./modules/static.js"; 2 | import { genRandomInfo, genGPS, initiatorFromExtension, isFirefox } from "./modules/util.js" 3 | import { downloadtimeShift } from "./modules/timeshift.js" 4 | import { retrieve_token } from "./modules/auth.js" 5 | import { updateRadioRules, setUpNHKRadio, setUpTVer, updateAreaRules, setUpMobileRadiko, setUpRecochokuUserAgent } from "./modules/rules.js"; 6 | import { radioAreaId, areaMap, areaList, areaSuffixList } from "./modules/constants.js"; 7 | import { stream_listener_builder } from "./modules/recording.js" 8 | 9 | // try { 10 | // // Even with declarativeNetRequestFeedback and extensions.dnr.feedback, 11 | // // onRuleMatchedDebug is still not work under Firefox. 12 | // chrome.declarativeNetRequest.onRuleMatchedDebug.addListener(info => console.log(info)); 13 | // } catch { } 14 | 15 | 16 | // Register listeners synchronously. so need to store device info in local 17 | chrome.runtime.onMessage.addListener(async function (msg, sender, respCallback) { 18 | if (msg["update-area"]) { 19 | let area_id = msg["update-area"]; 20 | 21 | await chrome.storage.local.set({ selected_areaid: area_id }); 22 | console.log(`Update area to ${area_id}`); 23 | 24 | let { device_info: info } = await chrome.storage.local.get("device_info"); 25 | if (!info) { 26 | console.warn("this shouldn't happen"); 27 | info = genRandomInfo(); 28 | } 29 | 30 | if (!isFirefox()) { 31 | updateAreaRules(area_id, info); 32 | } 33 | } else if (msg["share-redirect"]) { 34 | let param = msg["share-redirect"]; 35 | chrome.tabs.update(sender.tab.id, { "url": "https://radiko.jp/#!/ts/" + param.station + "/" + param.t }); 36 | } else if (msg["update-nhkradio"]) { 37 | // This works for firefox too 38 | await setUpNHKRadio(msg["update-nhkradio"] == "yes"); 39 | } else if (msg["update-recochoku"]) { 40 | await setUpRecochokuUserAgent(msg["update-recochoku"] == "yes") 41 | } else if (msg["update-tver"]) { 42 | await setUpTVer(msg["update-tver"] == "yes") 43 | } else if (msg["download-timeshift"]) { 44 | let { link: link, tf30: tf30 } = msg["download-timeshift"]; 45 | console.log(`start donwload timeshift ${link}`); 46 | 47 | let { timeshift_list: list, selected_areaid: area_id } = await chrome.storage.local.get(["timeshift_list", "selected_areaid"]); 48 | if (!list) { 49 | list = []; 50 | } 51 | list.push(link); 52 | await chrome.storage.local.set({ "timeshift_list": list }); 53 | 54 | chrome.action.setBadgeBackgroundColor?.({ color: "#e73c64" }); 55 | chrome.action.setBadgeText?.({ text: list.length.toString() }); 56 | downloadtimeShift(link, area_id, tf30); 57 | } else if (msg["start-recording"]) { 58 | let radioname = msg["start-recording"]; 59 | console.log(`Start recording ${radioname}`); 60 | // store in session, for popup menu to check if has running recording and get current recording's radioname 61 | await chrome.storage.session.set({ "current_recording": radioname }); 62 | 63 | // We need service worker to keepalive here. 64 | // Because user can prepare to record and after 30s (the service work became inactive), then click play button. 65 | 66 | // DO when aac request is completed 67 | //TODO Firefox listen on what? onCompleted or onBeforeSendHeaders 68 | chrome.webRequest.onCompleted.addListener( 69 | // create a listener function 70 | stream_listener_builder(radioname), 71 | { 72 | urls: [ 73 | `*://*.smartstream.ne.jp/${radioname}/*.aac*`, 74 | "*://rpaa.smartstream.ne.jp/segments/*.aac*", 75 | `*://*.radiko-cf.com/segments/*/*/${radioname}/*.aac*`, 76 | `*://*.smartstream.ne.jp/segments/*/*/${radioname}/*.aac*` 77 | ] 78 | , tabId: msg["tabId"] //restrict to specific tabid 79 | } 80 | ); 81 | chrome.action.setIcon?.({ path: 'Circle-icons-radio-red-48.png' }); 82 | } 83 | }); 84 | 85 | /** 86 | * User could be just visiting the page, not be intent on clicking the play button. 87 | * It's a bit aggreesive :( But i have no choice. Blame on MV3. 88 | * 89 | * On live page, if user pause the audio and stay on a page a long time (token expired), then play. this won't be triggered. 90 | * On timeshift page, it is the same. 91 | */ 92 | if (!isFirefox()) { 93 | chrome.webRequest.onBeforeRequest.addListener( 94 | async req => { 95 | if (initiatorFromExtension(req)) { return; } 96 | 97 | let [radioname] = req.url.split("/").at(-1).split("."); 98 | let { selected_areaid: selected_areaid } = await chrome.storage.local.get(["selected_areaid"]); 99 | console.log(`Hit ${req.url} radioname ${radioname} , area ${selected_areaid}`); 100 | 101 | if (radioAreaId[radioname].area.includes(selected_areaid)) { 102 | return; 103 | } 104 | 105 | let [token, area_id] = await retrieve_token(radioname, selected_areaid); 106 | // We update rules in `"*://*.radiko.jp/v3/station/stream/pc_html5/*"` listener. 107 | }, 108 | { 109 | urls: [ 110 | // This is used in live and timeshift 111 | // https://radiko.jp/v3/radioweb/bansen/station/RADIONAME.xml 112 | "*://*.radiko.jp/v3/radioweb/bansen/station/*.xml*", 113 | 114 | // Domain name is GCP_API_DOMAIN 115 | // https://api.radiko.jp/program/v3/weekly/RADIONAME.xml 116 | // This is usable, but we only use one to avoid fetch token twice since these reuqests are very closed. 117 | // "*://*.radiko.jp/program/v3/weekly/*.xml*" 118 | 119 | // Only in live page 120 | // "*://*.radiko.jp/v3/feed/pc/extra/*.xml*" 121 | 122 | // Not all radio have v4 API 123 | //https://api.radiko.jp/program/v4/date/DATE/station/RADIONAME.json 124 | 125 | // maybe cached? 126 | // https://radiko.jp/v2/static/station/logo/RADIONAME/224x100.png 127 | 128 | // DONT USE, TOO FREQUNCEY 129 | // https://radiko.jp/v3/feed/pc/cm/RADIONAME.xml?_= 130 | // Only in live page 131 | // https://api.radiko.jp/music/api/v1/noas/RADIONAME/latest?size=20 132 | // Only in timeshift page 133 | // https://api.radiko.jp/music/api/v1/noas/RADIONAME? 134 | ], 135 | } 136 | ); 137 | } 138 | 139 | 140 | /** 141 | * The most accurate listener for the playing event. 142 | * However when it happens, it is too late to generate token if token doesn't exist and then update rules. 143 | * So we can catch up the second request to m3u8 link, but the first one is 403 with wrong token. 144 | * Blame on MV3 145 | */ 146 | chrome.webRequest.onBeforeRequest.addListener( 147 | async req => { 148 | if (initiatorFromExtension(req)) { return; } 149 | 150 | let [radioname] = req.url.split("/").at(-1).split(".") 151 | let { selected_areaid: selected_areaid } = await chrome.storage.local.get(["selected_areaid"]); 152 | console.log(`Hit ${req.url} radioname ${radioname} , area ${selected_areaid}`); 153 | 154 | if (radioAreaId[radioname].area.includes(selected_areaid)) { 155 | return; 156 | } 157 | // Too LATE 158 | let [token, area_id] = await retrieve_token(radioname, selected_areaid); 159 | if (!isFirefox()) { 160 | updateRadioRules(radioname, area_id, token); 161 | } 162 | }, 163 | { 164 | // The request fetch playlist_create_url 165 | urls: ["*://*.radiko.jp/v3/station/stream/pc_html5/*"] 166 | }, 167 | isFirefox() ? ["blocking"] : [] 168 | ); 169 | 170 | // Register listeners synchronously. so need to store device info in session 171 | chrome.webRequest.onHeadersReceived.addListener( 172 | async resp => { 173 | if (initiatorFromExtension(resp)) { return; } 174 | 175 | let token = ""; 176 | let offset = 0; 177 | let length = 0; 178 | let set = 0; 179 | 180 | for (let i = 0; i < resp.responseHeaders.length; i++) { 181 | if (resp.responseHeaders[i].name.toLowerCase() == "x-radiko-keyoffset") { 182 | offset = parseInt(resp.responseHeaders[i].value); 183 | set ^= 1; 184 | } 185 | if (resp.responseHeaders[i].name.toLowerCase() == "x-radiko-keylength") { 186 | length = parseInt(resp.responseHeaders[i].value); 187 | set ^= 1 << 1; 188 | } 189 | if (resp.responseHeaders[i].name.toLowerCase() == "x-radiko-authtoken") { 190 | token = resp.responseHeaders[i].value; 191 | set ^= 1 << 2; 192 | } 193 | } 194 | 195 | if (set != 0b111) { 196 | console.error("no enough info from auth2 response."); 197 | return; 198 | } 199 | 200 | console.log(`onHeadersReceived of auth1: token ${token}, offset ${offset} length ${length}`); 201 | let { device_info: info } = await chrome.storage.local.get("device_info"); 202 | if (!info) { 203 | // This should not happen and is not recoverable 204 | // If generate again, X-Radiko-App may not be same in auth1 and auth2 then auth will fail. 205 | // TODO If all aSmartPhone8 then ok to regenerate info? 206 | // TempFix: assert all device are aSmartPhone8 207 | info = genRandomInfo(); 208 | await chrome.storage.local.set({ "device_info": info }); 209 | console.error("no device_info in local storage"); 210 | // return 211 | } 212 | 213 | let { selected_areaid: area_id } = await chrome.storage.local.get("selected_areaid"); 214 | if (!area_id) { 215 | // This should not happen and is not recoverable 216 | console.error("no area_id in local storage"); 217 | return; 218 | } 219 | 220 | 221 | let partial = btoa(atob(APP_KEY_MAP[APP_VERSION_MAP[info.appversion]]).slice(offset, offset + length)); 222 | // Allow cookie in header 223 | // However if in incognito:spanning, private window will load cookie from normal window (and you can't choose which cookie store to use) 224 | // But firefox doesn't support incognito:split 225 | // https://github.com/Tampermonkey/tampermonkey/issues/816 226 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1380812 227 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1670278 228 | // ** So firefox user should be warned ** 229 | let resp2 = await fetch('https://radiko.jp/v2/api/auth2', { 230 | headers: { 231 | 'X-Radiko-App': APP_VERSION_MAP[info.appversion], 232 | 'X-Radiko-App-Version': info.appversion, 233 | 'X-Radiko-Device': info.device, 234 | 'X-Radiko-User': info.userid, 235 | 'X-Radiko-AuthToken': token, 236 | 'X-Radiko-Partialkey': partial, 237 | 'X-Radiko-Location': genGPS(area_id), 238 | 'X-Radiko-Connection': "wifi", 239 | // modifying UA does not work here. so we use session rules RULEID.AUTH_FETCH. 240 | 'User-Agent': info.useragent 241 | }, 242 | }); 243 | 244 | // Don't save the token from default_area_id to avoid race condition. 245 | if (resp2.status == 200) { 246 | let { auth_tokens: authTokens } = await chrome.storage.session.get({ "auth_tokens": {} }); 247 | authTokens[area_id] = { token: token, requestTime: Date.now() }; 248 | await chrome.storage.session.set({ "auth_tokens": authTokens }); 249 | } 250 | }, 251 | { 252 | urls: ["*://*.radiko.jp/v2/api/auth1*"] 253 | }, 254 | // Is here blocking necessary? 255 | isFirefox() ? ["blocking", "responseHeaders"] : ["responseHeaders"] 256 | ); 257 | 258 | 259 | async function initialize() { 260 | let { 261 | selected_areaid: area_id, 262 | bonus_feature: bonus, 263 | recochoku_ua: recochoku_ua, 264 | nhkradio_bypass: nhkradio_bypass, 265 | tver_fix: tver_fix 266 | } = await chrome.storage.local.get(["selected_areaid", "bonus_feature", "recochoku_ua", "nhkradio_bypass", "tver_fix"]); 267 | //if not selected_areaid use default value:JP13 268 | if (!area_id) { area_id = "JP13"; } 269 | if (!recochoku_ua) { recochoku_ua = false; } 270 | if (!nhkradio_bypass) { nhkradio_bypass = false; } 271 | if (!tver_fix) { tver_fix = false; } 272 | // TODO: remove after several (like 5?) releases. 273 | if (!bonus) { bonus = false; } else { 274 | // Migration old bonus_feature key to tver_fix and nhkradio_bypass 275 | tver_fix = true; 276 | nhkradio_bypass = true; 277 | } 278 | 279 | //clean previous unfinshed recording or downloading content if exists. 280 | await chrome.storage.local.clear(); 281 | await chrome.storage.local.set({ 282 | "selected_areaid": area_id, 283 | "recochoku_ua": recochoku_ua, 284 | "tver_fix": tver_fix, 285 | "nhkradio_bypass": nhkradio_bypass, 286 | }); 287 | 288 | // Re-generate device info every initalization 289 | // Why use storage.local instead of storage.session for device info? 290 | // See issue: https://issues.chromium.org/issues/389232707 291 | let info = genRandomInfo(); 292 | console.log("Using ", info, " for ", area_id); 293 | await chrome.storage.local.set({ "device_info": info }); 294 | 295 | 296 | chrome.action.setBadgeText?.({ text: "" }); 297 | 298 | if (!isFirefox()) { 299 | updateAreaRules(area_id, info); 300 | } 301 | 302 | // Wired bug under incognito:split , contentscript id duplication? 303 | try { await setUpNHKRadio(nhkradio_bypass); } catch (ex) { console.warn(ex) } 304 | try { await setUpTVer(tver_fix); } catch (ex) { console.warn(ex) } 305 | try { await setUpRecochokuUserAgent(recochoku_ua); } catch (ex) { console.warn(ex) } 306 | try { await setUpMobileRadiko(); } catch (ex) { console.warn(ex) } 307 | } 308 | 309 | chrome.runtime.onInstalled.addListener(async (data) => { 310 | await initialize(); 311 | }) 312 | //main stuff do only once on profile started 313 | chrome.runtime.onStartup.addListener(async () => { 314 | await initialize(); 315 | }); 316 | 317 | /** 318 | * 319 | * Firefox Listeners 320 | * 321 | */ 322 | if (isFirefox()) { 323 | // Set request header in auth1 324 | chrome.webRequest.onBeforeSendHeaders.addListener(async req => { 325 | if (initiatorFromExtension(req)) { 326 | // TODO if from ext, then remove Accept-Language,Accept,User-Agent 327 | // like RULEID.AUTH_FETCH 328 | return; 329 | } 330 | let { device_info: info } = await chrome.storage.local.get("device_info"); 331 | if (!info) { 332 | // This should not happen and is not recoverable 333 | // If generate again, X-Radiko-App may not be same in auth1 and auth2 then auth will fail. 334 | // TODO If all aSmartPhone8 then ok to regenerate info? 335 | // TempFix: assert all device are aSmartPhone8 336 | info = genRandomInfo(); 337 | await chrome.storage.local.set({ "device_info": info }); 338 | console.error("no device_info in local storage"); 339 | // return 340 | } 341 | 342 | req.requestHeaders = req.requestHeaders.filter(function (x) { 343 | return !IGNORELIST.includes(x.name.toLowerCase()); 344 | }); 345 | 346 | req.requestHeaders.push({ 347 | name: "X-Radiko-User", 348 | value: info.userid 349 | }); 350 | req.requestHeaders.push({ 351 | name: "X-Radiko-App-Version", 352 | value: info.appversion 353 | }); 354 | req.requestHeaders.push({ 355 | name: "X-Radiko-App", 356 | value: APP_VERSION_MAP[info.appversion], 357 | }); 358 | req.requestHeaders.push({ 359 | name: "X-Radiko-Device", 360 | value: info.device 361 | }); 362 | req.requestHeaders.push({ 363 | name: "User-Agent", 364 | value: info.useragent 365 | }); 366 | return { 367 | requestHeaders: req.requestHeaders 368 | }; 369 | }, { 370 | urls: ["*://*.radiko.jp/v2/api/auth1*"] 371 | }, ["blocking", "requestHeaders"]); 372 | 373 | // Fix response header in auth1 374 | chrome.webRequest.onHeadersReceived.addListener(async resp => { 375 | if (initiatorFromExtension(resp)) { return; } 376 | for (let i = 0; i < resp.responseHeaders.length; i++) { 377 | if (resp.responseHeaders[i].name.toLowerCase() == "x-radiko-keyoffset") { 378 | resp.responseHeaders[i].value = "0"; //to avoid too big offset cause radiko's js error 379 | } 380 | } 381 | return { 382 | responseHeaders: resp.responseHeaders 383 | }; 384 | }, { 385 | urls: ["*://*.radiko.jp/v2/api/auth1*"] 386 | }, ["blocking", "responseHeaders"]); 387 | 388 | // Set area response content 389 | chrome.webRequest.onBeforeRequest.addListener(async req => { 390 | let filter = browser.webRequest.filterResponseData(req.requestId); 391 | 392 | filter.onstop = async event => { 393 | let { selected_areaid: area_id } = await chrome.storage.local.get("selected_areaid"); 394 | // Template document.write('OSAKA JAPAN'); 395 | let encoder = new TextEncoder(); 396 | filter.write(encoder.encode(`document.write('${areaMap[area_id]} JAPAN');`)); 397 | filter.close(); 398 | }; 399 | 400 | return {}; 401 | }, { 402 | // webRequest support "*://*.radiko.jp/area*" to match "https://radiko.jp/area" 403 | // But declarativeNetRequest don't, it only support "*://radiko.jp/area*" 404 | urls: ["*://*.radiko.jp/area*", "*://*.radiko.jp/apparea/area*"] 405 | }, ["blocking"]); 406 | 407 | // Set browser's auth2 response content 408 | chrome.webRequest.onBeforeRequest.addListener(async req => { 409 | if (initiatorFromExtension(req)) { 410 | // TODO add permission: requestHeaders 411 | // TODO if from ext, then remove Accept-Language,Accept,User-Agent 412 | // like RULEID.AUTH_FETCH 413 | return; 414 | } 415 | let filter = browser.webRequest.filterResponseData(req.requestId); 416 | 417 | filter.onstop = async event => { 418 | let { selected_areaid: area_id } = await chrome.storage.local.get("selected_areaid"); 419 | let encoder = new TextEncoder(); 420 | let area_id_num = parseInt(area_id.substr(2)) - 1; 421 | filter.write(encoder.encode(`${area_id},${areaList[area_id_num]}${areaSuffixList[area_id_num]},${areaMap[area_id].toLowerCase()} Japan`)); 422 | filter.close(); 423 | }; 424 | 425 | return {}; 426 | }, { 427 | urls: ["*://radiko.jp/v2/api/auth2*"] 428 | }, ["blocking"]); 429 | 430 | // THE correct way to set authtoken to m3u8 431 | chrome.webRequest.onBeforeSendHeaders.addListener(async req => { 432 | if (initiatorFromExtension(req)) { return; } 433 | let radioname = (new URL(req.url)).searchParams.get("station_id"); 434 | let { selected_areaid: selected_areaid } = await chrome.storage.local.get("selected_areaid"); 435 | if (radioAreaId[radioname].area.includes(selected_areaid)) { 436 | return {}; 437 | } 438 | 439 | // Good timing! Firefox! 440 | let [token, area_id] = await retrieve_token(radioname, selected_areaid); 441 | 442 | req.requestHeaders = req.requestHeaders.filter(function (x) { 443 | return !["x-radiko-authtoken", "x-radiko-areaid"].includes(x.name.toLowerCase()); //remove previous token 444 | }); 445 | req.requestHeaders.push({ 446 | name: "X-Radiko-AuthToken", 447 | value: token 448 | }); 449 | req.requestHeaders.push({ 450 | name: "X-Radiko-AreaId", 451 | value: area_id 452 | }); 453 | 454 | return { 455 | requestHeaders: req.requestHeaders 456 | }; 457 | 458 | }, { 459 | urls: ["*://radiko.jp/v2/api/ts/playlist.m3u8?*" /*for timeshift*/ 460 | , "*://*.smartstream.ne.jp/*/playlist.m3u8?*" // some new apis 461 | , "*://*.radiko-cf.com/*/playlist.m3u8?*" 462 | ] 463 | }, ["blocking", "requestHeaders"]); 464 | } -------------------------------------------------------------------------------- /manifest.jsonc.tmpl: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Rajiko", 4 | "version": "3.2025.10", 5 | "description": "__MSG_manifest_app_description__", 6 | "icons": { 7 | "48": "Circle-icons-radio-blue-48.png", 8 | "128": "Circle-icons-radio.png" 9 | }, 10 | "permissions": [ 11 | "activeTab", 12 | "cookies", 13 | "storage", 14 | "webRequest", 15 | "downloads", 16 | "unlimitedStorage", 17 | "declarativeNetRequest", 18 | "scripting", 19 | // manifest.json allows comments 20 | // https://discourse.mozilla.org/t/manifest-json-should-mention-it-allows-comments/27529/3 21 | // Chrome only 22 | "offscreen", 23 | // Firefox only 24 | "webRequestBlocking", 25 | "webRequestFilterResponse" 26 | ], 27 | "host_permissions": [ 28 | "*://*.radiko.jp/*", 29 | "*://*.smartstream.ne.jp/*", 30 | "*://*.radiko-cf.com/*" 31 | ], 32 | // Edge on Android don't support optional_host_permissions as of 2025/01/24 33 | "optional_host_permissions": [ 34 | "*://*.nhk.jp/*", 35 | "*://*.nhk.or.jp/*", 36 | "*://*.tver.jp/*", 37 | "*://*.recochoku.jp/*" 38 | ], 39 | "default_locale": "ja", 40 | "incognito": "split", 41 | "background": { 42 | // chrome 43 | "service_worker": "background.js", 44 | // firefox 45 | "scripts": [ 46 | "background.js" 47 | ], 48 | "type": "module" 49 | }, 50 | "content_scripts": [ 51 | { 52 | "matches": [ 53 | "*://*.radiko.jp/" 54 | ], 55 | "js": [ 56 | "ui/common_start.js" 57 | ], 58 | "run_at": "document_start" 59 | }, 60 | { 61 | "matches": [ 62 | "*://*.radiko.jp/share/?*noreload=1*" 63 | ], 64 | "js": [ 65 | "ui/share_redirect.js" 66 | ], 67 | "run_at": "document_idle" 68 | } 69 | ], 70 | "web_accessible_resources": [ 71 | { 72 | "resources": [ 73 | "ui/inspect_start.js", 74 | "ui/share_redirect_inject.js" 75 | ], 76 | "matches": [ 77 | "*://*.radiko.jp/*" 78 | ] 79 | }, 80 | // Only for Chrome 81 | { 82 | "resources": [ 83 | "response/*.html" 84 | ], 85 | "matches": [ 86 | "*://*.radiko.jp/*" 87 | ] 88 | } 89 | ], 90 | "action": { 91 | "default_icon": "Circle-icons-radio-blue-48.png", 92 | "default_popup": "pages/popup.html", 93 | "default_title": "__MSG_popup_title__" 94 | }, 95 | "options_ui": { 96 | "page": "pages/options.html", 97 | "open_in_tab": false 98 | }, 99 | "browser_specific_settings": { 100 | "gecko": { 101 | "id": "{3a8c3e6f-40c7-4eeb-9e42-8d7a803af62b}", 102 | // Firefox is adding support for manifest version 3 (MV3) extensions in Firefox 109.0. 103 | // optional_host_permissions requires 128.0 104 | "strict_min_version": "128.0" 105 | }, 106 | "gecko_android": {} 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /modules/auth.js: -------------------------------------------------------------------------------- 1 | import { APP_VERSION_MAP, APP_KEY_MAP } from "./static.js"; 2 | import { radioAreaId } from "./constants.js" 3 | import { genRandomInfo, genGPS } from "./util.js" 4 | 5 | /** 6 | * The max lifetime of a token is 90 mins. and Radiko web will refresh it after 70mins (42e5). 7 | * 8 | * Auth token generated with premium radiko_session cookie can access the tf30 resource. 9 | */ 10 | export async function retrieve_token(radioname, default_area_id) { 11 | let availableArea = radioAreaId[radioname].area; 12 | let { auth_tokens: authTokens } = await chrome.storage.session.get({ "auth_tokens": {} }); 13 | 14 | let hadTokenArea = availableArea.filter((area) => { 15 | return !!authTokens[area] && ((Date.now() - authTokens[area].requestTime) < 42e5); 16 | }) 17 | if (hadTokenArea.length > 0) { 18 | let pickArea = hadTokenArea.includes(default_area_id) ? default_area_id : hadTokenArea[0]; 19 | return [authTokens[pickArea].token, pickArea]; 20 | } else { 21 | // if session has exist but expired areas, pick one, remove others, else get random one. 22 | let expired = availableArea.filter((area) => { 23 | return !!authTokens[area] 24 | }) 25 | let pickArea; 26 | if (expired.length > 0) { 27 | pickArea = expired.pop(); 28 | for (let i in expired) { 29 | delete authTokens[i]; 30 | } 31 | } else { 32 | pickArea = availableArea[(Math.floor(Math.random() * availableArea.length)) >> 0]; 33 | } 34 | 35 | let info = genRandomInfo(); 36 | let rapp = APP_VERSION_MAP[info.appversion]; 37 | let auth1 = await fetch("https://radiko.jp/v2/api/auth1", { 38 | headers: { 39 | 'X-Radiko-App': rapp, 40 | 'X-Radiko-App-Version': info.appversion, 41 | 'X-Radiko-Device': info.device, 42 | 'X-Radiko-User': info.userid, 43 | }, 44 | }); 45 | 46 | let token = auth1.headers.get('x-radiko-authtoken') 47 | let offset = parseInt(auth1.headers.get('x-radiko-keyoffset')); 48 | let length = parseInt(auth1.headers.get('x-radiko-keylength')); 49 | let partial = btoa(atob(APP_KEY_MAP[rapp]).slice(offset, offset + length)); 50 | let auth2 = await fetch('https://radiko.jp/v2/api/auth2', { 51 | headers: { 52 | 'X-Radiko-App': rapp, 53 | 'X-Radiko-App-Version': info.appversion, 54 | 'X-Radiko-Device': info.device, 55 | 'X-Radiko-User': info.userid, 56 | 'X-Radiko-AuthToken': token, 57 | 'X-Radiko-Partialkey': partial, 58 | 'X-Radiko-Location': genGPS(pickArea), 59 | }, 60 | }) 61 | if (auth2.status == 200) { 62 | authTokens[pickArea] = { token: token, requestTime: Date.now() }; 63 | await chrome.storage.session.set({ "auth_tokens": authTokens }); 64 | return [token, pickArea]; 65 | } else { 66 | throw new Error("Retrieve token failed"); 67 | } 68 | } 69 | } 70 | 71 | 72 | /** 73 | * The auth flow on Android App 74 | * note: userid is 16hex (not 32) 75 | * note: lat,lang is float (not string) 76 | * 77 | * Android app will use api.annex.radiko.jp (and protobuf) for further commands? 78 | * protobuf details could be found in mobile website 79 | * 80 | * Analysis with FakeTraveler and PCAPdroid-mitm under root 81 | */ 82 | async function apk_auth(area_id, info) { 83 | let rapp = APP_VERSION_MAP[info.appversion]; 84 | // Android VERSION_MAP's key ' split("dot")[0] 85 | // Device like Google Pixel 6 86 | let ua = `radiko/${info.appversion} (Android;Android14, Google Pixel 6)` 87 | let auth1 = await fetch("https://api.radiko.jp/apparea/auth1", { 88 | method: "POST", 89 | headers: { 90 | "Content-Type": "application/json", 91 | "User-Agent": ua 92 | }, 93 | body: JSON.stringify({ "app_id": rapp, "app_version": info.appversion, "user_id": info.userid.slice(0, 16), "device": "android" } 94 | ) 95 | }); 96 | // Response sample {"app_type":"android","auth_token_info":{"auth_token":"","expires_at":"2025-01-01T00:00:00+09:00"},"delay":15,"key_length":16,"key_offset":18319,"tet_type":"android"} 97 | // `expires_at` shows -> 90 mins lifetime 98 | let resp = await auth1.json(); 99 | let token = resp["auth_token_info"]["auth_token"]; 100 | let requestTime = Date.parse(auth1.headers.get("date")); 101 | let length = resp["key_length"]; 102 | let offset = resp["key_offset"]; 103 | let partial = btoa(atob(APP_KEY_MAP[rapp]).slice(offset, offset + length)); 104 | 105 | // TODO: new genGPS function -> return Float! 106 | let gps = genGPS(area_id).split(","); 107 | let lat = parseFloat(gps[0]); 108 | let lang = parseFloat(gps[1]); 109 | 110 | let auth2 = await fetch("https://api.radiko.jp/apparea/auth2", { 111 | method: "POST", 112 | headers: { 113 | "Content-Type": "application/json", 114 | "User-Agent": ua 115 | }, 116 | body: JSON.stringify({ 117 | "auth_token": token, "partial_key": partial, "connection": "wifi", 118 | "location": { "latitude": lat, "longitude": lang } 119 | }) 120 | }); 121 | // Intersting error message: (when i pass string in lat,lang) 122 | // message "json error: json: cannot unmarshal string into Go struct field Location.location.latitude of type float64" 123 | let resp2 = await auth2.json(); 124 | // Response: { "areas": [ { "area_id": "JP13", "area_name": "東京都", "area_roman": "tokyo Japan" } ], "is_areafree": false, "is_out": false, "is_timefree_plus": false } 125 | // authTokens[pickArea] = { token: token, requestTime: requestTime }; 126 | // await chrome.storage.session.set({ "auth_tokens": authTokens }); 127 | 128 | } 129 | 130 | // Seem from yt-dlp-rajiko 131 | async function auth_check(token) { 132 | let resp = await fetch("https://radiko.jp/v2/api/auth_check", { headers: { 'X-Radiko-AuthToken': token } }); 133 | if (resp.status == 401) { return false; } 134 | let text = await resp.text(); 135 | if (text != "OK") { return false; } 136 | return true; 137 | } -------------------------------------------------------------------------------- /modules/constants.js: -------------------------------------------------------------------------------- 1 | // constant values for background and popup 2 | export const regions = [ 3 | { id: "hokkaido-tohoku", name: "北海道・東北" }, 4 | { id: "kanto", name: "関東" }, 5 | { id: "hokuriku-koushinetsu", name: "北陸・甲信越" }, 6 | { id: "chubu", name: "中部" }, 7 | { id: "kinki", name: "近畿" }, 8 | { id: "chugoku-shikoku", name: "中国・四国" }, 9 | { id: "kyushu", name: "九州・沖縄" }]; 10 | 11 | //http://radiko.jp/v3/station/region/full.xml 12 | 13 | 14 | //common.js 15 | export const areaList = ['北海道', '青森', '岩手', '宮城', '秋田', '山形', '福島', '茨城', '栃木', '群馬', '埼玉', '千葉', '東京', '神奈川', '新潟', '富山', '石川', '福井', '山梨', '長野', '岐阜', '静岡', '愛知', '三重', '滋賀', '京都', '大阪', '兵庫', '奈良', '和歌山', '鳥取', '島根', '岡山', '広島', '山口', '徳島', '香川', '愛媛', '高知', '福岡', '佐賀', '長崎', '熊本', '大分', '宮崎', '鹿児島', '沖縄']; 16 | // TODO: Firefox Only 17 | export const areaSuffixList = ['', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '都', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '府', '府', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県', '県']; 18 | 19 | 20 | // Only used for Firefox filterResponseData to pass area check API 21 | // https://www.iso.org/obp/ui/#iso:code:3166:JP With language code: en 22 | // Note: auth2 return KOUCHI instead of KOCHI 23 | export const areaMap = { "JP1": "HOKKAIDO", "JP2": "AOMORI", "JP3": "IWATE", "JP4": "MIYAGI", "JP5": "AKITA", "JP6": "YAMAGATA", "JP7": "FUKUSHIMA", "JP8": "IBARAKI", "JP9": "TOCHIGI", "JP10": "GUNMA", "JP11": "SAITAMA", "JP12": "CHIBA", "JP13": "TOKYO", "JP14": "KANAGAWA", "JP15": "NIIGATA", "JP16": "TOYAMA", "JP17": "ISHIKAWA", "JP18": "FUKUI", "JP19": "YAMANASHI", "JP20": "NAGANO", "JP21": "GIFU", "JP22": "SHIZUOKA", "JP23": "AICHI", "JP24": "MIE", "JP25": "SHIGA", "JP26": "KYOTO", "JP27": "OSAKA", "JP28": "HYOGO", "JP29": "NARA", "JP30": "WAKAYAMA", "JP31": "TOTTORI", "JP32": "SHIMANE", "JP33": "OKAYAMA", "JP34": "HIROSHIMA", "JP35": "YAMAGUCHI", "JP36": "TOKUSHIMA", "JP37": "KAGAWA", "JP38": "EHIME", "JP39": "KOCHI", "JP40": "FUKUOKA", "JP41": "SAGA", "JP42": "NAGASAKI", "JP43": "KUMAMOTO", "JP44": "OITA", "JP45": "MIYAZAKI", "JP46": "KAGOSHIMA", "JP47": "OKINAWA" }; 24 | 25 | export const areaListParRegion = { 26 | 'hokkaido-tohoku': [ 27 | { 28 | id: 'JP1', 29 | name: '北海道' 30 | }, 31 | { 32 | id: 'JP2', 33 | name: '青森' 34 | }, 35 | { 36 | id: 'JP3', 37 | name: '岩手' 38 | }, 39 | { 40 | id: 'JP4', 41 | name: '宮城' 42 | }, 43 | { 44 | id: 'JP5', 45 | name: '秋田' 46 | }, 47 | { 48 | id: 'JP6', 49 | name: '山形' 50 | }, 51 | { 52 | id: 'JP7', 53 | name: '福島​' 54 | } 55 | ], 56 | 'kanto': [ 57 | { 58 | id: 'JP8', 59 | name: '茨城' 60 | }, 61 | { 62 | id: 'JP9', 63 | name: '栃木' 64 | }, 65 | { 66 | id: 'JP10', 67 | name: '群馬' 68 | }, 69 | { 70 | id: 'JP11', 71 | name: '埼玉' 72 | }, 73 | { 74 | id: 'JP12', 75 | name: '千葉' 76 | }, 77 | { 78 | id: 'JP13', 79 | name: '東京' 80 | }, 81 | { 82 | id: 'JP14', 83 | name: '神奈川' 84 | } 85 | ], 86 | 'hokuriku-koushinetsu': [ 87 | { 88 | id: 'JP15', 89 | name: '新潟' 90 | }, 91 | { 92 | id: 'JP19', 93 | name: '山梨' 94 | }, 95 | { 96 | id: 'JP20', 97 | name: '長野' 98 | }, 99 | { 100 | id: 'JP17', 101 | name: '石川' 102 | }, 103 | { 104 | id: 'JP16', 105 | name: '富山' 106 | }, 107 | { 108 | id: 'JP18', 109 | name: '福井' 110 | } 111 | ], 112 | 'chubu': [ 113 | { 114 | id: 'JP23', 115 | name: '愛知' 116 | }, 117 | { 118 | id: 'JP21', 119 | name: '岐阜' 120 | }, 121 | { 122 | id: 'JP22', 123 | name: '静岡' 124 | }, 125 | { 126 | id: 'JP24', 127 | name: '三重' 128 | } 129 | ], 130 | 'kinki': [ 131 | { 132 | id: 'JP27', 133 | name: '大阪' 134 | }, 135 | { 136 | id: 'JP28', 137 | name: '兵庫' 138 | }, 139 | { 140 | id: 'JP26', 141 | name: '京都' 142 | }, 143 | { 144 | id: 'JP25', 145 | name: '滋賀' 146 | }, 147 | { 148 | id: 'JP29', 149 | name: '奈良' 150 | }, 151 | { 152 | id: 'JP30', 153 | name: '和歌山' 154 | } 155 | ], 156 | 'chugoku-shikoku': [ 157 | { 158 | id: 'JP33', 159 | name: '岡山' 160 | }, 161 | { 162 | id: 'JP34', 163 | name: '広島' 164 | }, 165 | { 166 | id: 'JP31', 167 | name: '鳥取' 168 | }, 169 | { 170 | id: 'JP32', 171 | name: '島根' 172 | }, 173 | { 174 | id: 'JP35', 175 | name: '山口' 176 | }, 177 | { 178 | id: 'JP37', 179 | name: '香川' 180 | }, 181 | { 182 | id: 'JP36', 183 | name: '徳島' 184 | }, 185 | { 186 | id: 'JP38', 187 | name: '愛媛' 188 | }, 189 | { 190 | id: 'JP39', 191 | name: '高知' 192 | } 193 | ], 194 | 'kyushu': [ 195 | { 196 | id: 'JP40', 197 | name: '福岡' 198 | }, 199 | { 200 | id: 'JP41', 201 | name: '佐賀' 202 | }, 203 | { 204 | id: 'JP42', 205 | name: '長崎' 206 | }, 207 | { 208 | id: 'JP43', 209 | name: '熊本' 210 | }, 211 | { 212 | id: 'JP44', 213 | name: '大分' 214 | }, 215 | { 216 | id: 'JP45', 217 | name: '宮崎' 218 | }, 219 | { 220 | id: 'JP46', 221 | name: '鹿児島' 222 | }, 223 | { 224 | id: 'JP47', 225 | name: '沖縄' 226 | } 227 | ] 228 | }; 229 | 230 | 231 | /* 232 | from urllib.request import urlopen 233 | import re,json 234 | d = {} 235 | footer = urlopen('https://radiko.jp/apps/js/footer/base.js').read().decode("utf-8") 236 | radio_name=re.findall(r"{ id: ['\"](.*?)['\"], name: ['\"](.*?)['\"] }",footer) 237 | for i in radio_name: 238 | radio = i[0] 239 | name = i[1] 240 | area = re.findall(r'/index/(JP\d*)/',re.findall('channel-detail-info__list.*?',urlopen('http://radiko.jp/index/'+radio).read().decode("utf-8"),re.S)[0],re.S) 241 | d[radio] = {"name":name, "area":area} 242 | print(json.dumps(d,ensure_ascii=False)) 243 | */ 244 | // update details refer to https://radiko.jp/#!/info/ and https://radiko.jp/#!/news_release 245 | // TODO: ability to update runtime. however service worker doesn't support parsing dom/xml. 246 | // Ref: https://radiko.jp/v3/station/region/full.xml 247 | // Special update: "MAJAL": { "name": "MAJ 2025 AUDIO LIVE", "area": ["JP26"] } 248 | export const radioAreaId = { "MAJAL": { "name": "MAJ 2025 AUDIO LIVE", "area": ["JP26"] }, "HBC": { "name": "\uff28\uff22\uff23\u30e9\u30b8\u30aa", "area": ["JP1"] }, "STV": { "name": "\uff33\uff34\uff36\u30e9\u30b8\u30aa", "area": ["JP1"] }, "AIR-G": { "name": "AIR-G'\uff08FM\u5317\u6d77\u9053\uff09", "area": ["JP1"] }, "NORTHWAVE": { "name": "FM NORTH WAVE", "area": ["JP1"] }, "RAB": { "name": "\uff32\uff21\uff22\u9752\u68ee\u653e\u9001", "area": ["JP2"] }, "AFB": { "name": "\u30a8\u30d5\u30a8\u30e0\u9752\u68ee", "area": ["JP2"] }, "IBC": { "name": "IBC\u30e9\u30b8\u30aa", "area": ["JP3"] }, "FMI": { "name": "\u30a8\u30d5\u30a8\u30e0\u5ca9\u624b", "area": ["JP3"] }, "TBC": { "name": "TBC\u30e9\u30b8\u30aa", "area": ["JP4"] }, "DATEFM": { "name": "Date fm\uff08\u30a8\u30d5\u30a8\u30e0\u4ed9\u53f0\uff09", "area": ["JP4"] }, "ABS": { "name": "ABS\u30e9\u30b8\u30aa", "area": ["JP5"] }, "AFM": { "name": "\u30a8\u30d5\u30a8\u30e0\u79cb\u7530", "area": ["JP5"] }, "YBC": { "name": "YBC\u5c71\u5f62\u653e\u9001", "area": ["JP6"] }, "RFM": { "name": "Rhythm Station\u3000\u30a8\u30d5\u30a8\u30e0\u5c71\u5f62", "area": ["JP6"] }, "RFC": { "name": "RFC\u30e9\u30b8\u30aa\u798f\u5cf6", "area": ["JP7"] }, "FMF": { "name": "\u3075\u304f\u3057\u307eFM", "area": ["JP7"] }, "JOIK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u672d\u5e4c\uff09", "area": ["JP1"] }, "JOHK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u4ed9\u53f0\uff09", "area": ["JP2", "JP3", "JP4", "JP5", "JP6", "JP7"] }, "TBS": { "name": "TBS\u30e9\u30b8\u30aa", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "QRR": { "name": "\u6587\u5316\u653e\u9001", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "LFR": { "name": "\u30cb\u30c3\u30dd\u30f3\u653e\u9001", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "INT": { "name": "interfm", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "FMT": { "name": "TOKYO FM", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "FMJ": { "name": "J-WAVE", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "JORF": { "name": "\u30e9\u30b8\u30aa\u65e5\u672c", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "BAYFM78": { "name": "BAYFM78", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "NACK5": { "name": "NACK5", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "YFM": { "name": "\uff26\uff2d\u30e8\u30b3\u30cf\u30de", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "IBS": { "name": "LuckyFM \u8328\u57ce\u653e\u9001", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14"] }, "CRT": { "name": "CRT\u6803\u6728\u653e\u9001", "area": ["JP9"] }, "RADIOBERRY": { "name": "RADIO BERRY", "area": ["JP9"] }, "FMGUNMA": { "name": "FM GUNMA", "area": ["JP10"] }, "JOAK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u6771\u4eac\uff09", "area": ["JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14", "JP15", "JP19", "JP20"] }, "BSN": { "name": "\uff22\uff33\uff2e\u30e9\u30b8\u30aa", "area": ["JP15"] }, "FMNIIGATA": { "name": "FM NIIGATA", "area": ["JP15"] }, "KNB": { "name": "\uff2b\uff2e\uff22\u30e9\u30b8\u30aa", "area": ["JP16"] }, "FMTOYAMA": { "name": "\uff26\uff2d\u3068\u3084\u307e", "area": ["JP16"] }, "MRO": { "name": "MRO\u30e9\u30b8\u30aa", "area": ["JP17"] }, "HELLOFIVE": { "name": "\u30a8\u30d5\u30a8\u30e0\u77f3\u5ddd", "area": ["JP17"] }, "FBC": { "name": "FBC\u30e9\u30b8\u30aa", "area": ["JP18"] }, "FMFUKUI": { "name": "FM\u798f\u4e95", "area": ["JP18"] }, "YBS": { "name": "YBS\u30e9\u30b8\u30aa", "area": ["JP19"] }, "FM-FUJI": { "name": "FM FUJI", "area": ["JP19"] }, "SBC": { "name": "SBC\u30e9\u30b8\u30aa", "area": ["JP20"] }, "FMN": { "name": "\uff26\uff2d\u9577\u91ce", "area": ["JP20"] }, "JOCK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u540d\u53e4\u5c4b\uff09", "area": ["JP16", "JP17", "JP18", "JP21", "JP22", "JP23", "JP24"] }, "CBC": { "name": "CBC\u30e9\u30b8\u30aa", "area": ["JP21", "JP23", "JP24"] }, "TOKAIRADIO": { "name": "TOKAI RADIO", "area": ["JP21", "JP23", "JP24"] }, "GBS": { "name": "\u304e\u3075\u30c1\u30e3\u30f3", "area": ["JP21", "JP23", "JP24"] }, "ZIP-FM": { "name": "ZIP-FM", "area": ["JP21", "JP23", "JP24"] }, "FMAICHI": { "name": "FM AICHI", "area": ["JP21", "JP23", "JP24"] }, "FMGIFU": { "name": "\uff26\uff2d \uff27\uff29\uff26\uff35", "area": ["JP21"] }, "SBS": { "name": "SBS\u30e9\u30b8\u30aa", "area": ["JP22"] }, "K-MIX": { "name": "K-MIX", "area": ["JP22"] }, "FMMIE": { "name": "\u30ec\u30c7\u30a3\u30aa\u30ad\u30e5\u30fc\u30d6 \uff26\uff2d\u4e09\u91cd", "area": ["JP24"] }, "ABC": { "name": "ABC\u30e9\u30b8\u30aa", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "MBS": { "name": "MBS\u30e9\u30b8\u30aa", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "OBC": { "name": "OBC\u30e9\u30b8\u30aa\u5927\u962a", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "CCL": { "name": "FM COCOLO", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "802": { "name": "FM802", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "FMO": { "name": "FM\u5927\u962a", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "CRK": { "name": "\u30e9\u30b8\u30aa\u95a2\u897f", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "KISSFMKOBE": { "name": "Kiss FM KOBE", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "E-RADIO": { "name": "e-radio FM\u6ecb\u8cc0", "area": ["JP25"] }, "KBS": { "name": "KBS\u4eac\u90fd\u30e9\u30b8\u30aa", "area": ["JP25", "JP26", "JP27"] }, "ALPHA-STATION": { "name": "\u03b1-STATION FM KYOTO", "area": ["JP25", "JP26", "JP27", "JP29"] }, "WBS": { "name": "wbs\u548c\u6b4c\u5c71\u653e\u9001", "area": ["JP30"] }, "JOBK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u5927\u962a\uff09", "area": ["JP25", "JP26", "JP27", "JP28", "JP29", "JP30"] }, "BSS": { "name": "BSS\u30e9\u30b8\u30aa", "area": ["JP31", "JP32"] }, "FM-SANIN": { "name": "\u30a8\u30d5\u30a8\u30e0\u5c71\u9670", "area": ["JP31", "JP32"] }, "RSK": { "name": "\uff32\uff33\uff2b\u30e9\u30b8\u30aa", "area": ["JP33"] }, "FM-OKAYAMA": { "name": "\uff26\uff2d\u5ca1\u5c71", "area": ["JP33"] }, "RCC": { "name": "RCC\u30e9\u30b8\u30aa", "area": ["JP34"] }, "HFM": { "name": "\u5e83\u5cf6FM", "area": ["JP34"] }, "KRY": { "name": "\uff2b\uff32\uff39\u5c71\u53e3\u653e\u9001", "area": ["JP35"] }, "FMY": { "name": "\u30a8\u30d5\u30a8\u30e0\u5c71\u53e3", "area": ["JP35"] }, "JRT": { "name": "\uff2a\uff32\uff34\u56db\u56fd\u653e\u9001", "area": ["JP36"] }, "FM807": { "name": "FM\u5fb3\u5cf6", "area": ["JP36"] }, "RNC": { "name": "RNC\u897f\u65e5\u672c\u653e\u9001", "area": ["JP37"] }, "FMKAGAWA": { "name": "FM\u9999\u5ddd", "area": ["JP37"] }, "RNB": { "name": "RNB\u5357\u6d77\u653e\u9001", "area": ["JP38"] }, "JOEU-FM": { "name": "FM\u611b\u5a9b", "area": ["JP38"] }, "RKC": { "name": "RKC\u9ad8\u77e5\u653e\u9001", "area": ["JP39"] }, "HI-SIX": { "name": "\u30a8\u30d5\u30a8\u30e0\u9ad8\u77e5", "area": ["JP39"] }, "JOFK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u5e83\u5cf6\uff09", "area": ["JP31", "JP32", "JP33", "JP34", "JP35"] }, "JOZK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u677e\u5c71\uff09", "area": ["JP36", "JP37", "JP38", "JP39"] }, "RKB": { "name": "RKB\u30e9\u30b8\u30aa", "area": ["JP40", "JP41"] }, "KBC": { "name": "KBC\u30e9\u30b8\u30aa", "area": ["JP40", "JP41"] }, "LOVEFM": { "name": "LOVE FM", "area": ["JP40"] }, "CROSSFM": { "name": "CROSS FM", "area": ["JP40"] }, "FMFUKUOKA": { "name": "FM FUKUOKA", "area": ["JP40"] }, "FMS": { "name": "\u30a8\u30d5\u30a8\u30e0\u4f50\u8cc0", "area": ["JP41"] }, "NBC": { "name": "NBC\u30e9\u30b8\u30aa", "area": ["JP41", "JP42"] }, "FMNAGASAKI": { "name": "FM\u9577\u5d0e", "area": ["JP42"] }, "RKK": { "name": "RKK\u30e9\u30b8\u30aa", "area": ["JP43"] }, "FMK": { "name": "FMK\u30a8\u30d5\u30a8\u30e0\u718a\u672c", "area": ["JP43"] }, "OBS": { "name": "OBS\u30e9\u30b8\u30aa", "area": ["JP44"] }, "FM_OITA": { "name": "\u30a8\u30d5\u30a8\u30e0\u5927\u5206", "area": ["JP44"] }, "MRT": { "name": "\u5bae\u5d0e\u653e\u9001", "area": ["JP45"] }, "JOYFM": { "name": "\u30a8\u30d5\u30a8\u30e0\u5bae\u5d0e", "area": ["JP45"] }, "MBC": { "name": "\uff2d\uff22\uff23\u30e9\u30b8\u30aa", "area": ["JP46"] }, "MYUFM": { "name": "\u03bc\uff26\uff2d", "area": ["JP46"] }, "RBC": { "name": "RBCi\u30e9\u30b8\u30aa", "area": ["JP47"] }, "ROK": { "name": "\u30e9\u30b8\u30aa\u6c96\u7e04", "area": ["JP47"] }, "FM_OKINAWA": { "name": "FM\u6c96\u7e04", "area": ["JP47"] }, "JOLK": { "name": "NHK\u30e9\u30b8\u30aa\u7b2c1\uff08\u798f\u5ca1\uff09", "area": ["JP40", "JP41", "JP42", "JP43", "JP44", "JP45", "JP46", "JP47"] }, "RN1": { "name": "\u30e9\u30b8\u30aaNIKKEI\u7b2c1", "area": ["JP1", "JP2", "JP3", "JP4", "JP5", "JP6", "JP7", "JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14", "JP15", "JP16", "JP17", "JP18", "JP19", "JP20", "JP21", "JP22", "JP23", "JP24", "JP25", "JP26", "JP27", "JP28", "JP29", "JP30", "JP31", "JP32", "JP33", "JP34", "JP35", "JP36", "JP37", "JP38", "JP39", "JP40", "JP41", "JP42", "JP43", "JP44", "JP45", "JP46", "JP47"] }, "RN2": { "name": "\u30e9\u30b8\u30aaNIKKEI\u7b2c2", "area": ["JP1", "JP2", "JP3", "JP4", "JP5", "JP6", "JP7", "JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14", "JP15", "JP16", "JP17", "JP18", "JP19", "JP20", "JP21", "JP22", "JP23", "JP24", "JP25", "JP26", "JP27", "JP28", "JP29", "JP30", "JP31", "JP32", "JP33", "JP34", "JP35", "JP36", "JP37", "JP38", "JP39", "JP40", "JP41", "JP42", "JP43", "JP44", "JP45", "JP46", "JP47"] }, "JOAK-FM": { "name": "NHK-FM\uff08\u6771\u4eac\uff09", "area": ["JP1", "JP2", "JP3", "JP4", "JP5", "JP6", "JP7", "JP8", "JP9", "JP10", "JP11", "JP12", "JP13", "JP14", "JP15", "JP16", "JP17", "JP18", "JP19", "JP20", "JP21", "JP22", "JP23", "JP24", "JP25", "JP26", "JP27", "JP28", "JP29", "JP30", "JP31", "JP32", "JP33", "JP34", "JP35", "JP36", "JP37", "JP38", "JP39", "JP40", "JP41", "JP42", "JP43", "JP44", "JP45", "JP46", "JP47"] } } 249 | // For calcuating RULEID 250 | export const radioIndex = Object.keys(radioAreaId) -------------------------------------------------------------------------------- /modules/recording.js: -------------------------------------------------------------------------------- 1 | import { str2ab, ab2str, parseAAC, getBlobUrl, revokeBlobUrl, initiatorFromExtension } from "./util.js" 2 | 3 | /** 4 | * Return datetime string in Asia/Tokyo timezone! 5 | */ 6 | function timestamp2Filename(t) { 7 | // Intl format to 'YYYY/MM/dd hh:mm:ss' 8 | return Intl.DateTimeFormat("ja", 9 | { 10 | timeZone: "Asia/Tokyo", 11 | year: "numeric", month: "2-digit", day: "2-digit", 12 | hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" 13 | }).format(new Date(t)).replaceAll("/", "").replaceAll(":", "").replaceAll(" ", ""); 14 | } 15 | 16 | export function stream_listener_builder(radioname) { 17 | // Mostly for preparing recording. 18 | // https://stackoverflow.com/questions/66618136/persistent-service-worker-in-chrome-extension/ 19 | // Chrome blog: The service worker terminates after 30 seconds of inactivity. (Receiving an event or calling an extension API resets this timer). 20 | // https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers#keep_a_service_worker_alive_until_a_long-running_operation_is_finished 21 | let heartbeatInterval; 22 | 23 | async function runHeartbeat() { 24 | await chrome.storage.session.set({ "last-heartbeat": Date.now() }); 25 | } 26 | 27 | runHeartbeat().then(() => { 28 | // Then again every 20 seconds. 29 | heartbeatInterval = setInterval(runHeartbeat, 20 * 1000); 30 | }); 31 | 32 | let started = false; 33 | let count = 0; 34 | let start_time; 35 | let end_time 36 | 37 | async function listener(req) { 38 | if (initiatorFromExtension(req)) { return; } 39 | end_time = Date.now(); 40 | if (!started) { 41 | started = true; 42 | start_time = end_time; 43 | } //seems like jump-back problem disappered! 44 | 45 | // Due to radiko aac response's cache-control:max-age=0 , we must download it again. no disk cache available. 46 | let resp = await fetch(req.url); 47 | let data = await resp.arrayBuffer(); 48 | 49 | let audio_string = ab2str(data, parseAAC(data)[0]); //timestamp not used for now. 50 | let storage_set = {}; 51 | storage_set[`${radioname}_${start_time}_${count}`] = audio_string; 52 | count += 1; 53 | 54 | await chrome.storage.local.set(storage_set); 55 | }; 56 | 57 | chrome.runtime.onMessage.addListener(async function stopme(msg, sender, respCallback) { 58 | if (msg["stop-recording"]) { 59 | chrome.runtime.onMessage.removeListener(stopme); 60 | // Note: change from onBeforeSendHeaders to onCompleted 61 | chrome.webRequest.onCompleted.removeListener(listener); 62 | 63 | clearInterval(heartbeatInterval); 64 | await chrome.storage.session.remove("last-heartbeat"); 65 | 66 | if (count == 0) { 67 | await chrome.storage.local.remove("current_recording"); 68 | return; 69 | } 70 | let filename = `${radioname}/${radioname}_${timestamp2Filename(start_time)}_${timestamp2Filename(end_time)}.aac`; 71 | 72 | let keyList = Array.from({ length: count }, (_, idx) => `${radioname}_${start_time}_${idx}`); 73 | let data = await chrome.storage.local.get(keyList); 74 | if (data) { 75 | let audio_buf = keyList.map(function (x) { 76 | return str2ab(data[x]); 77 | }); 78 | 79 | let audiodata = new Blob(audio_buf, { 80 | type: "audio/aac" 81 | }); 82 | let audiourl = await getBlobUrl(audiodata); 83 | chrome.downloads.onChanged.addListener(async function handler(delta) { 84 | if (delta.id == downloadId && delta.state && delta.state.current === "complete") { 85 | chrome.downloads.onChanged.removeListener(handler); 86 | await chrome.storage.local.remove(keyList); 87 | await chrome.storage.session.remove("current_recording"); 88 | 89 | await revokeBlobUrl(audiourl); 90 | } 91 | }); 92 | let downloadId = await chrome.downloads.download({ 93 | url: audiourl, 94 | filename: filename 95 | }); 96 | } 97 | chrome.action.setIcon?.({ 98 | path: 'Circle-icons-radio-blue-48.png' 99 | }); 100 | } 101 | }); 102 | 103 | return listener; 104 | } -------------------------------------------------------------------------------- /modules/rules.js: -------------------------------------------------------------------------------- 1 | import { radioIndex } from "./constants.js" 2 | import { PLAYER_RULE_TEMPLATE, TEMPLATE_RADIO_NAME, RULEID, NHK_PERMISSION, JAPAN_IPS, APP_VERSION_MAP, RECOCHOKU_PERMISSION, TVER_PERMISSION } from "./static.js"; 3 | import { genRandomIp } from "./util.js" 4 | 5 | /** 6 | * The max rule number is radioIndex.length * PLAYER_RULE_TEMPLATE.length 7 | * For now it is 109 * 5 = 540 8 | * TODO what about rule expired? 9 | * TODO what if there's a new radio. Fetch https://radiko.jp/index/RADIONAME then parse DOM in offscreen 10 | */ 11 | export function updateRadioRules(radioname, area_id, token) { 12 | let idx = radioIndex.indexOf(radioname); 13 | let rules = { addRules: [], removeRuleIds: [] }; 14 | let rules_count = PLAYER_RULE_TEMPLATE.length; 15 | 16 | for (let i = 0; i < rules_count; i++) { 17 | let id = RULEID.RADIO_BASE + rules_count * idx + i; 18 | let urlfilter = PLAYER_RULE_TEMPLATE[i].replaceAll(TEMPLATE_RADIO_NAME, radioname) 19 | rules.addRules.push({ 20 | id: id, 21 | action: { 22 | type: "modifyHeaders", 23 | requestHeaders: [ 24 | { 25 | header: "X-Radiko-AuthToken", 26 | operation: "set", 27 | value: token 28 | }, 29 | { 30 | header: "X-Radiko-AreaId", 31 | operation: "set", 32 | value: area_id 33 | }, 34 | ] 35 | }, 36 | condition: { 37 | excludedInitiatorDomains: [chrome.runtime.id], 38 | urlFilter: urlfilter, 39 | } 40 | }); 41 | rules.removeRuleIds.push(id); 42 | } 43 | console.log("updateRadioRules ", radioname, " with ", area_id, " rules:", rules); 44 | chrome.declarativeNetRequest.updateSessionRules(rules); 45 | } 46 | 47 | /** 48 | * Rules for NHK Radio. 49 | * These rules apply on Firefox and Chrome. 50 | */ 51 | export async function setUpNHKRadio(enabled) { 52 | if (enabled === true) { 53 | let matched = await chrome.permissions.contains(NHK_PERMISSION); 54 | if (!matched) { 55 | // reset to disabled 56 | await chrome.storage.local.set({ "nhkradio_bypass": false }); 57 | return; 58 | } 59 | let japan_ip = genRandomIp(JAPAN_IPS); 60 | console.log(`using japan ip ${japan_ip}`); 61 | chrome.declarativeNetRequest.updateSessionRules({ 62 | addRules: [ 63 | { 64 | id: RULEID.NHK_RADIO_LIVE, 65 | action: { 66 | type: "modifyHeaders", 67 | requestHeaders: [ 68 | { 69 | header: "X-Forwarded-For", 70 | operation: "set", 71 | value: japan_ip 72 | } 73 | ] 74 | }, 75 | condition: { 76 | // Only apply on nhk.or.jp site. 77 | initiatorDomains: ["nhk.or.jp"], 78 | urlFilter: "*://*.nhk.jp/hls/*" 79 | } 80 | }, 81 | { 82 | id: RULEID.NHK_RADIO_VOD, 83 | action: { 84 | type: "modifyHeaders", 85 | requestHeaders: [ 86 | { 87 | header: "X-Forwarded-For", 88 | operation: "set", 89 | value: japan_ip 90 | } 91 | ] 92 | }, 93 | condition: { 94 | 95 | initiatorDomains: ["nhk.or.jp"], 96 | urlFilter: "*://vod-stream.nhk.jp/*" 97 | } 98 | }], 99 | removeRuleIds: [RULEID.NHK_RADIO_LIVE, RULEID.NHK_RADIO_VOD] 100 | }); 101 | 102 | } else { 103 | // Should check if exists? 104 | chrome.declarativeNetRequest.updateSessionRules({ 105 | removeRuleIds: [RULEID.NHK_RADIO_LIVE, RULEID.NHK_RADIO_VOD] 106 | }); 107 | } 108 | } 109 | 110 | 111 | /** 112 | * Rules for TVer UI fix. 113 | * These rules apply on Firefox and Chrome. 114 | */ 115 | export async function setUpTVer(enabled) { 116 | if (enabled === true) { 117 | let matched = await chrome.permissions.contains(TVER_PERMISSION); 118 | if (!matched) { 119 | // reset to disabled 120 | await chrome.storage.local.set({ "tver_fix": false }); 121 | return; 122 | } 123 | 124 | // Tver treats Chrome under Linux as AndroidPC and then askes user to use its App. 125 | // NOTE: if using manifest-> "incognito": "split" 126 | // (for opening url in incognito tab instead of normal tab when clicking link in popup meu under incognito window), 127 | // add chrome.extension.inIncognitoContext in script id to avoid duplication. 128 | let info = await chrome.runtime.getPlatformInfo(); 129 | 130 | // Edge for Android can install extensions too. 131 | if (info.os == "linux" || info.os == "android") { 132 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["tver_playable_ua"] }); 133 | if (result && result.length > 0) { 134 | // Already registered 135 | return; 136 | } 137 | 138 | let script = { 139 | id: "tver_playable_ua", 140 | js: ["ui/tver_playable_ua_inspect.js"], 141 | matches: ["https://*.tver.jp/*"], 142 | // Keypoint 1: run before `getEnvType` in Tver. 143 | runAt: "document_start", 144 | // Keypoint 2: don't isolate. 145 | world: "MAIN" 146 | } 147 | // Edge for Android can install extensions too. 148 | if ((info.os == "android")) { 149 | script.css = ["ui/tver_playable_mobile.css"]; 150 | } 151 | 152 | await chrome.scripting.registerContentScripts([script]); 153 | } 154 | 155 | } else { 156 | let info = await chrome.runtime.getPlatformInfo(); 157 | // Edge for Android can install extensions too. 158 | if (info.os == "linux" || info.os == "android") { 159 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["tver_playable_ua"] }); 160 | if (result && result.length > 0) { 161 | // Already registered 162 | await chrome.scripting.unregisterContentScripts({ ids: ["tver_playable_ua"] }); 163 | } 164 | } 165 | } 166 | } 167 | 168 | 169 | /** 170 | * Rules for bypass Radiko area check and auth 171 | */ 172 | // TODO update rules per tab ? but how to delete? or update rules per region and only apply to some tabs? 173 | export function updateAreaRules(area_id, info) { 174 | chrome.declarativeNetRequest.updateSessionRules( 175 | { 176 | addRules: [ 177 | { 178 | // Use prepared response from `response` folder for RULEID.APPAREA, RULEID.AREA and RULEID.AUTH2. 179 | // I'm not sure why it works now. 180 | // As i can recall, previously 307 Internal Redirect is not a success code for `/area` API preflight. 181 | id: RULEID.APPAREA, 182 | action: { type: "redirect", redirect: { extensionPath: "/response/area-" + area_id + ".html" } }, 183 | condition: { urlFilter: "*://*.radiko.jp/apparea/area*" } 184 | }, 185 | { 186 | id: RULEID.AREA, 187 | action: { type: "redirect", redirect: { extensionPath: "/response/area-" + area_id + ".html" } }, 188 | condition: { urlFilter: "*://radiko.jp/area*" } 189 | }, 190 | { 191 | id: RULEID.AUTH1, 192 | action: { 193 | type: "modifyHeaders", 194 | requestHeaders: [ 195 | // Remove 196 | { 197 | header: "Accept-Language", 198 | operation: "remove" 199 | }, 200 | { 201 | header: "Accept", 202 | operation: "remove" 203 | }, 204 | { 205 | header: "Cookie", 206 | operation: "remove" 207 | }, 208 | { 209 | header: "Referer", 210 | operation: "remove" 211 | }, 212 | // Set 213 | { 214 | header: "X-Radiko-User", 215 | operation: "set", 216 | value: info.userid 217 | }, 218 | { 219 | header: "X-Radiko-App-Version", 220 | operation: "set", 221 | value: info.appversion 222 | }, 223 | { 224 | header: "X-Radiko-App", 225 | operation: "set", 226 | value: APP_VERSION_MAP[info.appversion] 227 | }, 228 | { 229 | header: "X-Radiko-Device", 230 | operation: "set", 231 | value: info.device 232 | }, 233 | { 234 | header: "User-Agent", 235 | operation: "set", 236 | value: info.useragent 237 | } 238 | ], 239 | responseHeaders: [ 240 | { 241 | // to avoid too big offset causing radiko's js error 242 | // Will this affect the calculation in onHeadersReceived? -> no 243 | // Note: yes on Firefox , so we don't use area rules in firefox. 244 | header: "x-radiko-keyoffset", 245 | operation: "set", 246 | value: "0" 247 | } 248 | ] 249 | }, 250 | condition: { 251 | // Exclude the req from extension 252 | excludedInitiatorDomains: [chrome.runtime.id], 253 | urlFilter: "*://radiko.jp/v2/api/auth1*" 254 | } 255 | }, 256 | { 257 | id: RULEID.AUTH2, 258 | action: { type: "redirect", redirect: { extensionPath: "/response/auth2-" + area_id + ".html" } }, 259 | condition: { 260 | // Exclude the req from extension 261 | excludedInitiatorDomains: [chrome.runtime.id], 262 | urlFilter: "*://radiko.jp/v2/api/auth2*" 263 | } 264 | }, 265 | { 266 | id: RULEID.AUTH_FETCH, 267 | action: { 268 | type: "modifyHeaders", 269 | requestHeaders: [ 270 | // Remove 271 | { 272 | header: "Accept-Language", 273 | operation: "remove" 274 | }, 275 | { 276 | header: "Accept", 277 | operation: "remove" 278 | }, 279 | { 280 | header: "User-Agent", 281 | operation: "set", 282 | value: info.useragent 283 | } 284 | ] 285 | }, 286 | condition: { 287 | // Only for extension's Fetch to remove unnecessary headers. 288 | initiatorDomains: [chrome.runtime.id], 289 | urlFilter: "*://radiko.jp/v2/api/auth*" 290 | } 291 | }], 292 | removeRuleIds: [RULEID.APPAREA, RULEID.AREA, RULEID.AUTH1, RULEID.AUTH2, RULEID.AUTH_FETCH] 293 | } 294 | ) 295 | } 296 | 297 | 298 | /** 299 | * script for rajiko on mobile 300 | * Edge for Android can install extensions too. 301 | */ 302 | export async function setUpMobileRadiko() { 303 | let platform = await chrome.runtime.getPlatformInfo(); 304 | if (platform.os == "android") { 305 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["radiko_mobile"] }); 306 | if (result && result.length > 0) { 307 | // Already registered 308 | return; 309 | } 310 | await chrome.scripting.registerContentScripts([ 311 | { 312 | id: "radiko_mobile", 313 | js: ["ui/mobile_start.js"], 314 | css: ["ui/mobile.css"], 315 | matches: ["https://*.radiko.jp/*"], 316 | runAt: "document_start", 317 | // Keypoint 2: don't isolate. 318 | world: "MAIN" 319 | } 320 | ]); 321 | } 322 | } 323 | 324 | export async function setUpRecochokuUserAgent(enabled) { 325 | if (enabled === true) { 326 | let matched = await chrome.permissions.contains(RECOCHOKU_PERMISSION); 327 | if (!matched) { 328 | // reset to disabled 329 | await chrome.storage.local.set({ "recochoku_ua": false }); 330 | return; 331 | } 332 | 333 | chrome.declarativeNetRequest.updateSessionRules({ 334 | addRules: [ 335 | { 336 | id: RULEID.RECOCHOKU_USERAGENT, 337 | action: { 338 | type: "modifyHeaders", 339 | requestHeaders: [ 340 | { 341 | header: "User-Agent", 342 | operation: "set", 343 | // or "wget" 344 | value: "curl" 345 | } 346 | ] 347 | }, 348 | condition: { 349 | // main_frame. 350 | // css/js is also blocked. 351 | // and https://recochoku.jp/trial/music 352 | resourceTypes: ["main_frame", "stylesheet", "script", "xmlhttprequest", "image"], 353 | requestDomains: ["recochoku.jp"], 354 | } 355 | } 356 | ], 357 | removeRuleIds: [RULEID.RECOCHOKU_USERAGENT] 358 | }); 359 | 360 | 361 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["recochoku_header_caution"] }); 362 | if (result && result.length > 0) { 363 | // Already registered 364 | return; 365 | } 366 | 367 | await chrome.scripting.registerContentScripts([{ 368 | id: "recochoku_header_caution", 369 | matches: ["https://*.recochoku.jp/*"], 370 | css: ["ui/recochoku_header_caution.css"], 371 | runAt: "document_start", 372 | }]); 373 | 374 | } else { 375 | chrome.declarativeNetRequest.updateSessionRules({ 376 | removeRuleIds: [RULEID.RECOCHOKU_USERAGENT] 377 | }); 378 | 379 | let result = await chrome.scripting.getRegisteredContentScripts({ ids: ["recochoku_header_caution"] }); 380 | if (result && result.length > 0) { 381 | // Already registered 382 | await chrome.scripting.unregisterContentScripts({ ids: ["recochoku_header_caution"] }); 383 | } 384 | } 385 | } 386 | -------------------------------------------------------------------------------- /modules/timeshift.js: -------------------------------------------------------------------------------- 1 | 2 | import { parseAAC, ab2str, str2ab, getBlobUrl, revokeBlobUrl } from "./util.js" 3 | import { retrieve_token } from "./auth.js" 4 | 5 | 6 | 7 | // https://stackoverflow.com/a/73517935 8 | async function worker(arr, func, limit = 5) { 9 | let results = []; 10 | let workers = []; 11 | let current = Math.min(arr.length, limit); 12 | async function process(i) { 13 | if (i < arr.length) { 14 | results[i] = await Promise.resolve(func(arr[i], i)); 15 | await process(current++); 16 | } 17 | } 18 | for (let i = 0; i < current; i++) { 19 | workers.push(process(i)); 20 | } 21 | await Promise.all(workers); 22 | return results; 23 | } 24 | 25 | 26 | async function cleanuptask(link) { 27 | let { timeshift_list: list } = await chrome.storage.local.get({ "timeshift_list": [] }); 28 | list = list.filter(function (l) { 29 | return l !== link; 30 | }); 31 | await chrome.storage.local.set({ 32 | "timeshift_list": list, 33 | }); 34 | chrome.action.setBadgeText?.({ text: list.length > 0 ? list.length.toString() : "" }); 35 | } 36 | 37 | function toDate(programDate) { 38 | return new Date(`${programDate.slice(0, 4)}-${programDate.slice(4, 6)}-${programDate.slice(6, 8)}T${programDate.slice(8, 10)}:${programDate.slice(10, 12)}:${programDate.slice(12, 14)}.000`); 39 | } 40 | 41 | // Not used currently. 42 | function isTimefreePlus(ft) { 43 | let programDate = toDate(ft); 44 | programDate.setHours(programDate.getHours() - 5); 45 | programDate.setHours(0, 0, 0, 0); 46 | 47 | const options = { 48 | year: "numeric", 49 | month: "2-digit", 50 | day: "2-digit", 51 | timeZone: "Asia/Tokyo", 52 | hour12: false, 53 | }; 54 | let dateTimeFormat = new Intl.DateTimeFormat('ja-jp', options); 55 | let parts = dateTimeFormat.formatToParts(); 56 | let year, month, day; 57 | parts.map(part => { 58 | switch (part.type) { 59 | case "day": 60 | day = part.value; 61 | break; 62 | case "month": 63 | month = part.value; 64 | break; 65 | case "year": 66 | year = part.value; 67 | break; 68 | } 69 | }); 70 | // Japan date , but treat as local datetime object. 71 | let today = new Date(`${year}-${month}-${day}T00:00:00.000`); 72 | today.setHours(today.getHours() - 5); 73 | today.setHours(0, 0, 0, 0); 74 | let diff = (today - programDate) / 1000.0 / 60 / 60 / 24; 75 | return diff > 7; 76 | } 77 | 78 | async function playlist_create_url(station_id) { 79 | let resp = await fetch(`https://radiko.jp/v3/station/stream/pc_html5/${station_id}.xml`); 80 | let data = await resp.text(); 81 | 82 | // Sadly serice worker doesn't support parse xml. 83 | 84 | // attribute: timefree=1, because this is a timeshift 85 | // areafree=0, we use stations's local area id. 86 | const regex = /((areafree="0".*?timefree="1")|(timefree="1".*?areafree="0")).*\n.*(.*?)<\/playlist_create_url>/gm; 87 | try { 88 | // TODO well not safe.... 89 | let match = regex.exec(data); 90 | return match[4]; 91 | } catch { 92 | // fallback 93 | return "https://tf-f-rpaa-radiko.smartstream.ne.jp/tf/playlist.m3u8" 94 | } 95 | } 96 | 97 | /** 98 | * 99 | * @param {string} dt YYYYMMDDHHmmss style string 100 | * @param {int} l seek seconds 101 | * 102 | * returns an array of [seeked date, YYYYMMDDHHmmss style string] 103 | */ 104 | function seek(dt, l) { 105 | dt.setSeconds(dt.getSeconds() + l); 106 | return [dt, `${dt.getFullYear()}${(dt.getMonth() + 1).toString().padStart(2, '0')}${dt.getDate().toString().padStart(2, '0')}${dt.getHours().toString().padStart(2, '0')}${dt.getMinutes().toString().padStart(2, '0')}${dt.getSeconds().toString().padStart(2, '0')}`] 107 | } 108 | 109 | /** 110 | * It looks like only https://radiko.jp/v2/api/ts/playlist.m3u8 API, returns full aac list 111 | * intermediate url: https://radiko.jp/v2/api/ts/chunklist/xxxxx.m3u8 112 | * Other endpoint returns `l` seconds. 113 | * 114 | * (Since this endpoint is for aSmartPhon7a) 115 | * radiko.jp/v2/api/ts/playlist.m3u8 API show expired seems that this API do not support older than 7days. 116 | * but other playlist_create_url show forbidden 117 | * 118 | * It is safe to treat non-tf30 program as tf30 program (maybe no, see below) , but reverse not. 119 | * so `isTimefreePlus` should be more strict 120 | * 121 | * Some program at girigiri 7 days , https://radiko.jp/v2/api/ts/playlist.m3u8 still work, but new api not work? 122 | */ 123 | export async function downloadtimeShift(link, default_area_id, tf30) { 124 | let searchParams = (new URL(link)).searchParams; 125 | let radioname = searchParams.get("station_id"); 126 | let from = searchParams.get("ft"); 127 | let to = searchParams.get("to"); 128 | 129 | let filename = radioname + '_' + from + '_' + to + '.aac'; 130 | console.log(`timeshift file ${filename}`); 131 | let [token, area_id] = await retrieve_token(radioname, default_area_id); 132 | 133 | let links = []; 134 | 135 | /** 136 | * Get every 5s aac in a seek period(300s) 137 | */ 138 | // if (isTimefreePlus(from)) { 139 | // Use the value from Radiko site. 140 | if (tf30) { 141 | // from yt-dlp-rajiko: the max accepted seek value. 142 | const FIXED_SEEK = 300; 143 | // whatever token has tf30 or not , just make a try 144 | let url = new URL(await playlist_create_url(radioname)); 145 | let param = url.searchParams; 146 | param.set("lsid", (() => { 147 | let hex = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; 148 | let s = ''; 149 | for (let i = 0; i < 32; i++) { 150 | s += hex[(Math.floor(Math.random() * hex.length)) >> 0]; 151 | } 152 | return s; 153 | })()); 154 | param.set("station_id", radioname); 155 | param.set("l", FIXED_SEEK); 156 | param.set("start_at", from); 157 | param.set("end_at", to); 158 | // b for station in area , c for not ,see `connectionType` 159 | param.set("type", "b"); 160 | // These are not necessary (but we should look the same) 161 | param.set("ft", from); 162 | param.set("to", to); 163 | 164 | // new style method like recording 165 | for ( 166 | // Init 167 | let end_date = toDate(to), seek_str = from, seek_date = toDate(from); 168 | // Condtion 169 | seek_date < end_date; 170 | // Increment 171 | [seek_date, seek_str] = seek(seek_date, FIXED_SEEK) 172 | ) { 173 | param.set("seek", seek_str); 174 | 175 | let response = await fetch(url.toString(), { 176 | headers: { 177 | 'X-Radiko-AreaId': area_id, 178 | 'X-Radiko-AuthToken': token 179 | } 180 | }); 181 | 182 | let resp = await response.text(); 183 | if (!response.ok || response.status == 403 || resp == "expired") { 184 | await cleanuptask(link); 185 | return; 186 | } 187 | let detailLink = resp.split('\n').filter(function (d) { 188 | return d[0] != '#' && d.trim() != ''; 189 | })[0]; 190 | 191 | let response2 = await fetch(detailLink); 192 | 193 | let resp2 = await response2.text(); 194 | let partLinks = resp2.split('\n').filter(function (d) { 195 | return d[0] != '#' && d.trim() != ''; 196 | }); 197 | links.push(...partLinks); 198 | } 199 | 200 | } else { 201 | let response = await fetch(link, { 202 | headers: { 203 | 'X-Radiko-AreaId': area_id, 204 | 'X-Radiko-AuthToken': token 205 | } 206 | }); 207 | 208 | let resp = await response.text(); 209 | 210 | if (!response.ok || response.status == 403 || resp == "expired") { 211 | await cleanuptask(link); 212 | return; 213 | } 214 | let detailLink = resp.split('\n').filter(function (d) { 215 | return d[0] != '#' && d.trim() != ''; 216 | })[0]; 217 | 218 | let response2 = await fetch(detailLink); 219 | 220 | let resp2 = await response2.text(); 221 | links = resp2.split('\n').filter(function (d) { 222 | return d[0] != '#' && d.trim() != ''; 223 | }); 224 | 225 | } 226 | 227 | // Map limit solutions: 228 | // https://stackoverflow.com/a/60622224 interesting solution but no error handle in the middle and not await-able 229 | 230 | let keyList = null; // links.map((v, idx, _) => filename + '_' + idx); 231 | 232 | try { 233 | keyList = await worker(links, async (url, idx) => { 234 | let resp = await fetch(url, { credentials: "omit" }); 235 | let data = await resp.arrayBuffer(); 236 | 237 | let storekey = filename + '_' + idx; 238 | let audio_string = ab2str(data, parseAAC(data)[0]); //timestamp not used for now. 239 | let storage_set = {}; 240 | storage_set[storekey] = audio_string; 241 | 242 | await chrome.storage.local.set(storage_set); 243 | return storekey; 244 | }, 6); 245 | 246 | let data = await chrome.storage.local.get(keyList); 247 | let audio_buf = keyList.map(function (x) { 248 | return str2ab(data[x]); 249 | }) 250 | let audiodata = new Blob(audio_buf, { 251 | type: "audio/aac" 252 | }); 253 | 254 | // Blame on MV3 and service worker. 255 | // Must create blob url in offscreen docuemnt. 256 | let audiourl = await getBlobUrl(audiodata); 257 | chrome.downloads.onChanged.addListener(async function handler(delta) { 258 | if (delta.id == downloadId && delta.state && delta.state.current === "complete") { 259 | chrome.downloads.onChanged.removeListener(handler); 260 | await chrome.storage.local.remove(keyList); 261 | 262 | await revokeBlobUrl(audiourl); 263 | } 264 | }); 265 | let downloadId = await chrome.downloads.download({ 266 | url: audiourl, 267 | filename: filename 268 | }); 269 | 270 | } catch ({ name, message }) { 271 | console.warn(name, message); 272 | await chrome.storage.local.remove(keyList.filter(function (val) { return !val })); 273 | } finally { 274 | await cleanuptask(link); 275 | } 276 | } -------------------------------------------------------------------------------- /modules/util.js: -------------------------------------------------------------------------------- 1 | import { areaList } from "./constants.js" 2 | import { coordinates, VERSION_MAP, MODEL_LIST, APP_VERSION_MAP } from "./static.js" 3 | 4 | export function genRandomInfo() { 5 | let version = Object.keys(VERSION_MAP)[(Math.floor(Math.random() * Object.keys(VERSION_MAP).length)) >> 0]; 6 | let sdk = VERSION_MAP[version].sdk; 7 | let build = VERSION_MAP[version].builds[(Math.floor(Math.random() * VERSION_MAP[version].builds.length)) >> 0]; 8 | //Dalvik/2.1.0 (Linux; U; Android %VERSION%; %MODEL%/%BUILD%) 9 | //X-Radiko-Device: %SDKVERSION%.%NORMALIZEMODEL% 10 | let model = MODEL_LIST[(Math.floor(Math.random() * MODEL_LIST.length)) >> 0]; 11 | let device = sdk + "." + model; 12 | let useragent = "Dalvik/2.1.0 (Linux; U; Android " + version + "; " + model + "/" + build + ")"; 13 | 14 | let appversion = Object.keys(APP_VERSION_MAP)[(Math.floor(Math.random() * Object.keys(APP_VERSION_MAP).length)) >> 0]; 15 | 16 | let userid = function () { 17 | let hex = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']; 18 | let s = ''; 19 | for (let i = 0; i < 32; i++) { 20 | s += hex[(Math.floor(Math.random() * hex.length)) >> 0]; 21 | } 22 | return s; 23 | }(); 24 | 25 | return { 26 | appversion: appversion, 27 | userid: userid, 28 | useragent: useragent, 29 | device: device 30 | } 31 | } 32 | 33 | export function genGPS(area_id) { 34 | let [lat, long] = coordinates[areaList[parseInt(area_id.substr(2)) - 1]]; 35 | // +/- 0 ~ 0.025 --> 0 ~ 1.5' -> +/- 0 ~ 2.77/2.13km 36 | lat = lat + Math.random() / 40.0 * (Math.random() > 0.5 ? 1 : -1); 37 | long = long + Math.random() / 40.0 * (Math.random() > 0.5 ? 1 : -1); 38 | return lat.toFixed(6) + "," + long.toFixed(6) + ",gps"; 39 | } 40 | 41 | function fromLong(ipl) { 42 | return ((ipl >>> 24) + '.' + (ipl >> 16 & 255) + '.' + (ipl >> 8 & 255) + '.' + (ipl & 255)); 43 | }; 44 | 45 | function toLong(ip) { 46 | var ipl = 0; 47 | ip.split('.').forEach(octet => { 48 | ipl <<= 8; 49 | ipl += parseInt(octet); 50 | }); 51 | return (ipl >>> 0); 52 | }; 53 | 54 | 55 | export function genRandomIp(cidr_array) { 56 | let selectedCIDR = cidr_array[(Math.floor(Math.random() * cidr_array.length)) >> 0]; 57 | let tmp = selectedCIDR.split('/'); 58 | let addr = tmp[0]; 59 | let maskLength = parseInt(tmp[1], 10); 60 | let maskLong = (0xffffffff << (32 - maskLength)) >>> 0 61 | let numberOfAddresses = Math.pow(2, 32 - maskLength); 62 | let first = (toLong(addr) & maskLong) >>> 0; 63 | let pick = (Math.random() * numberOfAddresses) >>> 0; 64 | return fromLong(first + pick); 65 | } 66 | 67 | export function initiatorFromExtension(r) { 68 | let ifInit = r.initiator && r.initiator.toLowerCase().indexOf("chrome-extension://" + chrome.runtime.id) != -1; //initiator since chrome 63 69 | let ifTabId = r.tabId && r.tabId == -1; //mean this resp is not from tab 70 | if (ifInit || ifTabId) { 71 | return true; 72 | } 73 | return false; 74 | } 75 | 76 | // aac parse stuff 77 | // parse hls packed audio (id3 tags and data) 78 | // return id3 tag size and timestamp of this packed audio if success else return [0,0] 79 | export function parseAAC(data) { //data -> Arraybuffer 80 | let processing = new DataView(data); 81 | if (processing.getUint8(0) != 73 || processing.getUint8(1) != 68 || processing.getUint8(2) != 51) { // ID3 82 | return [0, 0]; 83 | } 84 | let id3payloadsize = processing.getUint32(6, false); //bigendian 85 | let id3tagsize = 10 + id3payloadsize; //header size + payloadsize 86 | 87 | let timestampLow = processing.getUint32(id3tagsize - 4, false); // 32bit 88 | let timestampHigh = processing.getUint32(id3tagsize - 8, false); //need only the last bit 89 | let timestamp = timestampLow + 0xffffffff * timestampHigh; 90 | return [id3tagsize, timestamp]; 91 | } 92 | 93 | export function isFirefox() { 94 | return globalThis.browser && globalThis.browser.runtime && globalThis.browser.runtime.id; 95 | } 96 | // TODO split to Firefox and Chrome version 97 | export let ab2str; 98 | export let str2ab; 99 | 100 | if (!isFirefox()) { //see webextension-polyfill 101 | // for Chrome 102 | //see chroumium bug: https://bugs.chromium.org/p/chromium/issues/detail?id=831062 103 | ab2str = function ab2str(buf, offset) { 104 | return String.fromCharCode.apply(null, new Uint8Array(buf, offset)); //uint16 will raise must multiple of 2 error 105 | } 106 | str2ab = function str2ab(str) { 107 | let buf = new ArrayBuffer(str.length); // Uint16 -> 2 bytes for each char *2 108 | let bufView = new Uint8Array(buf); // U16 109 | 110 | for (let i = 0, strLen = str.length; i < strLen; i++) { 111 | bufView[i] = str.charCodeAt(i); 112 | } 113 | return buf; 114 | } 115 | } else { 116 | //for Firefox save memory via Uint16Array , use pkcs5 for padding. 117 | ab2str = function ab2str(buf, offset) { 118 | let len = buf.byteLength - offset; 119 | let padding = len % 8 == 0 ? 8 : len % 8; 120 | let crafted = new Uint8Array(len + padding); 121 | let p = new Array(padding); 122 | for (let i = 0; i < padding; i++) { 123 | p[i] = padding; 124 | } 125 | crafted.set(new Uint8Array(buf, offset), 0); 126 | crafted.set(p, len); 127 | return String.fromCharCode.apply(null, new Uint16Array(crafted.buffer)); 128 | } 129 | str2ab = function str2ab(str) { 130 | let buf = new ArrayBuffer(str.length * 2); 131 | let bufView = new Uint16Array(buf); 132 | let paddingView = new Uint8Array(buf); 133 | for (let i = 0, strLen = str.length; i < strLen; i++) { 134 | bufView[i] = str.charCodeAt(i); 135 | } 136 | let padding = paddingView[str.length * 2 - 1]; 137 | return buf.slice(0, str.length * 2 - padding); 138 | } 139 | 140 | //polyfill 141 | //see https://github.com/kiefferbp/webext-getBytesInUse-polyfill/blob/master/index.js 142 | //Poor performance!! 143 | chrome.storage.local.getBytesInUse = function (keys, callback) { 144 | let size = 0; 145 | if (typeof keys === 'string') { 146 | keys = [keys]; 147 | } 148 | chrome.storage.local.get(keys, function (results) { 149 | let lastError = chrome.runtime.lastError; 150 | if (lastError) { 151 | callback(-1); 152 | return; 153 | } 154 | Object.keys(results).forEach(function (key) { 155 | size += (key + JSON.stringify(results[key])).length; 156 | }); 157 | callback(size); 158 | }); 159 | } 160 | } 161 | 162 | 163 | // https://stackoverflow.com/questions/75527465/download-a-webpage-completely-chrome-extension-manifest-v3/75539867#75539867 164 | export async function getBlobUrl(blob) { 165 | if (!isFirefox()) { 166 | const url = chrome.runtime.getURL('pages/offscreen.html'); 167 | try { 168 | await chrome.offscreen.createDocument({ 169 | url, 170 | reasons: ['BLOBS'], 171 | justification: 'MV3 requirement', 172 | }); 173 | } catch (err) { 174 | if (!err.message.startsWith('Only a single offscreen')) throw err; 175 | } 176 | const client = (await clients.matchAll({ includeUncontrolled: true })) 177 | .find(c => c.url === url); 178 | const mc = new MessageChannel(); 179 | client.postMessage(blob, [mc.port2]); 180 | const res = await new Promise(cb => (mc.port1.onmessage = cb)); 181 | return res.data; 182 | } else { 183 | return URL.createObjectURL(blob); 184 | } 185 | } 186 | 187 | export async function revokeBlobUrl(blob) { 188 | if (!isFirefox()) { 189 | // TODO should we pass message to offscreen to free blob via URL.revokeObjectURL(blob); 190 | // or just closing the document is enough? 191 | await chrome.offscreen.closeDocument(); 192 | } else { 193 | URL.revokeObjectURL(blob); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /package.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | import zipfile 4 | 5 | ICON_LIST = [ 6 | "Circle-icons-radio-blue-48.png", 7 | "Circle-icons-radio.png", 8 | "Circle-icons-radio-red-48.png", 9 | "Circle-icons-radio-red.svg", 10 | "Circle-icons-radio.svg", 11 | ] 12 | FILE_LIST = [ 13 | "manifest.json", 14 | "README.md", 15 | "LICENSE", 16 | "background.js", 17 | "_locales", 18 | "modules", 19 | "pages", 20 | "response", 21 | "ui", 22 | ] + ICON_LIST 23 | 24 | if __name__ == "__main__": 25 | if len(sys.argv) < 2: 26 | print("python manifest.py ") 27 | sys.exit(-1) 28 | 29 | with open("manifest.jsonc.tmpl", "r") as m: 30 | MANIFEST_JSON = m.read() 31 | ## Support comments 32 | MANIFEST_JSON = "\n".join( 33 | l if not l.lstrip().startswith("//") else "" for l in MANIFEST_JSON.split("\n") 34 | ) 35 | 36 | manifest = json.loads(MANIFEST_JSON) 37 | ## Do remove instead of add 38 | if sys.argv[1] == "firefox": 39 | platform = "firefox" 40 | del manifest["incognito"] 41 | del manifest["background"]["service_worker"] 42 | manifest["permissions"] = list( 43 | filter(lambda x: x != "offscreen", manifest["permissions"]) 44 | ) 45 | elif sys.argv[1] == "chrome": 46 | platform = "chrome" 47 | del manifest["background"]["scripts"] 48 | manifest["permissions"] = list( 49 | filter( 50 | lambda x: x not in ["webRequestBlocking", "webRequestFilterResponse"], 51 | manifest["permissions"], 52 | ) 53 | ) 54 | else: 55 | print("python manifest.py ") 56 | sys.exit(-1) 57 | 58 | ## dump if debug? 59 | with open("manifest.json", "w") as f: 60 | json.dump(manifest, f, indent=2, ensure_ascii=False) 61 | 62 | version = manifest["version"] 63 | name = manifest["name"] 64 | 65 | zipfile.main(["-c", f"""{name}-{version}-{platform}.zip""", *FILE_LIST]) 66 | -------------------------------------------------------------------------------- /pages/offscreen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /pages/offscreen.js: -------------------------------------------------------------------------------- 1 | navigator.serviceWorker.onmessage = e => { 2 | e.ports[0].postMessage(URL.createObjectURL(e.data)); 3 | }; -------------------------------------------------------------------------------- /pages/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Options 8 | 9 | 29 | 30 | 31 | 32 | 33 |
34 |
    35 |
  • 36 | 40 |
  • 41 |
  • 42 | 46 |
  • 47 |
  • 48 | 52 |
  • 53 |
54 | 55 | 56 | 57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /pages/options.js: -------------------------------------------------------------------------------- 1 | import { NHK_PERMISSION, RECOCHOKU_PERMISSION, TVER_PERMISSION } from "../modules/static.js"; 2 | 3 | document.addEventListener("DOMContentLoaded", async function () { 4 | let { 5 | nhkradio_bypass: nhkradio_bypass, 6 | recochoku_ua: recochoku_ua, 7 | tver_fix: tver_fix 8 | } = await chrome.storage.local.get({ "nhkradio_bypass": false, "recochoku_ua": false, "tver_fix": false }); 9 | 10 | let nhkradio = document.getElementById("nhkradio"); 11 | nhkradio.checked = nhkradio_bypass === true; 12 | 13 | nhkradio.onclick = async (data) => { 14 | if (nhkradio.checked) { 15 | try { 16 | let permitted = await chrome.permissions.request(NHK_PERMISSION); 17 | if (!permitted) { 18 | nhkradio.checked = false; 19 | return; 20 | } 21 | } catch { 22 | nhkradio.checked = false; 23 | return; 24 | } 25 | } 26 | await chrome.storage.local.set({ "nhkradio_bypass": nhkradio.checked }); 27 | await chrome.runtime.sendMessage({ "update-nhkradio": nhkradio.checked ? "yes" : "no" }); 28 | }; 29 | 30 | let recochoku = document.getElementById("recochoku"); 31 | 32 | recochoku.checked = recochoku_ua === true; 33 | 34 | recochoku.onclick = async (data) => { 35 | if (recochoku.checked) { 36 | try { 37 | let permitted = await chrome.permissions.request(RECOCHOKU_PERMISSION); 38 | if (!permitted) { 39 | recochoku.checked = false; 40 | return; 41 | } 42 | } catch { 43 | recochoku.checked = false; 44 | return; 45 | } 46 | } 47 | await chrome.storage.local.set({ "recochoku_ua": recochoku.checked }); 48 | await chrome.runtime.sendMessage({ "update-recochoku": recochoku.checked ? "yes" : "no" }); 49 | } 50 | 51 | let tver = document.getElementById("tver"); 52 | 53 | tver.checked = tver_fix === true; 54 | 55 | tver.onclick = async (data) => { 56 | if (tver.checked) { 57 | try { 58 | let permitted = await chrome.permissions.request(TVER_PERMISSION); 59 | if (!permitted) { 60 | tver.checked = false; 61 | return; 62 | } 63 | } catch { 64 | tver.checked = false; 65 | return; 66 | } 67 | } 68 | await chrome.storage.local.set({ "tver_fix": tver.checked }); 69 | await chrome.runtime.sendMessage({ "update-tver": tver.checked ? "yes" : "no" }); 70 | } 71 | }); 72 | -------------------------------------------------------------------------------- /pages/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
64 | 65 | 66 |
67 | 68 | 69 |
70 | 71 |
72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /pages/popup.js: -------------------------------------------------------------------------------- 1 | import { regions, areaListParRegion, radioAreaId } from "../modules/constants.js"; 2 | import { isFirefox } from "../modules/util.js"; 3 | 4 | function loadArea(regionIdx) { 5 | let area_select = document.getElementById("rajiko-area"); 6 | while (area_select.lastChild) { 7 | area_select.removeChild(area_select.lastChild); 8 | } 9 | let id = regions[regionIdx].id; 10 | let areas = areaListParRegion[id]; 11 | for (let i = 0; i < areas.length; i++) { 12 | let tmp = document.createElement("option"); 13 | tmp.setAttribute("id", areas[i].id); 14 | tmp.innerText = areas[i].name; 15 | area_select.appendChild(tmp); 16 | } 17 | } 18 | 19 | // Looks not necessary for `tmpUrl`, timeshift only change `url` 20 | function stripM3u8link(link) { 21 | let m3u8link = new URL(link); 22 | m3u8link.searchParams.delete("seek"); 23 | return m3u8link.toString(); 24 | } 25 | 26 | 27 | document.addEventListener("DOMContentLoaded", async function () { 28 | //define event 29 | let region_select = document.getElementById("rajiko-region"); 30 | region_select.onchange = function (data) { 31 | loadArea(this.selectedIndex); 32 | }; 33 | 34 | let area_select = document.getElementById("rajiko-area"); 35 | 36 | let confirm_button = document.getElementById("rajiko-confirm"); 37 | confirm_button.innerText = chrome.i18n.getMessage("confirm_button"); 38 | 39 | let { selected_areaid: area_id } = await chrome.storage.local.get("selected_areaid"); 40 | 41 | confirm_button.onclick = async function (data) { 42 | let area = document.getElementById("rajiko-area"); 43 | if (area_id && area_id == area) { 44 | //same area; 45 | window.close(); 46 | } else { 47 | // Wake up service worker? Is this a bug? 48 | // await chrome.runtime.sendMessage({}); 49 | // Send command 50 | await chrome.runtime.sendMessage({ "update-area": area.selectedOptions[0].id }); 51 | chrome.tabs.query({ active: true, currentWindow: true }, function (arrayOfTabs) { 52 | if (!arrayOfTabs || arrayOfTabs.length < 1) { return } 53 | let tab = arrayOfTabs[0]; 54 | if (/radiko\.jp/.test(tab.url)) { 55 | chrome.tabs.reload(tab.id); 56 | } 57 | // in Windows ,chrome display extension's alert in it's own popup window however size is too small for alert window to close. 58 | window.close(); 59 | }); 60 | } 61 | }; 62 | 63 | if (!area_id) { area_id = "JP13"; } 64 | 65 | for (let i = 0; i < regions.length; i++) { 66 | let tmp = document.createElement("option"); 67 | tmp.setAttribute("id", regions[i].id); 68 | tmp.innerText = regions[i].name; 69 | region_select.appendChild(tmp); 70 | } 71 | 72 | Object.keys(areaListParRegion).forEach(function (key, keyindex) { 73 | for (let i = 0; i < areaListParRegion[key].length; i++) { 74 | if (areaListParRegion[key][i].id == area_id) { 75 | region_select.selectedIndex = keyindex 76 | loadArea(keyindex); 77 | area_select.selectedIndex = i; 78 | } 79 | } 80 | }); 81 | 82 | let download_button = document.getElementById("rajiko-download"); 83 | let record_button = document.getElementById("rajiko-record"); 84 | 85 | let { timeshift_list: timeshift_list } = await chrome.storage.local.get({ "timeshift_list": [] }); 86 | let { current_recording: current_recording } = await chrome.storage.session.get("current_recording"); 87 | 88 | if (current_recording) { 89 | record_button.hidden = false; 90 | record_button.innerText = chrome.i18n.getMessage("record_button_to_stop"); 91 | record_button.onclick = async function (data) { 92 | await chrome.runtime.sendMessage({ "stop-recording": true }); 93 | window.close(); 94 | } 95 | } 96 | 97 | let tabs = await chrome.tabs.query({ 98 | active: true, 99 | currentWindow: true, 100 | url: "*://radiko.jp/*" 101 | }); 102 | if (tabs && tabs.length >= 1 && tabs[0]) { 103 | let [tab] = tabs; 104 | let inject_results = await chrome.scripting.executeScript({ 105 | target: { tabId: tab.id }, 106 | func: () => { 107 | let result = { 108 | hasTimeFreePlusAuthority: $.Radiko.login_status.timefreeplus, 109 | tmpUrl: document.getElementById('tmpUrl') && document.getElementById('tmpUrl').value, 110 | url: document.getElementById('url') && document.getElementById('url').value 111 | } 112 | try { 113 | if (result.tmpUrl && result.tmpUrl[0] != "#") { 114 | let ft = (new URL(result.tmpUrl)).searchParams.get("ft") 115 | if (ft) { 116 | let businessDate = window.Radiko.Date.calcBusinessDate(moment(ft, 'YYYYMMDDHHmmss').toDate()); 117 | result.needsTimeFreePlusAuthority = window.Radiko.Utility.needsTimeFreePlusAuthority(businessDate); 118 | } 119 | } 120 | } catch { }; 121 | return result; 122 | }, 123 | // to access $ 124 | world: "MAIN" 125 | }); 126 | if (inject_results && inject_results.length >= 1 && inject_results[0] && !chrome.runtime.lastError) { 127 | let { result } = inject_results[0]; 128 | let href = tab.url; 129 | let url = result && result.url || ''; // #RADIO or http://m3u8list 130 | let tmpUrl = result && result.tmpUrl || ''; // #RADIO or http://m3u8list 131 | let needsTimeFreePlusAuthority = result && result.needsTimeFreePlusAuthority || false; 132 | let hasTimeFreePlusAuthority = result && result.hasTimeFreePlusAuthority || false; 133 | 134 | // only show live recoding when current no work. 135 | if (!current_recording) { 136 | if (url[0] == '#') { 137 | //playing live 138 | record_button.hidden = false; 139 | record_button.innerText = chrome.i18n.getMessage("record_button_to_start", radioAreaId[url.slice(1)].name); 140 | record_button.onclick = async function (data) { 141 | await chrome.runtime.sendMessage({ "start-recording": url.slice(1), "tabId": tab.id }); 142 | window.close(); 143 | } 144 | } else if (tmpUrl[0] == '#' && /\/live\//.test(href)) { 145 | //viewing live 146 | //Should care about service worker's lifecycle. 147 | record_button.hidden = false; 148 | record_button.innerText = chrome.i18n.getMessage("record_button_to_prepare", radioAreaId[tmpUrl.slice(1)].name); 149 | record_button.onclick = async function (data) { 150 | await chrome.runtime.sendMessage({ "start-recording": tmpUrl.slice(1), "tabId": tab.id }); 151 | window.close(); 152 | } 153 | } 154 | } 155 | if (tmpUrl.indexOf("https://radiko.jp/v2/api/ts/playlist.m3u8") != -1 && /\/ts\//.test(href)) { 156 | let stripedLink = stripM3u8link(tmpUrl); 157 | if (!timeshift_list.includes(stripedLink)) { 158 | //viewing timeshift 159 | download_button.hidden = false; 160 | download_button.innerText = chrome.i18n.getMessage("timeshift_button"); 161 | download_button.onclick = async function () { 162 | await chrome.runtime.sendMessage({ "download-timeshift": { link: stripedLink, tf30: needsTimeFreePlusAuthority } }); 163 | window.close(); 164 | } 165 | 166 | if (isFirefox() && chrome.extension.inIncognitoContext && hasTimeFreePlusAuthority && needsTimeFreePlusAuthority) { 167 | let hint = document.getElementById("firefox-tf30-incognito"); 168 | hint.textContent = chrome.i18n.getMessage("firefox_tf30_incognito"); 169 | hint.hidden = false; 170 | } 171 | } 172 | } 173 | } 174 | } 175 | }); 176 | 177 | 178 | 179 | -------------------------------------------------------------------------------- /response/area-JP1.html: -------------------------------------------------------------------------------- 1 | document.write('HOKKAIDO JAPAN'); -------------------------------------------------------------------------------- /response/area-JP10.html: -------------------------------------------------------------------------------- 1 | document.write('GUNMA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP11.html: -------------------------------------------------------------------------------- 1 | document.write('SAITAMA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP12.html: -------------------------------------------------------------------------------- 1 | document.write('CHIBA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP13.html: -------------------------------------------------------------------------------- 1 | document.write('TOKYO JAPAN'); -------------------------------------------------------------------------------- /response/area-JP14.html: -------------------------------------------------------------------------------- 1 | document.write('KANAGAWA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP15.html: -------------------------------------------------------------------------------- 1 | document.write('NIIGATA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP16.html: -------------------------------------------------------------------------------- 1 | document.write('TOYAMA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP17.html: -------------------------------------------------------------------------------- 1 | document.write('ISHIKAWA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP18.html: -------------------------------------------------------------------------------- 1 | document.write('FUKUI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP19.html: -------------------------------------------------------------------------------- 1 | document.write('YAMANASHI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP2.html: -------------------------------------------------------------------------------- 1 | document.write('AOMORI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP20.html: -------------------------------------------------------------------------------- 1 | document.write('NAGANO JAPAN'); -------------------------------------------------------------------------------- /response/area-JP21.html: -------------------------------------------------------------------------------- 1 | document.write('GIFU JAPAN'); -------------------------------------------------------------------------------- /response/area-JP22.html: -------------------------------------------------------------------------------- 1 | document.write('SHIZUOKA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP23.html: -------------------------------------------------------------------------------- 1 | document.write('AICHI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP24.html: -------------------------------------------------------------------------------- 1 | document.write('MIE JAPAN'); -------------------------------------------------------------------------------- /response/area-JP25.html: -------------------------------------------------------------------------------- 1 | document.write('SHIGA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP26.html: -------------------------------------------------------------------------------- 1 | document.write('KYOTO JAPAN'); -------------------------------------------------------------------------------- /response/area-JP27.html: -------------------------------------------------------------------------------- 1 | document.write('OSAKA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP28.html: -------------------------------------------------------------------------------- 1 | document.write('HYOGO JAPAN'); -------------------------------------------------------------------------------- /response/area-JP29.html: -------------------------------------------------------------------------------- 1 | document.write('NARA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP3.html: -------------------------------------------------------------------------------- 1 | document.write('IWATE JAPAN'); -------------------------------------------------------------------------------- /response/area-JP30.html: -------------------------------------------------------------------------------- 1 | document.write('WAKAYAMA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP31.html: -------------------------------------------------------------------------------- 1 | document.write('TOTTORI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP32.html: -------------------------------------------------------------------------------- 1 | document.write('SHIMANE JAPAN'); -------------------------------------------------------------------------------- /response/area-JP33.html: -------------------------------------------------------------------------------- 1 | document.write('OKAYAMA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP34.html: -------------------------------------------------------------------------------- 1 | document.write('HIROSHIMA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP35.html: -------------------------------------------------------------------------------- 1 | document.write('YAMAGUCHI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP36.html: -------------------------------------------------------------------------------- 1 | document.write('TOKUSHIMA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP37.html: -------------------------------------------------------------------------------- 1 | document.write('KAGAWA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP38.html: -------------------------------------------------------------------------------- 1 | document.write('EHIME JAPAN'); -------------------------------------------------------------------------------- /response/area-JP39.html: -------------------------------------------------------------------------------- 1 | document.write('KOCHI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP4.html: -------------------------------------------------------------------------------- 1 | document.write('MIYAGI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP40.html: -------------------------------------------------------------------------------- 1 | document.write('FUKUOKA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP41.html: -------------------------------------------------------------------------------- 1 | document.write('SAGA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP42.html: -------------------------------------------------------------------------------- 1 | document.write('NAGASAKI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP43.html: -------------------------------------------------------------------------------- 1 | document.write('KUMAMOTO JAPAN'); -------------------------------------------------------------------------------- /response/area-JP44.html: -------------------------------------------------------------------------------- 1 | document.write('OITA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP45.html: -------------------------------------------------------------------------------- 1 | document.write('MIYAZAKI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP46.html: -------------------------------------------------------------------------------- 1 | document.write('KAGOSHIMA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP47.html: -------------------------------------------------------------------------------- 1 | document.write('OKINAWA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP5.html: -------------------------------------------------------------------------------- 1 | document.write('AKITA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP6.html: -------------------------------------------------------------------------------- 1 | document.write('YAMAGATA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP7.html: -------------------------------------------------------------------------------- 1 | document.write('FUKUSHIMA JAPAN'); -------------------------------------------------------------------------------- /response/area-JP8.html: -------------------------------------------------------------------------------- 1 | document.write('IBARAKI JAPAN'); -------------------------------------------------------------------------------- /response/area-JP9.html: -------------------------------------------------------------------------------- 1 | document.write('TOCHIGI JAPAN'); -------------------------------------------------------------------------------- /response/auth2-JP1.html: -------------------------------------------------------------------------------- 1 | JP1,北海道,hokkaido Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP10.html: -------------------------------------------------------------------------------- 1 | JP10,群馬県,gunma Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP11.html: -------------------------------------------------------------------------------- 1 | JP11,埼玉県,saitama Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP12.html: -------------------------------------------------------------------------------- 1 | JP12,千葉県,chiba Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP13.html: -------------------------------------------------------------------------------- 1 | JP13,東京都,tokyo Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP14.html: -------------------------------------------------------------------------------- 1 | JP14,神奈川県,kanagawa Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP15.html: -------------------------------------------------------------------------------- 1 | JP15,新潟県,niigata Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP16.html: -------------------------------------------------------------------------------- 1 | JP16,富山県,toyama Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP17.html: -------------------------------------------------------------------------------- 1 | JP17,石川県,ishikawa Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP18.html: -------------------------------------------------------------------------------- 1 | JP18,福井県,fukui Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP19.html: -------------------------------------------------------------------------------- 1 | JP19,山梨県,yamanashi Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP2.html: -------------------------------------------------------------------------------- 1 | JP2,青森県,aomori Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP20.html: -------------------------------------------------------------------------------- 1 | JP20,長野県,nagano Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP21.html: -------------------------------------------------------------------------------- 1 | JP21,岐阜県,gifu Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP22.html: -------------------------------------------------------------------------------- 1 | JP22,静岡県,shizuoka Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP23.html: -------------------------------------------------------------------------------- 1 | JP23,愛知県,aichi Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP24.html: -------------------------------------------------------------------------------- 1 | JP24,三重県,mie Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP25.html: -------------------------------------------------------------------------------- 1 | JP25,滋賀県,shiga Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP26.html: -------------------------------------------------------------------------------- 1 | JP26,京都府,kyoto Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP27.html: -------------------------------------------------------------------------------- 1 | JP27,大阪府,osaka Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP28.html: -------------------------------------------------------------------------------- 1 | JP28,兵庫県,hyogo Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP29.html: -------------------------------------------------------------------------------- 1 | JP29,奈良県,nara Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP3.html: -------------------------------------------------------------------------------- 1 | JP3,岩手県,iwate Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP30.html: -------------------------------------------------------------------------------- 1 | JP30,和歌山県,wakayama Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP31.html: -------------------------------------------------------------------------------- 1 | JP31,鳥取県,tottori Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP32.html: -------------------------------------------------------------------------------- 1 | JP32,島根県,shimane Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP33.html: -------------------------------------------------------------------------------- 1 | JP33,岡山県,okayama Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP34.html: -------------------------------------------------------------------------------- 1 | JP34,広島県,hiroshima Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP35.html: -------------------------------------------------------------------------------- 1 | JP35,山口県,yamaguchi Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP36.html: -------------------------------------------------------------------------------- 1 | JP36,徳島県,tokushima Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP37.html: -------------------------------------------------------------------------------- 1 | JP37,香川県,kagawa Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP38.html: -------------------------------------------------------------------------------- 1 | JP38,愛媛県,ehime Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP39.html: -------------------------------------------------------------------------------- 1 | JP39,高知県,kouchi Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP4.html: -------------------------------------------------------------------------------- 1 | JP4,宮城県,miyagi Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP40.html: -------------------------------------------------------------------------------- 1 | JP40,福岡県,fukuoka Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP41.html: -------------------------------------------------------------------------------- 1 | JP41,佐賀県,saga Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP42.html: -------------------------------------------------------------------------------- 1 | JP42,長崎県,nagasaki Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP43.html: -------------------------------------------------------------------------------- 1 | JP43,熊本県,kumamoto Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP44.html: -------------------------------------------------------------------------------- 1 | JP44,大分県,oita Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP45.html: -------------------------------------------------------------------------------- 1 | JP45,宮崎県,miyazaki Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP46.html: -------------------------------------------------------------------------------- 1 | JP46,鹿児島県,kagoshima Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP47.html: -------------------------------------------------------------------------------- 1 | JP47,沖縄県,okinawa Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP5.html: -------------------------------------------------------------------------------- 1 | JP5,秋田県,akita Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP6.html: -------------------------------------------------------------------------------- 1 | JP6,山形県,yamagata Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP7.html: -------------------------------------------------------------------------------- 1 | JP7,福島県,fukushima Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP8.html: -------------------------------------------------------------------------------- 1 | JP8,茨城県,ibaraki Japan 2 | -------------------------------------------------------------------------------- /response/auth2-JP9.html: -------------------------------------------------------------------------------- 1 | JP9,栃木県,tochigi Japan 2 | -------------------------------------------------------------------------------- /ui/common_start.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function (event) { 2 | let inspect_script = document.createElement("script"); 3 | inspect_script.src = chrome.runtime.getURL('ui/inspect_start.js'); 4 | document.head.appendChild(inspect_script); 5 | 6 | // Implement stop recording by watching the play button's attribution. 7 | // TODO what about the big saisei button? 8 | let targetPlayButton = document.getElementById('play').getElementsByTagName('i')[0]; 9 | let observer = new MutationObserver(function (list) { 10 | for (let mutation of list) { 11 | if (mutation.type == 'attributes' && mutation.attributeName == 'class' && !mutation.target.classList.contains('on')) { 12 | chrome.runtime.sendMessage({ "stop-recording": true }); 13 | } 14 | } 15 | }); 16 | observer.observe(targetPlayButton, { attributes: true }); 17 | }); -------------------------------------------------------------------------------- /ui/inspect_start.js: -------------------------------------------------------------------------------- 1 | const needChangeTZ = Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase() != 'asia/tokyo'; 2 | if (needChangeTZ) { 3 | //for those who are from different timezones. 4 | //TODO: better solution? such as changing Date prototype? 5 | 6 | //! moment-timezone.js 7 | //! version : 0.5.14 8 | //! Copyright (c) JS Foundation and other contributors 9 | //! license : MIT 10 | //! github.com/moment/moment-timezone 11 | (function (root, factory) { 12 | "use strict"; 13 | 14 | /*global define*/ 15 | if (typeof define === 'function' && define.amd) { 16 | define(['moment'], factory); // AMD 17 | } else if (typeof module === 'object' && module.exports) { 18 | module.exports = factory(require('moment')); // Node 19 | } else { 20 | factory(root.moment); // Browser 21 | } 22 | 23 | }(this, function (moment) { 24 | "use strict"; 25 | 26 | // Do not load moment-timezone a second time. 27 | // if (moment.tz !== undefined) { 28 | // logError('Moment Timezone ' + moment.tz.version + ' was already loaded ' + (moment.tz.dataVersion ? 'with data from ' : 'without any data') + moment.tz.dataVersion); 29 | // return moment; 30 | // } 31 | 32 | var VERSION = "0.5.14", 33 | zones = {}, 34 | links = {}, 35 | names = {}, 36 | guesses = {}, 37 | cachedGuess, 38 | 39 | momentVersion = moment.version.split('.'), 40 | major = +momentVersion[0], 41 | minor = +momentVersion[1]; 42 | 43 | // Moment.js version check 44 | if (major < 2 || (major === 2 && minor < 6)) { 45 | logError('Moment Timezone requires Moment.js >= 2.6.0. You are using Moment.js ' + moment.version + '. See momentjs.com'); 46 | } 47 | 48 | /************************************ 49 | Unpacking 50 | ************************************/ 51 | 52 | function charCodeToInt(charCode) { 53 | if (charCode > 96) { 54 | return charCode - 87; 55 | } else if (charCode > 64) { 56 | return charCode - 29; 57 | } 58 | return charCode - 48; 59 | } 60 | 61 | function unpackBase60(string) { 62 | var i = 0, 63 | parts = string.split('.'), 64 | whole = parts[0], 65 | fractional = parts[1] || '', 66 | multiplier = 1, 67 | num, 68 | out = 0, 69 | sign = 1; 70 | 71 | // handle negative numbers 72 | if (string.charCodeAt(0) === 45) { 73 | i = 1; 74 | sign = -1; 75 | } 76 | 77 | // handle digits before the decimal 78 | for (i; i < whole.length; i++) { 79 | num = charCodeToInt(whole.charCodeAt(i)); 80 | out = 60 * out + num; 81 | } 82 | 83 | // handle digits after the decimal 84 | for (i = 0; i < fractional.length; i++) { 85 | multiplier = multiplier / 60; 86 | num = charCodeToInt(fractional.charCodeAt(i)); 87 | out += num * multiplier; 88 | } 89 | 90 | return out * sign; 91 | } 92 | 93 | function arrayToInt(array) { 94 | for (var i = 0; i < array.length; i++) { 95 | array[i] = unpackBase60(array[i]); 96 | } 97 | } 98 | 99 | function intToUntil(array, length) { 100 | for (var i = 0; i < length; i++) { 101 | array[i] = Math.round((array[i - 1] || 0) + (array[i] * 60000)); // minutes to milliseconds 102 | } 103 | 104 | array[length - 1] = Infinity; 105 | } 106 | 107 | function mapIndices(source, indices) { 108 | var out = [], i; 109 | 110 | for (i = 0; i < indices.length; i++) { 111 | out[i] = source[indices[i]]; 112 | } 113 | 114 | return out; 115 | } 116 | 117 | function unpack(string) { 118 | var data = string.split('|'), 119 | offsets = data[2].split(' '), 120 | indices = data[3].split(''), 121 | untils = data[4].split(' '); 122 | 123 | arrayToInt(offsets); 124 | arrayToInt(indices); 125 | arrayToInt(untils); 126 | 127 | intToUntil(untils, indices.length); 128 | 129 | return { 130 | name: data[0], 131 | abbrs: mapIndices(data[1].split(' '), indices), 132 | offsets: mapIndices(offsets, indices), 133 | untils: untils, 134 | population: data[5] | 0 135 | }; 136 | } 137 | 138 | /************************************ 139 | Zone object 140 | ************************************/ 141 | 142 | function Zone(packedString) { 143 | if (packedString) { 144 | this._set(unpack(packedString)); 145 | } 146 | } 147 | 148 | Zone.prototype = { 149 | _set: function (unpacked) { 150 | this.name = unpacked.name; 151 | this.abbrs = unpacked.abbrs; 152 | this.untils = unpacked.untils; 153 | this.offsets = unpacked.offsets; 154 | this.population = unpacked.population; 155 | }, 156 | 157 | _index: function (timestamp) { 158 | var target = +timestamp, 159 | untils = this.untils, 160 | i; 161 | 162 | for (i = 0; i < untils.length; i++) { 163 | if (target < untils[i]) { 164 | return i; 165 | } 166 | } 167 | }, 168 | 169 | parse: function (timestamp) { 170 | var target = +timestamp, 171 | offsets = this.offsets, 172 | untils = this.untils, 173 | max = untils.length - 1, 174 | offset, offsetNext, offsetPrev, i; 175 | 176 | for (i = 0; i < max; i++) { 177 | offset = offsets[i]; 178 | offsetNext = offsets[i + 1]; 179 | offsetPrev = offsets[i ? i - 1 : i]; 180 | 181 | if (offset < offsetNext && tz.moveAmbiguousForward) { 182 | offset = offsetNext; 183 | } else if (offset > offsetPrev && tz.moveInvalidForward) { 184 | offset = offsetPrev; 185 | } 186 | 187 | if (target < untils[i] - (offset * 60000)) { 188 | return offsets[i]; 189 | } 190 | } 191 | 192 | return offsets[max]; 193 | }, 194 | 195 | abbr: function (mom) { 196 | return this.abbrs[this._index(mom)]; 197 | }, 198 | 199 | offset: function (mom) { 200 | logError("zone.offset has been deprecated in favor of zone.utcOffset"); 201 | return this.offsets[this._index(mom)]; 202 | }, 203 | 204 | utcOffset: function (mom) { 205 | return this.offsets[this._index(mom)]; 206 | } 207 | }; 208 | 209 | /************************************ 210 | Current Timezone 211 | ************************************/ 212 | 213 | function OffsetAt(at) { 214 | var timeString = at.toTimeString(); 215 | var abbr = timeString.match(/\([a-z ]+\)/i); 216 | if (abbr && abbr[0]) { 217 | // 17:56:31 GMT-0600 (CST) 218 | // 17:56:31 GMT-0600 (Central Standard Time) 219 | abbr = abbr[0].match(/[A-Z]/g); 220 | abbr = abbr ? abbr.join('') : undefined; 221 | } else { 222 | // 17:56:31 CST 223 | // 17:56:31 GMT+0800 (台北標準時間) 224 | abbr = timeString.match(/[A-Z]{3,5}/g); 225 | abbr = abbr ? abbr[0] : undefined; 226 | } 227 | 228 | if (abbr === 'GMT') { 229 | abbr = undefined; 230 | } 231 | 232 | this.at = +at; 233 | this.abbr = abbr; 234 | this.offset = at.getTimezoneOffset(); 235 | } 236 | 237 | function ZoneScore(zone) { 238 | this.zone = zone; 239 | this.offsetScore = 0; 240 | this.abbrScore = 0; 241 | } 242 | 243 | ZoneScore.prototype.scoreOffsetAt = function (offsetAt) { 244 | this.offsetScore += Math.abs(this.zone.utcOffset(offsetAt.at) - offsetAt.offset); 245 | if (this.zone.abbr(offsetAt.at).replace(/[^A-Z]/g, '') !== offsetAt.abbr) { 246 | this.abbrScore++; 247 | } 248 | }; 249 | 250 | function findChange(low, high) { 251 | var mid, diff; 252 | 253 | while ((diff = ((high.at - low.at) / 12e4 | 0) * 6e4)) { 254 | mid = new OffsetAt(new Date(low.at + diff)); 255 | if (mid.offset === low.offset) { 256 | low = mid; 257 | } else { 258 | high = mid; 259 | } 260 | } 261 | 262 | return low; 263 | } 264 | 265 | function userOffsets() { 266 | var startYear = new Date().getFullYear() - 2, 267 | last = new OffsetAt(new Date(startYear, 0, 1)), 268 | offsets = [last], 269 | change, next, i; 270 | 271 | for (i = 1; i < 48; i++) { 272 | next = new OffsetAt(new Date(startYear, i, 1)); 273 | if (next.offset !== last.offset) { 274 | change = findChange(last, next); 275 | offsets.push(change); 276 | offsets.push(new OffsetAt(new Date(change.at + 6e4))); 277 | } 278 | last = next; 279 | } 280 | 281 | for (i = 0; i < 4; i++) { 282 | offsets.push(new OffsetAt(new Date(startYear + i, 0, 1))); 283 | offsets.push(new OffsetAt(new Date(startYear + i, 6, 1))); 284 | } 285 | 286 | return offsets; 287 | } 288 | 289 | function sortZoneScores(a, b) { 290 | if (a.offsetScore !== b.offsetScore) { 291 | return a.offsetScore - b.offsetScore; 292 | } 293 | if (a.abbrScore !== b.abbrScore) { 294 | return a.abbrScore - b.abbrScore; 295 | } 296 | return b.zone.population - a.zone.population; 297 | } 298 | 299 | function addToGuesses(name, offsets) { 300 | var i, offset; 301 | arrayToInt(offsets); 302 | for (i = 0; i < offsets.length; i++) { 303 | offset = offsets[i]; 304 | guesses[offset] = guesses[offset] || {}; 305 | guesses[offset][name] = true; 306 | } 307 | } 308 | 309 | function guessesForUserOffsets(offsets) { 310 | var offsetsLength = offsets.length, 311 | filteredGuesses = {}, 312 | out = [], 313 | i, j, guessesOffset; 314 | 315 | for (i = 0; i < offsetsLength; i++) { 316 | guessesOffset = guesses[offsets[i].offset] || {}; 317 | for (j in guessesOffset) { 318 | if (guessesOffset.hasOwnProperty(j)) { 319 | filteredGuesses[j] = true; 320 | } 321 | } 322 | } 323 | 324 | for (i in filteredGuesses) { 325 | if (filteredGuesses.hasOwnProperty(i)) { 326 | out.push(names[i]); 327 | } 328 | } 329 | 330 | return out; 331 | } 332 | 333 | function rebuildGuess() { 334 | 335 | // use Intl API when available and returning valid time zone 336 | try { 337 | var intlName = Intl.DateTimeFormat().resolvedOptions().timeZone; 338 | if (intlName && intlName.length > 3) { 339 | var name = names[normalizeName(intlName)]; 340 | if (name) { 341 | return name; 342 | } 343 | logError("Moment Timezone found " + intlName + " from the Intl api, but did not have that data loaded."); 344 | } 345 | } catch (e) { 346 | // Intl unavailable, fall back to manual guessing. 347 | } 348 | 349 | var offsets = userOffsets(), 350 | offsetsLength = offsets.length, 351 | guesses = guessesForUserOffsets(offsets), 352 | zoneScores = [], 353 | zoneScore, i, j; 354 | 355 | for (i = 0; i < guesses.length; i++) { 356 | zoneScore = new ZoneScore(getZone(guesses[i]), offsetsLength); 357 | for (j = 0; j < offsetsLength; j++) { 358 | zoneScore.scoreOffsetAt(offsets[j]); 359 | } 360 | zoneScores.push(zoneScore); 361 | } 362 | 363 | zoneScores.sort(sortZoneScores); 364 | 365 | return zoneScores.length > 0 ? zoneScores[0].zone.name : undefined; 366 | } 367 | 368 | function guess(ignoreCache) { 369 | if (!cachedGuess || ignoreCache) { 370 | cachedGuess = rebuildGuess(); 371 | } 372 | return cachedGuess; 373 | } 374 | 375 | /************************************ 376 | Global Methods 377 | ************************************/ 378 | 379 | function normalizeName(name) { 380 | return (name || '').toLowerCase().replace(/\//g, '_'); 381 | } 382 | 383 | function addZone(packed) { 384 | var i, name, split, normalized; 385 | 386 | if (typeof packed === "string") { 387 | packed = [packed]; 388 | } 389 | 390 | for (i = 0; i < packed.length; i++) { 391 | split = packed[i].split('|'); 392 | name = split[0]; 393 | normalized = normalizeName(name); 394 | zones[normalized] = packed[i]; 395 | names[normalized] = name; 396 | addToGuesses(normalized, split[2].split(' ')); 397 | } 398 | } 399 | 400 | function getZone(name, caller) { 401 | name = normalizeName(name); 402 | 403 | var zone = zones[name]; 404 | var link; 405 | 406 | if (zone instanceof Zone) { 407 | return zone; 408 | } 409 | 410 | if (typeof zone === 'string') { 411 | zone = new Zone(zone); 412 | zones[name] = zone; 413 | return zone; 414 | } 415 | 416 | // Pass getZone to prevent recursion more than 1 level deep 417 | if (links[name] && caller !== getZone && (link = getZone(links[name], getZone))) { 418 | zone = zones[name] = new Zone(); 419 | zone._set(link); 420 | zone.name = names[name]; 421 | return zone; 422 | } 423 | 424 | return null; 425 | } 426 | 427 | function getNames() { 428 | var i, out = []; 429 | 430 | for (i in names) { 431 | if (names.hasOwnProperty(i) && (zones[i] || zones[links[i]]) && names[i]) { 432 | out.push(names[i]); 433 | } 434 | } 435 | 436 | return out.sort(); 437 | } 438 | 439 | function addLink(aliases) { 440 | var i, alias, normal0, normal1; 441 | 442 | if (typeof aliases === "string") { 443 | aliases = [aliases]; 444 | } 445 | 446 | for (i = 0; i < aliases.length; i++) { 447 | alias = aliases[i].split('|'); 448 | 449 | normal0 = normalizeName(alias[0]); 450 | normal1 = normalizeName(alias[1]); 451 | 452 | links[normal0] = normal1; 453 | names[normal0] = alias[0]; 454 | 455 | links[normal1] = normal0; 456 | names[normal1] = alias[1]; 457 | } 458 | } 459 | 460 | function loadData(data) { 461 | addZone(data.zones); 462 | addLink(data.links); 463 | tz.dataVersion = data.version; 464 | } 465 | 466 | function zoneExists(name) { 467 | if (!zoneExists.didShowError) { 468 | zoneExists.didShowError = true; 469 | logError("moment.tz.zoneExists('" + name + "') has been deprecated in favor of !moment.tz.zone('" + name + "')"); 470 | } 471 | return !!getZone(name); 472 | } 473 | 474 | function needsOffset(m) { 475 | var isUnixTimestamp = (m._f === 'X' || m._f === 'x'); 476 | return !!(m._a && (m._tzm === undefined) && !isUnixTimestamp); 477 | } 478 | 479 | function logError(message) { 480 | if (typeof console !== 'undefined' && typeof console.error === 'function') { 481 | console.error(message); 482 | } 483 | } 484 | 485 | /************************************ 486 | moment.tz namespace 487 | ************************************/ 488 | 489 | function tz(input) { 490 | var args = Array.prototype.slice.call(arguments, 0, -1), 491 | name = arguments[arguments.length - 1], 492 | zone = getZone(name), 493 | out = moment.utc.apply(null, args); 494 | 495 | if (zone && !moment.isMoment(input) && needsOffset(out)) { 496 | out.add(zone.parse(out), 'minutes'); 497 | } 498 | 499 | out.tz(name); 500 | 501 | return out; 502 | } 503 | 504 | tz.version = VERSION; 505 | tz.dataVersion = ''; 506 | tz._zones = zones; 507 | tz._links = links; 508 | tz._names = names; 509 | tz.add = addZone; 510 | tz.link = addLink; 511 | tz.load = loadData; 512 | tz.zone = getZone; 513 | tz.zoneExists = zoneExists; // deprecated in 0.1.0 514 | tz.guess = guess; 515 | tz.names = getNames; 516 | tz.Zone = Zone; 517 | tz.unpack = unpack; 518 | tz.unpackBase60 = unpackBase60; 519 | tz.needsOffset = needsOffset; 520 | tz.moveInvalidForward = true; 521 | tz.moveAmbiguousForward = false; 522 | 523 | /************************************ 524 | Interface with Moment.js 525 | ************************************/ 526 | 527 | var fn = moment.fn; 528 | 529 | moment.tz = tz; 530 | 531 | moment.defaultZone = null; 532 | 533 | moment.updateOffset = function (mom, keepTime) { 534 | var zone = moment.defaultZone, 535 | offset; 536 | 537 | if (mom._z === undefined) { 538 | if (zone && needsOffset(mom) && !mom._isUTC) { 539 | mom._d = moment.utc(mom._a)._d; 540 | mom.utc().add(zone.parse(mom), 'minutes'); 541 | } 542 | mom._z = zone; 543 | } 544 | if (mom._z) { 545 | offset = mom._z.utcOffset(mom); 546 | if (Math.abs(offset) < 16) { 547 | offset = offset / 60; 548 | } 549 | if (mom.utcOffset !== undefined) { 550 | mom.utcOffset(-offset, keepTime); 551 | } else { 552 | mom.zone(offset, keepTime); 553 | } 554 | } 555 | }; 556 | 557 | fn.tz = function (name, keepTime) { 558 | if (name) { 559 | this._z = getZone(name); 560 | if (this._z) { 561 | moment.updateOffset(this, keepTime); 562 | } else { 563 | logError("Moment Timezone has no data for " + name + ". See http://momentjs.com/timezone/docs/#/data-loading/."); 564 | } 565 | return this; 566 | } 567 | if (this._z) { return this._z.name; } 568 | }; 569 | 570 | function abbrWrap(old) { 571 | return function () { 572 | if (this._z) { return this._z.abbr(this); } 573 | return old.call(this); 574 | }; 575 | } 576 | 577 | function resetZoneWrap(old) { 578 | return function () { 579 | this._z = null; 580 | return old.apply(this, arguments); 581 | }; 582 | } 583 | 584 | fn.zoneName = abbrWrap(fn.zoneName); 585 | fn.zoneAbbr = abbrWrap(fn.zoneAbbr); 586 | fn.utc = resetZoneWrap(fn.utc); 587 | 588 | moment.tz.setDefault = function (name) { 589 | if (major < 2 || (major === 2 && minor < 9)) { 590 | logError('Moment Timezone setDefault() requires Moment.js >= 2.9.0. You are using Moment.js ' + moment.version + '.'); 591 | } 592 | moment.defaultZone = name ? getZone(name) : null; 593 | return moment; 594 | }; 595 | 596 | // Cloning a moment should include the _z property. 597 | var momentProperties = moment.momentProperties; 598 | if (Object.prototype.toString.call(momentProperties) === '[object Array]') { 599 | // moment 2.8.1+ 600 | momentProperties.push('_z'); 601 | momentProperties.push('_a'); 602 | } else if (momentProperties) { 603 | // moment 2.7.0 604 | momentProperties._z = null; 605 | } 606 | 607 | // INJECT DATA 608 | 609 | return moment; 610 | })); 611 | //! moment-timezone.js end 612 | 613 | moment.tz.add("Asia/Tokyo|JST JDT|-90 -a0|010101010|-QJH0 QL0 1lB0 13X0 1zB0 NX0 1zB0 NX0|38e6"); 614 | moment.tz.link("Asia/Tokyo|Japan"); 615 | const tokyozone = moment.tz.zone("Asia/Tokyo"); 616 | moment.defaultZone = tokyozone; 617 | 618 | // moment.tz.zone('Asia/Tokyo').utcOffset(0)) -> -540 619 | const diffMin = (new Date()).getTimezoneOffset() - moment.tz.zone('Asia/Tokyo').utcOffset(0); 620 | const diffTimestamp = -1 * 60 * 1000 * diffMin; //different direction 621 | const diffSec = diffMin * 60; 622 | const diffHour = diffMin / 60; 623 | 624 | // for timeshift's timetable 625 | let oldsetScrollInit = setScrollInit; 626 | setScrollInit = function () { 627 | let oldGetHours = Date.prototype.getHours; 628 | Date.prototype.getHours = function () { return (24 + oldGetHours.bind(this)() + Math.floor(diffHour)) % 24; }; 629 | oldsetScrollInit(); 630 | Date.prototype.getHours = oldGetHours; 631 | } 632 | 633 | // var oldSetSeekPlayTime = $.Radiko.Player.setSeekPlayTime 634 | // $.Radiko.Player.setSeekPlayTime = function(startSec,endSec) { 635 | // return oldSetSeekPlayTime(startSec+diffSec,endSec-diffSec)} 636 | 637 | // note: this conflicts with setSeekPlayTime's modification 638 | let oldonChangeCurrentTime = $.Radiko.Player.View.seekBarView.__proto__.onChangeCurrentTime; 639 | $.Radiko.Player.View.seekBarView.stopListening($.Radiko.Player.Model, 'change:currentTime'); 640 | $.Radiko.Player.View.seekBarView.listenTo($.Radiko.Player.Model, 'change:currentTime', function (model, currentTime) { 641 | // for past timeshift on non-default region -> 0 642 | // other ( ongoing timeshift on default/non-default , past timeshift on default) -> diffSec 643 | return oldonChangeCurrentTime(model, currentTime + (player.chasing() ? diffSec : 0)); 644 | }) 645 | 646 | //because ftTime is JST time 647 | //apps/js/playerCommon.js?_=20180221 648 | 649 | // this conflicts with newonDragSeek 650 | //var oldupdateBalloon = $.Radiko.Player.View.seekBarView.__proto__.updateBalloon; 651 | //$.Radiko.Player.View.seekBarView.__proto__.updateBalloon = function (ftTime, addTime) { 652 | // oldupdateBalloon(ftTime + diffTimestamp, addTime); 653 | //} 654 | 655 | let oldonDragSeek = $.Radiko.Player.View.seekBarView.__proto__.onDragSeek; 656 | let newonDragSeek = function () { moment.defaultZone = null; oldonDragSeek.call($.Radiko.Player.View.seekBarView); moment.defaultZone = tokyozone; } 657 | 658 | $("#seekbar").find(".knob").draggable("option", { drag: newonDragSeek }); 659 | } 660 | 661 | 662 | 663 | //break timeshift 3hour limit 664 | // from tsdetail -> scheduleId (for 1 day , check every 1s ) && storeWatchId (for 3 hours,check on update) 665 | // this watcher is the first. 666 | store.watch('update', 667 | function (key, val, oldVal) { // if oldVal == undefined -> a new created one 668 | if (/[0-9]{14}$/.test(key)) { 669 | val.listened_time = 0; 670 | val.limit = moment(val.to, 'YYYYMMDDHHmmss').unix() + 8 * 24 * 60 * 60; //same as tsdetail.js, but will be delete after 8*2 days 671 | //use raw store api 672 | store.storage.write(key, JSON.stringify(val)); 673 | } 674 | } 675 | ); 676 | // Bypass question dialog 677 | if (!store.get('rdk_profile_data')) { 678 | store.set('rdk_profile_data', true); 679 | } 680 | 681 | 682 | 683 | 684 | //to bypass check at 685 | // to enfore select stream_smh_multi url areafree = 0 link (bypass containStation check) 686 | // also bypass connectiontype check! see allocateConnection 687 | // to pass our generated token 688 | // this may run after d2-app report premium? 689 | $.Radiko.login_status.areafree = 1; 690 | $.Radiko.login_status.premium = 1; 691 | 692 | window.isStationInArea = function () { return true; } 693 | // `Preroll` is CM/AD related 694 | // See onChunkListLoaded case 'AD-TYPE': 695 | // 0 is for premium, so i think it is no-ad 696 | // 2 maybe has AD 697 | window.getPrerollParam = () => { return '0'; } 698 | -------------------------------------------------------------------------------- /ui/mobile.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 100% 3 | } 4 | 5 | /*display: none*/ 6 | /*for login button*/ 7 | .header__utility { 8 | display: none 9 | } 10 | 11 | /*.header__utility {padding: unset;} 12 | .header__utility .header__inner {height: unset;} 13 | .header__logo {display: none} 14 | .header__menu ul:nth-child(1) {display: none} 15 | .header__banner {display: none} 16 | .header__menu .btn--login {position: fixed; bottom: 75px; z-index: 1000 ;right: 20px;font-size: 0;border-radius: 200px;border: unset; 17 | 18 | height:45px; 19 | width: 45px; 20 | background-color: #f8f8f8; 21 | } 22 | .header__menu .btn--setting .icon, .header__menu .btn--login .icon, .header__menu .btn--logout .icon { 23 | margin-right: 1px; 24 | vertical-align: -17px; 25 | background-position: -314px -371px; 26 | width: 15px; 27 | height: 20px; 28 | } 29 | .header__menu .item--premium {display: none} 30 | */ 31 | /* 32 | .icon--login-02 { 33 | 34 | 35 | .icon--visitor { 36 | background-image: url(../images/sprite.png); 37 | background-position: -473px -218px; 38 | width: 30px; 39 | height: 30px; } 40 | .icon -people { 41 | background-position: -221px -126px; 42 | width: 26px; 43 | height: 35px; 44 | } 45 | 46 | 47 | */ 48 | 49 | 50 | /**/ 51 | .header__nav-container { 52 | height: unset 53 | } 54 | 55 | .header__nav-outer { 56 | position: fixed; 57 | top: 0; 58 | left: 0; 59 | } 60 | 61 | .header__inner { 62 | width: unset 63 | } 64 | 65 | .header .header__nav .item__live { 66 | width: unset 67 | } 68 | 69 | .header .header__nav .item__timeshift { 70 | width: unset 71 | } 72 | 73 | .header .header__nav .item__areafree { 74 | width: unset 75 | } 76 | 77 | .header__nav .item { 78 | width: 33% 79 | } 80 | 81 | .header__nav .item__link { 82 | width: unset; 83 | background: unset 84 | } 85 | 86 | .header__station-list { 87 | display: none 88 | } 89 | 90 | .content .content__inner { 91 | width: 100% 92 | } 93 | 94 | .top-main-slider { 95 | display: none 96 | } 97 | 98 | /*Only display first 3 item live/timeshift/areafree .*/ 99 | .header__nav .item__outer :not(:nth-child(-n+3)) { 100 | display: none 101 | } 102 | 103 | .img-list__channel { 104 | display: inline; 105 | text-align: unset 106 | } 107 | 108 | .img-list__item { 109 | float: unset; 110 | height: unset; 111 | width: unset; 112 | padding: unset; 113 | margin-top: 4px; 114 | border-radius: unset; 115 | margin-right: unset 116 | } 117 | 118 | .img-list__img { 119 | float: right; 120 | width: 45%; 121 | line-height: unset; 122 | margin: unset; 123 | border-radius: unset; 124 | } 125 | 126 | .img-list__img img { 127 | padding-top: 20px 128 | } 129 | 130 | .img-list__title { 131 | margin-top: unset 132 | } 133 | 134 | .img-list__link { 135 | max-height: 150px; 136 | height: 150px 137 | } 138 | 139 | /*to avoid heightline*/ 140 | .play { 141 | display: none 142 | } 143 | 144 | .top-sns { 145 | display: none 146 | } 147 | 148 | #top-info { 149 | display: none 150 | } 151 | 152 | .footer { 153 | display: none 154 | } 155 | 156 | .img-list { 157 | margin-top: unset; 158 | margin-right: unset 159 | } 160 | 161 | .heading-lv02 { 162 | display: none 163 | } 164 | 165 | .content { 166 | padding-top: 50px 167 | } 168 | 169 | /*for detail page*/ 170 | .heading-area { 171 | display: none 172 | } 173 | 174 | .live-detail__content { 175 | width: unset 176 | } 177 | 178 | .live-detail__main { 179 | width: unset; 180 | padding-right: unset 181 | } 182 | 183 | #pr-image-slider { 184 | display: none 185 | } 186 | 187 | /*for detail page*/ 188 | .live-detail__title { 189 | text-align: center 190 | } 191 | 192 | .live-detail__text { 193 | width: unset; 194 | float: unset 195 | } 196 | 197 | .live-detail__img { 198 | float: unset; 199 | margin-right: unset 200 | } 201 | 202 | .live-detail__img img { 203 | margin: 0 auto; 204 | display: block; 205 | width: 100% 206 | } 207 | 208 | #ad-history { 209 | display: none 210 | } 211 | 212 | .live-detail__description { 213 | padding-left: 10px; 214 | padding-right: 10px 215 | } 216 | 217 | .live-detail__cast { 218 | height: unset 219 | } 220 | 221 | .live-detail__cast-title { 222 | display: unset; 223 | float: unset; 224 | width: unset 225 | } 226 | 227 | .live-detail__cast-name { 228 | width: unset; 229 | float: unset 230 | } 231 | 232 | .tune-list { 233 | margin-right: unset 234 | } 235 | 236 | .live-detail__sites { 237 | padding-left: 10px; 238 | padding-right: 10px 239 | } 240 | 241 | .topic-list { 242 | padding-left: 10px; 243 | padding-right: 10px 244 | } 245 | 246 | .live-detail__timeline .timeline-col { 247 | width: unset; 248 | } 249 | 250 | /*for search div*/ 251 | /* hide before loaded*/ 252 | #to-search { 253 | padding-bottom: 380px 254 | } 255 | 256 | .top-search__form .item__text { 257 | width: 100%; 258 | margin-right: unset; 259 | } 260 | 261 | .top-search__form .item__text .form__text { 262 | width: 100%; 263 | padding: unset; 264 | } 265 | 266 | .top-search__form .item__date { 267 | float: left; 268 | width: 50%; 269 | margin-right: unset; 270 | } 271 | 272 | .top-search__form .item__area { 273 | width: 50% 274 | } 275 | 276 | .top-search__form .item__title { 277 | width: 35% 278 | } 279 | 280 | .top-search__form .item__date .ui-form-select, 281 | .top-search__form .item__date select { 282 | width: 65% 283 | } 284 | 285 | .top-search__form .item__area .ui-form-select, 286 | .top-search__form .item__area select { 287 | width: 65% 288 | } 289 | 290 | .top-search__form .item__submit { 291 | width: 100% 292 | } 293 | 294 | .top-search__form .item__submit .btn { 295 | width: 100% 296 | } 297 | 298 | .top-search__form #search-form .suggest-dropdown { 299 | width: 100% 300 | } 301 | 302 | .search-result__list .img-list__text.separate { 303 | display: none 304 | } 305 | 306 | .search-result__list a { 307 | height: 200px; 308 | max-height: 200px 309 | } 310 | 311 | /*.top-search__form .ui-form-select {width: 65%}*/ 312 | .easy-select-box { 313 | width: 100% 314 | } 315 | 316 | #search-form .esb-dropdown { 317 | padding: unset; 318 | } 319 | 320 | #search-form .icon--cross { 321 | display: none 322 | } 323 | 324 | /*hide other program in live page because it conflict with .img-list__link 's height*/ 325 | /*#others-program {display: none;} 326 | #others-program .img-list__text {overflow: hidden; white-space: nowrap; text-overflow: ellipsis;}*/ 327 | #others-program .img-list__item { 328 | min-height: 200px 329 | } 330 | 331 | #others-program .img-list__genre { 332 | margin-top: 5px 333 | } 334 | 335 | .player-area__volume { 336 | display: none; 337 | } 338 | 339 | /*padding left for button click*/ 340 | #player-detail { 341 | width: unset; 342 | position: unset; 343 | padding-left: 122px; 344 | } 345 | 346 | /*just make it out of screen*/ 347 | #player-detail .tooltip { 348 | left: -100px 349 | } 350 | 351 | /*dose not work*/ 352 | /*TODO #to-search should pop up when click search botton or ..*/ 353 | 354 | .tune-list .tune-list__item { 355 | width: unset; 356 | float: unset; 357 | margin-left: 20px; 358 | } 359 | 360 | .page-timeshift .header__nav .item__link { 361 | background-image: unset 362 | } 363 | 364 | .timeshift-detail__attention { 365 | display: none 366 | } 367 | 368 | /*area free*/ 369 | .premium-area { 370 | padding: unset 371 | } 372 | 373 | .premium-area__text { 374 | display: none 375 | } 376 | 377 | .icon--attention-02 { 378 | display: none 379 | } 380 | 381 | .content__areafree-top .content__section .tab-list .item { 382 | margin-left: 35px 383 | } 384 | 385 | .content__areafree-top .content__section .tab-list .item:first-child { 386 | margin-left: 35px; 387 | } 388 | 389 | .content__areafree-top .content__section .tab-list .item__selected { 390 | height: 44px; 391 | border: unset; 392 | background-color: #00a7e9 393 | } 394 | 395 | /*share*/ 396 | #cboxLoadedContent { 397 | max-width: 100%; 398 | padding: unset; 399 | overflow: unset 400 | } 401 | 402 | #cboxContent { 403 | max-width: 100% 404 | } 405 | 406 | #cboxWrapper { 407 | max-width: 98%; 408 | padding-left: 1%; 409 | min-width: 98% 410 | } 411 | 412 | #colorbox { 413 | max-width: 100% 414 | } 415 | 416 | .colorbox__play .btn-list li a.skip-btn { 417 | margin: 16px 6px 0 6px 418 | } 419 | 420 | .colorbox__share { 421 | margin-top: 0px; 422 | padding-bottom: 10px; 423 | padding-top: 10px 424 | } 425 | 426 | /*TODO: 1.timeshift page select from time or kyoku */ 427 | .colorbox__area-list .colorbox-area-list__item { 428 | width: 33% 429 | } 430 | 431 | #colorbox--area-list { 432 | margin: 10px 433 | } 434 | 435 | .timeshift-page-nav { 436 | width: 100%; 437 | } 438 | 439 | /* For header's width fits device screen width, because of a fixed button for timetable */ 440 | .top-slider__nav .item--next { 441 | right: 10%; 442 | } 443 | 444 | .top-slider__nav .item--prev { 445 | left: 10%; 446 | } 447 | 448 | .program-table__pager .item--next { 449 | right: 10%; 450 | } 451 | 452 | .program-table__pager .item--prev { 453 | left: 10%; 454 | } 455 | 456 | .program-table__body { 457 | overflow: scroll; 458 | } 459 | 460 | .program-table-header__list-outer { 461 | overflow: scroll; 462 | } 463 | 464 | .program-table__channel-wrapper { 465 | overflow: unset; 466 | } 467 | 468 | .program-table__date-wrapper { 469 | overflow: unset; 470 | } 471 | 472 | .timeshift-detail__sub, 473 | .live-detail__sub { 474 | float: unset; 475 | margin: auto; 476 | width: 90%; 477 | } 478 | 479 | /* 480 | .program-table-header__list {overflow: unset;width: unset;height: unset;} 481 | .program-table-header__list .item:first-child {margin-left: 12px;} 482 | */ 483 | /*.program-table__pager .item--prev {position: absolute; left: 0px; z-index: 1;} 484 | .program-table__pager .item--next {position: absolute; right: 0; z-index: 1; } 485 | */ 486 | /*.program-table__channel-outer { width: 100%; display: inline-flex; } 487 | .program-table__items {width: 100%} 488 | .program-table__outer {width: 100%;} 489 | .program-table__channel .name img {right: unset;width: 56%} 490 | .program-table__body--col8 .program-table__channel {width: 11%} 491 | 492 | .program-table__date-outer {width: 100%; display: block; } 493 | .program-table__pager {display: none} 494 | .program-table__body--col8 .program-table__date {width: 10% ;padding: 2px} 495 | .program-table__body--col8 .program-table__items .item-outer {width: 11%}*/ 496 | /*.program-table-header__list-outer {overflow: scroll;}*/ -------------------------------------------------------------------------------- /ui/mobile_start.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function (event) { 2 | var meta = document.createElement('meta'); 3 | meta.name = "viewport"; 4 | meta.content = "width=" + screen.width; 5 | // Allow user to zoom or not? 6 | //+ ",user-scalable=no"; 7 | document.head.appendChild(meta); 8 | document.body.style.display = 'unset'; 9 | 10 | 11 | document.getElementById('pause') && document.getElementById('pause').addEventListener('click', function (evnet) { 12 | if (document.getElementById('play').children[0].children[0].classList.contains('on')) { 13 | document.getElementById('pause').children[0].children[0].style.opacity = '0.5'; 14 | } 15 | 16 | }); 17 | document.getElementById('play') && document.getElementById('play').addEventListener('click', function (evnet) { 18 | if (document.getElementById('pause').children[0].children[0].style.opacity == '0.5') { 19 | setTimeout(function () { 20 | document.getElementById('pause').children[0].children[0].style.opacity = '1'; 21 | }, 600); 22 | 23 | } 24 | }); 25 | }); 26 | 27 | // To bypass Radiko.Device.isMobile check when reloading on #! paths. 28 | function changeMobileUA() { 29 | let change_mobile_ua = document.createElement("script"); 30 | // inline script supported in Firefox MV3? Yes. Because: 31 | // and runtime.getURL -> no 32 | // Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld 33 | // Scripts in this environment do not have any access to APIs that are only available to content scripts. 34 | change_mobile_ua.textContent = `(function(){Object.defineProperty(window.navigator, 'userAgent', { value: window.navigator.userAgent.replace(/android.*?\;/gi, "").replace(/mobile/gi, "")});})();` 35 | document.head.appendChild(change_mobile_ua); 36 | }; 37 | 38 | var executed = false; 39 | document.addEventListener("readystatechange", function (event) { 40 | // complete interactive 41 | if (event.target.readyState !== "loading" && !executed) { 42 | changeMobileUA(); 43 | executed = true; 44 | } else { 45 | // loading 46 | document.addEventListener('DOMContentLoaded', changeMobileUA); 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /ui/recochoku_header_caution.css: -------------------------------------------------------------------------------- 1 | .js-sub-header { 2 | display: none !important; 3 | } -------------------------------------------------------------------------------- /ui/share_redirect.js: -------------------------------------------------------------------------------- 1 | let checkOverseas = document.getElementsByClassName("messageToOverseas") 2 | if (checkOverseas.length != 0) { 3 | window.addEventListener("message", async function (evt) { 4 | let param = evt.data["share-redirect"] || {}; 5 | if (param.t && param.station) { 6 | // Wake up service worker? Is this a bug? 7 | // await chrome.runtime.sendMessage({}); 8 | await chrome.runtime.sendMessage({ "share-redirect": param }); 9 | } 10 | }); 11 | 12 | let inspect_script = document.createElement("script"); 13 | inspect_script.src = chrome.runtime.getURL('ui/share_redirect_inject.js'); 14 | document.head.appendChild(inspect_script); 15 | } 16 | -------------------------------------------------------------------------------- /ui/share_redirect_inject.js: -------------------------------------------------------------------------------- 1 | let param = {}; 2 | get_share_log_url(param); 3 | window.postMessage({ "share-redirect": param }); -------------------------------------------------------------------------------- /ui/tver_playable_mobile.css: -------------------------------------------------------------------------------- 1 | /* For Firefox Android TVer .player_host__CCajQ remove min-width: 512px; */ 2 | div[class^="player_host"] { 3 | min-width: unset !important; 4 | } -------------------------------------------------------------------------------- /ui/tver_playable_ua_inspect.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | Object.defineProperty( 3 | window.navigator, 4 | 'userAgent', 5 | { 6 | value: window.navigator.userAgent.replaceAll(/linux/gi, "Windows").replaceAll(/android.*?;/gi, "Windows;") 7 | } 8 | ); 9 | })(); 10 | --------------------------------------------------------------------------------