diff --git a/package-lock.json b/package-lock.json index 178b6eb6..a194f108 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1552,6 +1552,14 @@ "lodash": "^4.17.4", "mkdirp": "^0.5.1", "source-map-support": "^0.4.15" + }, + "dependencies": { + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", + "dev": true + } } }, "babel-runtime": { @@ -1561,6 +1569,13 @@ "requires": { "core-js": "^2.4.0", "regenerator-runtime": "^0.11.0" + }, + "dependencies": { + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" + } } }, "babel-template": { @@ -2895,11 +2910,6 @@ } } }, - "core-js": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.5.tgz", - "integrity": "sha1-sU3ek2xkDAV5prUMq8wTLdYSfjs=" - }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -4346,6 +4356,12 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fast-xml-parser": { + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.17.3.tgz", + "integrity": "sha512-g3OSnHBWq5hrpS4LUgFWOS87F7B6UDklkI6v2VLC/89Ech00fabXleKEHTVXQBkKyVsfOxD+1QY6fkoIZMIO/Q==", + "dev": true + }, "fastparse": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.1.tgz", diff --git a/package.json b/package.json index 5d57f332..78b2f5db 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "eslint-plugin-vue": "^4.3.0", "eventsource-polyfill": "^0.9.6", "extract-text-webpack-plugin": "^2.0.0", + "fast-xml-parser": "^3.17.3", "file-loader": "^0.11.1", "friendly-errors-webpack-plugin": "^1.1.3", "html-webpack-plugin": "^2.28.0", diff --git a/src/store/modules/plex/actions.js b/src/store/modules/plex/actions.js index ffe355b1..e126b3da 100644 --- a/src/store/modules/plex/actions.js +++ b/src/store/modules/plex/actions.js @@ -1,4 +1,6 @@ +import axios from 'axios'; const request = require('request'); +import xmlutils from '@/utils/xmlutils'; const parseXMLString = require('xml2js').parseString; const _PlexAuth = require('./helpers/PlexAuth.js'); @@ -58,9 +60,9 @@ export default { }); }), - PLEX_GET_DEVICES: ({ state, commit, dispatch }, dontDelete) => new Promise((resolve, reject) => { + PLEX_GET_DEVICES: async ({ state, commit, dispatch }, dontDelete) => { if (!state.user) { - return reject(new Error('Sign in before getting devices')); + throw new Error('Sign in before getting devices'); } if (!dontDelete) { @@ -68,112 +70,101 @@ export default { commit('PLEX_SET_VALUE', ['servers', {}]); commit('PLEX_SET_VALUE', ['clients', {}]); } - const options = PlexAuth.getApiOptions('https://plex.tv/api/resources?includeHttps=1', state.user.authToken, 5000, 'GET'); - request(options, (error, response, body) => { - if (!error && response.statusCode === 200) { - // Valid response - parseXMLString(body, async (err, result) => { - if (err) { - return reject(err); - } - for (const index in result.MediaContainer.Device) { - // Handle the individual device - const device = result.MediaContainer.Device[index].$; - // Each device can have multiple network connections - // Any of them can be viable routes to interacting with the device - const connections = result.MediaContainer.Device[index].Connection; - const tempConnectionsArray = []; - // Create a temporary array of object:PlexConnection - for (const i in connections) { - const connection = connections[i].$; - // Exclude local IPs starting with 169.254 - if (!connection.address.startsWith('169.254')) { - const tempConnection = new PlexConnection(); - for (const key in connection) { - tempConnection[key] = connection[key]; - } - tempConnectionsArray.push(tempConnection); - if (connection.local === '1' && connection.uri.indexOf('plex') > -1) { - const rawConnection = new PlexConnection(); - Object.assign(rawConnection, connection); - rawConnection.uri = `${connection.protocol}://${connection.address}:${connection.port}`; - rawConnection.isManual = true; - tempConnectionsArray.push(rawConnection); - } - } - } - // If device is a player - if (device.provides.indexOf('player') !== -1) { - // If device is not Plex Web - if(device.product.indexOf('Plex Web') === -1) { - // This is a Client - // Create a new PlexClient object - const tempClient = new PlexClient(); - for (const key in device) { - tempClient[key] = device[key]; - } - tempClient.accessToken = state.user.authToken; - tempClient.plexConnections = tempConnectionsArray; - dispatch('PLEX_ADD_CLIENT', tempClient); - } - // If device is a server - } else if (device.provides.indexOf('server') !== -1) { - // This is a Server - // Create a new PlexServer object - const tempServer = new PlexServer(); - for (const key in device) { - tempServer[key] = device[key]; - } - // Push a manual connection string for when DNS rebind doesnt work - tempServer.plexConnections = tempConnectionsArray; - if (tempServer.accessToken == null) { - tempServer.accessToken = state.user.authToken; - } + try { + const { data } = await axios.get('https://plex.tv/api/resources?includeHttps=1', + PlexAuth.getRequestConfig(state.user.authToken, 5000)); + const result = xmlutils.parseXML(data); - dispatch('PLEX_ADD_SERVER', tempServer); + result.MediaContainer[0].Device.forEach((device) => { + // Create a temporary array of object:PlexConnection + // Exclude local IPs starting with 169.254 + const tempConnectionsArray = device.Connection + .filter((connection) => !connection.address.startsWith('169.254')) + .flatMap((connection) => { + const tempConnection = new PlexConnection(); + Object.assign(tempConnection, connection); + if (connection.local === '1' && connection.uri.indexOf('plex') > -1) { + const rawConnection = new PlexConnection(); + Object.assign(rawConnection, connection); + rawConnection.uri = `${connection.protocol}://${connection.address}:${connection.port}`; + rawConnection.isManual = true; + // Return both + return [tempConnection, rawConnection]; } + + return [tempConnection]; + }); + + tempConnectionsArray.sort((con1, con2) => parseInt(con1.port, 10) - parseInt(con2.port, 10)); + + // If device is a player + if (device.provides.indexOf('player') !== -1) { + // If device is not Plex Web + if (device.product.indexOf('Plex Web') === -1) { + // This is a Client + // Create a new PlexClient object + const tempClient = new PlexClient(); + Object.assign(tempClient, device); + + tempClient.accessToken = state.user.authToken; + tempClient.plexConnections = tempConnectionsArray; + dispatch('PLEX_ADD_CLIENT', tempClient); } - // Setup our slPlayer - const ptplayer = new PlexClient(); - ptplayer.provides = 'player'; - ptplayer.clientIdentifier = 'PTPLAYER9PLUS10'; - ptplayer.platform = 'Web'; - ptplayer.device = 'Web'; - ptplayer.product = 'SyncLounge'; - ptplayer.name = 'SyncLounge Player'; - ptplayer.labels = [ - ['Recommended', 'green'], - ]; - ptplayer.lastSeenAt = Math.round((new Date()).getTime() / 1000); - for (const i in state.clients) { - const client = state.clients[i]; - for (const j in client.plexConnections) { - const clientConnection = client.plexConnections[j]; - // Check if this URL matches any server connections - for (const x in state.servers) { - const server = state.servers[x]; - for (const y in server.plexConnections) { - const serverConnection = server.plexConnections[y]; - if (serverConnection.uri === clientConnection.uri) { - client.accessToken = server.accessToken; - } - } - } - } + } else if (device.provides.indexOf('server') !== -1) { + // This is a Server + // Create a new PlexServer object + const tempServer = new PlexServer(); + Object.assign(tempServer, device); + // Push a manual connection string for when DNS rebind doesnt work + tempServer.plexConnections = tempConnectionsArray; + if (tempServer.accessToken == null) { + tempServer.accessToken = state.user.authToken; } - dispatch('PLEX_ADD_CLIENT', ptplayer); - commit('PLEX_SET_VALUE', ['gotDevices', true]); - dispatch('PLEX_REFRESH_SERVER_CONNECTIONS'); - return resolve(true); + dispatch('PLEX_ADD_SERVER', tempServer); + } + }); + + // Setup our slPlayer + const ptplayer = new PlexClient(); + ptplayer.provides = 'player'; + ptplayer.clientIdentifier = 'PTPLAYER9PLUS10'; + ptplayer.platform = 'Web'; + ptplayer.device = 'Web'; + ptplayer.product = 'SyncLounge'; + ptplayer.name = 'SyncLounge Player'; + ptplayer.labels = [['Recommended', 'green']]; + ptplayer.lastSeenAt = Math.round(new Date().getTime() / 1000); + + // Get an array of {accessToken, uri} objects + const serverConnectionTokens = Object.entries(state.servers) + .flatMap(([, server]) => server.plexConnections + .map((serverConnection) => ({ + accessToken: serverConnection.accessToken, + uri: serverConnection.uri, + }))); + + Object.entries(state.clients).forEach(([, client]) => { + client.plexConnections.forEach((clientConnection) => { + const match = serverConnectionTokens + .find((serverCon) => serverCon.uri === clientConnection.uri); + if (match !== undefined) { + // Yeah it would be better if I didn't have to mutate client but oh well + // eslint-disable-next-line no-param-reassign + client.accessToken = match.accessToken; + } }); - } else { - // Invalid response - commit('PLEX_SET_VALUE', ['gotDevices', true]); - return reject(new Error('Invalid Response')); - } - }); - }), + }); + + dispatch('PLEX_ADD_CLIENT', ptplayer); + commit('PLEX_SET_VALUE', ['gotDevices', true]); + dispatch('PLEX_REFRESH_SERVER_CONNECTIONS'); + } catch (e) { + // Invalid response + commit('PLEX_SET_VALUE', ['gotDevices', true]); + throw e; + } + }, PLEX_REFRESH_SERVER_CONNECTIONS: ({ state, dispatch }) => { for (const id in state.servers) { diff --git a/src/store/modules/plex/helpers/PlexAuth.js b/src/store/modules/plex/helpers/PlexAuth.js index 01feb8c1..de3f1fd6 100644 --- a/src/store/modules/plex/helpers/PlexAuth.js +++ b/src/store/modules/plex/helpers/PlexAuth.js @@ -21,6 +21,17 @@ module.exports = function PlexAuth() { }; }; + this.getRequestConfig = function (accessToken, timeout) { + return { + headers: { + 'X-Plex-Client-Identifier': 'SyncLounge', + Accept: 'application/json', + 'X-Plex-Token': accessToken, + }, + timeout, + }; + }; + /** * * @param url diff --git a/src/store/modules/plex/helpers/PlexServer.js b/src/store/modules/plex/helpers/PlexServer.js index 1be0425a..260a760f 100644 --- a/src/store/modules/plex/helpers/PlexServer.js +++ b/src/store/modules/plex/helpers/PlexServer.js @@ -1,6 +1,7 @@ const request = require('request'); const safeParse = require('safe-json-parse/callback'); const _PlexAuth = require('./PlexAuth.js'); +import promiseutils from '@/utils/promiseutils'; const PlexAuth = new _PlexAuth(); @@ -64,56 +65,45 @@ module.exports = function PlexServer() { } }); }; - this.hitApiTestConnection = async function (command, connection) { - return new Promise(async (resolve, reject) => { - const _url = connection.uri + command; - const options = PlexAuth.getApiOptions(_url, this.accessToken, 7500, 'GET'); - request(options, (error, response, body) => { - if (!error) { - safeParse(body, (err, json) => { - if (err) { - return reject(err); - } - return resolve(json); - }); - } else { - return reject(error); - } - }); - }); + + this.hitApiTestConnection = function (command, connection) { + const url = `${connection.uri}${command}`; + const config = PlexAuth.getRequestConfig(this.accessToken, 7500); + return axios.get(url, config); }; + this.setChosenConnection = function (con) { this.chosenConnection = con; }; - this.findConnection = function () { + + this.findConnection = async function () { // This function iterates through all available connections and // if any of them return a valid response we'll set that connection // as the chosen connection for future use. - let resolved = false; - return new Promise(async (resolve, reject) => { - await Promise.all(this.plexConnections.map(async (connection, index) => - /*eslint-disable */ - new Promise(async (_resolve, _reject) => { - try { - let result = await this.hitApiTestConnection('', connection) - if (result) { - resolved = true - // console.log('Succesfully connected to', server, 'via', connection) - this.setValue('chosenConnection', connection) - resolve(true) - } - _resolve(false) - } catch (e) { - _resolve(false) - } - }) - /* eslint-enable */ - )); - if (!resolved) { - reject(new Error('Unable to find a connection')); - } - }); + // Prefer secure connections first. + const secureConnections = this.plexConnections.filter((connection) => connection.protocol === 'https'); + + try { + const secureConnection = promiseutils.any( + secureConnections.map((connection) => this.hitApiTestConnection('', connection).then(() => connection)) + ); + this.setValue('chosenConnection', secureConnection); + return true; + } catch (e) { + console.log('No secure connections found'); + } + + // If we are using synclounge over https, we can't access connections over http because + // most modern web browsers block mixed content + if (window.location.protocol === 'http:') { + const insecureConnections = this.plexConnections.filter((connection) => connection.protocol === 'http'); + const insecureConnection = promiseutils.any(insecureConnections.map((connection) => this.hitApiTestConnection('', connection).then(() => connection))); + this.setValue('chosenConnection', insecureConnection); + return true; + } + + throw new Error('Unable to find a connection'); }; // Functions for dealing with media diff --git a/src/utils/promiseutils.js b/src/utils/promiseutils.js new file mode 100644 index 00000000..6461c336 --- /dev/null +++ b/src/utils/promiseutils.js @@ -0,0 +1,12 @@ +// There doesn't seem to be a valid Promise.any polyfill in the current build config so I found one myself + +export default { + any: (promises) => { + return Promise.all(promises.map(promise => + promise.then((val) => { + throw val; + }, reason => reason))).then((reasons) => { + throw reasons; + }, firstResolved => firstResolved); + }, +}; diff --git a/src/utils/xmlutils.js b/src/utils/xmlutils.js new file mode 100644 index 00000000..2a2184eb --- /dev/null +++ b/src/utils/xmlutils.js @@ -0,0 +1,11 @@ +import parser from 'fast-xml-parser'; + +const options = { + attributeNamePrefix : "", + ignoreAttributes: false, + arrayMode: true, +}; + +export default { + parseXML: (xml) => parser.parse(xml, options), +};