diff --git a/jp_radio/README.md b/jp_radio/README.md new file mode 100644 index 000000000..9c3a7b6d0 --- /dev/null +++ b/jp_radio/README.md @@ -0,0 +1,18 @@ +# JP RADIO Volumio3 plugin +Japanese radio relay server for Volumio3 + +> **Alert**: This plugin is only accessible from Japan. Access is restricted from outside Japan. + +## Change log +### version 0.0.3(2024/03/16) +* Change to display a popup for prompting restart. +* Change to allow the user to specify the startup port. +### version 0.0.2(2023/11/04) +* Bug fix for not starting correctly on plugin restart +### version 0.0.1(2023/11/02) +* Initial Version + +## Acknowledgments +* [NanoPi NEOにインストールしたMPDでradikoを聞く](http://burro.hatenablog.com/entry/2019/02/16/175836) +* [Github for Streaming server for relaying "radiko" radio stream to Music Player Daemon (MPD)](https://github.com/burrocargado/RadioRelayServer) +* [Trunkene/volumio_jpradio: Japanese radio relay server for Volumio](https://github.com/Trunkene/volumio_jpradio) \ No newline at end of file diff --git a/jp_radio/README_JP.md b/jp_radio/README_JP.md new file mode 100644 index 000000000..0a953bc45 --- /dev/null +++ b/jp_radio/README_JP.md @@ -0,0 +1,18 @@ +# JP RADIO Volumio3 plugin +Japanese radio relay server for Volumio3 + +> **注意**: このプラグインは日本からのみアクセス可能です。日本国外からのアクセスは制限されています。 + +## 変更履歴 +### version 0.0.3(2024/03/16) +* 再起動を促す表示をポップアップで表示するように変更 +* 起動ポートをユーザ側で指定できるように変更 +### version 0.0.2(2023/11/04) +* プラグインの再起動時に正しく起動できないバグ修正 +### version 0.0.1(2023/11/02) +* 初期バージョン + +## Acknowledgments +* [NanoPi NEOにインストールしたMPDでradikoを聞く](http://burro.hatenablog.com/entry/2019/02/16/175836) +* [Github for Streaming server for relaying "radiko" radio stream to Music Player Daemon (MPD)](https://github.com/burrocargado/RadioRelayServer) +* [Trunkene/volumio_jpradio: Japanese radio relay server for Volumio](https://github.com/Trunkene/volumio_jpradio) \ No newline at end of file diff --git a/jp_radio/UIConfig.json b/jp_radio/UIConfig.json new file mode 100644 index 000000000..39d6b600d --- /dev/null +++ b/jp_radio/UIConfig.json @@ -0,0 +1,78 @@ +{ + "page": { + "label": "TRANSLATE.PLUGIN_CONFIGURATION", + "description": "TRANSLATE.PAGE_DESCRIPTION" + }, + "sections": [ + { + "id": "service_port", + "element": "section", + "label": "TRANSLATE.SERVICE_PORT_LABEL", + "icon": "fa-user", + "onSave": { + "type": "controller", + "endpoint": "music_service/jp_radio", + "method": "saveServicePort" + }, + "saveButton": { + "label": "TRANSLATE.SAVE", + "data": [ + "servicePort" + ] + }, + "content": [ + { + "id": "servicePort", + "type": "number", + "element": "input", + "label": "TRANSLATE.SERVICE_PORT_LABEL", + "attributes": [ + { + "min": 1024, + "max": 65535 + } + ], + "value": 9000, + "description": "TRANSLATE.SERVICE_PORT_DESC" + }, + {} + ] + }, + { + "id": "radiko_account", + "element": "section", + "label": "TRANSLATE.RADIKO_ACCOUNT_LABEL", + "icon": "fa-user", + "onSave": { + "type": "controller", + "endpoint": "music_service/jp_radio", + "method": "saveRadikoAccount" + }, + "saveButton": { + "label": "TRANSLATE.SAVE", + "data": [ + "radikoUser", + "radikoPass" + ] + }, + "content": [ + { + "id": "radikoUser", + "type": "text", + "element": "input", + "description": "TRANSLATE.RADIKO_ACCOUNT_USER_DESC", + "label": "TRANSLATE.RADIKO_ACCOUNT_USER_LABEL", + "value": "" + }, + { + "id": "radikoPass", + "type": "password", + "element": "input", + "description": "TRANSLATE.RADIKO_ACCOUNT_PASS_DESC", + "label": "TRANSLATE.RADIKO_ACCOUNT_PASS_LABEL", + "value": "" + } + ] + } + ] +} \ No newline at end of file diff --git a/jp_radio/config.json b/jp_radio/config.json new file mode 100644 index 000000000..ba8f70c78 --- /dev/null +++ b/jp_radio/config.json @@ -0,0 +1,14 @@ +{ + "servicePort": { + "type": "int", + "value": 9000 + }, + "radikoUser": { + "type": "string", + "value": "" + }, + "radikoPass": { + "type": "string", + "value": "" + } +} \ No newline at end of file diff --git a/jp_radio/i18n/strings_en.json b/jp_radio/i18n/strings_en.json new file mode 100644 index 000000000..985c783a9 --- /dev/null +++ b/jp_radio/i18n/strings_en.json @@ -0,0 +1,12 @@ +{ + "PLUGIN_CONFIGURATION": "JP Radio Plugin Configuration", + "SAVE": "Save", + "PAGE_DESCRIPTION":"Japanese radio relay server for Volumio3", + "RADIKO_ACCOUNT_LABEL": "Radiko Premium Account", + "RADIKO_ACCOUNT_USER_LABEL": "Username", + "RADIKO_ACCOUNT_USER_DESC": "This is the username of your Radiko account", + "RADIKO_ACCOUNT_PASS_LABEL": "Password", + "RADIKO_ACCOUNT_PASS_DESC": "This is the password of your Radiko account", + "SERVICE_PORT_LABEL": "Service Port", + "SERVICE_PORT_DESC": "Enter the service port number (1024-65535). The default value is 9000." +} \ No newline at end of file diff --git a/jp_radio/i18n/strings_ja.json b/jp_radio/i18n/strings_ja.json new file mode 100644 index 000000000..aadd33878 --- /dev/null +++ b/jp_radio/i18n/strings_ja.json @@ -0,0 +1,12 @@ +{ + "PLUGIN_CONFIGURATION": "JP Radio Plugin設定", + "SAVE": "保存", + "PAGE_DESCRIPTION":"Volumio3用日本製無線中継サーバー", + "RADIKO_ACCOUNT_LABEL": "ラジコ プレミアム アカウント", + "RADIKO_ACCOUNT_USER_LABEL": "ユーザ名", + "RADIKO_ACCOUNT_USER_DESC": "Radikoアカウントのユーザー名", + "RADIKO_ACCOUNT_PASS_LABEL": "パスワード", + "RADIKO_ACCOUNT_PASS_DESC": "Radikoアカウントのパスワード", + "SERVICE_PORT_LABEL": "サービスポート", + "SERVICE_PORT_DESC": "サービスポート番号(1024~65535)を入力します。デフォルト値は:9000" +} \ No newline at end of file diff --git a/jp_radio/images/app_radiko.svg b/jp_radio/images/app_radiko.svg new file mode 100644 index 000000000..9c8f3a7a2 --- /dev/null +++ b/jp_radio/images/app_radiko.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/jp_radio/index.js b/jp_radio/index.js new file mode 100644 index 000000000..f5b2f274f --- /dev/null +++ b/jp_radio/index.js @@ -0,0 +1,383 @@ +'use strict'; +var libQ = require('kew'); +const JpRadio = require('./lib/radio'); + +module.exports = ControllerJpRadio; +function ControllerJpRadio(context) { + var self = this; + + this.context = context; + this.commandRouter = this.context.coreCommand; + this.logger = this.context.logger; + this.configManager = this.context.configManager; + + this.serviceName = "jp_radio"; +} + +ControllerJpRadio.prototype.restartPlugin = function() { + var self = this; + self.onStop().then(() => { + self.onStart().catch(err => { + self.commandRouter.pushToastMessage('error', 'Restart Failed', 'The plugin could not be restarted.'); + }); + }); +}; + +ControllerJpRadio.prototype.saveServicePort = function (data) { + var self = this; + var defer = libQ.defer(); + var configUpdated = false; + + var message = { + title: 'Plugin Restart Required', + message: 'Changes have been made that require the JP Radio plugin to be restarted. Please click the restart button below.', + size: 'lg', + buttons: [ + { + name: self.commandRouter.getI18nString('COMMON.RESTART'), + class: 'btn btn-info', + emit: 'callMethod', + payload: { + endpoint: 'music_service/jp_radio', + method: 'restartPlugin', + data: {} + } + }, + { + name: self.commandRouter.getI18nString('COMMON.CANCEL'), + class: 'btn btn-info', + emit: 'closeModals', + payload: '' + } + ] + }; + + if (self.config.get('servicePort') != data['servicePort']) { + var servicePort = parseInt(data['servicePort']); + if (!isNaN(servicePort)) { + self.config.set('servicePort', servicePort); + configUpdated = true; + } + } + + if (configUpdated) { + self.commandRouter.broadcastMessage('openModal', message); + } + defer.resolve({}); + return defer.promise; +}; + +ControllerJpRadio.prototype.saveRadikoAccount = function (data) { + var self = this; + var defer = libQ.defer(); + var configUpdated = false; + + var message = { + title: 'Plugin Restart Required', + message: 'Changes have been made that require the JP Radio plugin to be restarted. Please click the restart button below.', + size: 'lg', + buttons: [ + { + name: self.commandRouter.getI18nString('COMMON.RESTART'), + class: 'btn btn-info', + emit: 'callMethod', + payload: { + endpoint: 'music_service/jp_radio', + method: 'restartPlugin', + data: {} + } + }, + { + name: self.commandRouter.getI18nString('COMMON.CANCEL'), + class: 'btn btn-info', + emit: 'closeModals', + payload: '' + } + ] + }; + + if (self.config.get('radikoUser') != data['radikoUser']) { + self.config.set('radikoUser', data['radikoUser']); + configUpdated = true; + } + if (self.config.get('radikoPass') != data['radikoPass']) { + self.config.set('radikoPass', data['radikoPass']); + configUpdated = true; + } + + if (configUpdated) { + self.commandRouter.broadcastMessage('openModal', message); + } + defer.resolve({}); + return defer.promise; +}; + +ControllerJpRadio.prototype.onVolumioStart = function () { + var self = this; + var configFile = this.commandRouter.pluginManager.getConfigurationFile(this.context, 'config.json'); + this.config = new (require('v-conf'))(); + this.config.loadFile(configFile); + + return libQ.resolve(); +}; + +ControllerJpRadio.prototype.onStart = function () { + var self = this; + var defer = libQ.defer(); + const radikoUser = self.config.get('radikoUser'); + const radikoPass = self.config.get('radikoPass'); + + var servicePort = self.config.get('servicePort'); + if (!(servicePort !== undefined && servicePort)) { + servicePort = 9000; + } + + let acct = null; + + if (radikoUser !== undefined && radikoUser && radikoPass !== undefined && radikoPass) { + acct = { + 'mail': radikoUser, + 'pass': radikoPass + }; + } + + self.appRadio = new JpRadio(servicePort, this.logger, acct); + + // Once the Plugin has successfull started resolve the promise + defer.resolve(); + + self.appRadio.start(); + + self.addToBrowseSources(); + + return defer.promise; +}; + +ControllerJpRadio.prototype.onStop = function () { + var self = this; + var defer = libQ.defer(); + + // Once the Plugin has successfull stopped resolve the promise + defer.resolve(); + + self.appRadio.stop(); + + self.commandRouter.volumioRemoveToBrowseSources('RADIKO'); + + return libQ.resolve(); +}; + +ControllerJpRadio.prototype.onRestart = function () { + var self = this; + // Optional, use if you need it +}; + +// Configuration Methods ----------------------------------------------------------------------------- + +ControllerJpRadio.prototype.getUIConfig = function () { + var defer = libQ.defer(); + var self = this; + + var lang_code = this.commandRouter.sharedVars.get('language_code'); + + self.commandRouter.i18nJson(__dirname + '/i18n/strings_' + lang_code + '.json', + __dirname + '/i18n/strings_en.json', + __dirname + '/UIConfig.json') + .then(function (uiconf) { + uiconf.sections[0].content[0].value = self.config.get('servicePort'); + uiconf.sections[1].content[0].value = self.config.get('radikoUser'); + uiconf.sections[1].content[1].value = self.config.get('radikoPass'); + defer.resolve(uiconf); + }).fail(function () { + defer.reject(new Error()); + }); + + return defer.promise; +}; + +ControllerJpRadio.prototype.getConfigurationFiles = function () { + return ['config.json']; +} + +ControllerJpRadio.prototype.setUIConfig = function (data) { + var self = this; + //Perform your installation tasks here +}; + +ControllerJpRadio.prototype.getConf = function (varName) { + var self = this; + //Perform your installation tasks here +}; + +ControllerJpRadio.prototype.setConf = function (varName, varValue) { + var self = this; + //Perform your installation tasks here +}; + +// Playback Controls --------------------------------------------------------------------------------------- +// If your plugin is not a music_sevice don't use this part and delete it + +ControllerJpRadio.prototype.addToBrowseSources = function () { + var self = this; + // Use this function to add your music service plugin to music sources + var radikoNow = { + name: 'RADIKO', + uri: 'radiko', + plugin_type: 'music_service', + plugin_name: self.serviceName, + albumart: '/albumart?sourceicon=music_service/jp_radio/images/app_radiko.svg' + }; + + self.commandRouter.volumioAddToBrowseSources(radikoNow); +}; + +ControllerJpRadio.prototype.handleBrowseUri = function (curUri) { + var self = this; + if (curUri.startsWith('radiko')) { + if (curUri === 'radiko') { + var response = { + navigation: { + lists: [{ + title: 'LIVE', + availableListViews: ['grid', 'list'], + items: self.appRadio.radioStations() + }] + } + }; + return libQ.resolve(response); + } + } + return libQ.resolve(); +}; + +// Define a method to clear, add, and play an array of tracks +ControllerJpRadio.prototype.clearAddPlayTrack = function (track) { + var self = this; + self.commandRouter.pushConsoleMessage('[' + Date.now() + '] ' + 'JP_Radio::clearAddPlayTrack'); + + self.commandRouter.logger.info(JSON.stringify(track)); + + return self.sendSpopCommand('uplay', [track.uri]); +}; + +ControllerJpRadio.prototype.seek = function (timepos) { + this.commandRouter.pushConsoleMessage('[' + Date.now() + '] ' + 'JP_Radio::seek to ' + timepos); + + return this.sendSpopCommand('seek ' + timepos, []); +}; + +// Stop +ControllerJpRadio.prototype.stop = function () { + var self = this; + self.commandRouter.pushConsoleMessage('[' + Date.now() + '] ' + 'JP_Radio::stop'); +}; + +// Spop pause +ControllerJpRadio.prototype.pause = function () { + var self = this; + self.commandRouter.pushConsoleMessage('[' + Date.now() + '] ' + 'JP_Radio::pause'); +}; + +// Get state +ControllerJpRadio.prototype.getState = function () { + var self = this; + self.commandRouter.pushConsoleMessage('[' + Date.now() + '] ' + 'JP_Radio::getState'); +}; + +//Parse state +ControllerJpRadio.prototype.parseState = function (sState) { + var self = this; + self.commandRouter.pushConsoleMessage('[' + Date.now() + '] ' + 'JP_Radio::parseState'); + + //Use this method to parse the state and eventually send it with the following function +}; + +// Announce updated State +ControllerJpRadio.prototype.pushState = function (state) { + var self = this; + self.commandRouter.pushConsoleMessage('[' + Date.now() + '] ' + 'JP_Radio::pushState'); + + return self.commandRouter.servicePushState(state, self.servicename); +}; + +ControllerJpRadio.prototype.explodeUri = function (uri) { + var self = this; + var defer = libQ.defer(); + + // Mandatory: retrieve all info for a given URI + + return defer.promise; +}; + +ControllerJpRadio.prototype.getAlbumArt = function (data, path) { + + var artist, album; + + if (data != undefined && data.path != undefined) { + path = data.path; + } + + var web; + + if (data != undefined && data.artist != undefined) { + artist = data.artist; + if (data.album != undefined) { + album = data.album; + } else { + album = data.artist + } + + web = '?web=' + nodetools.urlEncode(artist) + '/' + nodetools.urlEncode(album) + '/large' + } + + var url = '/albumart'; + + if (web != undefined) + url = url + web; + + if (web != undefined && path != undefined) { + url = url + '&'; + } else if (path != undefined) { + url = url + '?'; + } + + if (path != undefined) { + url = url + 'path=' + nodetools.urlEncode(path); + } + + return url; +}; + +ControllerJpRadio.prototype.search = function (query) { + var self = this; + var defer = libQ.defer(); + + // Mandatory, search. You can divide the search in sections using following functions + + return defer.promise; +}; + +ControllerJpRadio.prototype._searchArtists = function (results) { + +}; + +ControllerJpRadio.prototype._searchAlbums = function (results) { + +}; + +ControllerJpRadio.prototype._searchPlaylists = function (results) { + +}; + +ControllerJpRadio.prototype._searchTracks = function (results) { + +}; + +ControllerJpRadio.prototype.goto = function (data) { + var self = this + var defer = libQ.defer() + + // Handle go to artist and go to album function + + return defer.promise; +}; \ No newline at end of file diff --git a/jp_radio/install.sh b/jp_radio/install.sh new file mode 100644 index 000000000..2389a6bbf --- /dev/null +++ b/jp_radio/install.sh @@ -0,0 +1,3 @@ +#!/bin/bash +echo "JP Radio plugin installed" +echo "plugininstallend" diff --git a/jp_radio/lib/prog.js b/jp_radio/lib/prog.js new file mode 100644 index 000000000..53b31e31b --- /dev/null +++ b/jp_radio/lib/prog.js @@ -0,0 +1,102 @@ +'use strict'; +require('date-utils'); +const { format } = require('util'); +const got = require('got'); +const Datastore = require('nedb-promises'); +const { XMLParser } = require('fast-xml-parser'); + +const xmlOptions = { + attributeNamePrefix: '@', + ignoreAttributes: false, + ignoreNameSpace: true, + allowBooleanAttributes: true, +}; +const xmlParser = new XMLParser(xmlOptions); + +class RdkProg { + #PROG_URL = 'http://radiko.jp/v3/program/date/%s/%s.xml'; + + constructor(logger) { + this.logger = logger; + this.db = Datastore.create({ inMemoryOnly: true }); + this.db.ensureIndex({ fieldName: 'id', unique: true }); + this.db.ensureIndex({ fieldName: 'station' }); + this.db.ensureIndex({ fieldName: 'ft' }); + this.db.ensureIndex({ fieldName: 'tt' }); + this.station = null; + this.lastdt = null; + this.progdata = null; + } + + getCurProgram = async (station) => { + let curdt = new Date().toFormat('YYYYMMDDHH24MI'); + if (station != this.station || curdt != this.lastdt) { + try { + const rows = await this.db.find({ station, ft: { $lte: curdt }, tt: { $gte: curdt } }); + this.progdata = rows[0]; + } catch (error) { + this.logger.error('JP_Radio::DB Insert Error'); + } + } + this.station = station; + this.lastdt = curdt; + return this.progdata; + } + + putProgram = async (prog_data) => { + try { + await this.db.insert(prog_data); + } catch (error) { + if (error.errorType != 'uniqueViolated') { + this.logger.error('JP_Radio::DB Insert Error'); + } + } + } + + clearOldProgram = async () => { + let curdt = new Date().toFormat('YYYYMMDDHH24MI'); + try { + await this.db.remove({ tt: { $lt: curdt } }, { multi: true }); + } catch (error) { + this.logger.error('JP_Radio::DB Delete Error'); + } + } + + updatePrograms = async () => { + let curdt = new Date().toFormat('YYYYMMDD'); + for (let i = 1; i <= 47; i++) { + let areaID = format('JP%d', i); + let url = format(this.#PROG_URL, curdt, areaID) + + const response = await got(url); + + let data = xmlParser.parse(response.body); + + for (let stations of data.radiko.stations.station) { + let stationName = stations['@id']; + for (let progs of stations.progs.prog) { + await this.putProgram({ + station: stationName, + id: stationName + progs['@id'], + ft: progs['@ft'], + tt: progs['@to'], + title: progs['title'], + pfm: progs['pfm'] || '' + }); + } + } + } + } + + dbClose = async () => { + this.logger.info('JP_Radio::DB Delete'); + return this.db.persistence.compactDatafile(); + } + + allData = async () => { + const allData = await this.db.find({}); + return JSON.stringify(allData, null, 2); + } +} + +module.exports = RdkProg; diff --git a/jp_radio/lib/radiko.js b/jp_radio/lib/radiko.js new file mode 100644 index 000000000..a25e3d9b2 --- /dev/null +++ b/jp_radio/lib/radiko.js @@ -0,0 +1,351 @@ +'use strict'; +require('date-utils'); +const { format } = require('util'); +const got = require('got'); +const spawn = require('child_process').spawn; +const capitalize = require('capitalize'); + +const tough = require('tough-cookie'); + +const { XMLParser } = require('fast-xml-parser'); + +const xmlOptions = { + attributeNamePrefix: '@', + ignoreAttributes: false, + ignoreNameSpace: true, + allowBooleanAttributes: true, +}; + +const xmlParser = new XMLParser(xmlOptions); + +class Radiko { + #LOGIN_URL = 'https://radiko.jp/ap/member/webapi/member/login'; + #CHECK_URL = 'https://radiko.jp/ap/member/webapi/v2/member/login/check'; + #LOGOUT_URL = 'https://radiko.jp/ap/member/webapi/member/logout'; + #AUTH_KEY = 'bcd151073c03b352e1ef2fd66c32209da9ca0afa'; + #AUTH1_URL = 'https://radiko.jp/v2/api/auth1'; + #AUTH2_URL = 'https://radiko.jp/v2/api/auth2'; + #CHANNEL_AREA_URL = 'http://radiko.jp/v3/station/list/%s.xml'; + #CHANNEL_FULL_URL = 'http://radiko.jp/v3/station/region/full.xml'; + #PLAY_URL = 'http://f-radiko.smartstream.ne.jp/%s/_definst_/simul-stream.stream/playlist.m3u8'; + #MAX_RETRY_COUNT = 2; + #PROG_DAILY_URL = "https://radiko.jp/v3/program/station/date/%s/%s.xml" + constructor(port, logger) { + this.port = port; + this.logger = logger; + + this.token = null; + this.areaID = null; + + this.areaData = null; + this.stations = null; + this.cookieJar = null; + + this.stationData = []; + } + + init = async (acct = null, forceGetStations = false) => { + let cookieJar = new tough.CookieJar(); + if (acct) { + var loginState = null; + if (this.cookieJar) { + loginState = await this.#checkLogin(cookieJar); + } + if (!this.cookieJar || !this.loginState) { + cookieJar = await this.#login(acct); + loginState = await this.#checkLogin(cookieJar); + if (loginState) { + this.cookieJar = cookieJar; + } + } + this.loginState = loginState; + } else { + this.loginState = null; + } + + if (forceGetStations || !this.areaID) { + let [authToken, areaID] = await this.#getToken(cookieJar); + this.token = authToken; + this.areaID = areaID; + this.logger.info('JP_Radio::getting stations'); + await this.#getStations(); + } + } + + #getToken = async (cookieJar) => { + let authResponse = await this.#auth1(cookieJar); + let [partialKey, authToken] = await this.#getPartialKey(authResponse); + + let txt = await this.#auth2(authToken, partialKey, cookieJar); + this.logger.debug(txt.trim().toString()); + let [areaID, areaName, areaNameAscii] = txt.trim().split(','); + + return [authToken, areaID]; + } + + #login = async (acct) => { + let cookieJar = new tough.CookieJar(); + const options = { + cookieJar, + method: 'POST', + methodRewriting: true, + form: { + mail: acct['mail'], + pass: acct['pass'] + }, + } + try { + await got(this.#LOGIN_URL, options); + return cookieJar; + } catch (err) { + this.logger.error('JP_Radio::premium account login error'); + if (err.statusCode === 302) { + return cookieJar; + } + } + return null; + } + + #checkLogin = async (cookieJar) => { + if (!cookieJar) { + this.logger.info('JP_Radio::premium account not set'); + return null; + } + try { + const options = { + cookieJar, + method: 'GET', + responseType: 'json' + } + const response = await got(this.#CHECK_URL, options); + let memberType = response.body.member_type.type; + this.logger.info('JP_Radio::premium logged in - Member Type:%s', memberType); + return response.body; + } catch (err) { + if (err.statusCode === 400) { + this.logger.info('JP_Radio::premium not logged in'); + return null; + } + this.logger.error('JP_Radio::premium account login check error'); + } + return null; + } + + #logout = async (cookieJar) => { + if (this.loginState) { + let logout = await got(this.#LOGOUT_URL, { cookieJar }); + let txt = logout.read(); + this.loginState = null; + this.logger.info('premium logout'); + return json.loads(txt.decode()); + } + } + + #auth1 = async (cookieJar) => { + const options = { + cookieJar, + method: 'GET', + headers: { + 'User-Agent': 'curl/7.56.1', + 'Accept': '*/*', + 'X-Radiko-App': 'pc_html5', + 'X-Radiko-App-Version': '0.0.1', + 'X-Radiko-User': 'dummy_user', + 'X-Radiko-Device': 'pc', + } + } + const response = await got(this.#AUTH1_URL, options); + return { Headers: response.headers }; + } + + #getPartialKey = async (authResponse) => { + let authToken = authResponse.Headers['x-radiko-authtoken']; + let keyLength = parseInt(authResponse.Headers['x-radiko-keylength'], 10); + let keyOffset = parseInt(authResponse.Headers['x-radiko-keyoffset'], 10); + let partialKey = this.#AUTH_KEY.slice(keyOffset, (keyOffset + keyLength)); + partialKey = Buffer.from(partialKey).toString('base64'); + return [partialKey, authToken]; + }; + + #auth2 = async (authToken, partialKey, cookieJar) => { + const options = { + cookieJar, + method: 'GET', + headers: { + 'X-Radiko-AuthToken': authToken, + 'X-Radiko-Partialkey': partialKey, + 'X-Radiko-User': 'dummy_user', + 'X-Radiko-Device': 'pc', + } + } + const response = await got(this.#AUTH2_URL, options); + return response.body; + } + + #getStations = async () => { + this.areaData = new Map(); + this.stations = new Map(); + + const response = await got(this.#CHANNEL_FULL_URL); + + let stationsInfo = xmlParser.parse(response.body); + + let stationData = []; + + for (const stationsTemp of stationsInfo.region.stations) { + let data = {}; + data['region'] = new Map(); + data['stations'] = []; + + data['region'] = stationsTemp; + + for (const stationTemp of stationsTemp.station) { + data['stations'].push({ + 'id': stationTemp['id'], + 'name': stationTemp['name'], + 'ascii_name': stationTemp['ascii_name'], + 'areafree': stationTemp['areafree'], + 'timefree': stationTemp['timefree'], + 'banner': stationTemp['banner'], + 'area_id': stationTemp['area_id'], + }); + } + stationData.push(data); + } + + this.stationData = stationData; + + for (let i = 1; i <= 47; i++) { + let areaID = format('JP%d', i); + let url = format(this.#CHANNEL_AREA_URL, areaID); + const response = await got(url); + + let xmlDataArea = xmlParser.parse(response.body); + + let stations = []; + for (const stationInfo of xmlDataArea.stations.station) { + stations.push(stationInfo['id']); + }; + + this.areaData.set(areaID, { areaName: xmlDataArea.stations['@area_name'], stations: stations }); + } + let stations = new Map(); + for (const region of this.stationData) { + let regionData = region['region']; + for (const s of region['stations']) { + let stationID = s['id']; + let regionName = regionData['region_name']; + let bannerURL = s['banner']; + let areaID = s['area_id']; + let areaName = this.areaData.get(s['area_id'])['areaName'].replace(' JAPAN', ''); + let name = s['name']; + let asciiName = s['ascii_name']; + + if (this.loginState || this.areaData.get(this.areaID)['stations'].includes(stationID)) { + stations.set(stationID, { + RegionName: regionName, + BannerURL: bannerURL, + AreaID: areaID, + AreaName: areaName, + Name: name, + AsciiName: asciiName, + }); + } + } + } + this.stations = stations; + } + + getStationAsciiName = async (station) => { + let stationName = ''; + if (this.stations.has(station)) { + stationName = this.stations.get(station).AsciiName; + } + return stationName; + } + + play = async (station) => { + this.logger.info(format('JP_Radio::playing %s', station)); + if (this.stations.has(station)) { + let url = format(this.#PLAY_URL, station); + let m3u8 = null; + + for (let i = 0; i < this.#MAX_RETRY_COUNT; i++) { + m3u8 = await this.#genTempChunkM3u8URL(url, this.token); + if (m3u8) { + break; + } + this.logger.info('JP_Radio::getting new token'); + let [authToken, areaID] = await this.#getToken(); + this.token = authToken; + this.areaID = areaID; + } + + if (!m3u8) { + this.logger.error('JP_Radio::gen temp chunk m3u8 url fail'); + return null; + } else { + let cmd = format('ffmpeg -y -headers X-Radiko-Authtoken:%s -i %s -acodec copy -f adts -loglevel error pipe:1', this.token, m3u8); + let proc = spawn(cmd, { + shell: true, + stdio: [null, process.pipe, null, 'ipc'], + detached: true, + maxBuffer: 1024 * 1024 * 2 + }); + let pid = proc.pid; + this.logger.debug(format('JP_Radio::started subprocess: group id %s', pid)); + + proc.on('exit', (code) => { + this.logger.info(format('JP_Radio::stop playing %s', station)); + this.logger.debug(format('JP_Radio::killing process group %s', pid)); + }); + return proc; + } + } else { + this.logger.error(format('JP_Radio::%s not in available stations', station)); + } + return null; + } + + #genTempChunkM3u8URL = async (url, authToken) => { + const options = { + method: 'GET', + headers: { + 'X-Radiko-AuthToken': authToken, + } + } + try { + const response = await got(url, options); + const lines = response.body.match(/^https?:\/\/.+m3u8$/gm); + return lines[0]; + } catch (err) { + if (err.statusCode === 403) { + return null; + } + return null; + } + } + + radioStations = () => { + let radikoPlayLists = []; + for (let station of this.stations.keys()) { + let temp = this.stations.get(station); + let title = format('%s / %s', capitalize(this.stations.get(station).AreaName), temp['Name']); + + radikoPlayLists.push({ + service: 'webradio', + type: 'song', + title: title, + albumart: temp['BannerURL'], + uri: `http://127.0.0.1:${this.port}/radiko/${station}`, + name: '', + samplerate: '', + bitdepth: 0, + channels: 0 + }); + } + return radikoPlayLists; + } +} + +module.exports = Radiko; \ No newline at end of file diff --git a/jp_radio/lib/radio.js b/jp_radio/lib/radio.js new file mode 100644 index 000000000..4cc65fd2c --- /dev/null +++ b/jp_radio/lib/radio.js @@ -0,0 +1,112 @@ +'use strict'; +const express = require('express'); +const RdkProg = require('./prog'); +const Radiko = require('./radiko'); +const cron = require('node-cron'); +const IcyMetadata = require('icy-metadata'); + +class JpRadio { + constructor(port, logger, acct = null) { + this.app = express(); + this.server = null; + this.port = port || 9000; + this.logger = logger; + this.acct = acct; + + this.task = cron.schedule('0 3,9,15 * * *', async () => { + await this.#pgupdate(); + }, false); + + this.prg = null; + this.rdk = null; + + this.app.get('/radiko/', (req, res) => { + res.send('Hello, world. You\'re at the radiko_app index.'); + }); + + this.app.get('/radiko/:stationID', async (req, res) => { + let station = req.params['stationID']; + + if (this.rdk.stations.has(station)) { + await this.rdk.init(this.acct); + const icyMetadata = new IcyMetadata(); + + let ffmpeg = await this.rdk.play(station); + res.setHeader('HeaderCacheControl', 'no-cache, no-store'); + res.setHeader('icy-name', await this.rdk.getStationAsciiName(station)); + res.setHeader('icy-metaint', icyMetadata.metaInt); + res.setHeader('Content-Type', 'audio/aac'); + res.setHeader('Connection', 'keep-alive'); + + let progData = await this.prg.getCurProgram(station); + let title = null; + if (progData) { + title = (progData['pfm'] ? progData['pfm'] : '') + ' - ' + (progData['title'] ? progData['title'] : ''); + } + + if (title) { + icyMetadata.setStreamTitle(title); + } + + ffmpeg.stdout.pipe(icyMetadata).pipe(res); + + res.on('close', function () { + (async () => { + process.kill(-ffmpeg.pid, 'SIGTERM'); + })(); + }); + this.logger.debug('JP_Radio::get returning response'); + } else { + res.send(format('JP_Radio::%s not in available stations', station)); + this.logger.error(format('JP_Radio::%s not in available stations', station)) + } + }); + } + + start() { + if (this.server) { + this.logger.info('JP_Radio::App already started'); + return Promise.resolve(); + } + return new Promise(async (resolve, reject) => { + this.prg = new RdkProg(this.logger); + this.rdk = new Radiko(this.port, this.logger, this.acct); + await this.#init(this.acct); + this.server = this.app.listen(this.port, async () => { + this.logger.info(`JP_Radio::App is listening on port ${this.port}.`); + this.task.start(); + resolve(); + }).on('error', err => { + this.logger.error('JP_Radio::App error:', err); + reject(err); + }); + }); + } + + stop = async () => { + if (this.server) { + this.task.stop(); + this.server.close(); + this.server = null; + await this.prg.dbClose(); + this.prg = null; + this.rdk = null; + } + } + + radioStations = () => { + return this.rdk.radioStations() + } + + #init = async () => { + await this.rdk.init(this.acct); + await this.#pgupdate(); + } + + #pgupdate = async () => { + this.logger.info('JP_Radio::Updating program listings'); + await this.prg.updatePrograms(); + await this.prg.clearOldProgram(); + } +} +module.exports = JpRadio; \ No newline at end of file diff --git a/jp_radio/package.json b/jp_radio/package.json new file mode 100644 index 000000000..d1314ef82 --- /dev/null +++ b/jp_radio/package.json @@ -0,0 +1,45 @@ +{ + "name": "jp_radio", + "version": "0.0.3", + "description": "Japanese radio relay server for Volumio3", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "mOqOm", + "license": "ISC", + "repository": "https://github.com/mOqOm/", + "volumio_info": { + "prettyName": "JP Radio", + "icon": "fa-volume-up", + "plugin_type": "music_service", + "architectures": [ + "amd64", + "armhf", + "i386" + ], + "os": [ + "buster" + ], + "details": "Japanese radio relay server for Volumio3", + "changelog": "" + }, + "engines": { + "node": ">=14.15.4 <15.0.0", + "volumio": ">=3.546.0 <4.0.0" + }, + "dependencies": { + "capitalize": "^2.0.4", + "date-utils": "^1.2.21", + "express": "^4.18.2", + "fast-xml-parser": "^4.3.2", + "fs-extra": "^0.28.0", + "got": "^11.8.6", + "icy-metadata": "^0.1.2", + "kew": "^0.7.0", + "nedb-promises": "^6.2.3", + "node-cron": "^3.0.2", + "tough-cookie": "^4.1.3", + "v-conf": "^1.4.0" + } +} diff --git a/jp_radio/uninstall.sh b/jp_radio/uninstall.sh new file mode 100644 index 000000000..3507bb3ea --- /dev/null +++ b/jp_radio/uninstall.sh @@ -0,0 +1,2 @@ +#!/bin/bash +echo "JP_Radio plugin uninstalled" \ No newline at end of file