├── .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, "Forbidden403 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 [](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 | 
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 |
--------------------------------------------------------------------------------