├── .jshintrc ├── .gitignore ├── .travis.yml ├── docs └── plex-device-example.png ├── test ├── samples │ ├── resources │ │ └── movie-creative-commons-flowercat.jpg │ ├── library │ │ ├── sections │ │ │ └── 1 │ │ │ │ └── all.json │ │ └── sections.json │ ├── clients.json │ ├── devices.xml │ └── root.json ├── image-test.js ├── timeout-test.js ├── utils-test.js ├── put-test.js ├── postQuery-test.js ├── find-test.js ├── api-test.js ├── perform-test.js ├── server.js ├── query-test.js └── authenticator-test.js ├── .jscsrc ├── package.json ├── LICENSE ├── lib ├── uri.js └── api.js ├── CHANGELOG.md └── Readme.md /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 6 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | .DS_Store -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 4 5 | - 6 6 | -------------------------------------------------------------------------------- /docs/plex-device-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plexinc/node-plex-api/HEAD/docs/plex-device-example.png -------------------------------------------------------------------------------- /test/samples/resources/movie-creative-commons-flowercat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plexinc/node-plex-api/HEAD/test/samples/resources/movie-creative-commons-flowercat.jpg -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "crockford", 3 | "disallowDanglingUnderscores": null, 4 | "requireMultipleVarDecl": null, 5 | "requireSpaceAfterKeywords": [ 6 | "do", 7 | "for", 8 | "if", 9 | "else", 10 | "switch", 11 | "case", 12 | "try", 13 | "catch", 14 | "while", 15 | "return", 16 | "typeof" 17 | ] 18 | } -------------------------------------------------------------------------------- /test/samples/library/sections/1/all.json: -------------------------------------------------------------------------------- 1 | { "_elementType":"MediaContainer", 2 | "librarySectionID":"1", 3 | "librarySectionTitle":"Misc", 4 | "nocache":"1", 5 | "thumb":"/:/resources/show.png", 6 | "title1":"Misc", 7 | "title2":"All Shows", 8 | "viewGroup":"show", 9 | "_children":[{ 10 | "_elementType":"Directory", 11 | "key":"/library/metadata/9901/children", 12 | "type":"show", 13 | "title":"The Sample Show", 14 | "summary":"This is an example show." 15 | } 16 | ]} 17 | 18 | -------------------------------------------------------------------------------- /test/samples/clients.json: -------------------------------------------------------------------------------- 1 | { 2 | "_elementType": "MediaContainer", 3 | "_children": [ 4 | { 5 | "_elementType": "Server", 6 | "name": "mac-mini", 7 | "host": "192.168.0.47", 8 | "address": "192.168.0.47", 9 | "port": 3005, 10 | "machineIdentifier": "66f7da37", 11 | "version": "1.2.3.378-0c92ed32", 12 | "protocol": "plex", 13 | "product": "Plex Home Theater", 14 | "deviceClass": "HTPC", 15 | "protocolVersion": "1", 16 | "protocolCapabilities": "navigation,playback,timeline,mirror,playqueues" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /test/image-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js'); 2 | var server = require('./server'); 3 | 4 | var PlexAPI = require('..'); 5 | 6 | describe('query()', function() { 7 | var api; 8 | 9 | beforeEach(function() { 10 | server.start({ 11 | contentType: 'image/jpg' 12 | }); 13 | 14 | api = new PlexAPI('localhost'); 15 | }); 16 | 17 | afterEach(server.stop); 18 | 19 | 20 | it('resource endpoint should return a buffer', function() { 21 | return api.query('/resources/movie-creative-commons-flowercat.jpg').then(function(result) { 22 | expect(result).to.be.a(Buffer); 23 | }); 24 | }); 25 | 26 | }); -------------------------------------------------------------------------------- /test/timeout-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js'); 2 | var server = require('./server'); 3 | 4 | var ROOT_URL = '/'; 5 | 6 | var PlexAPI = require('..'); 7 | 8 | describe('timeout error', function() { 9 | var api; 10 | 11 | beforeEach(function() { 12 | server.timeoutError(); 13 | 14 | api = new PlexAPI({ hostname: 'localhost', timeout: 10}); 15 | }); 16 | 17 | afterEach(server.stop); 18 | 19 | it('returns error on timeout', function() { 20 | return api.query('/').then(() => { 21 | throw new Error('Should not succeed!'); 22 | }).catch(function (err) { 23 | expect(err.code).to.be('ESOCKETTIMEDOUT'); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/samples/devices.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/utils-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js'); 2 | 3 | var PlexAPI = require('..'); 4 | 5 | describe('_serverScheme', function() { 6 | it('should use http by default', function() { 7 | var api = new PlexAPI({hostname: 'localhost'}); 8 | expect(api._serverScheme()).to.equal('http://'); 9 | }); 10 | it('should use https when port 443 is specified', function() { 11 | var api = new PlexAPI({hostname: 'localhost', port: 443}); 12 | expect(api._serverScheme()).to.equal('https://'); 13 | }); 14 | it('should use https when the https parameter is true', function() { 15 | var api = new PlexAPI({hostname: 'localhost', https: true}); 16 | expect(api._serverScheme()).to.equal('https://'); 17 | }); 18 | it('should use http when the https parameter is false, even on port 443', function() { 19 | var api = new PlexAPI({hostname: 'localhost', port: 443, https: false}); 20 | expect(api._serverScheme()).to.equal('http://'); 21 | }); 22 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plex-api", 3 | "version": "5.0.4", 4 | "description": "Simple wrapper for querying against HTTP API on the Plex Media Server", 5 | "main": "lib/api.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "dependencies": { 10 | "plex-api-credentials": "^3.0.0", 11 | "plex-api-headers": "1.1.0", 12 | "request": "2.79.0", 13 | "uuid": "2.0.2", 14 | "xml2js": "0.4.16" 15 | }, 16 | "devDependencies": { 17 | "expect.js": "^0.3.1", 18 | "jscs": "^3.0.0", 19 | "jshint": "^2.8.0", 20 | "mocha": "^2.5.0", 21 | "nock": "^8.0.0", 22 | "proxyquire": "^1.7.3", 23 | "sinon": "^1.17.2" 24 | }, 25 | "scripts": { 26 | "test": "jshint lib/* && jscs lib/* && mocha test/*-test.js", 27 | "test:watch": "mocha -w test/*-test.js" 28 | }, 29 | "files": [ 30 | "lib" 31 | ], 32 | "repository": "git://github.com/phillipj/node-plex-api", 33 | "keywords": [ 34 | "plex", 35 | "api" 36 | ], 37 | "engines": { 38 | "node": ">=4.0" 39 | }, 40 | "author": "Phillip Johnsen ", 41 | "license": "MIT" 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2013-2016 Phillip Johnsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 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 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/uri.js: -------------------------------------------------------------------------------- 1 | var uriResolvers = { 2 | directory: function directory(parentUrl, dir) { 3 | addDirectoryUriProperty(parentUrl, dir); 4 | }, 5 | 6 | server: function server(parentUrl, srv) { 7 | addServerUriProperty(srv); 8 | } 9 | }; 10 | 11 | function addServerUriProperty(server) { 12 | server.uri = '/system/players/' + server.address; 13 | } 14 | 15 | function addDirectoryUriProperty(parentUrl, directory) { 16 | if (parentUrl[parentUrl.length - 1] !== '/') { 17 | parentUrl += '/'; 18 | } 19 | if (directory.key[0] === '/') { 20 | parentUrl = ''; 21 | } 22 | directory.uri = parentUrl + directory.key; 23 | } 24 | 25 | exports.attach = function attach(parentUrl) { 26 | return function resolveAndAttachUris(result) { 27 | var children = result._children || []; 28 | 29 | children.forEach(function (child) { 30 | var childType = child._elementType.toLowerCase(); 31 | var resolver = uriResolvers[childType]; 32 | 33 | if (resolver) { 34 | resolver(parentUrl, child); 35 | } 36 | }); 37 | 38 | return result; 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /test/samples/library/sections.json: -------------------------------------------------------------------------------- 1 | { 2 | "_elementType": "MediaContainer", 3 | "allowSync": "0", 4 | "identifier": "com.plexapp.plugins.library", 5 | "mediaTagPrefix": "/system/bundle/media/flags/", 6 | "mediaTagVersion": "1426798205", 7 | "title1": "Plex Library", 8 | "_children": [ 9 | { 10 | "_elementType": "Directory", 11 | "allowSync": "0", 12 | "art": "/:/resources/movie-fanart.jpg", 13 | "filters": "1", 14 | "refreshing": "0", 15 | "thumb": "/:/resources/movie.png", 16 | "key": "1", 17 | "type": "movie", 18 | "title": "Movies", 19 | "composite": "/library/sections/1/composite/1417268010", 20 | "agent": "com.plexapp.agents.imdb", 21 | "scanner": "Plex Movie Scanner", 22 | "language": "en", 23 | "uuid": "1209cc3c-1197-4280-ae3a-e9cc7382f01f", 24 | "updatedAt": "1417268010", 25 | "createdAt": "1417268009", 26 | "_children": [ 27 | { 28 | "_elementType": "Location", 29 | "id": 1, 30 | "path": "/Users/foobar/Movies" 31 | } 32 | ] 33 | }, 34 | { 35 | "_elementType": "Directory", 36 | "allowSync": "0", 37 | "art": "/:/resources/photo-fanart.jpg", 38 | "filters": "1", 39 | "refreshing": "0", 40 | "thumb": "/:/resources/photo.png", 41 | "key": "2", 42 | "type": "photo", 43 | "title": "Photos", 44 | "composite": "/library/sections/2/composite/1417268080", 45 | "agent": "com.plexapp.agents.none", 46 | "scanner": "Plex Photo Scanner", 47 | "language": "xn", 48 | "uuid": "a190b7e6-9acd-48fc-a641-0440fc07658f", 49 | "updatedAt": "1417268080", 50 | "createdAt": "1417268080", 51 | "_children": [ 52 | { 53 | "_elementType": "Location", 54 | "id": 2, 55 | "path": "/Users/foobar/Downloads/Photos" 56 | } 57 | ] 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /test/put-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js'); 2 | var server = require('./server'); 3 | 4 | var ROOT_URL = '/'; 5 | 6 | var PlexAPI = require('..'); 7 | 8 | describe('putQuery()', function() { 9 | var api; 10 | 11 | beforeEach(function() { 12 | server.expectsPut(); 13 | 14 | api = new PlexAPI('localhost'); 15 | }); 16 | 17 | afterEach(server.stop); 18 | 19 | it('should exist', function() { 20 | expect(api.putQuery).to.be.a('function'); 21 | }); 22 | 23 | describe('parameters', function() { 24 | it('requires url parameter', function() { 25 | expect(function() { 26 | api.putQuery(); 27 | }).to.throwException('TypeError'); 28 | }); 29 | 30 | it('can accept url parameter as only parameter', function() { 31 | return api.putQuery('/'); 32 | }); 33 | 34 | it('can accept url parameter as part of a parameter object', function() { 35 | return api.putQuery({uri: '/'}); 36 | }); 37 | 38 | it('uses extra headers passed in parameters', function() { 39 | server.stop(); 40 | var nockServer = server.expectsPut({'reqheaders': { 41 | 'X-TEST-HEADER':'X-TEST-HEADER-VAL' 42 | }}); 43 | 44 | return api.putQuery({uri: '/', extraHeaders: {'X-TEST-HEADER':'X-TEST-HEADER-VAL'}}).then(function(result) { 45 | nockServer.done(); 46 | return result; 47 | }); 48 | }); 49 | }); 50 | 51 | it('promise should fail when server responds with failure status code', function() { 52 | return api.putQuery(ROOT_URL).catch(function(err) { 53 | expect(err).not.to.be(null); 54 | }); 55 | }); 56 | 57 | it('promise should succeed when request response status code is 200', function() { 58 | return api.putQuery(ROOT_URL); 59 | }); 60 | 61 | it('promise should succeed when request response status code is 201', function() { 62 | server.stop(); 63 | server.expectsPut({statusCode: 201}); 64 | return api.putQuery(ROOT_URL); 65 | }); 66 | 67 | it('should result in a PUT request', function() { 68 | return api.putQuery(ROOT_URL); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/postQuery-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js'); 2 | var server = require('./server'); 3 | 4 | var ROOT_URL = '/'; 5 | 6 | var PlexAPI = require('..'); 7 | 8 | describe('postQuery()', function() { 9 | var api; 10 | 11 | beforeEach(function() { 12 | server.expectsPost(); 13 | 14 | api = new PlexAPI('localhost'); 15 | }); 16 | 17 | afterEach(server.stop); 18 | 19 | it('should exist', function() { 20 | expect(api.postQuery).to.be.a('function'); 21 | }); 22 | 23 | describe('parameters', function() { 24 | it('requires url parameter', function() { 25 | expect(function() { 26 | api.postQuery(); 27 | }).to.throwException('TypeError'); 28 | }); 29 | 30 | it('can accept url parameter as only parameter', function() { 31 | return api.postQuery('/'); 32 | }); 33 | 34 | it('can accept url parameter as part of a parameter object', function() { 35 | return api.postQuery({uri: '/'}); 36 | }); 37 | 38 | it('uses extra headers passed in parameters', function() { 39 | server.stop(); 40 | var nockServer = server.expectsPost({'reqheaders': { 41 | 'X-TEST-HEADER':'X-TEST-HEADER-VAL' 42 | }}); 43 | 44 | return api.postQuery({uri: '/', extraHeaders: {'X-TEST-HEADER':'X-TEST-HEADER-VAL'}}).then(function(result) { 45 | nockServer.done(); 46 | return result; 47 | }); 48 | }); 49 | }); 50 | 51 | it('promise should fail when server responds with failure status code', function() { 52 | return api.postQuery(ROOT_URL).catch(function(err) { 53 | expect(err).not.to.be(null); 54 | }); 55 | }); 56 | 57 | it('promise should succeed when request response status code is 200', function() { 58 | return api.postQuery(ROOT_URL); 59 | }); 60 | 61 | it('promise should succeed when request response status code is 201', function() { 62 | server.stop(); 63 | server.expectsPost({statusCode: 201}); 64 | return api.postQuery(ROOT_URL); 65 | }); 66 | 67 | it('should result in a POST request', function() { 68 | return api.postQuery(ROOT_URL); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/find-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js'); 2 | var server = require('./server'); 3 | 4 | var PlexAPI = require('..'); 5 | 6 | describe('find()', function() { 7 | var api; 8 | 9 | beforeEach(function() { 10 | server.start(); 11 | 12 | api = new PlexAPI('localhost'); 13 | }); 14 | 15 | afterEach(server.stop); 16 | 17 | it('should exist', function() { 18 | expect(api.find).to.be.a('function'); 19 | }); 20 | 21 | describe('parameters', function() { 22 | it('requires url parameter', function() { 23 | expect(function() { 24 | api.find(); 25 | }).to.throwException('TypeError'); 26 | }); 27 | 28 | it('can accept url parameter as only parameter', function() { 29 | return api.find('/').then(function(result) { 30 | expect(result).to.be.an('object'); 31 | }); 32 | }); 33 | 34 | it('can accept url parameter as part of a parameter object', function() { 35 | return api.find({uri: '/'}).then(function(result) { 36 | expect(result).to.be.an('object'); 37 | }); 38 | }); 39 | 40 | it('uses extra headers passed in parameters', function() { 41 | server.stop(); 42 | var nockServer = server.start({'reqheaders': { 43 | 'X-TEST-HEADER':'X-TEST-HEADER-VAL' 44 | }}); 45 | 46 | return api.find({uri: '/', extraHeaders: {'X-TEST-HEADER':'X-TEST-HEADER-VAL'}}).then(function(result) { 47 | expect(result).to.be.an('object'); 48 | nockServer.done(); 49 | }); 50 | }); 51 | }); 52 | 53 | it('should provide all child items found', function() { 54 | return api.find('/library/sections').then(function(directories) { 55 | expect(directories).to.be.an('array'); 56 | expect(directories.length).to.be(2); 57 | }); 58 | }); 59 | 60 | it('should filter items when given an object of criterias as second parameter', function() { 61 | return api.find('/library/sections', {type: 'movie'}).then(function(directories) { 62 | expect(directories.length).to.be(1); 63 | }); 64 | }); 65 | 66 | it('should match item attributes by regular expression', function() { 67 | return api.find('/library/sections', {type: 'movie|photo'}).then(function(directories) { 68 | expect(directories.length).to.be(2); 69 | }); 70 | }); 71 | 72 | it('should provide all Server items found', function() { 73 | return api.find('/clients').then(function(clients) { 74 | expect(clients[0].name).to.be('mac-mini'); 75 | }); 76 | }); 77 | }); -------------------------------------------------------------------------------- /test/api-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js'); 2 | var server = require('./server'); 3 | 4 | var ROOT_URL = '/'; 5 | 6 | var PlexAPI = require('..'); 7 | 8 | describe('Module API', function() { 9 | var api; 10 | 11 | beforeEach(function() { 12 | server.start(); 13 | 14 | api = new PlexAPI('localhost'); 15 | }); 16 | 17 | afterEach(server.stop); 18 | 19 | it('should expose constructor', function() { 20 | expect(PlexAPI).to.be.a('function'); 21 | }); 22 | 23 | it('should be instance of the PlexAPI', function() { 24 | expect('PlexAPI').to.be(api.constructor.name); 25 | }); 26 | 27 | it('should require server host as first constructor parameter', function() { 28 | expect(function() { 29 | new PlexAPI(); 30 | }).to.throwException('TypeError'); 31 | }); 32 | 33 | it('first parameter should set host of Plex Media Server', function() { 34 | expect(api.getHostname()).to.be('localhost'); 35 | }); 36 | 37 | it('should have configurable server port', function(done) { 38 | api = new PlexAPI({ 39 | hostname: 'localhost', 40 | port: 32401 41 | }); 42 | 43 | server.start({ port: 32401 }); 44 | 45 | api.query(ROOT_URL).then(function(result) { 46 | expect(result).to.be.an('object'); 47 | done(); 48 | }); 49 | }); 50 | 51 | it('should have configurable options that get sent in every request', function() { 52 | api = new PlexAPI({ 53 | hostname : 'localhost', 54 | token : 'mock-token', 55 | options: { 56 | identifier : 'mock-identifier', 57 | product : 'mock-product', 58 | version : 'mock-version', 59 | device : 'mock-device', 60 | deviceName : 'mock-deviceName', 61 | platform : 'mock-platform', 62 | platformVersion: 'mock-platformVersion' 63 | } 64 | }); 65 | 66 | server.stop(); 67 | var nockServer = server.start({ 68 | reqheaders: { 69 | 'X-Plex-Client-Identifier': 'mock-identifier', 70 | 'X-Plex-Product' : 'mock-product', 71 | 'X-Plex-Version' : 'mock-version', 72 | 'X-Plex-Device' : 'mock-device', 73 | 'X-Plex-Device-Name' : 'mock-deviceName', 74 | 'X-Plex-Platform' : 'mock-platform', 75 | 'X-Plex-Platform-Version' : 'mock-platformVersion', 76 | 'X-Plex-Token' : 'mock-token' 77 | } 78 | }); 79 | 80 | api.query(ROOT_URL).then(function(result) { 81 | expect(result).to.be.an('object'); 82 | nockServer.done(); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /test/perform-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js'); 2 | var server = require('./server'); 3 | 4 | var ROOT_URL = '/'; 5 | var PERFORM_URL = '/library/sections/1/refresh'; 6 | 7 | var PlexAPI = require('..'); 8 | 9 | describe('perform()', function() { 10 | var api; 11 | 12 | beforeEach(function() { 13 | server.start(); 14 | server.withoutContent(); 15 | 16 | api = new PlexAPI('localhost'); 17 | }); 18 | 19 | afterEach(server.stop); 20 | 21 | it('should exist', function() { 22 | expect(api.perform).to.be.a('function'); 23 | }); 24 | 25 | describe('parameters', function() { 26 | it('requires url parameter', function() { 27 | expect(function() { 28 | api.perform(); 29 | }).to.throwException('TypeError'); 30 | }); 31 | 32 | it('can accept url parameter as only parameter', function() { 33 | return api.perform('/'); 34 | }); 35 | 36 | it('can accept url parameter as part of a parameter object', function() { 37 | return api.perform({uri: '/'}); 38 | }); 39 | 40 | it('uses extra headers passed in parameters', function() { 41 | server.stop(); 42 | var nockServer = server.start({'reqheaders': { 43 | 'X-TEST-HEADER':'X-TEST-HEADER-VAL' 44 | }}); 45 | 46 | return api.perform({uri: '/', extraHeaders: {'X-TEST-HEADER':'X-TEST-HEADER-VAL'}}).then(function(result) { 47 | nockServer.done(); 48 | return result; 49 | }); 50 | }); 51 | }); 52 | 53 | it('promise should fail when request response status code is 403', function(done) { 54 | const AUTH_TOKEN = 'my-auth-token'; 55 | 56 | api = new PlexAPI({ 57 | hostname: 'localhost', 58 | token: AUTH_TOKEN 59 | }); 60 | 61 | // we need to clear the standard nock response from server.start() invoked in the test setup, 62 | // or else the mocked server will *not* respond with 403 status 63 | server.stop(); 64 | 65 | var scope = server.empty() 66 | .get('/library/sections/8/refresh') 67 | .matchHeader('X-Plex-Token', AUTH_TOKEN) 68 | .reply(403, "Forbidden

403 Forbidden

", { 'content-length': '85', 69 | 'content-type': 'text/html', 70 | connection: 'close', 71 | 'x-plex-protocol': '1.0', 72 | 'cache-control': 'no-cache' }); 73 | 74 | api.perform('/library/sections/8/refresh').catch(function(err) { 75 | expect(err.message).to.contain('Plex Server denied request due to lack of managed user permissions!'); 76 | done(); 77 | }); 78 | }); 79 | 80 | it('promise should fail when server responds with failure status code', function() { 81 | server.fails(); 82 | return api.perform(PERFORM_URL).catch(function(err) { 83 | expect(err).not.to.be(null); 84 | }); 85 | }); 86 | 87 | it('promise should succeed when request response status code is 200', function() { 88 | return api.perform(PERFORM_URL); 89 | }); 90 | 91 | it('promise should succeed when request response status code is 201', function() { 92 | server.stop(); 93 | server.start({ 94 | statusCode: 201 95 | }); 96 | server.withoutContent(); 97 | return api.perform(PERFORM_URL); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/samples/root.json: -------------------------------------------------------------------------------- 1 | { 2 | "_elementType": "MediaContainer", 3 | "friendlyName": "iMac", 4 | "machineIdentifier": "ccda04d6d8e157", 5 | "multiuser": "1", 6 | "myPlex": "1", 7 | "myPlexMappingState": "unknown", 8 | "myPlexSigninState": "unknown", 9 | "myPlexSubscription": "0", 10 | "myPlexUsername": "", 11 | "platform": "MacOSX", 12 | "platformVersion": "10.10.2", 13 | "requestParametersInCookie": "1", 14 | "sync": "1", 15 | "transcoderActiveVideoSessions": "0", 16 | "transcoderAudio": "1", 17 | "transcoderVideo": "1", 18 | "transcoderVideoBitrates": "64,96,208,320,720,1500,2000,3000,4000,8000,10000,12000,20000", 19 | "transcoderVideoQualities": "0,1,2,3,4,5,6,7,8,9,10,11,12", 20 | "transcoderVideoResolutions": "128,128,160,240,320,480,768,720,720,1080,1080,1080,1080", 21 | "updatedAt": "1426800045", 22 | "version": "0.9.11.4.739-a4e710f", 23 | "_children": [ 24 | { 25 | "_elementType": "Directory", 26 | "count": "1", 27 | "key": "butler", 28 | "title": "butler" 29 | }, 30 | { 31 | "_elementType": "Directory", 32 | "count": "1", 33 | "key": "channels", 34 | "title": "channels" 35 | }, 36 | { 37 | "_elementType": "Directory", 38 | "count": "1", 39 | "key": "clients", 40 | "title": "clients" 41 | }, 42 | { 43 | "_elementType": "Directory", 44 | "count": "1", 45 | "key": "hubs", 46 | "title": "hubs" 47 | }, 48 | { 49 | "_elementType": "Directory", 50 | "count": "1", 51 | "key": "library", 52 | "title": "library" 53 | }, 54 | { 55 | "_elementType": "Directory", 56 | "count": "1", 57 | "key": "music", 58 | "title": "music" 59 | }, 60 | { 61 | "_elementType": "Directory", 62 | "count": "1", 63 | "key": "photos", 64 | "title": "photos" 65 | }, 66 | { 67 | "_elementType": "Directory", 68 | "count": "1", 69 | "key": "playQueues", 70 | "title": "playQueues" 71 | }, 72 | { 73 | "_elementType": "Directory", 74 | "count": "1", 75 | "key": "player", 76 | "title": "player" 77 | }, 78 | { 79 | "_elementType": "Directory", 80 | "count": "1", 81 | "key": "playlists", 82 | "title": "playlists" 83 | }, 84 | { 85 | "_elementType": "Directory", 86 | "count": "1", 87 | "key": "search", 88 | "title": "search" 89 | }, 90 | { 91 | "_elementType": "Directory", 92 | "count": "1", 93 | "key": "servers", 94 | "title": "servers" 95 | }, 96 | { 97 | "_elementType": "Directory", 98 | "count": "1", 99 | "key": "system", 100 | "title": "system" 101 | }, 102 | { 103 | "_elementType": "Directory", 104 | "count": "1", 105 | "key": "transcode", 106 | "title": "transcode" 107 | }, 108 | { 109 | "_elementType": "Directory", 110 | "count": "1", 111 | "key": "updater", 112 | "title": "updater" 113 | }, 114 | { 115 | "_elementType": "Directory", 116 | "count": "2", 117 | "key": "video", 118 | "title": "video" 119 | } 120 | ] 121 | } 122 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var nock = require('nock'); 3 | 4 | var PLEX_SERVER_PORT = 32400; 5 | 6 | var respondWith; 7 | 8 | function hasExtension(filename) { 9 | return filename.indexOf('.') !== -1; 10 | } 11 | 12 | function replaceSlashWithRoot(uri) { 13 | return uri.replace(/^\/$/, '/root'); 14 | } 15 | 16 | function respondToRequest(uri, requestBody, cb) { 17 | uri = replaceSlashWithRoot(uri); 18 | 19 | var filepath = hasExtension(uri) ? uri : uri + '.json'; 20 | if (respondWith === 'content') { 21 | fs.readFile('test/samples/'+ filepath, cb); 22 | } else if (respondWith === 'failure') { 23 | return cb(new Error('Server decided to fail...')); 24 | } else { 25 | cb(null); 26 | } 27 | } 28 | 29 | // Looks kinda strange, but its needed for nock 30 | // not to explode as we've got one .get('/') in our nock scope 31 | function replaceActualPathToRoot(path) { 32 | return '/'; 33 | } 34 | 35 | module.exports = { 36 | start: function start(options) { 37 | options = options || {}; 38 | options.schemeAndHost = options.schemeAndHost || 'http://localhost'; 39 | options.port = options.port || PLEX_SERVER_PORT; 40 | options.contentType = options.contentType || 'application/json'; 41 | respondWith = 'content'; 42 | 43 | var scope = nock(options.schemeAndHost + ':' + options.port, { 44 | reqheaders: options.reqheaders 45 | }) 46 | .defaultReplyHeaders({ 47 | 'Content-Type': options.contentType 48 | }) 49 | .filteringPath(replaceActualPathToRoot) 50 | .get('/') 51 | .reply(options.statusCode || 200, respondToRequest); 52 | 53 | // NOT TO PLEASED ABOUT HARDCODING THIS MATCHHEADER() TOKEN ... 54 | if (options.expectRetry) { 55 | scope 56 | .get('/') 57 | .matchHeader('X-Plex-Token', 'abc-pretend-to-be-token') 58 | .reply(options.retryStatusCode || 200, respondToRequest); 59 | } 60 | 61 | return scope; 62 | }, 63 | 64 | empty: function empty() { 65 | return nock('http://localhost' + ':' + PLEX_SERVER_PORT); 66 | }, 67 | 68 | expectsPost: function expectsPost(options) { 69 | options = options || {}; 70 | options.port = options.port || PLEX_SERVER_PORT; 71 | options.contentType = options.contentType || 'application/json'; 72 | respondWith = 'content'; 73 | 74 | return nock('http://localhost:' + options.port, { 75 | reqheaders: options.reqheaders 76 | }) 77 | .defaultReplyHeaders({ 78 | 'Content-Type': options.contentType 79 | }) 80 | .filteringPath(replaceActualPathToRoot) 81 | .post('/') 82 | .reply(options.statusCode || 200, respondToRequest); 83 | }, 84 | 85 | expectsPut: function expectsPut(options) { 86 | options = options || {}; 87 | options.port = options.port || PLEX_SERVER_PORT; 88 | options.contentType = options.contentType || 'application/json'; 89 | respondWith = 'content'; 90 | 91 | return nock('http://localhost:' + options.port, { 92 | reqheaders: options.reqheaders 93 | }) 94 | .defaultReplyHeaders({ 95 | 'Content-Type': options.contentType 96 | }) 97 | .filteringPath(replaceActualPathToRoot) 98 | .put('/') 99 | .reply(options.statusCode || 200, respondToRequest); 100 | }, 101 | 102 | stop: function stop() { 103 | nock.cleanAll(); 104 | }, 105 | 106 | withoutContent: function withoutContent() { 107 | respondWith = null; 108 | }, 109 | 110 | fails: function fails() { 111 | respondWith = 'failure'; 112 | }, 113 | 114 | timeoutError: function timeoutError(options) { 115 | options = options || {}; 116 | options.port = options.port || PLEX_SERVER_PORT; 117 | options.delay = options.delay || 3000 118 | options.contentType = options.contentType || 'application/json'; 119 | respondWith = 'content'; 120 | 121 | return nock('http://localhost:' + options.port) 122 | .defaultReplyHeaders({ 123 | 'Content-Type': options.contentType 124 | }) 125 | .filteringPath(replaceActualPathToRoot) 126 | .get('/') 127 | .socketDelay(options.delay) 128 | .reply(options.statusCode || 200, respondToRequest); 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /test/query-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js'); 2 | var server = require('./server'); 3 | 4 | var ROOT_URL = '/'; 5 | var CLIENTS_URL = '/clients'; 6 | 7 | var PlexAPI = require('..'); 8 | 9 | describe('query()', function() { 10 | var api; 11 | 12 | beforeEach(function() { 13 | server.start(); 14 | 15 | api = new PlexAPI('localhost'); 16 | }); 17 | 18 | afterEach(server.stop); 19 | 20 | it('should exist', function() { 21 | expect(api.query).to.be.a('function'); 22 | }); 23 | 24 | describe('options', function() { 25 | it('requires url options', function() { 26 | expect(function() { 27 | api.query(); 28 | }).to.throwException('TypeError'); 29 | }); 30 | 31 | it('can accept url option as only parameter', function() { 32 | return api.query('/').then(function(result) { 33 | expect(result).to.be.an('object'); 34 | }); 35 | }); 36 | 37 | it('can accept url option as part of an options object', function() { 38 | return api.query({uri: '/'}).then(function(result) { 39 | expect(result).to.be.an('object'); 40 | }); 41 | }); 42 | 43 | it('uses extra headers passed in options', function() { 44 | server.stop(); 45 | var nockServer = server.start({'reqheaders': { 46 | 'X-TEST-HEADER':'X-TEST-HEADER-VAL' 47 | }}); 48 | 49 | return api.query({uri: '/', extraHeaders: {'X-TEST-HEADER':'X-TEST-HEADER-VAL'}}).then(function(result) { 50 | expect(result).to.be.an('object'); 51 | nockServer.done(); 52 | }); 53 | }); 54 | }); 55 | 56 | it('promise should fail when server fails', function(done) { 57 | server.fails(); 58 | 59 | api.query(ROOT_URL).then(function() { 60 | done(Error('Shouldnt succeed!')); 61 | }).catch(function(err) { 62 | expect(err).not.to.be(null); 63 | done(); 64 | }); 65 | }); 66 | 67 | it('promise should succeed when server responds', function() { 68 | return api.query(ROOT_URL).then(function(result) { 69 | expect(result).to.be.an('object'); 70 | }); 71 | }); 72 | 73 | it('should have response MediaContainer attributes as properties on the resolved result object', function() { 74 | return api.query(ROOT_URL).then(function(result) { 75 | expect(result.version).to.contain('0.9.11.4.739-a4e710f'); 76 | }); 77 | }); 78 | 79 | it('should have response child Directory items as result._children', function() { 80 | return api.query(ROOT_URL).then(function(result) { 81 | expect(result._children.length).to.be(16); 82 | }); 83 | }); 84 | 85 | describe('Directory URI', function() { 86 | it('should provide an uri property', function() { 87 | return api.query(ROOT_URL).then(function(result) { 88 | expect(result._children[0].uri).not.to.be(undefined); 89 | }); 90 | }); 91 | 92 | it('should provide an uri property combined of parent URI and the item key attribute', function() { 93 | return api.query('/library/sections').then(function(result) { 94 | expect(result._children[0].uri).to.be('/library/sections/1'); 95 | }); 96 | }); 97 | 98 | it('should use the key as the uri if the key is a root-relative path', function() { 99 | return api.query('/library/sections/1/all').then(function(result) { 100 | expect(result._children[0].uri).to.be(result._children[0].key); 101 | }); 102 | }); 103 | }); 104 | 105 | describe('Server URI', function() { 106 | it('should provide an uri property', function() { 107 | return api.query(CLIENTS_URL).then(function(result) { 108 | expect(result._children[0].uri).not.to.be(undefined); 109 | }); 110 | }); 111 | 112 | it('should provide uri property used to control Plex application', function() { 113 | return api.query(CLIENTS_URL).then(function(result) { 114 | expect(result._children[0].uri).to.be('/system/players/192.168.0.47'); 115 | }); 116 | }); 117 | }); 118 | 119 | describe('XML responses', function() { 120 | it('should convert XML to a JSON object', function() { 121 | var plexTvApi = new PlexAPI({ 122 | hostname: 'plex.tv', 123 | port: 443 124 | }); 125 | 126 | server.stop(); 127 | server.start({ 128 | schemeAndHost: 'https://plex.tv', 129 | port: 443, 130 | contentType: 'application/xml' 131 | }); 132 | 133 | return plexTvApi.query('/devices.xml').then(function(result) { 134 | expect(result.MediaContainer).to.be.an('object'); 135 | expect(result.MediaContainer.attributes.publicAddress).to.equal('47.1.2.4'); 136 | }); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /test/authenticator-test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var sinon = require('sinon'); 3 | var proxyquire = require('proxyquire'); 4 | 5 | var server = require('./server'); 6 | 7 | var ROOT_URL = '/'; 8 | 9 | 10 | describe('Authenticator', function() { 11 | var authenticatorStub; 12 | var credentialsStub; 13 | 14 | var PlexAPI; 15 | var api; 16 | 17 | beforeEach(function() { 18 | authenticatorStub = sinon.stub().yields(null, 'abc-pretend-to-be-token'); 19 | credentialsStub = sinon.stub().returns({ 20 | authenticate: authenticatorStub 21 | }); 22 | 23 | PlexAPI = proxyquire('..', { 24 | 'plex-api-credentials': credentialsStub 25 | }); 26 | 27 | api = new PlexAPI({ 28 | hostname: 'localhost', 29 | authenticator: { 30 | authenticate: authenticatorStub 31 | } 32 | }); 33 | }); 34 | 35 | afterEach(server.stop); 36 | 37 | describe('.initialize()', function() { 38 | 39 | it('is called on authenticator if method exists when creating PlexAPI instances', function() { 40 | var authenticatorSpy = { 41 | initialize: sinon.spy() 42 | }; 43 | 44 | api = new PlexAPI({ 45 | hostname: 'localhost', 46 | authenticator: authenticatorSpy 47 | }); 48 | 49 | assert(authenticatorSpy.initialize.calledOnce); 50 | }); 51 | 52 | it('provides created PlexAPI object as argument', function() { 53 | var authenticatorSpy = { 54 | initialize: sinon.spy() 55 | }; 56 | 57 | api = new PlexAPI({ 58 | hostname: 'localhost', 59 | authenticator: authenticatorSpy 60 | }); 61 | 62 | assert(authenticatorSpy.initialize.firstCall.calledWith(api)); 63 | }); 64 | 65 | }); 66 | 67 | describe('.authenticate()', function() { 68 | 69 | it('is called on authenticator when Plex Server responds with 401', function () { 70 | server.start({ 71 | statusCode: 401, 72 | expectRetry: true 73 | }); 74 | 75 | return api.query(ROOT_URL).then(function () { 76 | assert(authenticatorStub.firstCall.calledWith(api), 'authenticator was called'); 77 | }); 78 | }); 79 | 80 | it('provides options object and callback as arguments when calling authenticator', function() { 81 | server.start({ 82 | statusCode: 401, 83 | expectRetry: true 84 | }); 85 | 86 | return api.query(ROOT_URL).then(function () { 87 | var firstArg = authenticatorStub.firstCall.args[0]; 88 | var secondArg = authenticatorStub.firstCall.args[1]; 89 | 90 | assert.equal(typeof(firstArg), 'object'); 91 | assert.equal(typeof(secondArg), 'function'); 92 | }); 93 | }); 94 | 95 | it('retries original request with token given from authenticator', function () { 96 | scope = server.start({ 97 | statusCode: 401, 98 | expectRetry: true 99 | }); 100 | 101 | return api.query(ROOT_URL).then(function (result) { 102 | scope.done(); 103 | }); 104 | }); 105 | 106 | it('rejects when providing token and server still responds with 401', function () { 107 | scope = server.start({ 108 | statusCode: 401, 109 | retryStatusCode: 401, 110 | expectRetry: true 111 | }); 112 | 113 | return api.query(ROOT_URL).then(function onSuccess(result) { 114 | throw new Error('Query should not have succeeded!'); 115 | }, function onError() { 116 | scope.done(); 117 | }); 118 | }); 119 | 120 | }); 121 | 122 | describe('default authenticator', function() { 123 | 124 | it('uses the plex-api-credentials authenticator when options.username and .password are provided', function() { 125 | server.start({ 126 | statusCode: 401, 127 | expectRetry: true 128 | }); 129 | 130 | api = new PlexAPI({ 131 | hostname: 'localhost', 132 | username: 'foo', 133 | password: 'bar' 134 | }); 135 | 136 | return api.query(ROOT_URL).then(function () { 137 | assert(authenticatorStub.calledOnce, 'credentials authenticator was called'); 138 | }); 139 | }); 140 | 141 | it('rejects with a missing authenticator error when options.username and .password were missing and Plex Server responds with 401', function() { 142 | server.start({ 143 | statusCode: 401 144 | }); 145 | 146 | api = new PlexAPI({ 147 | hostname: 'localhost' 148 | }); 149 | 150 | return api.query(ROOT_URL).then(null, function (err) { 151 | assert(err instanceof Error, 'rejected with an error instance'); 152 | assert(err.message.match(/you must provide a way to authenticate/), 'error message says authenticator is needed'); 153 | }); 154 | }); 155 | 156 | }); 157 | 158 | }); 159 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Change log 2 | 3 | ### v5.0.2 4 | 5 | #### Fixed 6 | - reject() instead of throw when authenticating by @dabbers [#82](https://github.com/phillipj/node-plex-api/pull/82) 7 | 8 | ### v5.0.1 9 | 10 | #### Changed 11 | - Updated request v2.72.0 -> 2.79.0 by @SpaceK33z [#78](https://github.com/phillipj/node-plex-api/pull/78) 12 | 13 | ### v5.0.0 14 | 15 | #### Changed 16 | - Replace use of Q with native Promise by @phillipj [#77](https://github.com/phillipj/node-plex-api/pull/77) 17 | 18 | ### Fixed 19 | - Fix undefined runtime error when authenticating by @phillipj [#76](https://github.com/phillipj/node-plex-api/pull/76) 20 | 21 | #### BREAKING CHANGE 22 | 23 | All methods now return native Promise instances, rather than Q promises as before. 24 | 25 | ### v4.0.0 26 | 27 | #### Added 28 | - Support for managed users by @hyperlink [#70](https://github.com/phillipj/node-plex-api/pull/70)
29 | By specifying `options.managedUser` when creating a plex-api client, see more in [Readme.md](./Readme.md). 30 | 31 | [About managed users on support.plex.tv](https://support.plex.tv/hc/en-us/articles/203948776-Managed-Users). 32 | 33 | #### BREAKING CHANGE 34 | 35 | Requires at least Node.js v4.0. 36 | 37 | ### v3.5.0 38 | 39 | #### Added 40 | - Added support for PUT queries with .putQuery() by @IonicaBizau [#61](https://github.com/phillipj/node-plex-api/pull/61) 41 | 42 | ### v3.4.0 43 | 44 | #### Added 45 | - Added timeout option to control timeout on subsequent requests by @lokenx [#60](https://github.com/phillipj/node-plex-api/pull/60) 46 | 47 | #### Updates 48 | - xml2js from v0.4.15 to v0.4.16 49 | - request from v2.67.0 to v2.69.0 50 | 51 | ### v3.3.0 52 | 53 | #### Added 54 | - Add support for extra headers in all of the query() related methods by @OverloadUT [#48](https://github.com/phillipj/node-plex-api/pull/48) 55 | - Add support for `https` parameter to force https even on non-443 port by @OverloadUT [#47](https://github.com/phillipj/node-plex-api/pull/47) 56 | 57 | #### Fixed 58 | - Enabling gzip to fix a bug in some versions of PMS/PHT that silently fail when no Accept-Encoding header is sent by @OverloadUT [#51](https://github.com/phillipj/node-plex-api/pull/51) 59 | 60 | ### v3.2.0 61 | - Added `options.token` to specify authentication token at PlexAPI client instantiation by @MikeOne 62 | - Made responses with status code 2xx considered successfull, not just 200 by @MikeOne 63 | 64 | ### v3.1.2 65 | - Fixed XML parsing by @phillipj 66 | 67 | ### v3.1.1 68 | - Fixing broken authentication blooper by @phillipj 69 | 70 | ### v3.1.0 71 | 72 | - Extensible authentication mechanisms by @phillipj 73 | - Extracted HTTP headers generation into [plex-api-headers](https://www.npmjs.com/package/plex-api-headers) for re-use by @phillipj 74 | 75 | ### v3.0.0 76 | - Change xml2json to xml2js by @OverloadUT 77 | 78 | #### BREAKING CHANGE 79 | 80 | Some of URIs on the Plex Server responds with XML instead of JSON. Previous versions of plex-api used xml2json to translate between XML to JSON. We have now replaced xml2json with xml2js which might result in a different JSON format when requesting URIs responding with XML. 81 | 82 | Please see the documentation of [xml2json](https://github.com/buglabs/node-xml2json) and [xml2js](https://github.com/Leonidas-from-XIV/node-xml2js) for more details about their differences. 83 | 84 | ### v2.5.0 85 | - Updated the readme to explain each of the X-Plex headers by @OverloadUT 86 | - The X-Plex headers are now sent on every request by @OverloadUT 87 | - Added missing X-Plex headers: deviceName, platformVersion by @OverloadUT 88 | - Updated the default X-Plex headers to be a bit more descriptive by @OverloadUT 89 | 90 | ### v2.4.0 91 | - Added `postQuery()` to perform POST requests by @OverloadUT 92 | 93 | ### v2.3.0 94 | - PlexHome authentication if needed when calling `.perform()` as with `.query()`, by @OverloadUT 95 | 96 | ### v2.2.0 97 | - Convert to JSON or XML according to server response header, or resolve with raw server response buffer. This allows for image buffers to be fetched. By @YouriT 98 | 99 | ### v2.1.0 100 | - Add ability to define app options by @DMarby 101 | 102 | ### v2.0.1 103 | - Bugfix for wrong `.uri` in some cases by @pjeby 104 | 105 | ### v2.0.0 106 | - PlexHome support 107 | - Deprecated port argument of PlexAPI constructor in favor of an options object 108 | - Retrieves JSON from the Plex HTTP API instead of XML **see breaking changes below!** 109 | 110 | #### BREAKING CHANGES FROM v1.0.0 AND BELOW 111 | 112 | We're now retrieving JSON from the Plex HTTP API instead of XML, which got translated to JSON by this module. Direct consequences: 113 | - Attributes previously found in `result.attributes` are now available directly in `result` 114 | - Child items such as Directory and Server has moved from e.g. `result.directory` to `result._children` 115 | 116 | ```js 117 | client.query("/").then(function (result) { 118 | console.log(result.friendlyName); // was result.attributes.friendlyName 119 | console.log("Directory count:", result._children.length); // was result.directory.length 120 | }); 121 | ``` 122 | 123 | ### v1.0.0 124 | v1.0.0 mostly to be a better semver citizen and some housekeeping. 125 | 126 | ### v0.4.2 127 | - Updated dependencies 128 | - Housekeeping with some minor code refactor 129 | 130 | ### v0.4.1 131 | - Bugfix for not releasing HTTP agent sockets properly on .perform() 132 | 133 | ### v0.4.0 134 | - Converted all methods to return promises, rather than accepting callback as argument 135 | - Converted buster tests to mocha/expect.js 136 | 137 | ### v0.2.3 138 | - .find() matches attribute values by regular expression 139 | - Added getters for hostname and port 140 | - Made constructor hostname parameter required 141 | 142 | ### v0.2.2 143 | - Bugfix for .find() only working when having Directory items 144 | 145 | ### v0.2.1 146 | - Generalized URI resolving as bugfix for other types of items than Directories 147 | - Added URIs for Server items 148 | 149 | ### v0.2.0 150 | - **important** Removed explicit XML to JSON conversion to ensure consistent child item names. The main difference for those using previous module versions, is the need to change their use of result.directories to result.directory. 151 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | var os = require('os'); 2 | var uuid = require('uuid'); 3 | var url = require('url'); 4 | var request = require('request'); 5 | var xml2js = require('xml2js'); 6 | var headers = require('plex-api-headers'); 7 | var extend = require('util')._extend; 8 | 9 | var uri = require('./uri'); 10 | 11 | var PLEX_SERVER_PORT = 32400; 12 | 13 | function PlexAPI(options, deprecatedPort) { 14 | var opts = options || {}; 15 | var hostname = typeof options === 'string' ? options : options.hostname; 16 | 17 | this.hostname = hostname; 18 | this.port = deprecatedPort || opts.port || PLEX_SERVER_PORT; 19 | this.https = opts.https; 20 | this.timeout = opts.timeout; 21 | this.username = opts.username; 22 | this.password = opts.password; 23 | this.managedUser = opts.managedUser; 24 | this.authToken = opts.token; 25 | this.authenticator = opts.authenticator || this._credentialsAuthenticator(); 26 | this.options = opts.options || {}; 27 | this.options.identifier = this.options.identifier || uuid.v4(); 28 | this.options.product = this.options.product || 'Node.js App'; 29 | this.options.version = this.options.version || '1.0'; 30 | this.options.device = this.options.device || os.platform(); 31 | this.options.deviceName = this.options.deviceName || 'Node.js App'; 32 | this.options.platform = this.options.platform || 'Node.js'; 33 | this.options.platformVersion = this.options.platformVersion || process.version; 34 | 35 | if (typeof this.hostname !== 'string') { 36 | throw new TypeError('Invalid Plex Server hostname'); 37 | } 38 | if (typeof deprecatedPort !== 'undefined') { 39 | console.warn('PlexAPI constuctor port argument is deprecated, use an options object instead.'); 40 | } 41 | 42 | this.serverUrl = hostname + ':' + this.port; 43 | this._initializeAuthenticator(); 44 | } 45 | 46 | PlexAPI.prototype.getHostname = function getHostname() { 47 | return this.hostname; 48 | }; 49 | 50 | PlexAPI.prototype.getPort = function getPort() { 51 | return this.port; 52 | }; 53 | 54 | PlexAPI.prototype.getIdentifier = function getIdentifier() { 55 | return this.options.identifier; 56 | }; 57 | 58 | PlexAPI.prototype.query = function query(options) { 59 | if (typeof options === 'string') { 60 | // Support old method of only supplying a single `url` parameter 61 | options = { uri: options }; 62 | } 63 | if (options.uri === undefined) { 64 | throw new TypeError('Requires uri parameter'); 65 | } 66 | 67 | options.method = 'GET'; 68 | options.parseResponse = true; 69 | 70 | return this._request(options).then(uri.attach(options.uri)); 71 | }; 72 | 73 | PlexAPI.prototype.postQuery = function postQuery(options) { 74 | if (typeof options === 'string') { 75 | // Support old method of only supplying a single `url` parameter 76 | options = { uri: options }; 77 | } 78 | if (options.uri === undefined) { 79 | throw new TypeError('Requires uri parameter'); 80 | } 81 | 82 | options.method = 'POST'; 83 | options.parseResponse = true; 84 | 85 | return this._request(options).then(uri.attach(url)); 86 | }; 87 | 88 | PlexAPI.prototype.putQuery = function putQuery(options) { 89 | if (typeof options === 'string') { 90 | // Support old method of only supplying a single `url` parameter 91 | options = { uri: options }; 92 | } 93 | if (options.uri === undefined) { 94 | throw new TypeError('Requires uri parameter'); 95 | } 96 | 97 | options.method = 'PUT'; 98 | options.parseResponse = true; 99 | 100 | return this._request(options).then(uri.attach(url)); 101 | }; 102 | 103 | PlexAPI.prototype.perform = function perform(options) { 104 | if (typeof options === 'string') { 105 | // Support old method of only supplying a single `url` parameter 106 | options = { uri: options }; 107 | } 108 | if (options.uri === undefined) { 109 | throw new TypeError('Requires uri parameter'); 110 | } 111 | 112 | options.method = 'GET'; 113 | options.parseResponse = false; 114 | 115 | return this._request(options); 116 | }; 117 | 118 | PlexAPI.prototype.find = function find(options, criterias) { 119 | if (typeof options === 'string') { 120 | // Support old method of only supplying a single `url` parameter 121 | options = { uri: options }; 122 | } 123 | if (options.uri === undefined) { 124 | throw new TypeError('Requires uri parameter'); 125 | } 126 | 127 | return this.query(options).then(function (result) { 128 | return filterChildrenByCriterias(result._children, criterias); 129 | }); 130 | }; 131 | 132 | PlexAPI.prototype._request = function _request(options) { 133 | var reqUrl = this._generateRelativeUrl(options.uri); 134 | var method = options.method; 135 | var timeout = this.timeout; 136 | var parseResponse = options.parseResponse; 137 | var extraHeaders = options.extraHeaders || {}; 138 | var self = this; 139 | 140 | var requestHeaders = headers(this, extend({ 141 | 'Accept': 'application/json', 142 | 'X-Plex-Token': this.authToken, 143 | 'X-Plex-Username': this.username 144 | }, extraHeaders)); 145 | 146 | var reqOpts = { 147 | uri: url.parse(reqUrl), 148 | encoding: null, 149 | method: method || 'GET', 150 | timeout: timeout, 151 | gzip: true, 152 | headers: requestHeaders 153 | }; 154 | 155 | return new Promise((resolve, reject) => { 156 | request(reqOpts, function onResponse(err, response, body) { 157 | var resolveValue; 158 | 159 | if (err) { 160 | return reject(err); 161 | } 162 | 163 | resolveValue = body; 164 | 165 | // 403 forbidden when managed user does not have sufficient permission 166 | if (response.statusCode === 403) { 167 | return reject(new Error('Plex Server denied request due to lack of managed user permissions!')); 168 | } 169 | 170 | // 401 unauthorized when authentication is required against the requested URL 171 | if (response.statusCode === 401) { 172 | if (self.authenticator === undefined) { 173 | var authenticationError = new Error('Plex Server denied request, you must provide a way to authenticate! ' + 'Read more about plex-api authenticators on https://www.npmjs.com/package/plex-api#authenticators'); 174 | // If we have an auth token but receive a 401 we can assume the token as expired 175 | if (self.authToken) 176 | authenticationError.name = 'TokenExpiredError'; 177 | return reject(authenticationError); 178 | } 179 | 180 | return resolve(self._authenticate() 181 | .then(function () { 182 | return self._request(options); 183 | }) 184 | ); 185 | } 186 | 187 | if (response.statusCode < 200 || response.statusCode > 299) { 188 | return reject(new Error('Plex Server didnt respond with a valid 2xx status code, response code: ' + response.statusCode)); 189 | } 190 | 191 | response.on('error', function onError(err) { 192 | return reject(err); 193 | }); 194 | 195 | if (!parseResponse) { 196 | return resolve(); 197 | } 198 | 199 | if (response.headers['content-type'] === 'application/json') { 200 | resolveValue = JSON.parse(body.toString('utf8')); 201 | } else if (response.headers['content-type'].indexOf('xml') > -1) { 202 | resolveValue = xmlToJSON(body.toString('utf8'), { attrkey: 'attributes' }); 203 | } 204 | 205 | return resolve(resolveValue); 206 | }); 207 | }); 208 | }; 209 | 210 | PlexAPI.prototype._authenticate = function _authenticate() { 211 | return new Promise((resolve, reject) => { 212 | if (this.authToken) { 213 | return reject(new Error('Permission denied even after attempted authentication :( Wrong username and/or password maybe?')); 214 | } 215 | 216 | this.authenticator.authenticate(this, (err, token) => { 217 | if (err) { 218 | return reject(new Error('Authentication failed, reason: ' + err.message)); 219 | } 220 | this.authToken = token; 221 | resolve(); 222 | }); 223 | }); 224 | }; 225 | 226 | PlexAPI.prototype._credentialsAuthenticator = function _credentialsAuthenticator() { 227 | var credentials; 228 | 229 | if (this.username && this.password) { 230 | credentials = require('plex-api-credentials'); 231 | return credentials({ 232 | username: this.username, 233 | password: this.password, 234 | managedUser: this.managedUser 235 | }); 236 | } 237 | return undefined; 238 | }; 239 | 240 | PlexAPI.prototype._initializeAuthenticator = function _initializeAuthenticator() { 241 | if (this.authenticator && typeof this.authenticator.initialize === 'function') { 242 | this.authenticator.initialize(this); 243 | } 244 | }; 245 | 246 | PlexAPI.prototype._generateRelativeUrl = function _generateRelativeUrl(relativeUrl) { 247 | return this._serverScheme() + this.serverUrl + relativeUrl; 248 | }; 249 | 250 | PlexAPI.prototype._serverScheme = function _serverScheme() { 251 | if (typeof this.https !== 'undefined') { 252 | // If https is supplied by the user, always do what it says 253 | return this.https ? 'https://' : 'http://'; 254 | } 255 | // Otherwise, use https if it's on port 443, the standard https port. 256 | return this.port === 443 ? 'https://' : 'http://'; 257 | }; 258 | 259 | function xmlToJSON(str, options) { 260 | return new Promise((resolve, reject) => { 261 | xml2js.parseString(str, options, (err, jsonObj) => { 262 | if (err) { 263 | return reject(err); 264 | } 265 | resolve(jsonObj); 266 | }); 267 | }); 268 | } 269 | 270 | function filterChildrenByCriterias(children, criterias) { 271 | var context = { 272 | criterias: criterias || {} 273 | }; 274 | 275 | return children.filter(criteriasMatchChild, context); 276 | } 277 | 278 | function criteriasMatchChild(child) { 279 | var criterias = this.criterias; 280 | 281 | return Object.keys(criterias).reduce(function matchCriteria(hasFoundMatch, currentRule) { 282 | var regexToMatch = new RegExp(criterias[currentRule]); 283 | return regexToMatch.test(child[currentRule]); 284 | }, true); 285 | } 286 | 287 | 288 | module.exports = PlexAPI; 289 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # plex-api [![Build Status](https://api.travis-ci.org/phillipj/node-plex-api.png?branch=master)](http://travis-ci.org/phillipj/node-plex-api) 2 | 3 | Small module which helps you query the Plex Media Server HTTP API. 4 | 5 | ## Usage 6 | 7 | ```bash 8 | $ npm install plex-api --save 9 | ``` 10 | 11 | **PlexAPI(options | hostname)** 12 | 13 | Instantiate a PlexAPI client. 14 | 15 | The parameter can be a string representing the server's hostname, or an object with the following properties: 16 | 17 | Options: 18 | - **hostname**: hostname where Plex Server runs 19 | - **port**: port number Plex Server is listening on (optional, default: `32400`) 20 | - **https**: (optional, default: `false`) 21 | - **username**: plex.tv username (optional / required for PlexHome) 22 | - **password**: plex.tv password (optional / required for PlexHome) 23 | - **managedUser**: details required to perform operations as a managed PlexHome user 24 | - **name**: managed user name 25 | - **pin**: optional pin code for the managed user 26 | - **token**: plex.tv authentication token (optional) 27 | - **timeout**: timeout value in milliseconds to use when making requests (optional) 28 | - **options**: override additional PlexHome options (optional, but recommended for PlexHome) 29 | - **identifier**: A unique client identifier. Default is a `generated uuid v4`. *Note: you should really provide this rather than let it get generated. Every time your app runs, a new "device" will get registered on your Plex account, which can lead to poor performance once hundreds or thousands of them get created. Trust me!* 30 | - **product**: The name of your application. Official Plex examples: `Plex Web`, `Plex Home Theater`, `Plex for Xbox One`. Default `Node.js App` 31 | - **version**: The version of your app. Default `1.0` 32 | - **deviceName**: The "name" of the device your app is running on. For apps like Plex Home Theater and mobile apps, it's the computer or phone's name chosen by the user. Default `Node.js App` 33 | - **platform**: The platform your app is running on. The use of this is inconsistent in the official Plex apps. It is not displayed on the web interface. Official Plex examples: `Chrome`, `Plex Home Theater`, `Windows`. Default is `Node.js`. 34 | - **platformVersion**: The platform version. Default is the version of Node running. 35 | - **device**: The name of the type of computer your app is running on, usually the OS name. Official Plex examples: `Windows`, `iPhone`, `Xbox One`. Default is whatever `os.platform()` returns. 36 | 37 | Here's an example of what an app shows up as on the Plex web interface 38 | 39 | ![Plex Device Example](docs/plex-device-example.png?raw) 40 | 41 | The rows in that example from top to bottom are `deviceName`, `version`, `product`, and `device`. 42 | 43 | ### .query(options) 44 | 45 | **Retrieve content from URI** 46 | 47 | The parameter can be a string representing the URI, or an object with the following properties: 48 | - **uri**: the URI to query 49 | - (optional) **extraHeaders**: an object with extra headers to send in the HTTP request. Useful for things like X-Plex-Target-Client-Identifier 50 | 51 | Aside from requesting the API and returning its response, an `.uri` property are created to easier follow the URIs available in the HTTP API. At the moment URIs are attached for Directory and Server items. 52 | 53 | ```js 54 | var PlexAPI = require("plex-api"); 55 | var client = new PlexAPI("192.168.0.1"); 56 | 57 | client.query("/").then(function (result) { 58 | console.log("%s running Plex Media Server v%s", 59 | result.friendlyName, 60 | result.version); 61 | 62 | // array of children, such as Directory or Server items 63 | // will have the .uri-property attached 64 | console.log(result._children); 65 | }, function (err) { 66 | console.error("Could not connect to server", err); 67 | }); 68 | ``` 69 | 70 | ### .postQuery(options) 71 | 72 | **Send a POST request and retrieve the response** 73 | 74 | This is identical to `query()`, except that the request will be a POST rather than a GET. It has the same required and optional parameters as `query()`. 75 | 76 | Note that the parameters can only be passed as a query string as part of the uri, which is all Plex requires. (`Content-Length` will always be zero) 77 | 78 | ```js 79 | var PlexAPI = require("plex-api"); 80 | var client = new PlexAPI("192.168.0.1"); 81 | 82 | client.postQuery("/playQueue?type=video&uri=someuri&shuffle=0").then(function (result) { 83 | console.log("Added video to playQueue %s", 84 | result.playQueueID); 85 | 86 | // array of children, such as Directory or Server items 87 | // will have the .uri-property attached 88 | console.log(result._children); 89 | }, function (err) { 90 | console.error("Could not connect to server", err); 91 | }); 92 | ``` 93 | 94 | ### .putQuery(options) 95 | 96 | **Send a PUT request and retrieve the response** 97 | 98 | This is identical to `query()`, except that the request will be a PUT rather than a GET. It has the same required and optional parameters as `query()`. It's is used to update parts of your Plex library. 99 | 100 | Note that the parameters can only be passed as a query string as part of the uri, which is all Plex requires. (`Content-Length` will always be zero) 101 | 102 | ```js 103 | var PlexAPI = require("plex-api"); 104 | var client = new PlexAPI("192.168.0.1"); 105 | 106 | client.putQuery("/library/sections/3/all?type=1&id=123&summary.value=updatedSummaryText") 107 | .then(function (result) { 108 | console.log("Description of video by id 123 has been set to 'updatedSummaryText'"); 109 | }, function (err) { 110 | console.error("Could not connect to server", err); 111 | }); 112 | ``` 113 | 114 | ### .perform(options) 115 | 116 | **Perform an API action** 117 | 118 | When performing an "action" on the HTTP API, the response body will be empty. 119 | As the response content itself is worthless, `perform()` acts on the HTTP status codes the server responds with. 120 | It has the same required and optional parameters as `query()`. 121 | 122 | ```js 123 | var PlexAPI = require("plex-api"); 124 | var client = new PlexAPI("192.168.0.1"); 125 | 126 | // update library section of key "1" 127 | client.perform("/library/sections/1/refresh").then(function () { 128 | // successfully started to refresh library section #1 129 | }, function (err) { 130 | console.error("Could not connect to server", err); 131 | }); 132 | ``` 133 | 134 | ### .find(options, [{criterias}]) 135 | 136 | **Find matching child items on URI** 137 | 138 | Uses `query()` behind the scenes, giving all directories and servers the beloved `.uri` property. It has the same required and optional parameters as `query`, in addition to a second optional `criterias` parameter. 139 | 140 | ```js 141 | var PlexAPI = require("plex-api"); 142 | var client = new PlexAPI("192.168.0.1"); 143 | 144 | // filter directories on Directory attributes 145 | client.find("/library/sections", {type: "movie"}).then(function (directories) { 146 | // directories would be an array of sections whose type are "movie" 147 | }, function (err) { 148 | console.error("Could not connect to server", err); 149 | }); 150 | 151 | // criterias are interpreted as regular expressions 152 | client.find("/library/sections", {type: "movie|shows"}).then(function (directories) { 153 | // directories type would be "movie" OR "shows" 154 | }, function (err) { 155 | console.error("Could not connect to server", err); 156 | }); 157 | 158 | // shorthand to retrieve all Directories 159 | client.find("/").then(function (directories) { 160 | // directories would be an array of Directory items 161 | }, function (err) { 162 | throw new Error("Could not connect to server"); 163 | }); 164 | ``` 165 | 166 | ## Authenticators 167 | 168 | An authenticator is used by plex-api to authenticate its request against Plex Servers with a PlexHome setup. The most common authentication mechanism is by username and password. 169 | 170 | You can provide your own custom authentication mechanism, read more about custom authenticators below. 171 | 172 | ### Credentials: username and password 173 | 174 | Comes bundled with plex-api. Just provide `options.username` and `options.password` when creating a PlexAPI instance and you are good to go. 175 | 176 | See the [plex-api-credentials](https://www.npmjs.com/package/plex-api-credentials) module for more information about its inner workings. 177 | 178 | ### PIN: authenticate by PIN code 179 | 180 | An authentication module that provides an interface for authenticating with Plex using a PIN, like the official clients do. 181 | 182 | https://www.npmjs.com/package/plex-api-pinauth 183 | 184 | ### Custom authenticator 185 | 186 | In its simplest form an `authenticator` is an object with **one required** function `authenticate()` which should return the autentication token needed by plex-api to satisfy Plex Server. 187 | 188 | An optional method `initialize()` could be implemented if you need reference to the created PlexAPI instance when it's created. 189 | 190 | ```js 191 | { 192 | // OPTIONAL 193 | initialize: function(plexApi) { 194 | // plexApi === the PlexAPI instance just created 195 | }, 196 | // REQUIRED 197 | authenticate: function(plexApi, callback) { 198 | // plexApi === the PlexAPI instance requesting the authentication token 199 | 200 | // invoke callback if something fails 201 | if (somethingFailed) { 202 | return callback(new Error('I haz no cluez about token!')); 203 | } 204 | 205 | // or when you have a token 206 | callback(null, 'I-found-this-token'); 207 | } 208 | } 209 | ``` 210 | 211 | ## HTTP API Documentation 212 | For more information about the API capabilities, see the [unofficial Plex API documentation](https://github.com/Arcanemagus/plex-api/wiki). The [PlexInc's desktop client wiki](https://github.com/plexinc/plex-media-player/wiki/Remote-control-API) might also be valueable. 213 | 214 | ## Running tests 215 | 216 | ```shell 217 | $ npm install 218 | $ npm test 219 | ``` 220 | 221 | Automatically run all tests whenever files has been changed: 222 | ```shell 223 | $ npm run test:watch 224 | ``` 225 | 226 | ## Usage in the wild 227 | 228 | plex-api has proven to be useful in more than one project over the years. 229 | 230 | Do you have project which uses plex-api? Please tell us about it and we'll list it here :) 231 | 232 | ### alexa-plex 233 | 234 | Alexa (Amazon Echo) app for interacting with a Plex Server and controlling client playback. 235 | 236 | https://github.com/OverloadUT/alexa-plex by [@OverloadUT](https://github.com/OverloadUT). 237 | 238 | ### nl.kikkert.plex 239 | 240 | The Plex Remote control app for the Homey device. 241 | 242 | https://github.com/MikeOne/nl.kikkert.plex by [@MikeOne](https://github.com/MikeOne). 243 | 244 | ### plex2netflix 245 | 246 | See how much of your media from Plex is available on Netflix with a CLI command. 247 | 248 | https://github.com/SpaceK33z/plex2netflix by [@SpaceK33z](https://github.com/SpaceK33z). 249 | 250 | ## Contributing 251 | 252 | Contributions are more than welcome! Create an issue describing what you want to do. If that feature is seen to fit this project, send a pull request with the changes accompanied by tests. 253 | 254 | ## License 255 | (The MIT License) 256 | 257 | Copyright (c) 2013-2016 Phillip Johnsen <johphi@gmail.com> 258 | 259 | Permission is hereby granted, free of charge, to any person obtaining 260 | a copy of this software and associated documentation files (the 261 | "Software"), to deal in the Software without restriction, including 262 | without limitation the rights to use, copy, modify, merge, publish, 263 | distribute, sublicense, and/or sell copies of the Software, and to 264 | permit persons to whom the Software is furnished to do so, subject to 265 | the following conditions: 266 | 267 | The above copyright notice and this permission notice shall be 268 | included in all copies or substantial portions of the Software. 269 | 270 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 271 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 272 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 273 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 274 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 275 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 276 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 277 | --------------------------------------------------------------------------------