├── .gitignore ├── .ignore ├── .npmignore ├── README.md ├── docs ├── assets │ ├── css │ │ └── main.css │ ├── images │ │ ├── icons.png │ │ ├── icons@2x.png │ │ ├── widgets.png │ │ └── widgets@2x.png │ └── js │ │ ├── main.js │ │ └── search.js ├── classes │ ├── account.html │ ├── client.html │ ├── filter.html │ ├── filterdate.html │ ├── filternumber.html │ ├── filterreference.html │ ├── filterstring.html │ ├── filtervalue.html │ ├── library.html │ └── serverconnection.html ├── enums │ └── mediatype.html ├── globals.html ├── index.html └── interfaces │ ├── album.html │ ├── albumcontainer.html │ ├── artist.html │ ├── artistcontainer.html │ ├── clientoptions.html │ ├── connection.html │ ├── country.html │ ├── device.html │ ├── genre.html │ ├── media.html │ ├── mediacontainer.html │ ├── normalized.html │ ├── normalizedplaylist.html │ ├── normalizedplaylistcontainer.html │ ├── normalizedplaylistitem.html │ ├── parent.html │ ├── part.html │ ├── pin.html │ ├── playlist.html │ ├── playlistcontainer.html │ ├── playlistitem.html │ ├── profile.html │ ├── resourcecontainer.html │ ├── service.html │ ├── stream.html │ ├── subscription.html │ ├── tag.html │ ├── track.html │ ├── trackcontainer.html │ ├── user.html │ └── usersubscription.html ├── package.json ├── src ├── account.spec.ts ├── account.spec.ts.md ├── account.spec.ts.snap ├── account.ts ├── client.spec.ts ├── client.ts ├── filter.spec.ts ├── filter.ts ├── index.ts ├── library.spec.ts ├── library.spec.ts.md ├── library.spec.ts.snap ├── library.ts ├── normalize.spec.ts ├── normalize.ts ├── server-connection.spec.ts ├── server-connection.ts ├── test-helpers.ts ├── types │ ├── album.ts │ ├── artist.ts │ ├── country.ts │ ├── device.ts │ ├── genre.ts │ ├── hub.ts │ ├── media-container.ts │ ├── media.ts │ ├── parser.ts │ ├── part.ts │ ├── pin.ts │ ├── play-queue.ts │ ├── playlist.ts │ ├── resources.ts │ ├── section.ts │ ├── stream.ts │ ├── sync-list.ts │ ├── tag.ts │ ├── track.ts │ ├── types.ts │ └── user.ts └── utils │ ├── params.ts │ └── request.ts ├── testHelpers └── fixtures │ ├── api │ ├── library │ │ ├── metadata │ │ │ ├── 9027 │ │ │ │ └── children.json │ │ │ ├── 35339 │ │ │ │ ├── allLeaves.json │ │ │ │ └── children.json │ │ │ ├── 35340 │ │ │ │ └── children.json │ │ │ ├── 40812 │ │ │ │ ├── children.json │ │ │ │ └── children_includeRelated=1.json │ │ │ ├── 35339.json │ │ │ ├── 35341.json │ │ │ ├── 40812.json │ │ │ └── 40812_includeExtras=1.json │ │ ├── sections.json │ │ └── sections │ │ │ ├── 1 │ │ │ ├── albums.json │ │ │ ├── all.json │ │ │ ├── all_decade=2010_type=9.json │ │ │ ├── all_genre=1348.json │ │ │ ├── all_year=2016_type=9.json │ │ │ ├── collection.json │ │ │ ├── contentRating.json │ │ │ ├── decade.json │ │ │ ├── firstCharacter.json │ │ │ ├── firstCharacter │ │ │ │ └── G.json │ │ │ ├── folder.json │ │ │ ├── folder_parent=49.json │ │ │ ├── genre.json │ │ │ ├── newest.json │ │ │ ├── onDeck.json │ │ │ ├── recentlyAdded.json │ │ │ ├── recentlyViewed.json │ │ │ ├── search_type=10.json │ │ │ ├── search_type=8.json │ │ │ ├── search_type=9.json │ │ │ ├── unwatched.json │ │ │ └── year.json │ │ │ └── 1.json │ ├── playQueue │ │ └── 2597.json │ └── playlists │ │ └── all.json │ ├── devices.xml │ ├── library │ ├── album.json │ ├── albumTracks.json │ ├── albums.json │ ├── artist.json │ ├── artists.json │ ├── countries.json │ ├── genres.json │ ├── metadata.json │ ├── metadataChildren.json │ ├── playQueue.json │ ├── playlist.json │ ├── playlistTracks.json │ ├── playlists.json │ ├── searchAll.json │ ├── section.json │ ├── sectionItems.json │ ├── sections.json │ ├── track.json │ └── tracks.json │ ├── resources.xml │ └── user.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .nyc_output 4 | coverage 5 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | docs 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib 2 | test 3 | coverage 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plex Media Server HTTP API Client 2 | 3 | This library makes it easier to use the Plex Media Server HTTP API. 4 | 5 | This library was written to be used to create fully featured PMS clients, which 6 | would allow users to login with a Plex account and select an available server. 7 | 8 | It also provides quite a few helper methods in the `Library` class so you don't 9 | have to manually construct the API paths yourself. 10 | 11 | **Note: This library only support music libraries at the moment, but there is 12 | no reason it couldn't support other library types as well.** 13 | 14 | ## Usage 15 | 16 | First add the library to your project 17 | 18 | ``` 19 | $ yarn add perplexed 20 | ``` 21 | 22 | Then create a new client instance. This describes the client that is making the 23 | request. You can find more about these options on [the node-plex-api 24 | README](https://github.com/phillipj/node-plex-api). 25 | 26 | ```javascript 27 | const {Client} = require('perplexed') 28 | 29 | const client = new Client({ 30 | identifier: 'f5941591-ef73-45e1-99c0-8f3a56941617', 31 | product: 'Node.js App', 32 | version: '1.0.0', 33 | device: 'linux', 34 | deviceName: 'Node.js App', 35 | platform: 'Node.js', 36 | platformVersion: '7.2.0', 37 | }) 38 | ``` 39 | 40 | Now you can create an account instance. 41 | 42 | ```javascript 43 | const {Account} = require('perplexed') 44 | 45 | const account = new Account(client) 46 | 47 | account.authenticate(username, password).then(() => { 48 | // we now have an auth token 49 | }) 50 | ``` 51 | 52 | Now you can create an server connection. 53 | 54 | ```javascript 55 | const {ServerConnection} = require('perplexed') 56 | 57 | const uri = 'http://192.168.1.100:32400' 58 | const serverConnection = new ServerConnection(uri, account) 59 | ``` 60 | 61 | Now use this connection to create a Library, which allows you to do awesome 62 | stuff. 63 | 64 | ```javascript 65 | const {Library} = require('perplexed') 66 | 67 | const library = new Library(serverConnection) 68 | 69 | // like get all the playlists in a library 70 | library.playlists().then((playlists) => { 71 | // do something with playlists 72 | }) 73 | ``` 74 | -------------------------------------------------------------------------------- /docs/assets/images/icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stayradiated/perplexed/95e0b6136636a60cb2e4a635a0619b29279f2f66/docs/assets/images/icons.png -------------------------------------------------------------------------------- /docs/assets/images/icons@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stayradiated/perplexed/95e0b6136636a60cb2e4a635a0619b29279f2f66/docs/assets/images/icons@2x.png -------------------------------------------------------------------------------- /docs/assets/images/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stayradiated/perplexed/95e0b6136636a60cb2e4a635a0619b29279f2f66/docs/assets/images/widgets.png -------------------------------------------------------------------------------- /docs/assets/images/widgets@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stayradiated/perplexed/95e0b6136636a60cb2e4a635a0619b29279f2f66/docs/assets/images/widgets@2x.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "perplexed", 3 | "version": "5.22.0", 4 | "description": "Plex API Client", 5 | "main": "dist/index.js", 6 | "author": "George Czabania ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/stayradiated/perplexed.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/stayradiated/perplexed/issues" 14 | }, 15 | "homepage": "https://github.com/stayradiated/perplexed#readme", 16 | "keywords": [ 17 | "plex", 18 | "api", 19 | "client" 20 | ], 21 | "scripts": { 22 | "clean": "pkg-clean", 23 | "build": "pkg-build", 24 | "docs": "pkg-docs", 25 | "test": "pkg-test", 26 | "precommit": "pkg-precommit", 27 | "lint": "pkg-lint", 28 | "tidy": "pkg-tidy" 29 | }, 30 | "dependencies": { 31 | "@zwolf/prism": "2.8.0", 32 | "ky": "0.18.0", 33 | "ky-universal": "0.5.0", 34 | "normalizr": "3.6.0", 35 | "uuid": "7.0.2", 36 | "xml2js": "0.4.23" 37 | }, 38 | "devDependencies": { 39 | "@stayradiated/pkg": "4.2.0", 40 | "@types/node": "13.7.7", 41 | "@types/uuid": "7.0.0", 42 | "@types/xml2js": "0.4.5", 43 | "nock": "12.0.2" 44 | }, 45 | "docsExternal": [ 46 | "./src/utils/*", 47 | "./src/test-helpers.ts" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/account.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import nock from 'nock' 3 | 4 | import { fixture, snapshot } from './test-helpers' 5 | 6 | import Account from './account' 7 | import Client from './client' 8 | 9 | nock.disableNetConnect() 10 | 11 | const test = anyTest as TestInterface<{ 12 | client: Client, 13 | account: Account, 14 | }> 15 | 16 | const PLEX_API = 'https://plex.tv' 17 | const AUTH_TOKEN = 'AUTH_TOKEN' 18 | const CLIENT_HEADERS = { 19 | CLIENT_HEADERS: 'true', 20 | } 21 | 22 | test.beforeEach((t) => { 23 | t.context.client = { 24 | device: '', 25 | deviceName: '', 26 | identifier: '', 27 | platform: '', 28 | platformVersion: '', 29 | product: '', 30 | version: '', 31 | headers: () => CLIENT_HEADERS, 32 | } 33 | t.context.account = new Account(t.context.client, AUTH_TOKEN) 34 | }) 35 | 36 | test('constructor without auth token', (t) => { 37 | const account = new Account(t.context.client) 38 | t.is(account.client, t.context.client) 39 | t.is(account.authToken, undefined) 40 | }) 41 | 42 | test('constructor with auth token', (t) => { 43 | const { account } = t.context 44 | t.is(account.client, t.context.client) 45 | t.is(account.authToken, AUTH_TOKEN) 46 | }) 47 | 48 | test('headers', (t) => { 49 | const { account } = t.context 50 | t.deepEqual(account.headers(), { 51 | ...CLIENT_HEADERS, 52 | 'X-Plex-Token': AUTH_TOKEN, 53 | }) 54 | }) 55 | 56 | test('fetch', async (t) => { 57 | const { account } = t.context 58 | 59 | const scope = nock(PLEX_API, { reqheaders: { accept: 'application/json' } }) 60 | .get('/fetch') 61 | .reply(200, { 62 | key: 'value', 63 | }) 64 | 65 | const res = await account.fetch('/fetch') 66 | 67 | t.deepEqual(res, { key: 'value' }) 68 | 69 | scope.done() 70 | }) 71 | 72 | test('fetch with params', async (t) => { 73 | const { account } = t.context 74 | 75 | const scope = nock(PLEX_API, { reqheaders: { accept: 'application/json' } }) 76 | .get('/fetch-with-params?name=plex') 77 | .reply(200, { 78 | key: 'value', 79 | }) 80 | 81 | const res = await account.fetch('/fetch-with-params', { 82 | searchParams: { 83 | name: 'plex', 84 | }, 85 | }) 86 | 87 | t.deepEqual(res, { key: 'value' }) 88 | 89 | scope.done() 90 | }) 91 | 92 | test('fetchXML', async (t) => { 93 | const { account } = t.context 94 | 95 | const scope = nock(PLEX_API) 96 | .get('/fetch-xml') 97 | .reply(200, 'Plex') 98 | 99 | const res = await account.fetchXML('/fetch-xml') 100 | 101 | t.deepEqual(res, { 102 | container: { 103 | $: { size: '20' }, 104 | name: ['Plex'], 105 | }, 106 | }) 107 | 108 | scope.done() 109 | }) 110 | 111 | test('fetchXML with params', async (t) => { 112 | const { account } = t.context 113 | 114 | const scope = nock(PLEX_API) 115 | .get('/fetch-xml-with-params?name=plex') 116 | .reply(200, 'Plex') 117 | 118 | const res = await account.fetchXML('/fetch-xml-with-params', { 119 | searchParams: { 120 | name: 'plex', 121 | }, 122 | }) 123 | 124 | t.deepEqual(res, { 125 | container: { 126 | $: { size: '20' }, 127 | name: ['Plex'], 128 | }, 129 | }) 130 | 131 | scope.done() 132 | }) 133 | 134 | test('authenticate failure', async (t) => { 135 | const account = new Account(t.context.client) 136 | 137 | const username = 'username' 138 | const password = 'password' 139 | 140 | const scope = nock(PLEX_API) 141 | .post('/api/v2/users/signin') 142 | .reply(401, { 143 | error: 'Invalid email, username, or password.', 144 | }) 145 | 146 | t.plan(1) 147 | 148 | try { 149 | await account.authenticate(username, password) 150 | } catch (error) { 151 | const errorRes = await error.response.json() 152 | t.deepEqual(errorRes, { 153 | error: 'Invalid email, username, or password.', 154 | }) 155 | } 156 | 157 | scope.done() 158 | }) 159 | 160 | test('authenticate', async (t) => { 161 | const account = new Account(t.context.client) 162 | const response = fixture('user.json') 163 | 164 | const username = 'username' 165 | const password = 'password' 166 | 167 | const scope = nock(PLEX_API) 168 | .post('/api/v2/users/signin') 169 | .reply(200, response) 170 | 171 | await account.authenticate(username, password).then(snapshot(t, scope)) 172 | }) 173 | 174 | test('info', async (t) => { 175 | const { account } = t.context 176 | const response = fixture('user.json') 177 | 178 | const scope = nock(PLEX_API) 179 | .get('/api/v2/user') 180 | .reply(200, response) 181 | 182 | await account.info().then(snapshot(t, scope)) 183 | }) 184 | 185 | test('resources', async (t) => { 186 | const { account } = t.context 187 | const response = fixture('resources.xml') 188 | 189 | const scope = nock(PLEX_API) 190 | .get('/api/resources?includeHttps=1&includeRelay=1') 191 | .reply(200, response) 192 | 193 | await account.resources().then(snapshot(t, scope)) 194 | }) 195 | 196 | test('servers', async (t) => { 197 | const { account } = t.context 198 | 199 | const scope = nock(PLEX_API) 200 | .get('/api/resources?includeHttps=1&includeRelay=1') 201 | .reply( 202 | 200, 203 | ` 204 | 205 | 206 | 207 | `, 208 | ) 209 | 210 | const servers = await account.servers() 211 | 212 | t.is(servers.devices.length, 2) 213 | t.is(servers.devices[0].clientIdentifier, '1') 214 | t.is(servers.devices[1].clientIdentifier, '3') 215 | 216 | scope.done() 217 | }) 218 | 219 | test('devices', async (t) => { 220 | const { account } = t.context 221 | const response = fixture('devices.xml') 222 | 223 | const scope = nock(PLEX_API) 224 | .get('/devices.xml') 225 | .reply(200, response) 226 | 227 | await account.devices().then(snapshot(t, scope)) 228 | }) 229 | 230 | test('removeDevice', async (t) => { 231 | const { account } = t.context 232 | 233 | const scope = nock(PLEX_API) 234 | .delete('/devices/123.json') 235 | .reply(200, {}) 236 | 237 | await account.removeDevice('123') 238 | 239 | t.pass() 240 | 241 | scope.done() 242 | }) 243 | -------------------------------------------------------------------------------- /src/account.spec.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stayradiated/perplexed/95e0b6136636a60cb2e4a635a0619b29279f2f66/src/account.spec.ts.snap -------------------------------------------------------------------------------- /src/account.ts: -------------------------------------------------------------------------------- 1 | import Client from './client' 2 | 3 | import { requestJSON, requestXML, RequestOptions } from './utils/request' 4 | import { parseResourceContainer } from './types/resources' 5 | import { parseUser } from './types/user' 6 | import { parsePin } from './types/pin' 7 | import { parseDeviceList } from './types/device' 8 | import { parseSyncList } from './types/sync-list' 9 | 10 | const PLEX_API = 'https://plex.tv' 11 | 12 | /** 13 | * A plex.tv account 14 | * 15 | * @class Account 16 | * @param {string} [authToken] Plex auth token 17 | */ 18 | 19 | export default class Account { 20 | public client: Client 21 | public authToken: string 22 | 23 | constructor (client: Client, authToken?: string) { 24 | this.client = client 25 | this.authToken = authToken 26 | } 27 | 28 | /** 29 | * Headers we need to send to Plex whenever we make a request 30 | * 31 | * @private 32 | */ 33 | 34 | headers (): Record { 35 | return { 36 | ...this.client.headers(), 37 | 'X-Plex-Token': this.authToken || undefined, 38 | } 39 | } 40 | 41 | /** 42 | * Make a JSON request to the Plex.tv API 43 | * 44 | * @private 45 | */ 46 | 47 | fetch (path: string, options: RequestOptions = {}) { 48 | const url = PLEX_API + path 49 | return requestJSON(url, { 50 | ...options, 51 | headers: { 52 | ...options.headers, 53 | ...this.headers(), 54 | }, 55 | }) 56 | } 57 | 58 | /** 59 | * Make an XML request to the Plex.tv API 60 | * 61 | * @private 62 | */ 63 | 64 | fetchXML (path: string, options: RequestOptions = {}) { 65 | const url = PLEX_API + path 66 | return requestXML(url, { 67 | ...options, 68 | headers: { 69 | ...options.headers, 70 | ...this.headers(), 71 | }, 72 | }) 73 | } 74 | 75 | /** 76 | * Log in to a Plex account 77 | * 78 | * @param {string} username 79 | * @param {string} password 80 | * @returns {Promise} - User info 81 | */ 82 | 83 | async authenticate (username: string, password: string) { 84 | const formData = new URLSearchParams() 85 | formData.append('login', username) 86 | formData.append('password', password) 87 | formData.append('rememberMe', 'true') 88 | 89 | // we use requestJSON instead of this.fetch because if you send 90 | // the X-Plex-Token header it won't actually switch accounts. 91 | const res = await requestJSON(`${PLEX_API}/api/v2/users/signin`, { 92 | method: 'POST', 93 | headers: { 94 | ...this.client.headers(), 95 | 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 96 | }, 97 | body: formData, 98 | }) 99 | 100 | const user = await parseUser(res) 101 | 102 | this.authToken = user.authToken 103 | return user 104 | } 105 | 106 | async requestPin () { 107 | const res = await this.fetch('/pins.json', { method: 'POST' }) 108 | const pin = parsePin(res) 109 | return pin 110 | } 111 | 112 | async checkPin (pinId: string) { 113 | const res = await this.fetch(`/pins/${pinId}.json`) 114 | const pin = parsePin(res) 115 | if (pin.authToken != null) { 116 | this.authToken = pin.authToken 117 | } 118 | return pin 119 | } 120 | 121 | /** 122 | * Fetch information about the currently logged in user 123 | * 124 | * @returns {Promise} 125 | */ 126 | 127 | async info () { 128 | const res = await this.fetch('/api/v2/user') 129 | return parseUser(res) 130 | } 131 | 132 | /** 133 | * Fetch a list of servers and clients connected to this plex account. 134 | * Useful for figuring out which server to connect to. 135 | * 136 | * Note: this API doesn't support JSON at the moment, so we need 137 | * to parse the response as XML. 138 | * 139 | * @returns {Promise} 140 | */ 141 | 142 | async resources () { 143 | const res = await this.fetchXML('/api/resources', { 144 | searchParams: { 145 | includeHttps: '1', 146 | includeRelay: '1', 147 | }, 148 | }) 149 | return parseResourceContainer(res) 150 | } 151 | 152 | /** 153 | * Fetch a list of all the servers 154 | * 155 | * @returns {Promise} 156 | */ 157 | 158 | async servers () { 159 | const resources = await this.resources() 160 | return { 161 | ...resources, 162 | devices: resources.devices.filter((device) => 163 | device.provides.includes('server'), 164 | ), 165 | } 166 | } 167 | 168 | /** 169 | * Fetch a list of devices attached to an account 170 | * 171 | * @returns {Promise} 172 | */ 173 | 174 | async devices () { 175 | const res = await this.fetchXML('/devices.xml') 176 | return parseDeviceList(res) 177 | } 178 | 179 | /** 180 | * Remove a device from the account 181 | */ 182 | 183 | async removeDevice (deviceId: string) { 184 | await this.fetch(`/devices/${deviceId}.json`, { 185 | method: 'DELETE', 186 | }) 187 | return true 188 | } 189 | 190 | /** 191 | * Fetch a list of items that are syncing to a device 192 | */ 193 | 194 | async syncItems (deviceId: string) { 195 | const res = await this.fetchXML(`/devices/${deviceId}/sync_items`) 196 | return parseSyncList(res) 197 | } 198 | 199 | /** 200 | * Remove an item from the sync list 201 | */ 202 | 203 | async removeSyncItem (deviceId: string, syncItemId: string) { 204 | await this.fetch(`/devices/${deviceId}/sync_items/${syncItemId}`, { 205 | method: 'DELETE', 206 | }) 207 | return true 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/client.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import os from 'os' 3 | 4 | import Client from './client' 5 | 6 | const test = anyTest as TestInterface<{}> 7 | 8 | test('should create a new client with default options', (t) => { 9 | const client = new Client() 10 | t.is(typeof client.identifier, 'string') 11 | t.is(client.identifier.length, 36) 12 | t.is(client.product, 'Node.js App') 13 | t.is(client.version, '1.0.0') 14 | t.is(client.device, os.platform()) 15 | t.is(client.deviceName, 'Node.js App') 16 | t.is(client.platform, 'Node.js') 17 | t.is(client.platformVersion, process.version) 18 | }) 19 | 20 | test('should allow options to be overwritten', (t) => { 21 | const client = new Client({ 22 | identifier: 'identifier', 23 | product: 'product', 24 | version: 'version', 25 | device: 'device', 26 | deviceName: 'deviceName', 27 | platform: 'platform', 28 | platformVersion: 'platformVersion', 29 | }) 30 | 31 | t.is(client.identifier, 'identifier') 32 | t.is(client.product, 'product') 33 | t.is(client.version, 'version') 34 | t.is(client.device, 'device') 35 | t.is(client.deviceName, 'deviceName') 36 | t.is(client.platform, 'platform') 37 | t.is(client.platformVersion, 'platformVersion') 38 | }) 39 | 40 | test('should generate plex headers', (t) => { 41 | const client = new Client({ 42 | identifier: 'identifier', 43 | product: 'product', 44 | version: 'version', 45 | device: 'device', 46 | deviceName: 'deviceName', 47 | platform: 'platform', 48 | platformVersion: 'platformVersion', 49 | }) 50 | 51 | t.deepEqual(client.headers(), { 52 | 'X-Plex-Client-Identifier': 'identifier', 53 | 'X-Plex-Product': 'product', 54 | 'X-Plex-Version': 'version', 55 | 'X-Plex-Device': 'device', 56 | 'X-Plex-Device-Name': 'deviceName', 57 | 'X-Plex-Platform': 'platform', 58 | 'X-Plex-Platform-Version': 'platformVersion', 59 | 'X-Plex-Provides': 'controller', 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid' 2 | import os from 'os' 3 | 4 | interface ClientOptions { 5 | device?: string, 6 | deviceName?: string, 7 | identifier?: string, 8 | platform?: string, 9 | platformVersion?: string, 10 | product?: string, 11 | version?: string, 12 | } 13 | 14 | /** 15 | * A Plex API client 16 | * 17 | * @class Client 18 | * @param {Object} options 19 | * @param {string} options.device 20 | * @param {string} options.deviceName 21 | * @param {string} options.identifier 22 | * @param {string} options.platform 23 | * @param {string} options.platformVersion 24 | * @param {string} options.product 25 | * @param {string} options.version 26 | */ 27 | 28 | export default class Client { 29 | public device: string 30 | public deviceName: string 31 | public identifier: string 32 | public platform: string 33 | public platformVersion: string 34 | public product: string 35 | public version: string 36 | 37 | constructor (options: ClientOptions = {}) { 38 | this.device = options.device || os.platform() 39 | this.deviceName = options.deviceName || 'Node.js App' 40 | this.identifier = options.identifier || uuid.v4() 41 | this.platform = options.platform || 'Node.js' 42 | this.platformVersion = options.platformVersion || process.version 43 | this.product = options.product || 'Node.js App' 44 | this.version = options.version || '1.0.0' 45 | } 46 | 47 | /** 48 | * All the headers that let Plex know which device is making the request 49 | * 50 | * @private 51 | * @returns {Object} 52 | */ 53 | 54 | headers (): Record { 55 | return { 56 | 'X-Plex-Client-Identifier': this.identifier, 57 | 'X-Plex-Device': this.device, 58 | 'X-Plex-Device-Name': this.deviceName, 59 | 'X-Plex-Platform': this.platform, 60 | 'X-Plex-Platform-Version': this.platformVersion, 61 | 'X-Plex-Product': this.product, 62 | 'X-Plex-Provides': 'controller', 63 | 'X-Plex-Version': this.version, 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/filter.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | 3 | import * as filter from './filter' 4 | 5 | const test = anyTest as TestInterface<{ 6 | filterValue: filter.FilterValue, 7 | filterString: filter.FilterString, 8 | filterNumber: filter.FilterNumber, 9 | filterDate: filter.FilterDate, 10 | }> 11 | 12 | test.beforeEach((t) => { 13 | t.context.filterValue = new filter.FilterValue('key') 14 | t.context.filterString = new filter.FilterString('key') 15 | t.context.filterNumber = new filter.FilterNumber('key') 16 | t.context.filterDate = new filter.FilterDate('key') 17 | }) 18 | 19 | test('FilterValue.is', (t) => { 20 | const { filterValue } = t.context 21 | const f = filterValue.is('value') 22 | t.deepEqual(f, { 'key=': 'value' }) 23 | }) 24 | 25 | test('FilterValue.isNot', (t) => { 26 | const { filterValue } = t.context 27 | const f = filterValue.isNot('value') 28 | t.deepEqual(f, { 'key!=': 'value' }) 29 | }) 30 | 31 | test('FilterValue.options', (t) => { 32 | const { filterValue } = t.context 33 | const options = filterValue.options() 34 | t.deepEqual(options, { 35 | is: 'is', 36 | isNot: 'is not', 37 | }) 38 | }) 39 | 40 | test('FilterString.contains', (t) => { 41 | const { filterString } = t.context 42 | const f = filterString.contains('value') 43 | t.deepEqual(f, { key: 'value' }) 44 | }) 45 | 46 | test('FilterString.doesNotContain', (t) => { 47 | const { filterString } = t.context 48 | const f = filterString.doesNotContain('value') 49 | t.deepEqual(f, { 'key!': 'value' }) 50 | }) 51 | 52 | test('FilterString.beginsWith', (t) => { 53 | const { filterString } = t.context 54 | const f = filterString.beginsWith('value') 55 | t.deepEqual(f, { 'key<': 'value' }) 56 | }) 57 | 58 | test('FilterString.endsWith', (t) => { 59 | const { filterString } = t.context 60 | const f = filterString.endsWith('value') 61 | t.deepEqual(f, { 'key>': 'value' }) 62 | }) 63 | 64 | test('FilterString.is', (t) => { 65 | const { filterString } = t.context 66 | const f = filterString.is('value') 67 | t.deepEqual(f, { 'key=': 'value' }) 68 | }) 69 | 70 | test('FilterString.isNot', (t) => { 71 | const { filterString } = t.context 72 | const f = filterString.isNot('value') 73 | t.deepEqual(f, { 'key!=': 'value' }) 74 | }) 75 | 76 | test('FilterString.options', (t) => { 77 | const { filterString } = t.context 78 | const options = filterString.options() 79 | t.deepEqual(options, { 80 | contains: 'contains', 81 | doesNotContain: 'does not contain', 82 | beginsWith: 'begins with', 83 | endsWith: 'ends with', 84 | is: 'is', 85 | isNot: 'is not', 86 | }) 87 | }) 88 | 89 | test('FilterNumber.isGreaterThan', (t) => { 90 | const { filterNumber } = t.context 91 | const f = filterNumber.isGreaterThan(100) 92 | t.deepEqual(f, { 'key>>': '100' }) 93 | }) 94 | 95 | test('FilterNumber.isLessThan', (t) => { 96 | const { filterNumber } = t.context 97 | const f = filterNumber.isLessThan(100) 98 | t.deepEqual(f, { 'key<<': '100' }) 99 | }) 100 | 101 | test('FilterNumber.options', (t) => { 102 | const { filterNumber } = t.context 103 | const options = filterNumber.options() 104 | t.deepEqual(options, { 105 | is: 'is', 106 | isNot: 'is not', 107 | isGreaterThan: 'is greater than', 108 | isLessThan: 'is less than', 109 | }) 110 | }) 111 | 112 | test('FilterDate.isBefore', (t) => { 113 | const { filterDate } = t.context 114 | const f = filterDate.isBefore(100) 115 | t.deepEqual(f, { 'key<<': '100' }) 116 | }) 117 | 118 | test('FilterDate.isAfter', (t) => { 119 | const { filterDate } = t.context 120 | const f = filterDate.isAfter(100) 121 | t.deepEqual(f, { 'key>>': '100' }) 122 | }) 123 | 124 | test('FilterDate.inTheLast', (t) => { 125 | const { filterDate } = t.context 126 | const f = filterDate.inTheLast(10, 's') 127 | t.deepEqual(f, { 'key>>': '-10s' }) 128 | }) 129 | 130 | test('FilterDate.inNotTheLast', (t) => { 131 | const { filterDate } = t.context 132 | const f = filterDate.inNotTheLast(10, 's') 133 | t.deepEqual(f, { 'key<<': '-10s' }) 134 | }) 135 | 136 | test('FilterDate.options', (t) => { 137 | const { filterDate } = t.context 138 | const options = filterDate.options() 139 | t.deepEqual(options, { 140 | isAfter: 'is after', 141 | isBefore: 'is before', 142 | inTheLast: 'in the last', 143 | inNotTheLast: 'in not the last', 144 | }) 145 | }) 146 | 147 | test('artistTitle', (t) => { 148 | const f = filter.artistTitle.is('value') 149 | t.deepEqual(f, { 'artist.title=': 'value' }) 150 | }) 151 | 152 | test('artistRating', (t) => { 153 | const f = filter.artistRating.is('value') 154 | t.deepEqual(f, { 'artist.userRating=': 'value' }) 155 | }) 156 | 157 | test('artistGenre', (t) => { 158 | const f = filter.artistGenre.is(100) 159 | t.deepEqual(f, { 'artist.genre': '100' }) 160 | }) 161 | 162 | test('artistCollection', (t) => { 163 | const f = filter.artistCollection.is('value') 164 | t.deepEqual(f, { 'artist.collection=': 'value' }) 165 | }) 166 | 167 | test('artistCountry', (t) => { 168 | const f = filter.artistCountry.is(100) 169 | t.deepEqual(f, { 'artist.country': '100' }) 170 | }) 171 | 172 | test('dateArtistAdded', (t) => { 173 | const f = filter.dateArtistAdded.isBefore(100) 174 | t.deepEqual(f, { 'artist.addedAt<<': '100' }) 175 | }) 176 | 177 | test('albumTitle', (t) => { 178 | const f = filter.albumTitle.is('value') 179 | t.deepEqual(f, { 'album.title=': 'value' }) 180 | }) 181 | 182 | test('year', (t) => { 183 | const f = filter.year.is('value') 184 | t.deepEqual(f, { 'album.year=': 'value' }) 185 | }) 186 | 187 | test('albumGenre', (t) => { 188 | const f = filter.albumGenre.isNot(100) 189 | t.deepEqual(f, { 'album.genre!': '100' }) 190 | }) 191 | 192 | test('albumPlays', (t) => { 193 | const f = filter.albumPlays.is('value') 194 | t.deepEqual(f, { 'album.viewCount=': 'value' }) 195 | }) 196 | 197 | test('albumLastPlayed', (t) => { 198 | const f = filter.albumLastPlayed.isBefore(100) 199 | t.deepEqual(f, { 'album.lastViewdAt<<': '100' }) 200 | }) 201 | 202 | test('albumRating', (t) => { 203 | const f = filter.albumRating.is('value') 204 | t.deepEqual(f, { 'album.userRating=': 'value' }) 205 | }) 206 | 207 | test('albumDecade', (t) => { 208 | const f = filter.albumDecade.is('value') 209 | t.deepEqual(f, { 'album.decade=': 'value' }) 210 | }) 211 | 212 | test('albumCollection', (t) => { 213 | const f = filter.albumCollection.is('value') 214 | t.deepEqual(f, { 'album.collection=': 'value' }) 215 | }) 216 | 217 | test('dateAlbumAdded', (t) => { 218 | const f = filter.dateAlbumAdded.isBefore(100) 219 | t.deepEqual(f, { 'album.addedAt<<': '100' }) 220 | }) 221 | 222 | test('trackTitle', (t) => { 223 | const f = filter.trackTitle.is('value') 224 | t.deepEqual(f, { 'track.title=': 'value' }) 225 | }) 226 | 227 | test('trackPlays', (t) => { 228 | const f = filter.trackPlays.is('value') 229 | t.deepEqual(f, { 'track.viewCount=': 'value' }) 230 | }) 231 | 232 | test('trackLastPlayed', (t) => { 233 | const f = filter.trackLastPlayed.isBefore(100) 234 | t.deepEqual(f, { 'track.viewCount<<': '100' }) 235 | }) 236 | 237 | test('trackSkips', (t) => { 238 | const f = filter.trackSkips.is('value') 239 | t.deepEqual(f, { 'track.skipCount=': 'value' }) 240 | }) 241 | 242 | test('trackLastSkipped', (t) => { 243 | const f = filter.trackLastSkipped.isBefore(100) 244 | t.deepEqual(f, { 'track.lastSkippedAt<<': '100' }) 245 | }) 246 | 247 | test('trackRating', (t) => { 248 | const f = filter.trackRating.is('value') 249 | t.deepEqual(f, { 'track.userRating=': 'value' }) 250 | }) 251 | 252 | test('limit', (t) => { 253 | const f = filter.limit(100) 254 | t.deepEqual(f, { limit: '100' }) 255 | }) 256 | -------------------------------------------------------------------------------- /src/filter.ts: -------------------------------------------------------------------------------- 1 | export const SECONDS = 's' 2 | export const MINUTES = 'm' 3 | export const HOURS = 'h' 4 | export const DAYS = 'd' 5 | export const WEEKS = 'w' 6 | export const MONTHS = 'mon' 7 | export const YEARS = 'y' 8 | 9 | type Value = number | string | string[] 10 | 11 | export const serialize = (value: Value): string => { 12 | if (Array.isArray(value)) { 13 | return value.join(',') 14 | } 15 | if (typeof value === 'number') { 16 | return value.toString() 17 | } 18 | return value 19 | } 20 | 21 | export class Filter { 22 | public title: string 23 | 24 | constructor (title: string) { 25 | this.title = title 26 | } 27 | 28 | options () { 29 | return {} 30 | } 31 | 32 | filter (symbol: string, value: Value) { 33 | return { [`${this.title}${symbol}`]: serialize(value) } 34 | } 35 | } 36 | 37 | export class FilterReference extends Filter { 38 | options () { 39 | return { 40 | ...super.options(), 41 | is: 'is', 42 | isNot: 'is not', 43 | } 44 | } 45 | 46 | is (value: number) { 47 | return this.filter('', value) 48 | } 49 | 50 | isNot (value: number) { 51 | return this.filter('!', value) 52 | } 53 | } 54 | 55 | export class FilterValue extends Filter { 56 | options () { 57 | return { 58 | ...super.options(), 59 | is: 'is', 60 | isNot: 'is not', 61 | } 62 | } 63 | 64 | is (value: number | string) { 65 | return this.filter('=', value) 66 | } 67 | 68 | isNot (value: number | string) { 69 | return this.filter('!=', value) 70 | } 71 | } 72 | 73 | export class FilterString extends FilterValue { 74 | options () { 75 | return { 76 | ...super.options(), 77 | contains: 'contains', 78 | doesNotContain: 'does not contain', 79 | beginsWith: 'begins with', 80 | endsWith: 'ends with', 81 | } 82 | } 83 | 84 | contains (value: string) { 85 | return this.filter('', value) 86 | } 87 | 88 | doesNotContain (value: string) { 89 | return this.filter('!', value) 90 | } 91 | 92 | beginsWith (value: Value) { 93 | return this.filter('<', value) 94 | } 95 | 96 | endsWith (value: string) { 97 | return this.filter('>', value) 98 | } 99 | } 100 | 101 | export class FilterNumber extends FilterValue { 102 | options () { 103 | return { 104 | ...super.options(), 105 | isGreaterThan: 'is greater than', 106 | isLessThan: 'is less than', 107 | } 108 | } 109 | 110 | isGreaterThan (value: number) { 111 | return this.filter('>>', value) 112 | } 113 | 114 | isLessThan (value: number) { 115 | return this.filter('<<', value) 116 | } 117 | } 118 | 119 | export class FilterDate extends Filter { 120 | options () { 121 | return { 122 | ...super.options(), 123 | isBefore: 'is before', 124 | isAfter: 'is after', 125 | inTheLast: 'in the last', 126 | inNotTheLast: 'in not the last', 127 | } 128 | } 129 | 130 | isBefore (value: number) { 131 | return this.filter('<<', value) 132 | } 133 | 134 | isAfter (value: number) { 135 | return this.filter('>>', value) 136 | } 137 | 138 | inTheLast (value: number, unit: string) { 139 | return this.filter('>>', `-${value}${unit}`) 140 | } 141 | 142 | inNotTheLast (value: number, unit: string) { 143 | return this.filter('<<', `-${value}${unit}`) 144 | } 145 | } 146 | 147 | export const artistTitle = new FilterString('artist.title') 148 | export const artistRating = new FilterNumber('artist.userRating') 149 | export const artistGenre = new FilterReference('artist.genre') 150 | export const artistCollection = new FilterValue('artist.collection') 151 | export const artistCountry = new FilterReference('artist.country') 152 | export const dateArtistAdded = new FilterDate('artist.addedAt') 153 | 154 | export const albumTitle = new FilterString('album.title') 155 | export const year = new FilterNumber('album.year') 156 | export const albumGenre = new FilterReference('album.genre') 157 | export const albumPlays = new FilterNumber('album.viewCount') 158 | export const albumLastPlayed = new FilterDate('album.lastViewdAt') 159 | export const albumRating = new FilterNumber('album.userRating') 160 | export const albumDecade = new FilterNumber('album.decade') 161 | export const albumCollection = new FilterValue('album.collection') 162 | export const dateAlbumAdded = new FilterDate('album.addedAt') 163 | 164 | export const trackTitle = new FilterString('track.title') 165 | export const trackPlays = new FilterNumber('track.viewCount') 166 | export const trackLastPlayed = new FilterDate('track.viewCount') 167 | export const trackSkips = new FilterNumber('track.skipCount') 168 | export const trackLastSkipped = new FilterDate('track.lastSkippedAt') 169 | export const trackRating = new FilterNumber('track.userRating') 170 | 171 | export const availableTrackOptions = { 172 | artistTitle: 'Artist Title', 173 | artistRating: 'Artist Rating', 174 | artistGenre: 'Artist Genre', 175 | artistCollection: 'Artist Collection', 176 | artistCountry: 'Artist Country', 177 | dateArtistAdded: 'Date Artist Added', 178 | 179 | albumTitle: 'Album Title', 180 | year: 'Year', 181 | albumGenre: 'Album Genre', 182 | albumPlays: 'Album Plays', 183 | albumLastPlayed: 'Album Last Played', 184 | albumRating: 'Album Rating', 185 | albumDecade: 'Album Decade', 186 | albumCollection: 'Album Collection', 187 | dateAlbumAdded: 'Date Album Added', 188 | 189 | trackTitle: 'Track Title', 190 | trackPlays: 'Track Plays', 191 | trackLastPlayed: 'Track Last Played', 192 | trackSkips: 'Track Skips', 193 | trackLastSkipped: 'Track Last Skipped', 194 | trackRating: 'Track Rating', 195 | } 196 | 197 | export const availableDateUnits = { 198 | [SECONDS]: 'Seconds', 199 | [MINUTES]: 'Minutes', 200 | [HOURS]: 'Hours', 201 | [DAYS]: 'Days', 202 | [WEEKS]: 'Weeks', 203 | [MONTHS]: 'Months', 204 | [YEARS]: 'Years', 205 | } 206 | 207 | export const limit = (value: number) => { 208 | return { limit: value.toString() } 209 | } 210 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // custom filters 2 | import * as filter from './filter' 3 | export { filter } 4 | 5 | export { default as Account } from './account' 6 | export { default as Client } from './client' 7 | export { default as ServerConnection } from './server-connection' 8 | export { default as normalize, normalizeSync } from './normalize' 9 | 10 | export { Album, AlbumContainer } from './types/album' 11 | export { Artist, ArtistContainer } from './types/artist' 12 | export { Country } from './types/country' 13 | export { Genre } from './types/genre' 14 | export { Media } from './types/media' 15 | export { MediaContainer } from './types/media-container' 16 | export { Part } from './types/part' 17 | export { Pin } from './types/pin' 18 | export { Device, Connection } from './types/device' 19 | export { ResourceContainer } from './types/resources' 20 | export { Stream } from './types/stream' 21 | export { PlaylistItem, Playlist, PlaylistContainer } from './types/playlist' 22 | export { Tag } from './types/tag' 23 | export { Track, TrackContainer } from './types/track' 24 | export { 25 | User, 26 | UserSubscription, 27 | Subscription, 28 | Service, 29 | Profile, 30 | } from './types/user' 31 | 32 | export { default as Library, MediaType } from './library' 33 | 34 | const sort = (asc: string, desc = `${asc}:desc`) => [asc, desc] 35 | 36 | // sort methods 37 | export const SORT_ARTISTS_BY_TITLE = sort('titleSort') 38 | export const SORT_ARTISTS_BY_DATE_ADDED = sort('addedAt') 39 | export const SORT_ARTISTS_BY_DATE_PLAYED = sort('lastViewedAt') 40 | export const SORT_ARTISTS_BY_PLAYS = sort('viewCount') 41 | 42 | export const SORT_ALBUMS_BY_TITLE = sort('titleSort') 43 | export const SORT_ALBUMS_BY_ALBUM_ARTIST = sort( 44 | 'artist.titleSort,album.year', 45 | 'artist.titleSort:desc,album.year', 46 | ) 47 | export const SORT_ALBUMS_BY_YEAR = sort('year') 48 | export const SORT_ALBUMS_BY_RELEASE_DATE = sort('originallyAvailableAt') 49 | export const SORT_ALBUMS_BY_RATING = sort('userRating') 50 | export const SORT_ALBUMS_BY_DATE_ADDED = sort('addedAt') 51 | export const SORT_ALBUMS_BY_DATE_PLAYED = sort('lastViewedAt') 52 | export const SORT_ALBUMS_BY_VIEWS = sort('viewCount') 53 | 54 | export const SORT_PLAYLISTS_BY_NAME = sort('titleSort') 55 | export const SORT_PLAYLISTS_BY_PLAYS = sort('viewCount') 56 | export const SORT_PLAYLISTS_BY_LAST_PLAYED = sort('lastViewedAt') 57 | export const SORT_PLAYLISTS_BY_DURATION = sort('duration') 58 | export const SORT_PLAYLISTS_BY_DATE_ADDED = sort('addedAt') 59 | export const SORT_PLAYLISTS_BY_ITEM_COUNT = sort('mediaCount') 60 | 61 | export const SORT_TRACKS_BY_TITLE = sort('titleSort') 62 | export const SORT_TRACKS_BY_ALBUM_ARTIST = sort( 63 | 'artist.titleSort,album.titleSort,track.index', 64 | ) 65 | export const SORT_TRACKS_BY_ARTIST = sort( 66 | 'track.originalTitle,album.titleSort,track.index', 67 | ) 68 | export const SORT_TRACKS_BY_ALBUM = sort('album.titleSort,track.index') 69 | export const SORT_TRACKS_BY_YEAR = sort('year') 70 | export const SORT_TRACKS_BY_RATING = sort('userRating') 71 | export const SORT_TRACKS_BY_DURATION = sort('duration') 72 | export const SORT_TRACKS_BY_PLAYS = sort('viewCount') 73 | export const SORT_TRACKS_BY_DATE_ADDED = sort('addedAt') 74 | export const SORT_TRACKS_BY_BITRATE = sort('mediaBitrate') 75 | export const SORT_TRACKS_BY_POPULARITY = sort('ratingCount') 76 | 77 | // playlist types 78 | export const PLAYLIST_TYPE_MUSIC = 'audio' 79 | export const PLAYLIST_TYPE_PHOTO = 'photo' 80 | export const PLAYLIST_TYPE_VIDEO = 'video' 81 | -------------------------------------------------------------------------------- /src/library.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | 3 | import nock from 'nock' 4 | 5 | import { fixture, snapshot } from './test-helpers' 6 | 7 | import Library, { MediaType } from './library' 8 | import ServerConnection from './server-connection' 9 | 10 | const URI = 'http://192.168.0.100:32400' 11 | const PARENT_HEADERS = { 12 | 'X-Plex-Token': 'xxxx', 13 | } 14 | const PARENT = { 15 | headers: () => PARENT_HEADERS, 16 | } 17 | 18 | const test = anyTest as TestInterface<{ 19 | sc: ServerConnection, 20 | library: Library, 21 | }> 22 | 23 | test.beforeEach((t) => { 24 | t.context.sc = new ServerConnection(URI, PARENT) 25 | t.context.library = new Library(t.context.sc) 26 | }) 27 | 28 | test('constructor', (t) => { 29 | const { sc, library } = t.context 30 | t.is(sc, library.api) 31 | }) 32 | 33 | test('fetch', async (t) => { 34 | const { library } = t.context 35 | 36 | const scope = nock(URI) 37 | .get('/path') 38 | .query({ 39 | key: 'value', 40 | 'X-Plex-Container-Start': '5', 41 | 'X-Plex-Container-Size': '10', 42 | }) 43 | .reply(200, { value: true }) 44 | 45 | const res = await library.fetch('/path', { 46 | searchParams: { 47 | key: 'value', 48 | start: '5', 49 | size: '10', 50 | }, 51 | }) 52 | 53 | t.deepEqual(res, { value: true }) 54 | 55 | scope.done() 56 | }) 57 | 58 | test('sections', async (t) => { 59 | const { library } = t.context 60 | const response = fixture('library/sections.json') 61 | 62 | const scope = nock(URI) 63 | .get('/library/sections') 64 | .reply(200, response) 65 | 66 | await library.sections().then(snapshot(t, scope)) 67 | }) 68 | 69 | test('section', async (t) => { 70 | const { library } = t.context 71 | const response = fixture('library/section.json') 72 | 73 | const scope = nock(URI) 74 | .get('/library/sections/1') 75 | .reply(200, response) 76 | 77 | await library.section(1).then(snapshot(t, scope)) 78 | }) 79 | 80 | test('sectionItems', async (t) => { 81 | const { library } = t.context 82 | const response = fixture('library/sectionItems.json') 83 | 84 | const scope = nock(URI) 85 | .get('/library/sections/1/all') 86 | .query({ 87 | type: MediaType.ARTIST, 88 | }) 89 | .reply(200, response) 90 | 91 | await library.sectionItems(1, MediaType.ARTIST).then(snapshot(t, scope)) 92 | }) 93 | 94 | test('metadata', async (t) => { 95 | const { library } = t.context 96 | const response = fixture('library/metadata.json') 97 | 98 | const scope = nock(URI) 99 | .get('/library/metadata/74892') 100 | .reply(200, response) 101 | 102 | await library.metadata(74892, MediaType.ALBUM).then(snapshot(t, scope)) 103 | }) 104 | 105 | test('metadataChildren', async (t) => { 106 | const { library } = t.context 107 | const response = fixture('library/metadataChildren.json') 108 | 109 | const scope = nock(URI) 110 | .get('/library/metadata/41409/children') 111 | .reply(200, response) 112 | 113 | await library 114 | .metadataChildren(41409, MediaType.TRACK) 115 | .then(snapshot(t, scope)) 116 | }) 117 | 118 | test('countries', async (t) => { 119 | const { library } = t.context 120 | const response = fixture('library/countries.json') 121 | 122 | const scope = nock(URI) 123 | .get('/library/sections/1/country') 124 | .reply(200, response) 125 | 126 | await library.countries(1).then(snapshot(t, scope)) 127 | }) 128 | 129 | test('genres', async (t) => { 130 | const { library } = t.context 131 | const response = fixture('library/genres.json') 132 | 133 | const scope = nock(URI) 134 | .get('/library/sections/1/genre') 135 | .reply(200, response) 136 | 137 | await library.genres(1).then(snapshot(t, scope)) 138 | }) 139 | 140 | test('tracks', async (t) => { 141 | const { library } = t.context 142 | const response = fixture('library/tracks.json') 143 | 144 | const scope = nock(URI) 145 | .get('/library/sections/1/all') 146 | .query({ 147 | type: 10, 148 | }) 149 | .reply(200, response) 150 | 151 | await library.tracks(1).then(snapshot(t, scope)) 152 | }) 153 | 154 | test('track', async (t) => { 155 | const { library } = t.context 156 | const response = fixture('library/track.json') 157 | 158 | const scope = nock(URI) 159 | .get('/library/metadata/35341') 160 | .reply(200, response) 161 | 162 | await library.track(35341).then(snapshot(t, scope)) 163 | }) 164 | 165 | test('albums', async (t) => { 166 | const { library } = t.context 167 | const response = fixture('library/albums.json') 168 | 169 | const scope = nock(URI) 170 | .get('/library/sections/1/all') 171 | .query({ 172 | type: 9, 173 | }) 174 | .reply(200, response) 175 | 176 | await library.albums(1).then(snapshot(t, scope)) 177 | }) 178 | 179 | test('album', async (t) => { 180 | const { library } = t.context 181 | const response = fixture('library/album.json') 182 | 183 | const scope = nock(URI) 184 | .get('/library/metadata/40812') 185 | .reply(200, response) 186 | 187 | await library.album(40812).then(snapshot(t, scope)) 188 | }) 189 | 190 | test('albumTracks', async (t) => { 191 | const { library } = t.context 192 | const response = fixture('library/albumTracks.json') 193 | 194 | const scope = nock(URI) 195 | .get('/library/metadata/40812/children') 196 | .reply(200, response) 197 | 198 | await library.albumTracks(40812).then(snapshot(t, scope)) 199 | }) 200 | 201 | test('artist', async (t) => { 202 | const { library } = t.context 203 | const response = fixture('library/artist.json') 204 | 205 | const scope = nock(URI) 206 | .get('/library/metadata/8670') 207 | .query({ 208 | includePopularLeaves: 1, 209 | }) 210 | .reply(200, response) 211 | 212 | await library.artist(8670, { includePopular: true }).then(snapshot(t, scope)) 213 | }) 214 | 215 | test('artists', async (t) => { 216 | const { library } = t.context 217 | const response = fixture('library/artists.json') 218 | 219 | const scope = nock(URI) 220 | .get('/library/sections/1/all') 221 | .query({ 222 | type: 8, 223 | }) 224 | .reply(200, response) 225 | 226 | await library.artists(1).then(snapshot(t, scope)) 227 | }) 228 | 229 | test('playlists', async (t) => { 230 | const { library } = t.context 231 | const response = fixture('library/playlists.json') 232 | 233 | const scope = nock(URI) 234 | .get('/playlists/all') 235 | .query({ 236 | type: 15, 237 | }) 238 | .reply(200, response) 239 | 240 | await library.playlists().then(snapshot(t, scope)) 241 | }) 242 | 243 | test('playlist', async (t) => { 244 | const { library } = t.context 245 | const response = fixture('library/playlist.json') 246 | 247 | const scope = nock(URI) 248 | .get('/playlists/45606') 249 | .reply(200, response) 250 | 251 | await library.playlist(45606).then(snapshot(t, scope)) 252 | }) 253 | 254 | test('playlistTracks', async (t) => { 255 | const { library } = t.context 256 | const response = fixture('library/playlistTracks.json') 257 | 258 | const scope = nock(URI) 259 | .get('/playlists/123/items') 260 | .reply(200, response) 261 | 262 | await library.playlistTracks(123).then(snapshot(t, scope)) 263 | }) 264 | 265 | test('playQueue', async (t) => { 266 | const { library } = t.context 267 | const response = fixture('library/playQueue.json') 268 | 269 | const scope = nock(URI) 270 | .get('/playQueues/3147') 271 | .reply(200, response) 272 | 273 | await library.playQueue(3147).then(snapshot(t, scope)) 274 | }) 275 | 276 | test('shufflePlayQueue', async (t) => { 277 | const { library } = t.context 278 | const response = fixture('library/playQueue.json') 279 | 280 | const scope = nock(URI) 281 | .put('/playQueues/3921/shuffle') 282 | .reply(200, response) 283 | 284 | await library.shufflePlayQueue(3921).then(snapshot(t, scope)) 285 | }) 286 | 287 | test('unshufflePlayQueue', async (t) => { 288 | const { library } = t.context 289 | const response = fixture('library/playQueue.json') 290 | 291 | const scope = nock(URI) 292 | .put('/playQueues/3921/unshuffle') 293 | .reply(200, response) 294 | 295 | await library.unshufflePlayQueue(3921).then(snapshot(t, scope)) 296 | }) 297 | 298 | test('searchAll', async (t) => { 299 | const { library } = t.context 300 | const response = fixture('library/searchAll.json') 301 | 302 | const scope = nock(URI) 303 | .get('/hubs/search') 304 | .query({ 305 | query: 'ride', 306 | limit: 10, 307 | }) 308 | .reply(200, response) 309 | 310 | await library.searchAll('ride', 10).then(snapshot(t, scope)) 311 | }) 312 | 313 | test('resizePhoto', (t) => { 314 | const { library } = t.context 315 | 316 | const image = 'https://images.unsplash.com/photo-1429728001698-8ba1c4c64783' 317 | const encodedImage = encodeURIComponent(image) 318 | 319 | const url = library.resizePhoto({ 320 | uri: image, 321 | width: 200, 322 | height: 200, 323 | }) 324 | 325 | t.is( 326 | url, 327 | `${URI}/photo/:/transcode?uri=${encodedImage}&width=200&height=200&X-Plex-Token=xxxx`, 328 | ) 329 | }) 330 | -------------------------------------------------------------------------------- /src/library.spec.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stayradiated/perplexed/95e0b6136636a60cb2e4a635a0619b29279f2f66/src/library.spec.ts.snap -------------------------------------------------------------------------------- /src/normalize.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | 3 | import { normalizeSync } from './normalize' 4 | import { Artist } from './types/artist' 5 | 6 | const test = anyTest as TestInterface<{}> 7 | 8 | const ARTIST: Artist = { 9 | _type: 'artist', 10 | id: 1, 11 | genre: [], 12 | country: [], 13 | popularTracks: [], 14 | addedAt: new Date(), 15 | art: '', 16 | deletedAt: new Date(), 17 | guid: '', 18 | index: 0, 19 | key: '', 20 | lastViewedAt: new Date(), 21 | ratingKey: '', 22 | summary: '', 23 | thumb: '', 24 | title: '', 25 | titleSort: '', 26 | type: '', 27 | updatedAt: new Date(), 28 | viewCount: 0, 29 | } 30 | 31 | test('normalizeSync', (t) => { 32 | const res = normalizeSync({ 33 | _type: 'artistContainer', 34 | artists: [ARTIST], 35 | }) 36 | 37 | t.deepEqual(res, { 38 | entities: { 39 | artists: { 40 | 1: ARTIST, 41 | }, 42 | }, 43 | result: { 44 | id: { _type: 'artistContainer', artists: [1] }, 45 | schema: 'artistContainer', 46 | }, 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/normalize.ts: -------------------------------------------------------------------------------- 1 | import { normalize as normalizeSchema, schema } from 'normalizr' 2 | 3 | import { Album, albumSchema, albumContainerSchema } from './types/album' 4 | import { Artist, artistSchema, artistContainerSchema } from './types/artist' 5 | import { hubSchema, hubContainerSchema } from './types/hub' 6 | import { 7 | NormalizedPlaylist, 8 | playlistSchema, 9 | playlistContainerSchema, 10 | } from './types/playlist' 11 | import { 12 | playQueueItemSchema, 13 | playQueueContainerSchema, 14 | } from './types/play-queue' 15 | import { 16 | connectionSchema, 17 | deviceSchema, 18 | deviceContainerSchema, 19 | } from './types/device' 20 | import { resourceContainerSchema } from './types/resources' 21 | import { sectionSchema, sectionContainerSchema } from './types/section' 22 | import { Track, trackSchema, trackContainerSchema } from './types/track' 23 | 24 | export interface Normalized { 25 | entities: { 26 | albums?: Record, 27 | artists?: Record, 28 | playlists?: Record, 29 | tracks?: Record, 30 | }, 31 | result: { 32 | schema: string, 33 | id: T, 34 | }, 35 | } 36 | 37 | /** 38 | * @ignore 39 | * 40 | * Normalize a __parsed__ plex response based on the data type. 41 | * This is done based on the `_type` property all plex objects are given. 42 | * 43 | * @param {Object} data - parsed plex response 44 | * @returns {Object} normalized plex response 45 | */ 46 | 47 | const dataSchema = new schema.Union( 48 | { 49 | album: albumSchema, 50 | albumContainer: albumContainerSchema, 51 | 52 | artist: artistSchema, 53 | artistContainer: artistContainerSchema, 54 | 55 | hub: hubSchema, 56 | hubContainer: hubContainerSchema, 57 | 58 | playlist: playlistSchema, 59 | playlistContainer: playlistContainerSchema, 60 | 61 | playQueueItem: playQueueItemSchema, 62 | playQueueContainer: playQueueContainerSchema, 63 | 64 | connection: connectionSchema, 65 | device: deviceSchema, 66 | resourceContainer: resourceContainerSchema, 67 | deviceContaienr: deviceContainerSchema, 68 | 69 | section: sectionSchema, 70 | sectionContainer: sectionContainerSchema, 71 | 72 | track: trackSchema, 73 | trackContainer: trackContainerSchema, 74 | }, 75 | '_type', 76 | ) 77 | 78 | /* 79 | * normalizeSync 80 | * 81 | * Accepts an object 82 | * 83 | * Returns an object 84 | */ 85 | export function normalizeSync ( 86 | data: Record, 87 | ): Normalized { 88 | return normalizeSchema(data, dataSchema) 89 | } 90 | 91 | /* 92 | * normalize 93 | * 94 | * Accepts an object or a promise of an object 95 | * 96 | * Returns a promise 97 | */ 98 | export default async function normalize ( 99 | promise: Promise>, 100 | ): Promise> { 101 | const resolvedData = await promise 102 | return normalizeSync(resolvedData) 103 | } 104 | -------------------------------------------------------------------------------- /src/server-connection.spec.ts: -------------------------------------------------------------------------------- 1 | import anyTest, { TestInterface } from 'ava' 2 | import nock from 'nock' 3 | 4 | import ServerConnection from './server-connection' 5 | 6 | const test = anyTest as TestInterface<{ 7 | sc: ServerConnection, 8 | }> 9 | 10 | const URI = 'http://192.168.1.100:32400' 11 | const PARENT_HEADERS = { 12 | 'X-Plex-Token': 'abc', 13 | } 14 | const PARENT = { 15 | headers: () => PARENT_HEADERS, 16 | } 17 | 18 | test.beforeEach((t) => { 19 | t.context.sc = new ServerConnection(URI, PARENT) 20 | }) 21 | 22 | test('constructor without parent', (t) => { 23 | const sc = new ServerConnection(URI) 24 | t.is(sc.uri, URI) 25 | t.is(sc.parent, undefined) 26 | }) 27 | 28 | test('constructor with parent', (t) => { 29 | const sc = new ServerConnection(URI, PARENT) 30 | t.is(sc.uri, URI) 31 | t.is(sc.parent, PARENT) 32 | }) 33 | 34 | test('headers', (t) => { 35 | const { sc } = t.context 36 | t.deepEqual(sc.headers(), PARENT_HEADERS) 37 | }) 38 | 39 | test('getUrl', (t) => { 40 | const { sc } = t.context 41 | const url = sc.getUrl('/path', { key: 'value' }) 42 | t.is(url, 'http://192.168.1.100:32400/path?key=value') 43 | }) 44 | 45 | test('getAuthenticatedUrl', (t) => { 46 | const { sc } = t.context 47 | const url = sc.getAuthenticatedUrl('/path', { key: 'value' }) 48 | t.is(url, 'http://192.168.1.100:32400/path?key=value&X-Plex-Token=abc') 49 | }) 50 | 51 | test('fetch', async (t) => { 52 | const { sc } = t.context 53 | 54 | const scope = nock(URI) 55 | .get('/path') 56 | .reply(200, {}) 57 | 58 | const res = await sc.fetch('/path') 59 | 60 | t.deepEqual(res, {}) 61 | 62 | scope.done() 63 | }) 64 | 65 | test('fetch with params', async (t) => { 66 | const { sc } = t.context 67 | 68 | const scope = nock(URI) 69 | .post('/path?key=value') 70 | .reply(200, {}) 71 | 72 | const res = await sc.fetch('/path', { 73 | method: 'post', 74 | searchParams: { 75 | key: 'value', 76 | }, 77 | }) 78 | 79 | t.deepEqual(res, {}) 80 | 81 | scope.done() 82 | }) 83 | -------------------------------------------------------------------------------- /src/server-connection.ts: -------------------------------------------------------------------------------- 1 | import { Params, withParams } from './utils/params' 2 | import { RequestOptions, requestJSON } from './utils/request' 3 | 4 | interface Parent { 5 | headers: () => Record, 6 | } 7 | 8 | /** 9 | * A connection to a Plex server 10 | */ 11 | 12 | export default class ServerConnection { 13 | public uri: string 14 | public parent: Parent 15 | 16 | constructor (uri: string, parent?: Parent) { 17 | this.uri = uri 18 | this.parent = parent 19 | } 20 | 21 | /** 22 | * Get headers 23 | */ 24 | 25 | headers () { 26 | return this.parent && this.parent.headers() 27 | } 28 | 29 | /** 30 | * Given a path, return a fully qualified URL 31 | */ 32 | 33 | getUrl (path: string, params: Params = {}) { 34 | return withParams(this.uri + path, params) 35 | } 36 | 37 | /** 38 | * Given a path, return a fully qualified URL 39 | * Includes the X-Plex-Token parameter in the URL 40 | */ 41 | 42 | getAuthenticatedUrl (path: string, params: Params = {}) { 43 | return this.getUrl(path, { 44 | ...params, 45 | 'X-Plex-Token': this.headers()['X-Plex-Token'], 46 | }) 47 | } 48 | 49 | /** 50 | * Fetch a path on this server as JSON. If the response is not JSON, it will 51 | * return a promise of the response. 52 | */ 53 | 54 | fetch (path: string, options: RequestOptions = {}) { 55 | const url = this.getUrl(path) 56 | return requestJSON(url, { 57 | ...options, 58 | headers: { 59 | ...this.headers(), 60 | ...options.headers, 61 | }, 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { ExecutionContext } from 'ava' 3 | import { join } from 'path' 4 | import { Scope } from 'nock' 5 | 6 | import { normalizeSync } from './normalize' 7 | 8 | const fixture = (name: string) => { 9 | const path = join(__dirname, '../testHelpers/fixtures', name) 10 | const content = fs.readFileSync(path, { encoding: 'utf8' }) 11 | if (path.match(/\.json$/)) { 12 | return JSON.parse(content) 13 | } 14 | return content 15 | } 16 | 17 | const snapshot = (t: ExecutionContext, scope: Scope) => { 18 | return async (res: Record) => { 19 | scope.done() 20 | t.snapshot(res) 21 | t.snapshot(normalizeSync(res)) 22 | } 23 | } 24 | 25 | export { fixture, snapshot } 26 | -------------------------------------------------------------------------------- /src/types/album.ts: -------------------------------------------------------------------------------- 1 | /* @external */ 2 | 3 | import Prism from '@zwolf/prism' 4 | import { schema } from 'normalizr' 5 | 6 | import { createParser } from './parser' 7 | 8 | import { MediaContainer, toMediaContainer } from './media-container' 9 | import { Tag, toTagList } from './tag' 10 | import { toNumber, toTimestamp, toDate } from './types' 11 | 12 | /** 13 | * @ignore 14 | */ 15 | const albumSchema = new schema.Entity('albums') 16 | 17 | /** 18 | * @ignore 19 | */ 20 | const albumContainerSchema = new schema.Object({ 21 | albums: new schema.Array(albumSchema), 22 | }) 23 | 24 | export interface Album { 25 | _type: string, 26 | 27 | id: number, 28 | parentId: number, 29 | 30 | genre: Tag[], 31 | mood: Tag[], 32 | style: Tag[], 33 | director: Tag[], 34 | 35 | addedAt: Date, 36 | art: string, 37 | deletedAt: Date, 38 | guid: string, 39 | index: number, 40 | key: string, 41 | lastRatedAt: Date, 42 | lastViewedAt: Date, 43 | leafCount: number, 44 | librarySectionID: string, 45 | librarySectionKey: string, 46 | librarySectionTitle: string, 47 | loudnessAnalysisVersion: string, 48 | originallyAvailableAt: Date, 49 | parentGuid: string, 50 | parentKey: string, 51 | parentRatingKey: string, 52 | parentThumb: string, 53 | parentTitle: string, 54 | ratingKey: string, 55 | studio: string, 56 | summary: string, 57 | thumb: string, 58 | title: string, 59 | type: string, 60 | updatedAt: Date, 61 | userRating: number, 62 | viewCount: number, 63 | viewedLeafCount: number, 64 | year: number, 65 | } 66 | 67 | /** 68 | * @ignore 69 | */ 70 | const toAlbum = ($data: Prism): Album => { 71 | return { 72 | _type: 'album', 73 | id: $data.get('ratingKey').transform(toNumber).value, 74 | parentId: $data.get('parentRatingKey').transform(toNumber).value, 75 | 76 | genre: $data.get('Genre', { quiet: true }).transform(toTagList).value, 77 | director: $data.get('Director', { quiet: true }).transform(toTagList).value, 78 | style: $data.get('Style', { quiet: true }).transform(toTagList).value, 79 | mood: $data.get('Mood', { quiet: true }).transform(toTagList).value, 80 | 81 | addedAt: $data 82 | .get('addedAt') 83 | .transform(toTimestamp) 84 | .transform(toDate).value, 85 | art: $data.get('art', { quiet: true }).value, 86 | deletedAt: $data 87 | .get('deletedAt', { quiet: true }) 88 | .transform(toTimestamp) 89 | .transform(toDate).value, 90 | guid: $data.get('guid').value, 91 | index: $data.get('index').value, 92 | key: $data.get('key').value, 93 | lastRatedAt: $data 94 | .get('lastRatedAt', { quiet: true }) 95 | .transform(toTimestamp) 96 | .transform(toDate).value, 97 | lastViewedAt: $data 98 | .get('lastViewedAt', { quiet: true }) 99 | .transform(toTimestamp) 100 | .transform(toDate).value, 101 | leafCount: $data.get('leafCount', { quiet: true }).value, 102 | librarySectionID: $data.get('librarySectionID', { quiet: true }) 103 | .value, 104 | librarySectionKey: $data.get('librarySectionKey', { quiet: true }) 105 | .value, 106 | librarySectionTitle: $data.get('librarySectionTitle', { 107 | quiet: true, 108 | }).value, 109 | loudnessAnalysisVersion: $data.get('loudnessAnalysisVersion', { 110 | quiet: true, 111 | }).value, 112 | originallyAvailableAt: $data 113 | .get('originallyAvailableAt', { quiet: true }) 114 | .transform(toDate).value, 115 | parentGuid: $data.get('parentGuid', { quiet: true }).value, 116 | parentKey: $data.get('parentKey').value, 117 | parentRatingKey: $data.get('parentRatingKey').value, 118 | parentThumb: $data.get('parentThumb', { quiet: true }).value, 119 | parentTitle: $data.get('parentTitle').value, 120 | ratingKey: $data.get('ratingKey').value, 121 | studio: $data.get('studio', { quiet: true }).value, 122 | summary: $data.get('summary', { quiet: true }).value, 123 | thumb: $data.get('thumb', { quiet: true }).value, 124 | title: $data.get('title').value, 125 | type: $data.get('type').value, 126 | updatedAt: $data 127 | .get('updatedAt', { quiet: true }) 128 | .transform(toTimestamp) 129 | .transform(toDate).value, 130 | userRating: $data.get('userRating', { quiet: true }).value, 131 | viewCount: $data.get('viewCount', { quiet: true }).value, 132 | viewedLeafCount: $data.get('viewedLeafCount', { quiet: true }) 133 | .value, 134 | year: $data.get('year', { quiet: true }).value, 135 | } 136 | } 137 | 138 | export interface AlbumContainer extends MediaContainer { 139 | _type: string, 140 | albums: Album[], 141 | allowSync: boolean, 142 | art: string, 143 | mixedParents: boolean, 144 | nocache: boolean, 145 | thumb: string, 146 | title1: string, 147 | title2: string, 148 | viewGroup: string, 149 | viewMode: number, 150 | } 151 | 152 | /** 153 | * @ignore 154 | */ 155 | const toAlbumContainer = ($data: Prism): AlbumContainer => { 156 | if ($data.has('MediaContainer')) { 157 | $data = $data.get('MediaContainer') 158 | } 159 | 160 | return { 161 | ...$data.transform(toMediaContainer).value, 162 | 163 | _type: 'albumContainer', 164 | 165 | albums: $data 166 | .get('Metadata', { quiet: true }) 167 | .toArray() 168 | .map(toAlbum), 169 | 170 | allowSync: $data.get('allowSync').value, 171 | art: $data.get('art', { quiet: true }).value, 172 | mixedParents: $data.get('mixedParents', { quiet: true }).value, 173 | nocache: $data.get('nocache', { quiet: true }).value, 174 | thumb: $data.get('thumb', { quiet: true }).value, 175 | title1: $data.get('title1', { quiet: true }).value, 176 | title2: $data.get('title2', { quiet: true }).value, 177 | viewGroup: $data.get('viewGroup', { quiet: true }).value, 178 | viewMode: $data.get('viewMode', { quiet: true }).value, 179 | } 180 | } 181 | 182 | /** 183 | * @ignore 184 | */ 185 | const parseAlbumContainer = createParser('albumContainer', toAlbumContainer) 186 | 187 | export { 188 | albumSchema, 189 | albumContainerSchema, 190 | toAlbum, 191 | toAlbumContainer, 192 | parseAlbumContainer, 193 | } 194 | -------------------------------------------------------------------------------- /src/types/artist.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | import { schema } from 'normalizr' 3 | 4 | import { createParser } from './parser' 5 | 6 | import { MediaContainer, toMediaContainer } from './media-container' 7 | import { Tag, toTagList } from './tag' 8 | import { Track, trackSchema, toTrack } from './track' 9 | import { toNumber, toDateFromSeconds } from './types' 10 | 11 | /** 12 | * @ignore 13 | */ 14 | const artistSchema = new schema.Entity('artists', { 15 | popularTracks: new schema.Array(trackSchema), 16 | }) 17 | 18 | /** 19 | * @ignore 20 | */ 21 | const artistContainerSchema = new schema.Object({ 22 | artists: new schema.Array(artistSchema), 23 | }) 24 | 25 | /** 26 | * @ignore 27 | */ 28 | const toPopularTracks = ($data: Prism) => { 29 | if ($data.has('PopularLeaves')) { 30 | return $data 31 | .get('PopularLeaves') 32 | .get('Metadata') 33 | .toArray() 34 | .map(toTrack) 35 | } 36 | return [] 37 | } 38 | 39 | export interface Artist { 40 | _type: string, 41 | id: number, 42 | 43 | genre: Tag[], 44 | country: Tag[], 45 | popularTracks: Track[], 46 | 47 | addedAt: Date, 48 | art: string, 49 | deletedAt: Date, 50 | guid: string, 51 | index: number, 52 | key: string, 53 | lastViewedAt: Date, 54 | ratingKey: string, 55 | summary: string, 56 | thumb: string, 57 | title: string, 58 | titleSort: string, 59 | type: string, 60 | updatedAt: Date, 61 | viewCount: number, 62 | } 63 | 64 | /** 65 | * @ignore 66 | */ 67 | const toArtist = ($data: Prism): Artist => { 68 | return { 69 | _type: 'artist', 70 | id: $data.get('ratingKey').transform(toNumber).value, 71 | 72 | genre: $data.get('Genre', { quiet: true }).transform(toTagList).value, 73 | country: $data.get('Country', { quiet: true }).transform(toTagList).value, 74 | popularTracks: $data.transform(toPopularTracks).value, 75 | 76 | addedAt: $data.get('addedAt').transform(toDateFromSeconds).value, 77 | deletedAt: $data 78 | .get('deletedAt', { quiet: true }) 79 | .transform(toDateFromSeconds).value, 80 | art: $data.get('art', { quiet: true }).value, 81 | guid: $data.get('guid', { quiet: true }).value, 82 | index: $data.get('index').value, 83 | key: $data.get('key').value, 84 | lastViewedAt: $data 85 | .get('lastViewedAt', { quiet: true }) 86 | .transform(toDateFromSeconds).value, 87 | ratingKey: $data.get('ratingKey').value, 88 | summary: $data.get('summary', { quiet: true }).value, 89 | thumb: $data.get('thumb', { quiet: true }).value, 90 | title: $data.get('title').value, 91 | titleSort: $data.get('titleSort', { quiet: true }).value, 92 | type: $data.get('type').value, 93 | updatedAt: $data.get('updatedAt').transform(toDateFromSeconds) 94 | .value, 95 | viewCount: $data.get('viewCount', { quiet: true }).value, 96 | } 97 | } 98 | 99 | export interface ArtistContainer extends MediaContainer { 100 | _type: string, 101 | 102 | artists: Artist[], 103 | 104 | allowSync: boolean, 105 | art: string, 106 | librarySectionID: string, 107 | librarySectionTitle: string, 108 | librarySectionUUID: string, 109 | nocache: string, 110 | thumb: string, 111 | title1: string, 112 | title2: string, 113 | viewGroup: string, 114 | viewMode: string, 115 | } 116 | 117 | /** 118 | * @ignore 119 | */ 120 | const toArtistContainer = ($data: Prism): ArtistContainer => { 121 | if ($data.has('MediaContainer')) { 122 | $data = $data.get('MediaContainer') 123 | } 124 | 125 | return { 126 | ...$data.transform(toMediaContainer).value, 127 | 128 | _type: 'artistContainer', 129 | 130 | artists: $data 131 | .get('Metadata', { quiet: true }) 132 | .toArray() 133 | .map(toArtist), 134 | 135 | allowSync: $data.get('allowSync').value, 136 | art: $data.get('art', { quiet: true }).value, 137 | librarySectionID: $data.get('librarySectionID').value, 138 | librarySectionTitle: $data.get('librarySectionTitle').value, 139 | librarySectionUUID: $data.get('librarySectionUUID').value, 140 | nocache: $data.get('nocache', { quiet: true }).value, 141 | thumb: $data.get('thumb', { quiet: true }).value, 142 | title1: $data.get('title1', { quiet: true }).value, 143 | title2: $data.get('title2', { quiet: true }).value, 144 | viewGroup: $data.get('viewGroup', { quiet: true }).value, 145 | viewMode: $data.get('viewMode', { quiet: true }).value, 146 | } 147 | } 148 | 149 | /** 150 | * @ignore 151 | */ 152 | const parseArtistContainer = createParser('artistContainer', toArtistContainer) 153 | 154 | export { 155 | artistSchema, 156 | artistContainerSchema, 157 | toArtist, 158 | toArtistContainer, 159 | parseArtistContainer, 160 | } 161 | -------------------------------------------------------------------------------- /src/types/country.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | import { createParser } from './parser' 4 | 5 | import { toNumber } from './types' 6 | 7 | export interface Country { 8 | id: number, 9 | title: string, 10 | } 11 | 12 | /** 13 | * @ignore 14 | */ 15 | const toCountry = ($data: Prism): Country => { 16 | return { 17 | id: $data.get('key').transform(toNumber).value, 18 | title: $data.get('title').value, 19 | } 20 | } 21 | 22 | export type CountryRecord = Record 23 | 24 | /** 25 | * @ignore 26 | */ 27 | const toCountryRecord = ($data: Prism): CountryRecord => { 28 | if ($data.has('MediaContainer')) { 29 | $data = $data.get('MediaContainer') 30 | } 31 | 32 | const countriesArray = $data 33 | .get('Directory') 34 | .toArray() 35 | .map(toCountry) 36 | 37 | const countriesObject = countriesArray.reduce((obj, country) => { 38 | obj[country.title] = country.id 39 | return obj 40 | }, {} as Record) 41 | 42 | return countriesObject 43 | } 44 | 45 | /** 46 | * @ignore 47 | */ 48 | const parseCountryRecord = createParser('countryRecord', toCountryRecord) 49 | 50 | export { parseCountryRecord } 51 | -------------------------------------------------------------------------------- /src/types/device.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | import { schema } from 'normalizr' 3 | 4 | import { createParser } from './parser' 5 | 6 | import { toBoolean, toDateFromSeconds } from './types' 7 | 8 | /** 9 | * @ignore 10 | */ 11 | const connectionSchema = new schema.Entity( 12 | 'connections', 13 | {}, 14 | { 15 | idAttribute: 'uri', 16 | }, 17 | ) 18 | 19 | /** 20 | * @ignore 21 | */ 22 | const deviceSchema = new schema.Entity('devices', { 23 | connections: new schema.Array(connectionSchema), 24 | }) 25 | 26 | /** 27 | * @ignore 28 | */ 29 | const deviceContainerSchema = new schema.Object({ 30 | devices: new schema.Array(deviceSchema), 31 | }) 32 | 33 | export interface Connection { 34 | _type: string, 35 | protocol: string, 36 | address: string, 37 | port: string, 38 | uri: string, 39 | local: boolean, 40 | } 41 | 42 | /** 43 | * @ignore 44 | */ 45 | const toConnection = ($data: Prism): Connection => { 46 | const $prop = $data.get('$') 47 | return { 48 | _type: 'connection', 49 | protocol: $prop.get('protocol', { quiet: true }).value, 50 | address: $prop.get('address', { quiet: true }).value, 51 | port: $prop.get('port', { quiet: true }).value, 52 | uri: $prop.get('uri').value, 53 | local: $prop.get('local', { quiet: true }).transform(toBoolean).value, 54 | } 55 | } 56 | 57 | export interface Device { 58 | _type: string, 59 | id: string, 60 | 61 | connections: Connection[], 62 | 63 | name: string, 64 | product: string, 65 | productVersion: string, 66 | platform: string, 67 | platformVersion: string, 68 | device: string, 69 | clientIdentifier: string, 70 | createdAt: Date, 71 | lastSeenAt: Date, 72 | provides: string[], 73 | owned: boolean, 74 | accessToken: string, 75 | publicAddress: string, 76 | httpsRequired: boolean, 77 | synced: boolean, 78 | relay: boolean, 79 | dnsRebindingProtection: boolean, 80 | natLoopbackSupported: boolean, 81 | publicAddressMatches: boolean, 82 | presence: boolean, 83 | } 84 | 85 | /** 86 | * @ignore 87 | */ 88 | const toDevice = ($data: Prism): Device => { 89 | const $prop = $data.get('$') 90 | 91 | return { 92 | _type: 'device', 93 | 94 | id: $prop.get('clientIdentifier').value, 95 | 96 | connections: $data 97 | .get('Connection', { quiet: true }) 98 | .toArray() 99 | .map(toConnection), 100 | 101 | name: $prop.get('name', { quiet: true }).value, 102 | product: $prop.get('product', { quiet: true }).value, 103 | productVersion: $prop.get('productVersion', { quiet: true }).value, 104 | platform: $prop.get('platform', { quiet: true }).value, 105 | platformVersion: $prop.get('platformVersion', { quiet: true }).value, 106 | device: $prop.get('device', { quiet: true }).value, 107 | clientIdentifier: $prop.get('clientIdentifier').value, 108 | createdAt: $prop 109 | .get('createdAt', { quiet: true }) 110 | .transform(toDateFromSeconds).value, 111 | lastSeenAt: $prop 112 | .get('lastSeenAt', { quiet: true }) 113 | .transform(toDateFromSeconds).value, 114 | provides: $prop.get('provides').transform((prism): string[] => { 115 | if (typeof prism.value === 'string') { 116 | return prism.value.split(',') 117 | } 118 | return [] 119 | }).value, 120 | owned: $prop.get('owned', { quiet: true }).transform(toBoolean).value, 121 | accessToken: $prop.get('accessToken', { quiet: true }).value, 122 | publicAddress: $prop.get('publicAddress', { quiet: true }).value, 123 | httpsRequired: $prop 124 | .get('httpsRequired', { quiet: true }) 125 | .transform(toBoolean).value, 126 | synced: $prop.get('synced', { quiet: true }).transform(toBoolean).value, 127 | relay: $prop.get('relay', { quiet: true }).transform(toBoolean).value, 128 | dnsRebindingProtection: $prop 129 | .get('dnsRebindingProtection', { quiet: true }) 130 | .transform(toBoolean).value, 131 | natLoopbackSupported: $prop 132 | .get('natLoopbackSupported', { quiet: true }) 133 | .transform(toBoolean).value, 134 | publicAddressMatches: $prop 135 | .get('publicAddressMatches', { quiet: true }) 136 | .transform(toBoolean).value, 137 | presence: $prop.get('presence', { quiet: true }).transform(toBoolean).value, 138 | } 139 | } 140 | 141 | /** 142 | * @ignore 143 | */ 144 | const toDeviceList = ($data: Prism): Device[] => { 145 | if ($data.has('MediaContainer')) { 146 | $data = $data.get('MediaContainer') 147 | } 148 | 149 | return $data 150 | .get('Device') 151 | .toArray() 152 | .map(toDevice) 153 | } 154 | 155 | /** 156 | * @ignore 157 | */ 158 | const parseDeviceList = createParser('deviceList', toDeviceList) 159 | 160 | export { 161 | connectionSchema, 162 | deviceSchema, 163 | deviceContainerSchema, 164 | toDevice, 165 | toDeviceList, 166 | parseDeviceList, 167 | } 168 | -------------------------------------------------------------------------------- /src/types/genre.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | import { createParser } from './parser' 4 | 5 | import { toNumber } from './types' 6 | 7 | export interface Genre { 8 | id: number, 9 | title: string, 10 | } 11 | 12 | /** 13 | * @ignore 14 | */ 15 | const toGenre = ($data: Prism): Genre => { 16 | return { 17 | id: $data.get('key').transform(toNumber).value, 18 | title: $data.get('title').value, 19 | } 20 | } 21 | 22 | export type GenreRecord = Record 23 | 24 | /** 25 | * @ignore 26 | */ 27 | const toGenreRecord = ($data: Prism): GenreRecord => { 28 | if ($data.has('MediaContainer')) { 29 | $data = $data.get('MediaContainer') 30 | } 31 | 32 | const genresArray = $data 33 | .get('Directory') 34 | .toArray() 35 | .map(toGenre) 36 | 37 | const genresObject = genresArray.reduce((obj, genre) => { 38 | obj[genre.title] = genre.id 39 | return obj 40 | }, {} as Record) 41 | 42 | return genresObject 43 | } 44 | 45 | /** 46 | * @ignore 47 | */ 48 | const parseGenreRecord = createParser('genreRecord', toGenreRecord) 49 | 50 | export { toGenre, toGenreRecord, parseGenreRecord } 51 | -------------------------------------------------------------------------------- /src/types/hub.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | import { schema } from 'normalizr' 3 | 4 | import { createParser } from './parser' 5 | 6 | import { toAlbum, albumSchema } from './album' 7 | import { toArtist, artistSchema } from './artist' 8 | import { toTrack, trackSchema } from './track' 9 | import { toPlaylist, playlistSchema } from './playlist' 10 | 11 | /** 12 | * @ignore 13 | */ 14 | const hubSchema = new schema.Entity( 15 | 'hubs', 16 | { 17 | items: new schema.Array( 18 | new schema.Union( 19 | { 20 | playlist: playlistSchema, 21 | artist: artistSchema, 22 | album: albumSchema, 23 | track: trackSchema, 24 | }, 25 | 'type', 26 | ), 27 | ), 28 | }, 29 | { 30 | idAttribute: 'type', 31 | }, 32 | ) 33 | 34 | /** 35 | * @ignore 36 | */ 37 | const hubContainerSchema = new schema.Object({ 38 | hubs: new schema.Array(hubSchema), 39 | }) 40 | 41 | /** 42 | * @ignore 43 | */ 44 | type TransformFunction = ($data: Prism) => Record 45 | 46 | /** 47 | * @ignore 48 | */ 49 | const defaultTransformFunction: TransformFunction = ($data) => $data.value 50 | 51 | /** 52 | * @ignore 53 | */ 54 | const getTransformFunction = (type: string): TransformFunction => { 55 | switch (type) { 56 | case 'album': 57 | return toAlbum 58 | case 'artist': 59 | return toArtist 60 | case 'track': 61 | return toTrack 62 | case 'playlist': 63 | return toPlaylist 64 | default: 65 | return defaultTransformFunction 66 | } 67 | } 68 | 69 | /** 70 | * @ignore 71 | */ 72 | const toHub = ($data: Prism) => { 73 | const transformFunction = getTransformFunction($data.get('type').value) 74 | 75 | // dedupe items without changing the order 76 | const items = $data 77 | .get('Metadata', { quiet: true }) 78 | .toArray() 79 | .map(transformFunction) 80 | .filter((itemA, index, array) => { 81 | return ( 82 | array.slice(index + 1).findIndex((itemB) => { 83 | return itemB.id === itemA.id 84 | }) < 0 85 | ) 86 | }) 87 | 88 | return { 89 | _type: 'hub', 90 | type: $data.get('type').value, 91 | hubIdentifier: $data.get('hubIdentifier').value, 92 | size: $data.get('size').value, 93 | title: $data.get('title').value, 94 | more: $data.get('more').value, 95 | items, 96 | } 97 | } 98 | 99 | /** 100 | * @ignore 101 | */ 102 | const toHubContainer = ($data: Prism) => { 103 | if ($data.has('MediaContainer')) { 104 | $data = $data.get('MediaContainer') 105 | } 106 | 107 | return { 108 | _type: 'hubContainer', 109 | hubs: $data 110 | .get('Hub') 111 | .toArray() 112 | .map(toHub), 113 | } 114 | } 115 | 116 | /** 117 | * @ignore 118 | */ 119 | const parseHubContainer = createParser('hubContainer', toHubContainer) 120 | 121 | export { hubSchema, hubContainerSchema, parseHubContainer } 122 | -------------------------------------------------------------------------------- /src/types/media-container.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | import { toNumber } from './types' 4 | 5 | /** 6 | * @ignore 7 | */ 8 | const toTotalSize = ($data: Prism): number => { 9 | if ($data.has('totalSize')) { 10 | return $data.get('totalSize').transform(toNumber).value 11 | } 12 | return $data.get('size', { quiet: true }).transform(toNumber).value 13 | } 14 | 15 | export interface MediaContainer { 16 | _type: string, 17 | size: number, 18 | totalSize: number, 19 | offset: number, 20 | identifier: string, 21 | mediaTagPrefix: string, 22 | mediaTagVersion: string, 23 | } 24 | 25 | /** 26 | * @ignore 27 | */ 28 | const toMediaContainer = ($data: Prism): MediaContainer => { 29 | return { 30 | _type: 'mediaContainer', 31 | 32 | size: $data.get('size', { quiet: true }).transform(toNumber).value, 33 | totalSize: $data.transform(toTotalSize).value, 34 | offset: $data.get('offset', { quiet: true }).transform(toNumber).value, 35 | 36 | identifier: $data.get('identifier', { quiet: true }).value, 37 | mediaTagPrefix: $data.get('mediaTagPrefix', { quiet: true }).value, 38 | mediaTagVersion: $data.get('mediaTagVersion', { quiet: true }).value, 39 | } 40 | } 41 | 42 | export { toMediaContainer } 43 | -------------------------------------------------------------------------------- /src/types/media.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | import { Part, toPart } from './part' 4 | 5 | export interface Media { 6 | _type: string, 7 | id: string, 8 | duration: string, 9 | bitrate: string, 10 | audioChannels: string, 11 | audioCodec: string, 12 | container: string, 13 | parts: Part[], 14 | } 15 | 16 | /** 17 | * @ignore 18 | */ 19 | const toMedia = ($data: Prism): Media => { 20 | return { 21 | _type: 'media', 22 | 23 | id: $data.get('id').value, 24 | duration: $data.get('duration').value, 25 | bitrate: $data.get('bitrate').value, 26 | audioChannels: $data.get('audioChannels').value, 27 | audioCodec: $data.get('audioCodec').value, 28 | container: $data.get('container').value, 29 | 30 | parts: $data 31 | .get('Part') 32 | .toArray() 33 | .map(toPart), 34 | } 35 | } 36 | 37 | export { toMedia } 38 | -------------------------------------------------------------------------------- /src/types/parser.ts: -------------------------------------------------------------------------------- 1 | import Prism, { printWarnings } from '@zwolf/prism' 2 | 3 | /** 4 | * @ignore 5 | */ 6 | const createParser = ( 7 | name: string, 8 | transformer: ($data: Prism) => T, 9 | ) => { 10 | return (data: Record): T => { 11 | const $data = new Prism(data) 12 | const result = $data.transform(transformer).value 13 | printWarnings($data.warnings, name) 14 | return result 15 | } 16 | } 17 | 18 | export { createParser } 19 | -------------------------------------------------------------------------------- /src/types/part.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | import { Stream, toStream } from './stream' 4 | import { toNumber, toBoolean } from './types' 5 | 6 | export interface Part { 7 | _type: string, 8 | id: string, 9 | key: string, 10 | duration: string, 11 | file: string, 12 | size: number, 13 | container: string, 14 | hasThumbnail: boolean, 15 | streams: Stream[], 16 | } 17 | 18 | /** 19 | * @ignore 20 | */ 21 | const toPart = ($data: Prism) => { 22 | return { 23 | _type: 'part', 24 | 25 | id: $data.get('id').value, 26 | key: $data.get('key').value, 27 | duration: $data.get('duration').value, 28 | file: $data.get('file').value, 29 | size: $data.get('size').transform(toNumber).value, 30 | container: $data.get('container').value, 31 | hasThumbnail: $data 32 | .get('hasThumbnail', { quiet: true }) 33 | .transform(toBoolean).value, 34 | 35 | streams: $data 36 | .get('Stream', { quiet: true }) 37 | .transform(($data: Prism) => { 38 | const { value } = $data 39 | if (value == null) { 40 | return [] 41 | } 42 | if (Array.isArray(value)) { 43 | return value 44 | } 45 | return [value] 46 | }) 47 | .toArray() 48 | .map(toStream), 49 | } 50 | } 51 | 52 | export { toPart } 53 | -------------------------------------------------------------------------------- /src/types/pin.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | import { createParser } from './parser' 4 | 5 | export interface Pin { 6 | id: number, 7 | code: string, 8 | expiresAt: Date, 9 | userId: string, 10 | clientIdentifier: string, 11 | authToken: string, 12 | } 13 | 14 | /** 15 | * @ignore 16 | */ 17 | const toPin = ($data: Prism): Pin => { 18 | return { 19 | id: $data.get('id').value, 20 | code: $data.get('code').value, 21 | expiresAt: $data.get('expires_at').value, 22 | userId: $data.get('user_id').value, 23 | clientIdentifier: $data.get('client_identifier').value, 24 | authToken: $data.get('auth_token').value, 25 | } 26 | } 27 | 28 | /** 29 | * @ignore 30 | */ 31 | const parsePin = createParser('pin', toPin) 32 | 33 | export { parsePin } 34 | -------------------------------------------------------------------------------- /src/types/play-queue.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | import { schema } from 'normalizr' 3 | 4 | import { createParser } from './parser' 5 | 6 | import { toMediaContainer } from './media-container' 7 | import { toTrack, trackSchema } from './track' 8 | 9 | /** 10 | * @ignore 11 | */ 12 | const playQueueItemSchema = new schema.Object({ 13 | track: trackSchema, 14 | }) 15 | 16 | /** 17 | * @ignore 18 | */ 19 | const playQueueContainerSchema = new schema.Object({ 20 | items: new schema.Array(playQueueItemSchema), 21 | }) 22 | 23 | /** 24 | * @ignore 25 | */ 26 | const toPlayQueueItem = ($data: Prism) => { 27 | return { 28 | _type: 'playQueueItem', 29 | id: $data.get('playQueueItemID').value, 30 | guid: $data.get('guid').value, 31 | librarySectionId: $data.get('librarySectionID').value, 32 | track: $data.transform(toTrack).value, 33 | } 34 | } 35 | 36 | /** 37 | * @ignore 38 | */ 39 | const toPlayQueue = ($data: Prism) => { 40 | if ($data.has('MediaContainer')) { 41 | $data = $data.get('MediaContainer') 42 | } 43 | 44 | return { 45 | ...$data.transform(toMediaContainer).value, 46 | 47 | _type: 'playQueueContainer', 48 | 49 | id: $data.get('playQueueID').value, 50 | selectedItemId: $data.get('playQueueSelectedItemID').value, 51 | selectedItemOffset: $data.get('playQueueSelectedItemOffset').value, 52 | selectedMetadataItemId: $data.get('playQueueSelectedMetadataItemID').value, 53 | shuffled: $data.get('playQueueShuffled').value, 54 | sourceURI: $data.get('playQueueSourceURI').value, 55 | totalCount: $data.get('playQueueTotalCount').value, 56 | version: $data.get('playQueueVersion').value, 57 | 58 | items: $data 59 | .get('Metadata') 60 | .toArray() 61 | .map(toPlayQueueItem), 62 | } 63 | } 64 | 65 | /** 66 | * @ignore 67 | */ 68 | const parsePlayQueue = createParser('playQueue', toPlayQueue) 69 | 70 | export { 71 | playQueueItemSchema, 72 | playQueueContainerSchema, 73 | toPlayQueueItem, 74 | toPlayQueue, 75 | parsePlayQueue, 76 | } 77 | -------------------------------------------------------------------------------- /src/types/playlist.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | import { schema } from 'normalizr' 3 | 4 | import { createParser } from './parser' 5 | 6 | import { Track, toTrack, trackSchema } from './track' 7 | import { MediaContainer, toMediaContainer } from './media-container' 8 | import { toDateFromSeconds, toNumber } from './types' 9 | 10 | /** 11 | * @ignore 12 | */ 13 | const playlistItemSchema = new schema.Object({ 14 | track: trackSchema, 15 | }) 16 | 17 | /** 18 | * @ignore 19 | */ 20 | const playlistSchema = new schema.Entity('playlists', { 21 | items: new schema.Array(playlistItemSchema), 22 | }) 23 | 24 | /** 25 | * @ignore 26 | */ 27 | const playlistContainerSchema = new schema.Object({ 28 | playlists: new schema.Array(playlistSchema), 29 | }) 30 | 31 | export interface PlaylistItem { 32 | _type: string, 33 | id: number, 34 | playlistId: number, 35 | librarySectionID: number, 36 | librarySectionKey: string, 37 | librarySectionTitle: string, 38 | track: Track, 39 | } 40 | 41 | export interface NormalizedPlaylistItem extends Omit { 42 | track: number, 43 | } 44 | 45 | /** 46 | * @ignore 47 | */ 48 | const toPlaylistItem = (playlistId: number) => ( 49 | $data: Prism, 50 | ): PlaylistItem => { 51 | return { 52 | _type: 'playlistItem', 53 | // items in a smart playlist do not have a playlistItemID 54 | id: $data.get('playlistItemID', { quiet: true }).value, 55 | librarySectionID: $data.get('librarySectionID').value, 56 | librarySectionKey: $data.get('librarySectionKey').value, 57 | librarySectionTitle: $data.get('librarySectionTitle').value, 58 | playlistId, 59 | track: $data.transform(toTrack).value, 60 | } 61 | } 62 | 63 | export interface Playlist extends MediaContainer { 64 | _type: string, 65 | 66 | id: number, 67 | 68 | addedAt: Date, 69 | composite: string, 70 | duration: number, 71 | guid: string, 72 | key: string, 73 | lastViewedAt: Date, 74 | leafCount: number, 75 | playlistType: string, 76 | ratingKey: string, 77 | smart: boolean, 78 | summary: string, 79 | title: string, 80 | type: string, 81 | updatedAt: Date, 82 | viewCount: number, 83 | titleSort: string, 84 | 85 | items: PlaylistItem[], 86 | } 87 | 88 | export interface NormalizedPlaylist extends Omit { 89 | items: NormalizedPlaylistItem[], 90 | } 91 | 92 | /** 93 | * @ignore 94 | */ 95 | const toPlaylist = ($data: Prism): Playlist => { 96 | if ($data.has('MediaContainer')) { 97 | $data = $data.get('MediaContainer') 98 | } 99 | 100 | const playlistId = $data.get('ratingKey').transform(toNumber).value 101 | 102 | return { 103 | ...$data.transform(toMediaContainer).value, 104 | 105 | _type: 'playlist', 106 | 107 | id: $data.get('ratingKey').transform(toNumber).value, 108 | 109 | addedAt: $data 110 | .get('addedAt', { quiet: true }) 111 | .transform(toDateFromSeconds).value, 112 | composite: $data.get('composite').value, 113 | duration: $data.get('duration').value, 114 | guid: $data.get('guid', { quiet: true }).value, 115 | key: $data.get('key', { quiet: true }).value, 116 | lastViewedAt: $data 117 | .get('lastViewedAt', { quiet: true }) 118 | .transform(toDateFromSeconds).value, 119 | leafCount: $data.get('leafCount').value, 120 | playlistType: $data.get('playlistType').value, 121 | ratingKey: $data.get('ratingKey').value, 122 | smart: $data.get('smart').value, 123 | summary: $data.get('summary', { quiet: true }).value, 124 | title: $data.get('title').value, 125 | titleSort: $data.get('titleSort', { quiet: true }).value, 126 | type: $data.get('type', { quiet: true }).value, 127 | updatedAt: $data 128 | .get('updatedAt', { quiet: true }) 129 | .transform(toDateFromSeconds).value, 130 | viewCount: $data.get('viewCount', { quiet: true }).value, 131 | 132 | items: $data 133 | .get('Metadata', { quiet: true }) 134 | .toArray() 135 | .map(toPlaylistItem(playlistId)), 136 | } 137 | } 138 | 139 | export interface PlaylistContainer extends MediaContainer { 140 | _type: string, 141 | playlists: Playlist[], 142 | } 143 | 144 | export interface NormalizedPlaylistContainer 145 | extends Omit { 146 | playlists: NormalizedPlaylist[], 147 | } 148 | 149 | /** 150 | * @ignore 151 | */ 152 | const toPlaylistContainer = ($data: Prism): PlaylistContainer => { 153 | if ($data.has('MediaContainer')) { 154 | $data = $data.get('MediaContainer') 155 | } 156 | 157 | return { 158 | ...$data.transform(toMediaContainer).value, 159 | 160 | _type: 'playlistContainer', 161 | 162 | playlists: $data 163 | .get('Metadata') 164 | .toArray() 165 | .map(toPlaylist), 166 | } 167 | } 168 | 169 | /** 170 | * @ignore 171 | */ 172 | const parsePlaylist = createParser('playlist', toPlaylist) 173 | 174 | /** 175 | * @ignore 176 | */ 177 | const parsePlaylistContainer = createParser( 178 | 'playlistContainer', 179 | toPlaylistContainer, 180 | ) 181 | 182 | export { 183 | playlistItemSchema, 184 | playlistSchema, 185 | playlistContainerSchema, 186 | toPlaylist, 187 | toPlaylistContainer, 188 | parsePlaylist, 189 | parsePlaylistContainer, 190 | } 191 | -------------------------------------------------------------------------------- /src/types/resources.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | import { schema } from 'normalizr' 3 | 4 | import { createParser } from './parser' 5 | 6 | import { Device, toDevice, deviceSchema } from './device' 7 | 8 | /** 9 | * @ignore 10 | */ 11 | const resourceContainerSchema = new schema.Object({ 12 | devices: new schema.Array(deviceSchema), 13 | }) 14 | 15 | export interface ResourceContainer { 16 | _type: string, 17 | devices: Device[], 18 | } 19 | 20 | /** 21 | * @ignore 22 | */ 23 | const toResourceContainer = ($data: Prism): ResourceContainer => { 24 | if ($data.has('MediaContainer')) { 25 | $data = $data.get('MediaContainer') 26 | } 27 | 28 | return { 29 | _type: 'resourceContainer', 30 | 31 | devices: $data 32 | .get('Device') 33 | .toArray() 34 | .map(toDevice), 35 | } 36 | } 37 | 38 | /** 39 | * @ignore 40 | */ 41 | const parseResourceContainer = createParser( 42 | 'resourceContainer', 43 | toResourceContainer, 44 | ) 45 | 46 | export { resourceContainerSchema, toResourceContainer, parseResourceContainer } 47 | -------------------------------------------------------------------------------- /src/types/section.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | import { schema } from 'normalizr' 3 | 4 | import { createParser } from './parser' 5 | 6 | import { toMediaContainer } from './media-container' 7 | import { toNumber } from './types' 8 | 9 | /** 10 | * @ignore 11 | */ 12 | const sectionSchema = new schema.Entity('sections') 13 | 14 | /** 15 | * @ignore 16 | */ 17 | const sectionContainerSchema = new schema.Object({ 18 | sections: new schema.Array(sectionSchema), 19 | }) 20 | 21 | /** 22 | * @ignore 23 | */ 24 | const toSection = ($data: Prism) => { 25 | return { 26 | _type: 'section', 27 | id: $data.get('key').transform(toNumber).value, 28 | allowSync: $data.get('allowSync').value, 29 | art: $data.get('art').value, 30 | composite: $data.get('composite').value, 31 | filters: $data.get('filters').value, 32 | refreshing: $data.get('refreshing').value, 33 | thumb: $data.get('thumb').value, 34 | key: $data.get('key').value, 35 | type: $data.get('type').value, 36 | title: $data.get('title').value, 37 | agent: $data.get('agent').value, 38 | scanner: $data.get('scanner').value, 39 | language: $data.get('language').value, 40 | uuid: $data.get('uuid').value, 41 | updatedAt: $data.get('updatedAt').value, 42 | createdAt: $data.get('createdAt').value, 43 | location: $data.get('Location').value, 44 | } 45 | } 46 | 47 | /** 48 | * @ignore 49 | */ 50 | const toSectionContainer = ($data: Prism) => { 51 | if ($data.has('MediaContainer')) { 52 | $data = $data.get('MediaContainer') 53 | } 54 | 55 | return { 56 | ...$data.transform(toMediaContainer).value, 57 | 58 | _type: 'sectionContainer', 59 | 60 | title: $data.get('title1').value, 61 | sections: $data 62 | .get('Directory') 63 | .toArray() 64 | .map(toSection), 65 | } 66 | } 67 | 68 | /** 69 | * @ignore 70 | */ 71 | const parseSectionContainer = createParser( 72 | 'sectionContainer', 73 | toSectionContainer, 74 | ) 75 | 76 | export { sectionSchema, sectionContainerSchema, parseSectionContainer } 77 | -------------------------------------------------------------------------------- /src/types/stream.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | import { toBoolean } from './types' 4 | 5 | export interface Stream { 6 | _type: string, 7 | audioChannelLayout: string, 8 | bitrate: number, 9 | channels: number, 10 | codec: string, 11 | format: string, 12 | id: number, 13 | index: number, 14 | key: string, 15 | provider: string, 16 | samplingRate: number, 17 | selected: boolean, 18 | streamType: number, 19 | timed: boolean, 20 | } 21 | 22 | /** 23 | * @ignore 24 | */ 25 | const toStream = ($data: Prism): Stream => { 26 | return { 27 | _type: 'stream', 28 | audioChannelLayout: $data.get('audioChannelLayout', { quiet: true }).value, 29 | bitrate: $data.get('bitrate', { quiet: true }).value, 30 | channels: $data.get('channels', { quiet: true }).value, 31 | codec: $data.get('codec').value, 32 | format: $data.get('format', { quiet: true }).value, 33 | id: $data.get('id').value, 34 | index: $data.get('index', { quiet: true }).value, 35 | key: $data.get('key', { quiet: true }).value, 36 | provider: $data.get('provider', { quiet: true }).value, 37 | samplingRate: $data.get('samplingRate', { quiet: true }).value, 38 | selected: $data.get('selected', { quiet: true }).value, 39 | streamType: $data.get('streamType').value, 40 | timed: $data.get('timed', { quiet: true }).transform(toBoolean).value, 41 | } 42 | } 43 | 44 | export { toStream } 45 | -------------------------------------------------------------------------------- /src/types/sync-list.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | import { createParser } from './parser' 4 | 5 | import { toMediaContainer } from './media-container' 6 | import { toDevice } from './device' 7 | 8 | /** 9 | * @ignore 10 | */ 11 | const toServer = ($data: Prism) => { 12 | return { 13 | machineIdentifier: $data.get('machineIdentifier').value, 14 | } 15 | } 16 | 17 | /** 18 | * @ignore 19 | */ 20 | const toStatus = ($data: Prism) => { 21 | return { 22 | failureCode: $data.get('failureCode').value, 23 | failure: $data.get('failure').value, 24 | state: $data.get('state').value, 25 | itemsCount: $data.get('itemsCount').value, 26 | itemsCompleteCount: $data.get('itemsCompleteCount').value, 27 | totalSize: $data.get('totalSize').value, 28 | itemsDownloadedCount: $data.get('itemsDownloadedCount').value, 29 | itemsReadyCount: $data.get('itemsReadyCount').value, 30 | itemsSuccessfulCount: $data.get('itemsSuccessfulCount').value, 31 | } 32 | } 33 | 34 | /** 35 | * @ignore 36 | */ 37 | const toMediaSettings = ($data: Prism) => { 38 | return { 39 | audioBoost: $data.get('audioBoost').value, 40 | maxVideoBitrate: $data.get('maxVideoBitrate').value, 41 | musicBitrate: $data.get('musicBitrate').value, 42 | photoQuality: $data.get('photoQuality').value, 43 | photoResolution: $data.get('photoResolution').value, 44 | subtitleSize: $data.get('subtitleSize').value, 45 | videoQuality: $data.get('videoQuality').value, 46 | videoResolution: $data.get('videoResolution').value, 47 | } 48 | } 49 | 50 | /** 51 | * @ignore 52 | */ 53 | const toPolicy = ($data: Prism) => { 54 | return { 55 | scope: $data.get('scope').value, 56 | unwatched: $data.get('unwatched').value, 57 | } 58 | } 59 | 60 | /** 61 | * @ignore 62 | */ 63 | const toLocation = ($data: Prism) => { 64 | return { 65 | uri: $data.get('uri').value, 66 | } 67 | } 68 | 69 | /** 70 | * @ignore 71 | */ 72 | const toSyncItem = ($data: Prism) => { 73 | const $prop = $data.get('$') 74 | 75 | return { 76 | id: $prop.get('id').value, 77 | version: $prop.get('version').value, 78 | rootTitle: $prop.get('rootTitle').value, 79 | title: $prop.get('title').value, 80 | metadataType: $prop.get('metadataType').value, 81 | contentType: $prop.get('contentType').value, 82 | 83 | server: $data 84 | .get('Server') 85 | .get(0) 86 | .get('$') 87 | .transform(toServer).value, 88 | status: $data 89 | .get('Status') 90 | .get(0) 91 | .get('$') 92 | .transform(toStatus).value, 93 | mediaSettings: $data 94 | .get('MediaSettings') 95 | .get(0) 96 | .get('$') 97 | .transform(toMediaSettings).value, 98 | policy: $data 99 | .get('Policy') 100 | .get(0) 101 | .get('$') 102 | .transform(toPolicy).value, 103 | location: $data 104 | .get('Location') 105 | .get(0) 106 | .get('$') 107 | .transform(toLocation).value, 108 | } 109 | } 110 | 111 | /** 112 | * @ignore 113 | */ 114 | const toSyncList = ($data: Prism) => { 115 | if ($data.has('SyncList')) { 116 | $data = $data.get('SyncList') 117 | } 118 | 119 | return { 120 | ...$data.get('$').transform(toMediaContainer).value, 121 | 122 | _type: 'syncList', 123 | 124 | device: $data 125 | .get('Device') 126 | .get(0) 127 | .transform(toDevice), 128 | syncItems: $data 129 | .get('SyncItems') 130 | .get(0) 131 | .get('SyncItem') 132 | .toArray() 133 | .map(toSyncItem), 134 | } 135 | } 136 | 137 | /** 138 | * @ignore 139 | */ 140 | const parseSyncList = createParser('syncList', toSyncList) 141 | 142 | export { parseSyncList } 143 | -------------------------------------------------------------------------------- /src/types/tag.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | export interface Tag { 4 | id: number, 5 | filter: string, 6 | tag: string, 7 | } 8 | 9 | /** 10 | * @ignore 11 | */ 12 | const toTag = ($data: Prism): Tag => { 13 | return { 14 | id: $data.get('id', { quiet: true }).value, 15 | filter: $data.get('filter', { quiet: true }).value, 16 | tag: $data.get('tag').value, 17 | } 18 | } 19 | 20 | /** 21 | * @ignore 22 | */ 23 | const toTagList = ($data: Prism) => { 24 | return $data.toArray().map(toTag) 25 | } 26 | 27 | export { toTagList } 28 | -------------------------------------------------------------------------------- /src/types/track.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | import { schema } from 'normalizr' 3 | 4 | import { createParser } from './parser' 5 | 6 | import { MediaContainer, toMediaContainer } from './media-container' 7 | import { Media, toMedia } from './media' 8 | import { toNumber, toDateFromSeconds } from './types' 9 | 10 | /** 11 | * @ignore 12 | */ 13 | const trackSchema = new schema.Entity('tracks') 14 | 15 | /** 16 | * @ignore 17 | */ 18 | const trackContainerSchema = new schema.Object({ 19 | tracks: new schema.Array(trackSchema), 20 | }) 21 | 22 | export interface Track { 23 | _type: string, 24 | id: number, 25 | parentId: number, 26 | grandparentId: number, 27 | 28 | media: Media[], 29 | 30 | plexMix: unknown, 31 | 32 | addedAt: Date, 33 | deletedAt: Date, 34 | duration: number, 35 | grandparentGuid: string, 36 | grandparentKey: string, 37 | grandparentRatingKey: string, 38 | grandparentThumb: string, 39 | grandparentTitle: string, 40 | guid: string, 41 | index: number, 42 | key: string, 43 | lastRatedAt: Date, 44 | lastViewedAt: Date, 45 | originalTitle: string, 46 | parentGuid: string, 47 | parentIndex: number, 48 | parentKey: string, 49 | parentRatingKey: string, 50 | parentThumb: string, 51 | parentTitle: string, 52 | ratingCount: number, 53 | ratingKey: string, 54 | summary: string, 55 | thumb: string, 56 | title: string, 57 | titleSort: string, 58 | type: string, 59 | updatedAt: Date, 60 | userRating: number, 61 | viewCount: number, 62 | } 63 | 64 | /** 65 | * @ignore 66 | */ 67 | const toTrack = ($data: Prism): Track => { 68 | return { 69 | _type: 'track', 70 | 71 | id: $data.get('ratingKey').transform(toNumber).value, 72 | parentId: $data.get('parentRatingKey').transform(toNumber).value, 73 | grandparentId: $data.get('grandparentRatingKey').transform(toNumber) 74 | .value, 75 | 76 | media: $data 77 | .get('Media') 78 | .toArray() 79 | .map(toMedia), 80 | 81 | plexMix: $data 82 | .get('Related', { quiet: true }) 83 | .get('Directory', { quiet: true }).value, 84 | 85 | addedAt: $data.get('addedAt').transform(toDateFromSeconds).value, 86 | deletedAt: $data 87 | .get('deletedAt', { quiet: true }) 88 | .transform(toDateFromSeconds).value, 89 | duration: $data.get('duration').value, 90 | grandparentKey: $data.get('grandparentKey').value, 91 | grandparentRatingKey: $data.get('grandparentRatingKey').value, 92 | grandparentThumb: $data.get('grandparentThumb', { quiet: true }) 93 | .value, 94 | grandparentTitle: $data.get('grandparentTitle').value, 95 | grandparentGuid: $data.get('grandparentGuid', { quiet: true }) 96 | .value, 97 | guid: $data.get('guid', { quiet: true }).value, 98 | index: $data.get('index', { quiet: true }).value, 99 | key: $data.get('key').value, 100 | lastRatedAt: $data 101 | .get('lastRatedAt', { quiet: true }) 102 | .transform(toDateFromSeconds).value, 103 | lastViewedAt: $data 104 | .get('lastViewedAt', { quiet: true }) 105 | .transform(toDateFromSeconds).value, 106 | originalTitle: $data.get('originalTitle', { quiet: true }).value, 107 | parentGuid: $data.get('parentGuid', { quiet: true }).value, 108 | parentIndex: $data.get('parentIndex', { quiet: true }).value, 109 | parentKey: $data.get('parentKey').value, 110 | parentRatingKey: $data.get('parentRatingKey').value, 111 | parentThumb: $data.get('parentThumb', { quiet: true }).value, 112 | parentTitle: $data.get('parentTitle').value, 113 | ratingCount: $data.get('ratingCount', { quiet: true }).value, 114 | ratingKey: $data.get('ratingKey').value, 115 | summary: $data.get('summary', { quiet: true }).value, 116 | thumb: $data.get('thumb', { quiet: true }).value, 117 | title: $data.get('title').value, 118 | titleSort: $data.get('titleSort', { quiet: true }).value, 119 | type: $data.get('type').value, 120 | updatedAt: $data 121 | .get('updatedAt', { quiet: true }) 122 | .transform(toDateFromSeconds).value, 123 | userRating: $data.get('userRating', { quiet: true }).value, 124 | viewCount: $data.get('viewCount', { quiet: true }).value, 125 | } 126 | } 127 | 128 | export interface TrackContainer extends MediaContainer { 129 | _type: string, 130 | tracks: Track[], 131 | allowSync: string, 132 | art: string, 133 | grandparentRatingKey: string, 134 | grandparentThumb: string, 135 | grandparentTitle: string, 136 | key: string, 137 | librarySectionID: string, 138 | librarySectionTitle: string, 139 | librarySectionUUID: string, 140 | nocache: string, 141 | parentIndex: string, 142 | parentTitle: string, 143 | parentYear: string, 144 | thumb: string, 145 | title1: string, 146 | title2: string, 147 | viewGroup: string, 148 | viewMode: string, 149 | } 150 | 151 | /** 152 | * @ignore 153 | */ 154 | const toTrackContainer = ($data: Prism): TrackContainer => { 155 | if ($data.has('MediaContainer')) { 156 | $data = $data.get('MediaContainer') 157 | } 158 | 159 | return { 160 | ...$data.transform(toMediaContainer).value, 161 | 162 | _type: 'trackContainer', 163 | 164 | tracks: $data 165 | .get('Metadata', { quiet: true }) 166 | .toArray() 167 | .map(toTrack), 168 | 169 | allowSync: $data.get('allowSync').value, 170 | art: $data.get('art', { quiet: true }).value, 171 | grandparentRatingKey: $data.get('grandparentRatingKey', { quiet: true }) 172 | .value, 173 | grandparentThumb: $data.get('grandparentThumb', { quiet: true }).value, 174 | grandparentTitle: $data.get('grandparentTitle', { quiet: true }).value, 175 | key: $data.get('key', { quiet: true }).value, 176 | librarySectionID: $data.get('librarySectionID').value, 177 | librarySectionTitle: $data.get('librarySectionTitle').value, 178 | librarySectionUUID: $data.get('librarySectionUUID').value, 179 | nocache: $data.get('nocache', { quiet: true }).value, 180 | parentIndex: $data.get('parentIndex', { quiet: true }).value, 181 | parentTitle: $data.get('parentTitle', { quiet: true }).value, 182 | parentYear: $data.get('parentYear', { quiet: true }).value, 183 | thumb: $data.get('thumb', { quiet: true }).value, 184 | title1: $data.get('title1', { quiet: true }).value, 185 | title2: $data.get('title2', { quiet: true }).value, 186 | viewGroup: $data.get('viewGroup', { quiet: true }).value, 187 | viewMode: $data.get('viewMode', { quiet: true }).value, 188 | } 189 | } 190 | 191 | /** 192 | * @ignore 193 | */ 194 | const parseTrackContainer = createParser('trackContainer', toTrackContainer) 195 | 196 | export { 197 | trackSchema, 198 | trackContainerSchema, 199 | toTrack, 200 | toTrackContainer, 201 | parseTrackContainer, 202 | } 203 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | /** 4 | * @ignore 5 | */ 6 | const toBoolean = ($data: Prism): boolean => { 7 | const value = $data.value 8 | if (value == null) { 9 | return undefined 10 | } 11 | return value === 1 || value === '1' || value === 'true' || value === true 12 | } 13 | 14 | /** 15 | * @ignore 16 | */ 17 | const toNumber = ($data: Prism): number => { 18 | const value = $data.value 19 | if (value == null) { 20 | return undefined 21 | } 22 | return parseInt(value, 10) 23 | } 24 | 25 | /** 26 | * @ignore 27 | */ 28 | const toTimestamp = ($data: Prism) => { 29 | const value = $data.value 30 | if (value == null) { 31 | return undefined 32 | } 33 | return $data.transform(toNumber).value * 1000 34 | } 35 | 36 | /** 37 | * @ignore 38 | */ 39 | const toDate = ($data: Prism): Date => { 40 | const value = $data.value 41 | if (value == null) { 42 | return undefined 43 | } 44 | return new Date($data.value) 45 | } 46 | 47 | /** 48 | * @ignore 49 | */ 50 | const toDateFromSeconds = ($data: Prism): Date => { 51 | return $data.transform(toTimestamp).transform(toDate).value 52 | } 53 | 54 | export { toBoolean, toNumber, toTimestamp, toDate, toDateFromSeconds } 55 | -------------------------------------------------------------------------------- /src/types/user.ts: -------------------------------------------------------------------------------- 1 | import Prism from '@zwolf/prism' 2 | 3 | import { createParser } from './parser' 4 | import { toDate, toDateFromSeconds } from './types' 5 | 6 | export interface Profile { 7 | autoSelectAudio: boolean, 8 | defaultAudioLanguage: string, 9 | defaultSubtitleLanguage: string, 10 | autoSelectSubtitle: number, 11 | defaultSubtitleAccessibility: number, 12 | defaultSubtitleForced: number, 13 | } 14 | 15 | /** 16 | * @ignore 17 | */ 18 | const toProfile = ($data: Prism): Profile => { 19 | return { 20 | autoSelectAudio: $data.get('autoSelectAudio').value, 21 | defaultAudioLanguage: $data.get('defaultAudioLanguage').value, 22 | defaultSubtitleLanguage: $data.get('defaultSubtitleLanguage').value, 23 | autoSelectSubtitle: $data.get('autoSelectSubtitle').value, 24 | defaultSubtitleAccessibility: $data.get( 25 | 'defaultSubtitleAccessibility', 26 | ).value, 27 | defaultSubtitleForced: $data.get('defaultSubtitleForced').value, 28 | } 29 | } 30 | 31 | export interface UserSubscription { 32 | active: boolean, 33 | subscribedAt: Date, 34 | status: string, 35 | paymentService: string, 36 | plan: string, 37 | features: string[], 38 | } 39 | 40 | /** 41 | * @ignore 42 | */ 43 | const toUserSubscription = ($data: Prism): UserSubscription => { 44 | return { 45 | active: $data.get('active').value, 46 | subscribedAt: $data.get('subscribedAt').transform(toDate).value, 47 | status: $data.get('status').value, 48 | paymentService: $data.get('paymentService').value, 49 | plan: $data.get('plan').value, 50 | features: $data.get('features').value, 51 | } 52 | } 53 | 54 | export interface Subscription { 55 | id: unknown, 56 | mode: string, 57 | renewsAt: number, 58 | endsAt: number, 59 | type: string, 60 | transfer: boolean, 61 | state: string, 62 | } 63 | 64 | /** 65 | * @ignore 66 | */ 67 | const toSubscription = ($data: Prism): Subscription => { 68 | return { 69 | id: $data.get('id').value, 70 | mode: $data.get('mode').value, 71 | renewsAt: $data.get('renewsAt').value, 72 | endsAt: $data.get('endsAt').value, 73 | type: $data.get('type').value, 74 | transfer: $data.get('transfer').value, 75 | state: $data.get('state').value, 76 | } 77 | } 78 | 79 | export interface Service { 80 | identifier: string, 81 | endpoint: string, 82 | token: string, 83 | status: string, 84 | } 85 | 86 | /** 87 | * @ignore 88 | */ 89 | const toService = ($data: Prism): Service => { 90 | return { 91 | identifier: $data.get('identifier').value, 92 | endpoint: $data.get('endpoint').value, 93 | token: $data.get('token', { quiet: true }).value, 94 | status: $data.get('status').value, 95 | } 96 | } 97 | 98 | export interface User { 99 | _type: string, 100 | id: number, 101 | authToken: string, 102 | certificateVersion: number, 103 | country: string, 104 | email: string, 105 | emailOnlyAuth: boolean, 106 | entitlements: string[], 107 | guest: boolean, 108 | hasPassword: boolean, 109 | home: boolean, 110 | homeAdmin: boolean, 111 | homeSize: number, 112 | locale: string, 113 | mailingListActive: boolean, 114 | mailingListStatus: string, 115 | maxHomeSize: number, 116 | profile: Profile, 117 | protected: boolean, 118 | queueEmail: string, 119 | queueUid: unknown, 120 | rememberExpiresAt: Date, 121 | restricted: boolean, 122 | roles: string[], 123 | scrobbleTypes: string, 124 | services: Service[], 125 | subscription: UserSubscription, 126 | subscriptionDescription: string, 127 | subscriptions: Subscription[], 128 | thumb: string, 129 | title: string, 130 | username: string, 131 | uuid: string, 132 | } 133 | 134 | /** 135 | * @ignore 136 | */ 137 | const toUser = ($data: Prism): User => { 138 | return { 139 | _type: 'user', 140 | 141 | id: $data.get('id').value, 142 | 143 | authToken: $data.get('authToken').value, 144 | certificateVersion: $data.get('certificateVersion').value, 145 | country: $data.get('country').value, 146 | email: $data.get('email').value, 147 | emailOnlyAuth: $data.get('emailOnlyAuth').value, 148 | entitlements: $data.get('entitlements').value, 149 | guest: $data.get('guest').value, 150 | hasPassword: $data.get('hasPassword').value, 151 | home: $data.get('home').value, 152 | homeAdmin: $data.get('homeAdmin').value, 153 | homeSize: $data.get('homeSize').value, 154 | locale: $data.get('locale').value, 155 | mailingListActive: $data.get('mailingListActive').value, 156 | mailingListStatus: $data.get('mailingListStatus').value, 157 | maxHomeSize: $data.get('maxHomeSize').value, 158 | profile: $data.get>('profile').transform(toProfile) 159 | .value, 160 | protected: $data.get('protected').value, 161 | queueEmail: $data.get('queueEmail', { quiet: true }).value, 162 | queueUid: $data.get('queueUid', { quiet: true }).value, 163 | rememberExpiresAt: $data 164 | .get('rememberExpiresAt') 165 | .transform(toDateFromSeconds).value, 166 | restricted: $data.get('restricted').value, 167 | roles: $data.get('roles').value, 168 | scrobbleTypes: $data.get('scrobbleTypes').value, 169 | services: $data 170 | .get('services') 171 | .toArray() 172 | .map(toService), 173 | subscription: $data 174 | .get>('subscription') 175 | .transform(toUserSubscription).value, 176 | subscriptionDescription: $data.get('subscriptionDescription').value, 177 | subscriptions: $data 178 | .get('subscriptions') 179 | .toArray() 180 | .map(toSubscription), 181 | thumb: $data.get('thumb').value, 182 | title: $data.get('title').value, 183 | username: $data.get('username').value, 184 | uuid: $data.get('uuid').value, 185 | } 186 | } 187 | 188 | /** 189 | * @ignore 190 | */ 191 | const parseUser = createParser('user', toUser) 192 | 193 | export { toUser, parseUser } 194 | -------------------------------------------------------------------------------- /src/utils/params.ts: -------------------------------------------------------------------------------- 1 | export type Params = Record 2 | 3 | const withParams = (url: string, params: Params = {}) => { 4 | if (Object.keys(params).length > 0) { 5 | const searchParams = new URLSearchParams(params as Record) 6 | return `${url}?${searchParams.toString()}` 7 | } 8 | return url 9 | } 10 | 11 | /** 12 | * Handle container params. 13 | * 14 | * Important: the `start` parameter is only respected by Plex if you pass the 15 | * `size` parameter as well. 16 | * 17 | * @private 18 | * @params {Object} [options={}] 19 | * @returns {Object} 20 | */ 21 | 22 | interface WithContainerParamsOptions extends Params { 23 | start?: number, 24 | size?: number, 25 | } 26 | 27 | const withContainerParams = (params: WithContainerParamsOptions = {}) => { 28 | const { start, size, ...searchParams } = params 29 | 30 | if (size != null) { 31 | searchParams['X-Plex-Container-Size'] = size.toString() 32 | searchParams['X-Plex-Container-Start'] = 33 | start != null ? start.toString() : '0' 34 | } 35 | 36 | return searchParams 37 | } 38 | 39 | export { withParams, withContainerParams } 40 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import xml2js from 'xml2js' 2 | import { promisify } from 'util' 3 | import { Options as KyOptions } from 'ky' 4 | import ky from 'ky-universal' 5 | 6 | import { Params } from './params' 7 | 8 | export interface RequestOptions extends KyOptions { 9 | searchParams?: Params, 10 | headers?: Record, 11 | } 12 | 13 | const parseXMLString = promisify(xml2js.parseString) 14 | 15 | // delete undefined headers 16 | // mutates the object 17 | const cleanHeaders = (headers: Record = {}) => { 18 | Object.keys(headers).forEach((key) => { 19 | if (headers[key] === undefined) { 20 | delete headers[key] 21 | } 22 | }) 23 | return headers 24 | } 25 | 26 | const request = (url: string, options: RequestOptions = {}) => { 27 | const { 28 | method, 29 | body, 30 | json, 31 | searchParams, 32 | prefixUrl, 33 | retry, 34 | timeout, 35 | throwHttpErrors, 36 | headers, 37 | } = options 38 | 39 | cleanHeaders(options.headers as Record) 40 | 41 | return ky(url, { 42 | method, 43 | body, 44 | json, 45 | searchParams, 46 | prefixUrl, 47 | retry, 48 | timeout, 49 | throwHttpErrors, 50 | hooks: { 51 | beforeRequest: [ 52 | (request) => { 53 | Object.keys(headers).forEach((key) => { 54 | request.headers.set(key, headers[key]) 55 | }) 56 | }, 57 | ], 58 | }, 59 | }) 60 | } 61 | 62 | const requestJSON = async (url: string, options: RequestOptions = {}) => { 63 | const res = await request(url, { 64 | timeout: 1000 * 60, 65 | ...options, 66 | headers: Object.assign({ accept: 'application/json' }, options.headers), 67 | }) 68 | 69 | if (res.headers.get('content-type').includes('application/json')) { 70 | return res.json() 71 | } 72 | 73 | return res.text() 74 | } 75 | 76 | const requestXML = async (url: string, options: RequestOptions) => { 77 | const res = await request(url, options) 78 | const text = await res.text() 79 | const xml = await parseXMLString(text) 80 | return xml 81 | } 82 | 83 | export { request, requestJSON, requestXML } 84 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/metadata/35339.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": true, 5 | "identifier": "com.plexapp.plugins.library", 6 | "librarySectionID": 1, 7 | "librarySectionTitle": "Music", 8 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 9 | "mediaTagPrefix": "/system/bundle/media/flags/", 10 | "mediaTagVersion": 1481160546, 11 | "Metadata": [ 12 | { 13 | "ratingKey": "35339", 14 | "key": "/library/metadata/35339/children", 15 | "guid": "com.plexapp.agents.plexmusic://gracenote/artist/05038B2716CB52E2?lang=en", 16 | "librarySectionID": 1, 17 | "type": "artist", 18 | "title": "The Moth & The Flame", 19 | "summary": "Brandon Robbins, often referred to as The Flame, and Mark Garbett, commensurately dubbed The Moth, first met on a train ride through the Alaskan outback. It's said that Mark was humming some indistinct tune while Brandon, seated rowed the opposite of his tall passenger-companion, feverishly scribed words into a moleskin. A few measures into his tune, respectively a few stanzas into his quatrain, Mark's and Brandon's efforts reached a pinnacle of symbiosis. Mark's vocal modulations and Brandon's muttered assonance's suddenly fell into a perfect rhythm. Slowly they turned to look at one another, their eyes wide. There was struck the ongoing friendship between these two. Musically, it never stopped. Together they agreed to leave their wonderland of lumber-jacking to pursue a musical career.\nLeaving their bucolic ice-land, they trekked to Provo, Utah with only their feet to carry them. The result of their 11 month journey to Utah Valley was a renewed sense of wonder and enough know-how to get them a weekly slot at the local open mic.\nIt didn't take long for word of their matching hair and musical prowess to spread and soon The Moth & The Flame had officially formed.", 20 | "index": 1, 21 | "viewCount": 2, 22 | "lastViewedAt": 1478405744, 23 | "thumb": "/library/metadata/35339/thumb/1482677884", 24 | "addedAt": 1473509786, 25 | "updatedAt": 1482677884, 26 | "Genre": [ 27 | { 28 | "id": 25689, 29 | "tag": "Electronic, Progressive Rock" 30 | } 31 | ], 32 | "Country": [ 33 | { 34 | "id": 147, 35 | "tag": "United States" 36 | } 37 | ], 38 | "Location": [ 39 | { 40 | "path": "/mnt/data/music/albums" 41 | } 42 | ] 43 | } 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/metadata/35339/allLeaves.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 6, 4 | "allowSync": true, 5 | "art": "/:/resources/artist-fanart.jpg", 6 | "identifier": "com.plexapp.plugins.library", 7 | "key": "35339", 8 | "librarySectionID": 1, 9 | "librarySectionTitle": "Music", 10 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 11 | "mediaTagPrefix": "/system/bundle/media/flags/", 12 | "mediaTagVersion": 1481160546, 13 | "mixedParents": true, 14 | "nocache": true, 15 | "parentIndex": "1", 16 | "parentTitle": "The Moth & The Flame", 17 | "title1": "Music", 18 | "title2": "The Moth & The Flame", 19 | "viewGroup": "track", 20 | "viewMode": 65593, 21 | "Metadata": [ 22 | { 23 | "ratingKey": "35341", 24 | "key": "/library/metadata/35341", 25 | "parentRatingKey": "35340", 26 | "type": "track", 27 | "title": "Sorry", 28 | "parentKey": "/library/metadata/35340", 29 | "grandparentTitle": "The Moth & The Flame", 30 | "parentTitle": "&", 31 | "originalTitle": "The Moth & The Flame", 32 | "summary": "", 33 | "index": 1, 34 | "parentIndex": 1, 35 | "ratingCount": 11, 36 | "userRating": 7, 37 | "viewCount": 2, 38 | "lastViewedAt": 1478405744, 39 | "parentYear": 2013, 40 | "thumb": "/library/metadata/35340/thumb/1473509934", 41 | "parentThumb": "/library/metadata/35340/thumb/1473509934", 42 | "duration": 210495, 43 | "addedAt": 1473509786, 44 | "updatedAt": 1473509933, 45 | "Media": [ 46 | { 47 | "id": 49448, 48 | "duration": 210495, 49 | "bitrate": 320, 50 | "audioChannels": 2, 51 | "audioCodec": "mp3", 52 | "container": "mp3", 53 | "Part": [ 54 | { 55 | "id": 49448, 56 | "key": "/library/parts/49448/1473509724/file.mp3", 57 | "duration": 210495, 58 | "file": "/mnt/data/music/albums/The Moth & The Flame/&/01 Sorry.mp3", 59 | "size": 9999313, 60 | "container": "mp3", 61 | "hasThumbnail": "1" 62 | } 63 | ] 64 | } 65 | ] 66 | }, 67 | { 68 | "ratingKey": "35342", 69 | "key": "/library/metadata/35342", 70 | "parentRatingKey": "35340", 71 | "type": "track", 72 | "title": "Winsome", 73 | "parentKey": "/library/metadata/35340", 74 | "grandparentTitle": "The Moth & The Flame", 75 | "parentTitle": "&", 76 | "originalTitle": "The Moth & The Flame", 77 | "summary": "", 78 | "index": 2, 79 | "parentIndex": 1, 80 | "ratingCount": 5767, 81 | "parentYear": 2013, 82 | "thumb": "/library/metadata/35340/thumb/1473509934", 83 | "parentThumb": "/library/metadata/35340/thumb/1473509934", 84 | "duration": 236408, 85 | "addedAt": 1473509786, 86 | "updatedAt": 1473509933, 87 | "Media": [ 88 | { 89 | "id": 49449, 90 | "duration": 236408, 91 | "bitrate": 320, 92 | "audioChannels": 2, 93 | "audioCodec": "mp3", 94 | "container": "mp3", 95 | "Part": [ 96 | { 97 | "id": 49449, 98 | "key": "/library/parts/49449/1473509724/file.mp3", 99 | "duration": 236408, 100 | "file": "/mnt/data/music/albums/The Moth & The Flame/&/02 Winsome.mp3", 101 | "size": 11036891, 102 | "container": "mp3", 103 | "hasThumbnail": "1" 104 | } 105 | ] 106 | } 107 | ] 108 | }, 109 | { 110 | "ratingKey": "35343", 111 | "key": "/library/metadata/35343", 112 | "parentRatingKey": "35340", 113 | "type": "track", 114 | "title": "Silver Tongue", 115 | "parentKey": "/library/metadata/35340", 116 | "grandparentTitle": "The Moth & The Flame", 117 | "parentTitle": "&", 118 | "originalTitle": "The Moth & The Flame", 119 | "summary": "", 120 | "index": 3, 121 | "parentIndex": 1, 122 | "ratingCount": 5350, 123 | "parentYear": 2013, 124 | "thumb": "/library/metadata/35340/thumb/1473509934", 125 | "parentThumb": "/library/metadata/35340/thumb/1473509934", 126 | "duration": 231393, 127 | "addedAt": 1473509786, 128 | "updatedAt": 1473509933, 129 | "Media": [ 130 | { 131 | "id": 49450, 132 | "duration": 231393, 133 | "bitrate": 320, 134 | "audioChannels": 2, 135 | "audioCodec": "mp3", 136 | "container": "mp3", 137 | "Part": [ 138 | { 139 | "id": 49450, 140 | "key": "/library/parts/49450/1473509724/file.mp3", 141 | "duration": 231393, 142 | "file": "/mnt/data/music/albums/The Moth & The Flame/&/03 Silver Tongue.mp3", 143 | "size": 10836076, 144 | "container": "mp3", 145 | "hasThumbnail": "1" 146 | } 147 | ] 148 | } 149 | ] 150 | }, 151 | { 152 | "ratingKey": "35344", 153 | "key": "/library/metadata/35344", 154 | "parentRatingKey": "35340", 155 | "type": "track", 156 | "title": "Monster", 157 | "parentKey": "/library/metadata/35340", 158 | "grandparentTitle": "The Moth & The Flame", 159 | "parentTitle": "&", 160 | "originalTitle": "The Moth & The Flame", 161 | "summary": "", 162 | "index": 4, 163 | "parentIndex": 1, 164 | "ratingCount": 7052, 165 | "parentYear": 2013, 166 | "thumb": "/library/metadata/35340/thumb/1473509934", 167 | "parentThumb": "/library/metadata/35340/thumb/1473509934", 168 | "duration": 231105, 169 | "addedAt": 1473509786, 170 | "updatedAt": 1473509934, 171 | "Media": [ 172 | { 173 | "id": 49451, 174 | "duration": 231105, 175 | "bitrate": 320, 176 | "audioChannels": 2, 177 | "audioCodec": "mp3", 178 | "container": "mp3", 179 | "Part": [ 180 | { 181 | "id": 49451, 182 | "key": "/library/parts/49451/1473509724/file.mp3", 183 | "duration": 231105, 184 | "file": "/mnt/data/music/albums/The Moth & The Flame/&/04 Monster.mp3", 185 | "size": 10824565, 186 | "container": "mp3", 187 | "hasThumbnail": "1" 188 | } 189 | ] 190 | } 191 | ] 192 | }, 193 | { 194 | "ratingKey": "35345", 195 | "key": "/library/metadata/35345", 196 | "parentRatingKey": "35340", 197 | "type": "track", 198 | "title": "Holy War", 199 | "parentKey": "/library/metadata/35340", 200 | "grandparentTitle": "The Moth & The Flame", 201 | "parentTitle": "&", 202 | "originalTitle": "The Moth & The Flame", 203 | "summary": "", 204 | "index": 5, 205 | "parentIndex": 1, 206 | "ratingCount": 3943, 207 | "parentYear": 2013, 208 | "thumb": "/library/metadata/35340/thumb/1473509934", 209 | "parentThumb": "/library/metadata/35340/thumb/1473509934", 210 | "duration": 136046, 211 | "addedAt": 1473509787, 212 | "updatedAt": 1473509934, 213 | "Media": [ 214 | { 215 | "id": 49452, 216 | "duration": 136046, 217 | "bitrate": 320, 218 | "audioChannels": 2, 219 | "audioCodec": "mp3", 220 | "container": "mp3", 221 | "Part": [ 222 | { 223 | "id": 49452, 224 | "key": "/library/parts/49452/1473509724/file.mp3", 225 | "duration": 136046, 226 | "file": "/mnt/data/music/albums/The Moth & The Flame/&/05 Holy War.mp3", 227 | "size": 7018380, 228 | "container": "mp3", 229 | "hasThumbnail": "1" 230 | } 231 | ] 232 | } 233 | ] 234 | }, 235 | { 236 | "ratingKey": "35346", 237 | "key": "/library/metadata/35346", 238 | "parentRatingKey": "35340", 239 | "type": "track", 240 | "title": "How We Woke Up", 241 | "parentKey": "/library/metadata/35340", 242 | "grandparentTitle": "The Moth & The Flame", 243 | "parentTitle": "&", 244 | "originalTitle": "The Moth & The Flame", 245 | "summary": "", 246 | "index": 6, 247 | "parentIndex": 1, 248 | "ratingCount": 4364, 249 | "parentYear": 2013, 250 | "thumb": "/library/metadata/35340/thumb/1473509934", 251 | "parentThumb": "/library/metadata/35340/thumb/1473509934", 252 | "duration": 282932, 253 | "addedAt": 1473509787, 254 | "updatedAt": 1473509934, 255 | "Media": [ 256 | { 257 | "id": 49453, 258 | "duration": 282932, 259 | "bitrate": 320, 260 | "audioChannels": 2, 261 | "audioCodec": "mp3", 262 | "container": "mp3", 263 | "Part": [ 264 | { 265 | "id": 49453, 266 | "key": "/library/parts/49453/1473509724/file.mp3", 267 | "duration": 282932, 268 | "file": "/mnt/data/music/albums/The Moth & The Flame/&/06 How We Woke Up.mp3", 269 | "size": 12899722, 270 | "container": "mp3", 271 | "hasThumbnail": "1" 272 | } 273 | ] 274 | } 275 | ] 276 | } 277 | ] 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/metadata/35339/children.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": true, 5 | "art": "/:/resources/artist-fanart.jpg", 6 | "identifier": "com.plexapp.plugins.library", 7 | "key": "35339", 8 | "librarySectionID": 1, 9 | "librarySectionTitle": "Music", 10 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 11 | "mediaTagPrefix": "/system/bundle/media/flags/", 12 | "mediaTagVersion": 1481160546, 13 | "nocache": true, 14 | "parentIndex": "1", 15 | "parentTitle": "The Moth & The Flame", 16 | "summary": "Brandon Robbins, often referred to as The Flame, and Mark Garbett, commensurately dubbed The Moth, first met on a train ride through the Alaskan outback. It's said that Mark was humming some indistinct tune while Brandon, seated rowed the opposite of his tall passenger-companion, feverishly scribed words into a moleskin. A few measures into his tune, respectively a few stanzas into his quatrain, Mark's and Brandon's efforts reached a pinnacle of symbiosis. Mark's vocal modulations and Brandon's muttered assonance's suddenly fell into a perfect rhythm. Slowly they turned to look at one another, their eyes wide. There was struck the ongoing friendship between these two. Musically, it never stopped. Together they agreed to leave their wonderland of lumber-jacking to pursue a musical career.\nLeaving their bucolic ice-land, they trekked to Provo, Utah with only their feet to carry them. The result of their 11 month journey to Utah Valley was a renewed sense of wonder and enough know-how to get them a weekly slot at the local open mic.\nIt didn't take long for word of their matching hair and musical prowess to spread and soon The Moth & The Flame had officially formed.", 17 | "thumb": "/library/metadata/35339/thumb/1482677884", 18 | "title1": "Music", 19 | "title2": "The Moth & The Flame", 20 | "viewGroup": "album", 21 | "viewMode": 65592, 22 | "Metadata": [ 23 | { 24 | "ratingKey": "35340", 25 | "key": "/library/metadata/35340/children", 26 | "parentRatingKey": "35339", 27 | "studio": "Hidden records", 28 | "type": "album", 29 | "title": "&", 30 | "parentKey": "/library/metadata/35339", 31 | "parentTitle": "The Moth & The Flame", 32 | "summary": "", 33 | "index": 1, 34 | "viewCount": 2, 35 | "lastViewedAt": 1478405744, 36 | "year": 2013, 37 | "thumb": "/library/metadata/35340/thumb/1473509934", 38 | "parentThumb": "/library/metadata/35339/thumb/1482677884", 39 | "originallyAvailableAt": "2013-11-05", 40 | "addedAt": 1473509786, 41 | "updatedAt": 1473509934, 42 | "Genre": [ 43 | { 44 | "tag": "Electronic, Progressive Rock" 45 | } 46 | ] 47 | } 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/metadata/35341.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": true, 5 | "identifier": "com.plexapp.plugins.library", 6 | "librarySectionID": 1, 7 | "librarySectionTitle": "Music", 8 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 9 | "mediaTagPrefix": "/system/bundle/media/flags/", 10 | "mediaTagVersion": 1481160546, 11 | "Metadata": [ 12 | { 13 | "ratingKey": "35341", 14 | "key": "/library/metadata/35341", 15 | "parentRatingKey": "35340", 16 | "grandparentRatingKey": "35339", 17 | "guid": "com.plexapp.agents.plexmusic://gracenote/track/382423778-038B27E8A8D214DA255CC25B1B41AB1C/382423779-E927213289AA4219AD6EB9C8481F06D1?lang=en", 18 | "librarySectionID": 1, 19 | "type": "track", 20 | "title": "Sorry", 21 | "grandparentKey": "/library/metadata/35339", 22 | "parentKey": "/library/metadata/35340", 23 | "grandparentTitle": "The Moth & The Flame", 24 | "parentTitle": "&", 25 | "originalTitle": "The Moth & The Flame", 26 | "summary": "", 27 | "index": 1, 28 | "parentIndex": 1, 29 | "ratingCount": 11, 30 | "userRating": 7, 31 | "viewCount": 2, 32 | "lastViewedAt": 1478405744, 33 | "thumb": "/library/metadata/35340/thumb/1473509934", 34 | "parentThumb": "/library/metadata/35340/thumb/1473509934", 35 | "grandparentThumb": "/library/metadata/35339/thumb/1482677884", 36 | "duration": 210495, 37 | "addedAt": 1473509786, 38 | "updatedAt": 1473509933, 39 | "Media": [ 40 | { 41 | "id": 49448, 42 | "duration": 210495, 43 | "bitrate": 320, 44 | "audioChannels": 2, 45 | "audioCodec": "mp3", 46 | "container": "mp3", 47 | "Part": [ 48 | { 49 | "id": 49448, 50 | "key": "/library/parts/49448/1473509724/file.mp3", 51 | "duration": 210495, 52 | "file": "/mnt/data/music/albums/The Moth & The Flame/&/01 Sorry.mp3", 53 | "size": 9999313, 54 | "container": "mp3", 55 | "hasThumbnail": "1", 56 | "Stream": { 57 | "id": 92913, 58 | "streamType": 2, 59 | "selected": true, 60 | "codec": "mp3", 61 | "index": 0, 62 | "channels": 2, 63 | "bitrate": 320, 64 | "bitrateMode": "cbr", 65 | "duration": 210677, 66 | "samplingRate": 44100 67 | } 68 | } 69 | ] 70 | } 71 | ], 72 | "Mood": [ 73 | { 74 | "id": 56, 75 | "tag": "Dreamy Brooding" 76 | } 77 | ] 78 | } 79 | ] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/metadata/40812.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": true, 5 | "identifier": "com.plexapp.plugins.library", 6 | "librarySectionID": 1, 7 | "librarySectionTitle": "Music", 8 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 9 | "mediaTagPrefix": "/system/bundle/media/flags/", 10 | "mediaTagVersion": 1481160546, 11 | "Metadata": [ 12 | { 13 | "ratingKey": "40812", 14 | "key": "/library/metadata/40812/children", 15 | "parentRatingKey": "40811", 16 | "guid": "com.plexapp.agents.plexmusic://gracenote/album/0556B27724C2CFD0/616746960-56B2773DE8F6B29DD4EA1DD8C5C3979B?lang=en", 17 | "librarySectionID": 1, 18 | "studio": "Kobalt", 19 | "type": "album", 20 | "title": "Echo of Love", 21 | "parentKey": "/library/metadata/40811", 22 | "parentTitle": "The Shutes", 23 | "summary": "", 24 | "index": 1, 25 | "viewCount": 16, 26 | "lastViewedAt": 1481962116, 27 | "year": 2012, 28 | "thumb": "/library/metadata/40812/thumb/1481013384", 29 | "parentThumb": "/library/metadata/40811/thumb/1482333809", 30 | "originallyAvailableAt": "2012-01-01", 31 | "leafCount": 5, 32 | "viewedLeafCount": 5, 33 | "addedAt": 1481013349, 34 | "updatedAt": 1481013384, 35 | "Genre": [ 36 | { 37 | "id": 1980, 38 | "tag": "General Pop" 39 | }, 40 | { 41 | "id": 1981, 42 | "tag": "Other Pop" 43 | }, 44 | { 45 | "id": 76, 46 | "tag": "Pop" 47 | } 48 | ] 49 | } 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/metadata/40812/children.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 5, 4 | "allowSync": true, 5 | "art": "/:/resources/artist-fanart.jpg", 6 | "grandparentRatingKey": 40811, 7 | "grandparentThumb": "/library/metadata/40811/thumb/1482333809", 8 | "grandparentTitle": "The Shutes", 9 | "identifier": "com.plexapp.plugins.library", 10 | "key": "40812", 11 | "librarySectionID": 1, 12 | "librarySectionTitle": "Music", 13 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 14 | "mediaTagPrefix": "/system/bundle/media/flags/", 15 | "mediaTagVersion": 1481160546, 16 | "nocache": true, 17 | "parentIndex": "1", 18 | "parentTitle": "Echo of Love", 19 | "parentYear": "2012", 20 | "thumb": "/library/metadata/40812/thumb/1481013384", 21 | "title1": "The Shutes", 22 | "title2": "Echo of Love", 23 | "viewGroup": "track", 24 | "viewMode": 65593, 25 | "Metadata": [ 26 | { 27 | "ratingKey": "40813", 28 | "key": "/library/metadata/40813", 29 | "parentRatingKey": "40812", 30 | "grandparentRatingKey": "40811", 31 | "type": "track", 32 | "title": "Echo of Love", 33 | "grandparentKey": "/library/metadata/40811", 34 | "parentKey": "/library/metadata/40812", 35 | "grandparentTitle": "The Shutes", 36 | "parentTitle": "Echo of Love", 37 | "originalTitle": "The Shutes", 38 | "summary": "", 39 | "index": 1, 40 | "parentIndex": 1, 41 | "userRating": 10, 42 | "viewCount": 5, 43 | "lastViewedAt": 1481962116, 44 | "thumb": "/library/metadata/40812/thumb/1481013384", 45 | "parentThumb": "/library/metadata/40812/thumb/1481013384", 46 | "grandparentThumb": "/library/metadata/40811/thumb/1482333809", 47 | "duration": 288313, 48 | "addedAt": 1481013349, 49 | "updatedAt": 1481013414, 50 | "Media": [ 51 | { 52 | "id": 54538, 53 | "duration": 288313, 54 | "bitrate": 320, 55 | "audioChannels": 2, 56 | "audioCodec": "mp3", 57 | "container": "mp3", 58 | "Part": [ 59 | { 60 | "id": 54538, 61 | "key": "/library/parts/54538/1481013322/file.mp3", 62 | "duration": 288313, 63 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/01 Echo of Love.mp3", 64 | "size": 11534741, 65 | "container": "mp3" 66 | } 67 | ] 68 | } 69 | ] 70 | }, 71 | { 72 | "ratingKey": "40814", 73 | "key": "/library/metadata/40814", 74 | "parentRatingKey": "40812", 75 | "grandparentRatingKey": "40811", 76 | "type": "track", 77 | "title": "Here, My Blood Runs Clear", 78 | "grandparentKey": "/library/metadata/40811", 79 | "parentKey": "/library/metadata/40812", 80 | "grandparentTitle": "The Shutes", 81 | "parentTitle": "Echo of Love", 82 | "originalTitle": "The Shutes", 83 | "summary": "", 84 | "index": 2, 85 | "parentIndex": 1, 86 | "userRating": 8, 87 | "viewCount": 3, 88 | "lastViewedAt": 1481738643, 89 | "thumb": "/library/metadata/40812/thumb/1481013384", 90 | "parentThumb": "/library/metadata/40812/thumb/1481013384", 91 | "grandparentThumb": "/library/metadata/40811/thumb/1482333809", 92 | "duration": 192627, 93 | "addedAt": 1481013349, 94 | "updatedAt": 1481013414, 95 | "Media": [ 96 | { 97 | "id": 54539, 98 | "duration": 192627, 99 | "bitrate": 320, 100 | "audioChannels": 2, 101 | "audioCodec": "mp3", 102 | "container": "mp3", 103 | "Part": [ 104 | { 105 | "id": 54539, 106 | "key": "/library/parts/54539/1481013322/file.mp3", 107 | "duration": 192627, 108 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/02 Here, My Blood Runs Clear.mp3", 109 | "size": 7707293, 110 | "container": "mp3" 111 | } 112 | ] 113 | } 114 | ] 115 | }, 116 | { 117 | "ratingKey": "40815", 118 | "key": "/library/metadata/40815", 119 | "parentRatingKey": "40812", 120 | "grandparentRatingKey": "40811", 121 | "type": "track", 122 | "title": "Only", 123 | "grandparentKey": "/library/metadata/40811", 124 | "parentKey": "/library/metadata/40812", 125 | "grandparentTitle": "The Shutes", 126 | "parentTitle": "Echo of Love", 127 | "originalTitle": "The Shutes", 128 | "summary": "", 129 | "index": 3, 130 | "parentIndex": 1, 131 | "userRating": 10, 132 | "viewCount": 3, 133 | "lastViewedAt": 1481738883, 134 | "thumb": "/library/metadata/40812/thumb/1481013384", 135 | "parentThumb": "/library/metadata/40812/thumb/1481013384", 136 | "grandparentThumb": "/library/metadata/40811/thumb/1482333809", 137 | "duration": 245864, 138 | "addedAt": 1481013349, 139 | "updatedAt": 1481013414, 140 | "Media": [ 141 | { 142 | "id": 54540, 143 | "duration": 245864, 144 | "bitrate": 320, 145 | "audioChannels": 2, 146 | "audioCodec": "mp3", 147 | "container": "mp3", 148 | "Part": [ 149 | { 150 | "id": 54540, 151 | "key": "/library/parts/54540/1481013322/file.mp3", 152 | "duration": 245864, 153 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/03 Only.mp3", 154 | "size": 9836774, 155 | "container": "mp3" 156 | } 157 | ] 158 | } 159 | ] 160 | }, 161 | { 162 | "ratingKey": "40816", 163 | "key": "/library/metadata/40816", 164 | "parentRatingKey": "40812", 165 | "grandparentRatingKey": "40811", 166 | "type": "track", 167 | "title": "She Said", 168 | "grandparentKey": "/library/metadata/40811", 169 | "parentKey": "/library/metadata/40812", 170 | "grandparentTitle": "The Shutes", 171 | "parentTitle": "Echo of Love", 172 | "originalTitle": "The Shutes", 173 | "summary": "", 174 | "index": 4, 175 | "parentIndex": 1, 176 | "userRating": 10, 177 | "viewCount": 3, 178 | "lastViewedAt": 1481739085, 179 | "thumb": "/library/metadata/40812/thumb/1481013384", 180 | "parentThumb": "/library/metadata/40812/thumb/1481013384", 181 | "grandparentThumb": "/library/metadata/40811/thumb/1482333809", 182 | "duration": 197407, 183 | "addedAt": 1481013349, 184 | "updatedAt": 1481013414, 185 | "Media": [ 186 | { 187 | "id": 54541, 188 | "duration": 197407, 189 | "bitrate": 320, 190 | "audioChannels": 2, 191 | "audioCodec": "mp3", 192 | "container": "mp3", 193 | "Part": [ 194 | { 195 | "id": 54541, 196 | "key": "/library/parts/54541/1481013322/file.mp3", 197 | "duration": 197407, 198 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/04 She Said.mp3", 199 | "size": 7898492, 200 | "container": "mp3" 201 | } 202 | ] 203 | } 204 | ] 205 | }, 206 | { 207 | "ratingKey": "40817", 208 | "key": "/library/metadata/40817", 209 | "parentRatingKey": "40812", 210 | "grandparentRatingKey": "40811", 211 | "type": "track", 212 | "title": "Bright Blue Berlin Sky", 213 | "grandparentKey": "/library/metadata/40811", 214 | "parentKey": "/library/metadata/40812", 215 | "grandparentTitle": "The Shutes", 216 | "parentTitle": "Echo of Love", 217 | "originalTitle": "The Shutes", 218 | "summary": "", 219 | "index": 5, 220 | "parentIndex": 1, 221 | "userRating": 10, 222 | "viewCount": 2, 223 | "lastViewedAt": 1481739228, 224 | "thumb": "/library/metadata/40812/thumb/1481013384", 225 | "parentThumb": "/library/metadata/40812/thumb/1481013384", 226 | "grandparentThumb": "/library/metadata/40811/thumb/1482333809", 227 | "duration": 344660, 228 | "addedAt": 1481013349, 229 | "updatedAt": 1481013414, 230 | "Media": [ 231 | { 232 | "id": 54542, 233 | "duration": 344660, 234 | "bitrate": 320, 235 | "audioChannels": 2, 236 | "audioCodec": "mp3", 237 | "container": "mp3", 238 | "Part": [ 239 | { 240 | "id": 54542, 241 | "key": "/library/parts/54542/1481013322/file.mp3", 242 | "duration": 344660, 243 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/05 Bright Blue Berlin Sky.mp3", 244 | "size": 13788596, 245 | "container": "mp3" 246 | } 247 | ] 248 | } 249 | ] 250 | } 251 | ] 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/metadata/40812_includeExtras=1.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": true, 5 | "identifier": "com.plexapp.plugins.library", 6 | "librarySectionID": 1, 7 | "librarySectionTitle": "Music", 8 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 9 | "mediaTagPrefix": "/system/bundle/media/flags/", 10 | "mediaTagVersion": 1481160546, 11 | "Metadata": [ 12 | { 13 | "ratingKey": "40812", 14 | "key": "/library/metadata/40812/children", 15 | "parentRatingKey": "40811", 16 | "guid": "com.plexapp.agents.plexmusic://gracenote/album/0556B27724C2CFD0/616746960-56B2773DE8F6B29DD4EA1DD8C5C3979B?lang=en", 17 | "librarySectionID": 1, 18 | "studio": "Kobalt", 19 | "type": "album", 20 | "title": "Echo of Love", 21 | "parentKey": "/library/metadata/40811", 22 | "parentTitle": "The Shutes", 23 | "summary": "", 24 | "index": 1, 25 | "viewCount": 16, 26 | "lastViewedAt": 1481962116, 27 | "year": 2012, 28 | "thumb": "/library/metadata/40812/thumb/1481013384", 29 | "parentThumb": "/library/metadata/40811/thumb/1482333809", 30 | "originallyAvailableAt": "2012-01-01", 31 | "leafCount": 5, 32 | "viewedLeafCount": 5, 33 | "addedAt": 1481013349, 34 | "updatedAt": 1481013384, 35 | "Genre": [ 36 | { 37 | "id": 1980, 38 | "tag": "General Pop" 39 | }, 40 | { 41 | "id": 1981, 42 | "tag": "Other Pop" 43 | }, 44 | { 45 | "id": 76, 46 | "tag": "Pop" 47 | } 48 | ], 49 | "Extras": { 50 | "size": 0 51 | } 52 | } 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": false, 5 | "identifier": "com.plexapp.plugins.library", 6 | "mediaTagPrefix": "/system/bundle/media/flags/", 7 | "mediaTagVersion": 1481160546, 8 | "title1": "Plex Library", 9 | "Directory": [ 10 | { 11 | "allowSync": true, 12 | "art": "/:/resources/artist-fanart.jpg", 13 | "composite": "/library/sections/1/composite/1465382234", 14 | "filters": true, 15 | "refreshing": false, 16 | "thumb": "/:/resources/artist.png", 17 | "key": "1", 18 | "type": "artist", 19 | "title": "Music", 20 | "agent": "com.plexapp.agents.plexmusic", 21 | "scanner": "Plex Premium Music Scanner", 22 | "language": "en", 23 | "uuid": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 24 | "updatedAt": 1465382234, 25 | "createdAt": 1465382234, 26 | "Location": [ 27 | { 28 | "id": 1, 29 | "path": "/mnt/data/music" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 11, 4 | "allowSync": false, 5 | "art": "/:/resources/artist-fanart.jpg", 6 | "content": "secondary", 7 | "identifier": "com.plexapp.plugins.library", 8 | "librarySectionID": 1, 9 | "mediaTagPrefix": "/system/bundle/media/flags/", 10 | "mediaTagVersion": 1481160546, 11 | "nocache": true, 12 | "thumb": "/:/resources/artist.png", 13 | "title1": "Music", 14 | "viewGroup": "secondary", 15 | "viewMode": 65592, 16 | "Directory": [ 17 | { 18 | "key": "all", 19 | "title": "All Artists" 20 | }, 21 | { 22 | "key": "albums", 23 | "title": "By Album" 24 | }, 25 | { 26 | "secondary": true, 27 | "key": "genre", 28 | "title": "By Genre" 29 | }, 30 | { 31 | "secondary": true, 32 | "key": "decade", 33 | "title": "By Decade" 34 | }, 35 | { 36 | "secondary": true, 37 | "key": "year", 38 | "title": "By Year" 39 | }, 40 | { 41 | "secondary": true, 42 | "key": "collection", 43 | "title": "By Collection" 44 | }, 45 | { 46 | "key": "recentlyAdded", 47 | "title": "Recently Added" 48 | }, 49 | { 50 | "key": "folder", 51 | "title": "By Folder" 52 | }, 53 | { 54 | "prompt": "Search for Artists", 55 | "search": true, 56 | "key": "search?type=8", 57 | "title": "Search Artists..." 58 | }, 59 | { 60 | "prompt": "Search for Albums", 61 | "search": true, 62 | "key": "search?type=9", 63 | "title": "Search Albums..." 64 | }, 65 | { 66 | "prompt": "Search for Tracks", 67 | "search": true, 68 | "key": "search?type=10", 69 | "title": "Search Tracks..." 70 | } 71 | ] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/all_genre=1348.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 3, 4 | "totalSize": 3, 5 | "allowSync": true, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "librarySectionID": 1, 9 | "librarySectionTitle": "Music", 10 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 11 | "mediaTagPrefix": "/system/bundle/media/flags/", 12 | "mediaTagVersion": 1481160546, 13 | "nocache": true, 14 | "offset": 0, 15 | "thumb": "/:/resources/artist.png", 16 | "title1": "Music", 17 | "title2": "All Artists", 18 | "viewGroup": "artist", 19 | "viewMode": 65592, 20 | "Metadata": [ 21 | { 22 | "ratingKey": "3526", 23 | "key": "/library/metadata/3526/children", 24 | "type": "artist", 25 | "title": "The Barr Brothers", 26 | "titleSort": "Barr Brothers, The", 27 | "summary": "The Barr Brothers is a Canadian folk quartet founded in Montreal, Quebec, consisting of Andrew and Brad Barr (of The Slip), Sarah Page and Andres Vial. Their first (and eponymous-titled) album was released in 2011.", 28 | "index": 1, 29 | "viewCount": 6, 30 | "lastViewedAt": 1477711156, 31 | "thumb": "/library/metadata/3526/thumb/1482592755", 32 | "addedAt": 1445677698, 33 | "updatedAt": 1482592755, 34 | "Genre": [ 35 | { 36 | "tag": "Alt Country" 37 | }, 38 | { 39 | "tag": "Alt Country" 40 | }, 41 | { 42 | "tag": "Indie" 43 | } 44 | ], 45 | "Country": [ 46 | { 47 | "tag": "Canada" 48 | } 49 | ] 50 | }, 51 | { 52 | "ratingKey": "34904", 53 | "key": "/library/metadata/34904/children", 54 | "type": "artist", 55 | "title": "Carl Broemel", 56 | "titleSort": "Broemel, Carl", 57 | "summary": "Carl Broemel is a guitarist best known for playing guitar, pedal steel guitar, saxophone and singing back-up for the Louisville, Kentucky band My Morning Jacket. He is a former member of Old Pike and Planet Earth. Broemel is a classically trained guitarist and graduated from Indiana University. was listed among Rolling Stone's \"20 New Guitar Gods\" along with My Morning Jacket frontman Jim James under the title of \"Skynard-Art Theorists. He currently resides in Nashville, TN. He lived in Los Angeles for several years when he recorded a solo record, Lose What's Left. In 2010 he released the album All Birds Say. Read more on Last.fm. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply", 58 | "index": 1, 59 | "viewCount": 9, 60 | "lastViewedAt": 1480922547, 61 | "thumb": "/library/metadata/34904/thumb/1482592564", 62 | "addedAt": 1473333150, 63 | "updatedAt": 1482592564, 64 | "Genre": [ 65 | { 66 | "tag": "Alt Country" 67 | }, 68 | { 69 | "tag": "Alt Country" 70 | }, 71 | { 72 | "tag": "Indie" 73 | } 74 | ], 75 | "Country": [ 76 | { 77 | "tag": "United States" 78 | } 79 | ] 80 | }, 81 | { 82 | "ratingKey": "3166", 83 | "key": "/library/metadata/3166/children", 84 | "type": "artist", 85 | "title": "The Donkeys", 86 | "titleSort": "Donkeys, The", 87 | "summary": "Multiple artists exist by this name:", 88 | "index": 1, 89 | "viewCount": 6, 90 | "lastViewedAt": 1480572451, 91 | "thumb": "/library/metadata/3166/thumb/1482593146", 92 | "addedAt": 1437907650, 93 | "updatedAt": 1482593146, 94 | "Genre": [ 95 | { 96 | "tag": "Alt Country" 97 | }, 98 | { 99 | "tag": "Alt Country" 100 | }, 101 | { 102 | "tag": "Indie" 103 | } 104 | ], 105 | "Country": [ 106 | { 107 | "tag": "United States" 108 | } 109 | ] 110 | } 111 | ] 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 0, 4 | "totalSize": 0, 5 | "allowSync": false, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1481160546, 10 | "nocache": true, 11 | "offset": 0, 12 | "thumb": "/:/resources/artist.png", 13 | "title1": "Music", 14 | "title2": "By Collection", 15 | "viewGroup": "track", 16 | "viewMode": 65593 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/contentRating.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "totalSize": 1, 5 | "allowSync": false, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1481160546, 10 | "nocache": true, 11 | "offset": 0, 12 | "thumb": "/:/resources/artist.png", 13 | "title1": "Music", 14 | "title2": "By Content Rating", 15 | "viewGroup": "track", 16 | "viewMode": 65593, 17 | "Directory": [ 18 | { 19 | "fastKey": "/library/sections/1/all?contentRating=None", 20 | "key": "None", 21 | "title": "None" 22 | } 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/decade.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 7, 4 | "totalSize": 7, 5 | "allowSync": false, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1481160546, 10 | "nocache": true, 11 | "offset": 0, 12 | "thumb": "/:/resources/artist.png", 13 | "title1": "Music", 14 | "title2": "By Decade", 15 | "viewGroup": "track", 16 | "viewMode": 65593, 17 | "Directory": [ 18 | { 19 | "fastKey": "/library/sections/1/all?decade=2010&type=9", 20 | "key": "2010", 21 | "title": "2010s" 22 | }, 23 | { 24 | "fastKey": "/library/sections/1/all?decade=2000&type=9", 25 | "key": "2000", 26 | "title": "2000s" 27 | }, 28 | { 29 | "fastKey": "/library/sections/1/all?decade=1990&type=9", 30 | "key": "1990", 31 | "title": "1990s" 32 | }, 33 | { 34 | "fastKey": "/library/sections/1/all?decade=1980&type=9", 35 | "key": "1980", 36 | "title": "1980s" 37 | }, 38 | { 39 | "fastKey": "/library/sections/1/all?decade=1970&type=9", 40 | "key": "1970", 41 | "title": "1970s" 42 | }, 43 | { 44 | "fastKey": "/library/sections/1/all?decade=1960&type=9", 45 | "key": "1960", 46 | "title": "1960s" 47 | }, 48 | { 49 | "fastKey": "/library/sections/1/all?decade=1950&type=9", 50 | "key": "1950", 51 | "title": "1950s" 52 | } 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/firstCharacter.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 10, 4 | "totalSize": 30, 5 | "allowSync": false, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1481160546, 10 | "nocache": true, 11 | "offset": 0, 12 | "thumb": "/:/resources/artist.png", 13 | "title1": "Music", 14 | "title2": "By First Letter", 15 | "viewGroup": "track", 16 | "viewMode": 65593, 17 | "Directory": [ 18 | { 19 | "size": 7, 20 | "key": "%23", 21 | "title": "#" 22 | }, 23 | { 24 | "size": 65, 25 | "key": "A", 26 | "title": "A" 27 | }, 28 | { 29 | "size": 139, 30 | "key": "B", 31 | "title": "B" 32 | }, 33 | { 34 | "size": 93, 35 | "key": "C", 36 | "title": "C" 37 | }, 38 | { 39 | "size": 65, 40 | "key": "D", 41 | "title": "D" 42 | }, 43 | { 44 | "size": 38, 45 | "key": "E", 46 | "title": "E" 47 | }, 48 | { 49 | "size": 70, 50 | "key": "F", 51 | "title": "F" 52 | }, 53 | { 54 | "size": 54, 55 | "key": "G", 56 | "title": "G" 57 | }, 58 | { 59 | "size": 53, 60 | "key": "H", 61 | "title": "H" 62 | }, 63 | { 64 | "size": 19, 65 | "key": "I", 66 | "title": "I" 67 | } 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/folder.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 3, 4 | "totalSize": 3, 5 | "allowSync": true, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "librarySectionID": 1, 9 | "librarySectionTitle": "Music", 10 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 11 | "mediaTagPrefix": "/system/bundle/media/flags/", 12 | "mediaTagVersion": 1481160546, 13 | "nocache": true, 14 | "offset": 0, 15 | "thumb": "/:/resources/artist.png", 16 | "title1": "Music", 17 | "title2": "By Folder", 18 | "viewGroup": "track", 19 | "viewMode": 65593, 20 | "Directory": [ 21 | { 22 | "key": "/library/sections/1/folder?parent=49", 23 | "title": "albums" 24 | }, 25 | { 26 | "key": "/library/sections/1/folder?parent=2", 27 | "title": "compilations" 28 | }, 29 | { 30 | "key": "/library/sections/1/folder?parent=2539", 31 | "title": "iTunes" 32 | } 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/folder_parent=49.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 0, 4 | "totalSize": 0, 5 | "allowSync": true, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "librarySectionID": 1, 9 | "librarySectionTitle": "Music", 10 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 11 | "mediaTagPrefix": "/system/bundle/media/flags/", 12 | "mediaTagVersion": 1481160546, 13 | "nocache": true, 14 | "offset": 0, 15 | "thumb": "/:/resources/artist.png", 16 | "title1": "Music", 17 | "viewGroup": "artist", 18 | "viewMode": 65592 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/genre.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 10, 4 | "totalSize": 974, 5 | "allowSync": false, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1481160546, 10 | "nocache": true, 11 | "offset": 0, 12 | "thumb": "/:/resources/artist.png", 13 | "title1": "Music", 14 | "title2": "By Genre", 15 | "viewGroup": "track", 16 | "viewMode": 65593, 17 | "Directory": [ 18 | { 19 | "fastKey": "/library/sections/1/all?genre=1500", 20 | "key": "1500", 21 | "title": "60's Oldies", 22 | "type": "genre" 23 | }, 24 | { 25 | "fastKey": "/library/sections/1/all?genre=49", 26 | "key": "49", 27 | "title": "Acid Jazz", 28 | "type": "genre" 29 | }, 30 | { 31 | "fastKey": "/library/sections/1/all?genre=4385", 32 | "key": "4385", 33 | "title": "Acid Jazz, Chillout, Jazz", 34 | "type": "genre" 35 | }, 36 | { 37 | "fastKey": "/library/sections/1/all?genre=2613", 38 | "key": "2613", 39 | "title": "Acid Rock", 40 | "type": "genre" 41 | }, 42 | { 43 | "fastKey": "/library/sections/1/all?genre=4884", 44 | "key": "4884", 45 | "title": "Adult Alternative New Age", 46 | "type": "genre" 47 | }, 48 | { 49 | "fastKey": "/library/sections/1/all?genre=169", 50 | "key": "169", 51 | "title": "Adult Alternative Pop", 52 | "type": "genre" 53 | }, 54 | { 55 | "fastKey": "/library/sections/1/all?genre=432", 56 | "key": "432", 57 | "title": "Adult Alternative Rock", 58 | "type": "genre" 59 | }, 60 | { 61 | "fastKey": "/library/sections/1/all?genre=3241", 62 | "key": "3241", 63 | "title": "Adult Contemporary", 64 | "type": "genre" 65 | }, 66 | { 67 | "fastKey": "/library/sections/1/all?genre=1348", 68 | "key": "1348", 69 | "title": "Alt Country", 70 | "type": "genre" 71 | }, 72 | { 73 | "fastKey": "/library/sections/1/all?genre=637", 74 | "key": "637", 75 | "title": "Alt-Rock", 76 | "type": "genre" 77 | } 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/onDeck.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 0, 4 | "totalSize": 0, 5 | "allowSync": true, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "librarySectionID": 1, 9 | "librarySectionTitle": "Music", 10 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 11 | "mediaTagPrefix": "/system/bundle/media/flags/", 12 | "mediaTagVersion": 1481160546, 13 | "mixedParents": true, 14 | "nocache": true, 15 | "offset": 0, 16 | "thumb": "/:/resources/artist.png", 17 | "title1": "Music", 18 | "title2": "On Deck", 19 | "viewGroup": "track", 20 | "viewMode": 65593 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/search_type=10.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "totalSize": 1, 5 | "allowSync": false, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1481160546, 10 | "mixedParents": true, 11 | "nocache": true, 12 | "offset": 0, 13 | "thumb": "/:/resources/artist.png", 14 | "title1": "Music", 15 | "title2": "Search for 'superglue'", 16 | "viewGroup": "track", 17 | "viewMode": 65593, 18 | "Metadata": [ 19 | { 20 | "ratingKey": "37567", 21 | "key": "/library/metadata/37567", 22 | "parentRatingKey": "37562", 23 | "grandparentRatingKey": "3748", 24 | "type": "track", 25 | "title": "Superglue", 26 | "grandparentKey": "/library/metadata/3748", 27 | "parentKey": "/library/metadata/37562", 28 | "grandparentTitle": "Teleman", 29 | "parentTitle": "Brilliant Sanity", 30 | "originalTitle": "Teleman", 31 | "summary": "", 32 | "index": 5, 33 | "parentIndex": 1, 34 | "ratingCount": 8425, 35 | "userRating": 6, 36 | "viewCount": 196, 37 | "lastViewedAt": 1482789617, 38 | "thumb": "/library/metadata/37562/thumb/1476222334", 39 | "parentThumb": "/library/metadata/37562/thumb/1476222334", 40 | "grandparentThumb": "/library/metadata/3748/thumb/1482677826", 41 | "duration": 201404, 42 | "addedAt": 1476222283, 43 | "updatedAt": 1476222333, 44 | "Media": [ 45 | { 46 | "id": 51465, 47 | "duration": 201404, 48 | "bitrate": 320, 49 | "audioChannels": 2, 50 | "audioCodec": "mp3", 51 | "container": "mp3", 52 | "Part": [ 53 | { 54 | "id": 51465, 55 | "key": "/library/parts/51465/1476220814/file.mp3", 56 | "duration": 201404, 57 | "file": "/mnt/data/music/albums/Teleman/Brilliant Sanity/05 Superglue.mp3", 58 | "size": 9196695, 59 | "container": "mp3", 60 | "hasThumbnail": "1" 61 | } 62 | ] 63 | } 64 | ] 65 | } 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/search_type=8.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "totalSize": 1, 5 | "allowSync": false, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1481160546, 10 | "nocache": true, 11 | "offset": 0, 12 | "thumb": "/:/resources/artist.png", 13 | "title1": "Music", 14 | "title2": "Search for 'teleman'", 15 | "viewGroup": "artist", 16 | "viewMode": 65592, 17 | "Metadata": [ 18 | { 19 | "ratingKey": "3748", 20 | "key": "/library/metadata/3748/children", 21 | "type": "artist", 22 | "title": "Teleman", 23 | "summary": "Teleman are Thomas Sanders (vocals, guitar), Jonny Sanders (synths), Pete Cattermoul (bass) and Hiro Amamiya (drums). Read more on Last.fm. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply", 24 | "index": 1, 25 | "viewCount": 325, 26 | "lastViewedAt": 1482789438, 27 | "thumb": "/library/metadata/3748/thumb/1482677826", 28 | "addedAt": 1460961756, 29 | "updatedAt": 1482677826, 30 | "Genre": [ 31 | { 32 | "tag": "Indie Pop, Soft Rock, Pop" 33 | } 34 | ], 35 | "Country": [ 36 | { 37 | "tag": "United Kingdom" 38 | } 39 | ] 40 | } 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/search_type=9.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 3, 4 | "totalSize": 3, 5 | "allowSync": false, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1481160546, 10 | "mixedParents": true, 11 | "nocache": true, 12 | "offset": 0, 13 | "thumb": "/:/resources/artist.png", 14 | "title1": "Music", 15 | "title2": "Search for 'ocean'", 16 | "viewGroup": "album", 17 | "viewMode": 65592, 18 | "Metadata": [ 19 | { 20 | "ratingKey": "34331", 21 | "key": "/library/metadata/34331/children", 22 | "parentRatingKey": "34330", 23 | "type": "album", 24 | "title": "The Atlantic Ocean", 25 | "titleSort": "Atlantic Ocean", 26 | "parentKey": "/library/metadata/34330", 27 | "parentTitle": "Richard Swift", 28 | "summary": "", 29 | "index": 1, 30 | "lastViewedAt": 1479963344, 31 | "thumb": "/library/metadata/34330/thumb/1482592412", 32 | "parentThumb": "/library/metadata/34330/thumb/1482592412", 33 | "addedAt": 1472548676, 34 | "updatedAt": 1472573367 35 | }, 36 | { 37 | "ratingKey": "25085", 38 | "key": "/library/metadata/25085/children", 39 | "parentRatingKey": "25084", 40 | "studio": "Nettwerk", 41 | "type": "album", 42 | "title": "Indian Ocean", 43 | "parentKey": "/library/metadata/25084", 44 | "parentTitle": "Frazey Ford", 45 | "summary": "The second solo long-player from the Canadian folk-pop songstress and former Be Good Tanya, Indian Ocean finds Frazey Ford enlisting the help of the legendary Hi Rhythm Section, who were Al Green's not-so-secret weapons and the prime architects of the Memphis soul sound during the Stax era, and kicking out a warm, breezy, and not surprisingly soulful set of R&B-kissed country-pop confections that sound as timeless as they do of a particular era. Falling somewhere between Cat Power, Carole King, and Linda Ronstadt, Ford's sophomore outing dials back on some of the on the nose, soul-pop contrivances of 2010's Obadiah, which while solid and surprising enough at the time, at this point sounds more like an abandoned set of blueprints for what would eventually become Indian Ocean. The songs and performances are altogether more confident, due in large part to the near constant presence of some talented guests, most notably the aforementioned sibling soul alchemists Charles Hodges (organ), Leroy Hodges (bass), and Teenie Hodges (guitar), the latter of whom passed away during the recording of the album, and standout cuts like the world weary \"September Fields,\" the bluesy and evocative \"Runnin',\" the gospel-tinged \"Season After Season,\" and the epic and elegiac title track bring with them a patina of pure, tube-driven, smoky goodness that surrounds the listener in a cloud of nostalgia that yields no obvious compass points. Indian Ocean is sad, sweet, and warm as an August afternoon, and while its charms may feel old-fashioned and better suited to vinyl, the hardships it details are undeniably contemporary, and their conclusions oddly comforting. [Indian Ocean was also released on LP.] ~ James Christopher Monger", 46 | "index": 1, 47 | "viewCount": 65, 48 | "lastViewedAt": 1482788236, 49 | "year": 2014, 50 | "thumb": "/library/metadata/25085/thumb/1468741515", 51 | "parentThumb": "/library/metadata/25084/thumb/1482676718", 52 | "originallyAvailableAt": "2014-10-14", 53 | "addedAt": 1466422717, 54 | "updatedAt": 1468741515, 55 | "Genre": [ 56 | { 57 | "tag": "Country, Americana" 58 | } 59 | ] 60 | }, 61 | { 62 | "ratingKey": "99", 63 | "key": "/library/metadata/99/children", 64 | "parentRatingKey": "1", 65 | "type": "album", 66 | "title": "Tom Novy: Chillin' at Ocean Drive Hotel Ibiza, Volume 1", 67 | "parentKey": "/library/metadata/1", 68 | "parentTitle": "Various Artists", 69 | "summary": "", 70 | "index": 1, 71 | "viewCount": 1, 72 | "lastViewedAt": 1463181016, 73 | "year": 2010, 74 | "thumb": "/library/metadata/99/thumb/1481019670", 75 | "art": "/library/metadata/1/art/1482333860", 76 | "parentThumb": "/library/metadata/1/thumb/1482333860", 77 | "originallyAvailableAt": "2010-01-01", 78 | "addedAt": 1437907650, 79 | "updatedAt": 1481019670, 80 | "Genre": [ 81 | { 82 | "tag": "Acid Jazz" 83 | } 84 | ] 85 | } 86 | ] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/unwatched.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 0, 4 | "totalSize": 0, 5 | "allowSync": true, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "librarySectionID": 1, 9 | "librarySectionTitle": "Music", 10 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 11 | "mediaTagPrefix": "/system/bundle/media/flags/", 12 | "mediaTagVersion": 1481160546, 13 | "nocache": true, 14 | "offset": 0, 15 | "thumb": "/:/resources/artist.png", 16 | "title1": "Music", 17 | "title2": "Unwatched", 18 | "viewGroup": "track", 19 | "viewMode": 65593 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/library/sections/1/year.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 10, 4 | "totalSize": 60, 5 | "allowSync": false, 6 | "art": "/:/resources/artist-fanart.jpg", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1481160546, 10 | "nocache": true, 11 | "offset": 0, 12 | "thumb": "/:/resources/artist.png", 13 | "title1": "Music", 14 | "title2": "By Year", 15 | "viewGroup": "track", 16 | "viewMode": 65593, 17 | "Directory": [ 18 | { 19 | "fastKey": "/library/sections/1/all?year=2016&type=9", 20 | "key": "2016", 21 | "title": "2016" 22 | }, 23 | { 24 | "fastKey": "/library/sections/1/all?year=2015&type=9", 25 | "key": "2015", 26 | "title": "2015" 27 | }, 28 | { 29 | "fastKey": "/library/sections/1/all?year=2014&type=9", 30 | "key": "2014", 31 | "title": "2014" 32 | }, 33 | { 34 | "fastKey": "/library/sections/1/all?year=2013&type=9", 35 | "key": "2013", 36 | "title": "2013" 37 | }, 38 | { 39 | "fastKey": "/library/sections/1/all?year=2012&type=9", 40 | "key": "2012", 41 | "title": "2012" 42 | }, 43 | { 44 | "fastKey": "/library/sections/1/all?year=2011&type=9", 45 | "key": "2011", 46 | "title": "2011" 47 | }, 48 | { 49 | "fastKey": "/library/sections/1/all?year=2010&type=9", 50 | "key": "2010", 51 | "title": "2010" 52 | }, 53 | { 54 | "fastKey": "/library/sections/1/all?year=2009&type=9", 55 | "key": "2009", 56 | "title": "2009" 57 | }, 58 | { 59 | "fastKey": "/library/sections/1/all?year=2008&type=9", 60 | "key": "2008", 61 | "title": "2008" 62 | }, 63 | { 64 | "fastKey": "/library/sections/1/all?year=2007&type=9", 65 | "key": "2007", 66 | "title": "2007" 67 | } 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /testHelpers/fixtures/api/playlists/all.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "totalSize": 12, 5 | "offset": 0, 6 | "Metadata": [ 7 | { 8 | "allowSync": true, 9 | "ratingKey": "36071", 10 | "key": "/playlists/36071/items", 11 | "guid": "com.plexapp.agents.none://33a79fcb-0f7c-4b21-9ad9-136304c024e6", 12 | "type": "playlist", 13 | "title": "Best Of", 14 | "summary": "", 15 | "smart": 0, 16 | "playlistType": "audio", 17 | "composite": "/playlists/36071/composite/1483683470", 18 | "viewCount": 7, 19 | "lastViewedAt": 1483683465, 20 | "duration": 1365000, 21 | "leafCount": 6, 22 | "addedAt": 1474330764, 23 | "updatedAt": 1483683470 24 | } 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /testHelpers/fixtures/devices.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /testHelpers/fixtures/library/album.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": true, 5 | "identifier": "com.plexapp.plugins.library", 6 | "librarySectionID": 1, 7 | "librarySectionTitle": "Music", 8 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 9 | "mediaTagPrefix": "/system/bundle/media/flags/", 10 | "mediaTagVersion": 1576869286, 11 | "Metadata": [ 12 | { 13 | "ratingKey": "40812", 14 | "key": "/library/metadata/40812/children", 15 | "parentRatingKey": "40811", 16 | "guid": "com.plexapp.agents.plexmusic://gracenote/album/0556B27724C2CFD0/616746960-56B2773DE8F6B29DD4EA1DD8C5C3979B?lang=en", 17 | "parentGuid": "com.plexapp.agents.plexmusic://gracenote/artist/0556B27724C2CFD0?lang=en", 18 | "studio": "Kobalt", 19 | "type": "album", 20 | "title": "Echo of Love", 21 | "parentKey": "/library/metadata/40811", 22 | "librarySectionTitle": "Music", 23 | "librarySectionID": 1, 24 | "librarySectionKey": "/library/sections/1", 25 | "parentTitle": "The Shutes", 26 | "summary": "", 27 | "index": 1, 28 | "viewCount": 56, 29 | "lastViewedAt": 1572261532, 30 | "year": 2012, 31 | "thumb": "/library/metadata/40812/thumb/1483270420", 32 | "parentThumb": "/library/metadata/40811/thumb/1569812433", 33 | "originallyAvailableAt": "2012-01-01", 34 | "leafCount": 5, 35 | "viewedLeafCount": 5, 36 | "addedAt": 1481060149, 37 | "updatedAt": 1483270420, 38 | "deletedAt": 1578805638, 39 | "loudnessAnalysisVersion": "1", 40 | "Genre": [ 41 | { 42 | "id": 1980, 43 | "filter": "genre=1980", 44 | "tag": "General Pop" 45 | }, 46 | { 47 | "id": 1981, 48 | "filter": "genre=1981", 49 | "tag": "Other Pop" 50 | }, 51 | { 52 | "id": 76, 53 | "filter": "genre=76", 54 | "tag": "Pop" 55 | } 56 | ] 57 | } 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /testHelpers/fixtures/library/albumTracks.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 5, 4 | "allowSync": true, 5 | "art": "/:/resources/artist-fanart.jpg", 6 | "grandparentRatingKey": 40811, 7 | "grandparentThumb": "/library/metadata/40811/thumb/1484063045", 8 | "grandparentTitle": "The Shutes", 9 | "identifier": "com.plexapp.plugins.library", 10 | "key": "40812", 11 | "librarySectionID": 1, 12 | "librarySectionTitle": "Music", 13 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 14 | "mediaTagPrefix": "/system/bundle/media/flags/", 15 | "mediaTagVersion": 1481837967, 16 | "nocache": true, 17 | "parentIndex": "1", 18 | "parentTitle": "Echo of Love", 19 | "parentYear": "2012", 20 | "thumb": "/library/metadata/40812/thumb/1483223620", 21 | "title1": "The Shutes", 22 | "title2": "Echo of Love", 23 | "viewGroup": "track", 24 | "viewMode": 65593, 25 | "Metadata": [ 26 | { 27 | "ratingKey": "40813", 28 | "key": "/library/metadata/40813", 29 | "parentRatingKey": "40812", 30 | "grandparentRatingKey": "40811", 31 | "type": "track", 32 | "title": "Echo of Love", 33 | "grandparentKey": "/library/metadata/40811", 34 | "parentKey": "/library/metadata/40812", 35 | "grandparentTitle": "The Shutes", 36 | "parentTitle": "Echo of Love", 37 | "originalTitle": "The Shutes", 38 | "summary": "", 39 | "index": 1, 40 | "parentIndex": 1, 41 | "ratingCount": 32597, 42 | "userRating": 10, 43 | "viewCount": 5, 44 | "lastViewedAt": 1481962116, 45 | "thumb": "/library/metadata/40812/thumb/1483223620", 46 | "parentThumb": "/library/metadata/40812/thumb/1483223620", 47 | "grandparentThumb": "/library/metadata/40811/thumb/1484063045", 48 | "duration": 288313, 49 | "addedAt": 1481013349, 50 | "updatedAt": 1483223620, 51 | "Media": [ 52 | { 53 | "id": 54538, 54 | "duration": 288313, 55 | "bitrate": 320, 56 | "audioChannels": 2, 57 | "audioCodec": "mp3", 58 | "container": "mp3", 59 | "Part": [ 60 | { 61 | "id": 54538, 62 | "key": "/library/parts/54538/1481013322/file.mp3", 63 | "duration": 288313, 64 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/01 Echo of Love.mp3", 65 | "size": 11534741, 66 | "container": "mp3" 67 | } 68 | ] 69 | } 70 | ] 71 | }, 72 | { 73 | "ratingKey": "40814", 74 | "key": "/library/metadata/40814", 75 | "parentRatingKey": "40812", 76 | "grandparentRatingKey": "40811", 77 | "type": "track", 78 | "title": "Here, My Blood Runs Clear", 79 | "grandparentKey": "/library/metadata/40811", 80 | "parentKey": "/library/metadata/40812", 81 | "grandparentTitle": "The Shutes", 82 | "parentTitle": "Echo of Love", 83 | "originalTitle": "The Shutes", 84 | "summary": "", 85 | "index": 2, 86 | "parentIndex": 1, 87 | "ratingCount": 4227, 88 | "userRating": 8, 89 | "viewCount": 3, 90 | "lastViewedAt": 1481738643, 91 | "thumb": "/library/metadata/40812/thumb/1483223620", 92 | "parentThumb": "/library/metadata/40812/thumb/1483223620", 93 | "grandparentThumb": "/library/metadata/40811/thumb/1484063045", 94 | "duration": 192627, 95 | "addedAt": 1481013349, 96 | "updatedAt": 1483223620, 97 | "Media": [ 98 | { 99 | "id": 54539, 100 | "duration": 192627, 101 | "bitrate": 320, 102 | "audioChannels": 2, 103 | "audioCodec": "mp3", 104 | "container": "mp3", 105 | "Part": [ 106 | { 107 | "id": 54539, 108 | "key": "/library/parts/54539/1481013322/file.mp3", 109 | "duration": 192627, 110 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/02 Here, My Blood Runs Clear.mp3", 111 | "size": 7707293, 112 | "container": "mp3" 113 | } 114 | ] 115 | } 116 | ] 117 | }, 118 | { 119 | "ratingKey": "40815", 120 | "key": "/library/metadata/40815", 121 | "parentRatingKey": "40812", 122 | "grandparentRatingKey": "40811", 123 | "type": "track", 124 | "title": "Only", 125 | "grandparentKey": "/library/metadata/40811", 126 | "parentKey": "/library/metadata/40812", 127 | "grandparentTitle": "The Shutes", 128 | "parentTitle": "Echo of Love", 129 | "originalTitle": "The Shutes", 130 | "summary": "", 131 | "index": 3, 132 | "parentIndex": 1, 133 | "ratingCount": 4102, 134 | "userRating": 10, 135 | "viewCount": 3, 136 | "lastViewedAt": 1481738883, 137 | "thumb": "/library/metadata/40812/thumb/1483223620", 138 | "parentThumb": "/library/metadata/40812/thumb/1483223620", 139 | "grandparentThumb": "/library/metadata/40811/thumb/1484063045", 140 | "duration": 245864, 141 | "addedAt": 1481013349, 142 | "updatedAt": 1483223620, 143 | "Media": [ 144 | { 145 | "id": 54540, 146 | "duration": 245864, 147 | "bitrate": 320, 148 | "audioChannels": 2, 149 | "audioCodec": "mp3", 150 | "container": "mp3", 151 | "Part": [ 152 | { 153 | "id": 54540, 154 | "key": "/library/parts/54540/1481013322/file.mp3", 155 | "duration": 245864, 156 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/03 Only.mp3", 157 | "size": 9836774, 158 | "container": "mp3" 159 | } 160 | ] 161 | } 162 | ] 163 | }, 164 | { 165 | "ratingKey": "40816", 166 | "key": "/library/metadata/40816", 167 | "parentRatingKey": "40812", 168 | "grandparentRatingKey": "40811", 169 | "type": "track", 170 | "title": "She Said", 171 | "grandparentKey": "/library/metadata/40811", 172 | "parentKey": "/library/metadata/40812", 173 | "grandparentTitle": "The Shutes", 174 | "parentTitle": "Echo of Love", 175 | "originalTitle": "The Shutes", 176 | "summary": "", 177 | "index": 4, 178 | "parentIndex": 1, 179 | "ratingCount": 9190, 180 | "userRating": 10, 181 | "viewCount": 3, 182 | "lastViewedAt": 1481739085, 183 | "thumb": "/library/metadata/40812/thumb/1483223620", 184 | "parentThumb": "/library/metadata/40812/thumb/1483223620", 185 | "grandparentThumb": "/library/metadata/40811/thumb/1484063045", 186 | "duration": 197407, 187 | "addedAt": 1481013349, 188 | "updatedAt": 1483223620, 189 | "Media": [ 190 | { 191 | "id": 54541, 192 | "duration": 197407, 193 | "bitrate": 320, 194 | "audioChannels": 2, 195 | "audioCodec": "mp3", 196 | "container": "mp3", 197 | "Part": [ 198 | { 199 | "id": 54541, 200 | "key": "/library/parts/54541/1481013322/file.mp3", 201 | "duration": 197407, 202 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/04 She Said.mp3", 203 | "size": 7898492, 204 | "container": "mp3" 205 | } 206 | ] 207 | } 208 | ] 209 | }, 210 | { 211 | "ratingKey": "40817", 212 | "key": "/library/metadata/40817", 213 | "parentRatingKey": "40812", 214 | "grandparentRatingKey": "40811", 215 | "type": "track", 216 | "title": "Bright Blue Berlin Sky", 217 | "grandparentKey": "/library/metadata/40811", 218 | "parentKey": "/library/metadata/40812", 219 | "grandparentTitle": "The Shutes", 220 | "parentTitle": "Echo of Love", 221 | "originalTitle": "The Shutes", 222 | "summary": "", 223 | "index": 5, 224 | "parentIndex": 1, 225 | "ratingCount": 7726, 226 | "userRating": 10, 227 | "viewCount": 2, 228 | "lastViewedAt": 1481739228, 229 | "thumb": "/library/metadata/40812/thumb/1483223620", 230 | "parentThumb": "/library/metadata/40812/thumb/1483223620", 231 | "grandparentThumb": "/library/metadata/40811/thumb/1484063045", 232 | "duration": 344660, 233 | "addedAt": 1481013349, 234 | "updatedAt": 1483223620, 235 | "Media": [ 236 | { 237 | "id": 54542, 238 | "duration": 344660, 239 | "bitrate": 320, 240 | "audioChannels": 2, 241 | "audioCodec": "mp3", 242 | "container": "mp3", 243 | "Part": [ 244 | { 245 | "id": 54542, 246 | "key": "/library/parts/54542/1481013322/file.mp3", 247 | "duration": 344660, 248 | "file": "/mnt/data/music/albums/The Shutes/Echo of Love/05 Bright Blue Berlin Sky.mp3", 249 | "size": 13788596, 250 | "container": "mp3" 251 | } 252 | ] 253 | } 254 | ] 255 | } 256 | ] 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /testHelpers/fixtures/library/countries.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 47, 4 | "allowSync": false, 5 | "art": "/:/resources/artist-fanart.jpg", 6 | "content": "secondary", 7 | "identifier": "com.plexapp.plugins.library", 8 | "mediaTagPrefix": "/system/bundle/media/flags/", 9 | "mediaTagVersion": 1513172439, 10 | "nocache": true, 11 | "thumb": "/:/resources/artist.png", 12 | "title1": "Music", 13 | "title2": "By Country", 14 | "viewGroup": "secondary", 15 | "viewMode": 65592, 16 | "Directory": [ 17 | { 18 | "fastKey": "/library/sections/1/all?country=134875", 19 | "key": "134875", 20 | "title": "Argentina" 21 | }, 22 | { 23 | "fastKey": "/library/sections/1/all?country=581", 24 | "key": "581", 25 | "title": "Australia" 26 | }, 27 | { 28 | "fastKey": "/library/sections/1/all?country=4238", 29 | "key": "4238", 30 | "title": "Austria" 31 | }, 32 | { 33 | "fastKey": "/library/sections/1/all?country=8514", 34 | "key": "8514", 35 | "title": "Bahamas" 36 | }, 37 | { 38 | "fastKey": "/library/sections/1/all?country=86887", 39 | "key": "86887", 40 | "title": "Barbados" 41 | }, 42 | { 43 | "fastKey": "/library/sections/1/all?country=5777", 44 | "key": "5777", 45 | "title": "Belgium" 46 | }, 47 | { 48 | "fastKey": "/library/sections/1/all?country=7651", 49 | "key": "7651", 50 | "title": "Bolivia" 51 | }, 52 | { 53 | "fastKey": "/library/sections/1/all?country=8585", 54 | "key": "8585", 55 | "title": "Brazil" 56 | }, 57 | { 58 | "fastKey": "/library/sections/1/all?country=87132", 59 | "key": "87132", 60 | "title": "British Virgin Islands" 61 | }, 62 | { 63 | "fastKey": "/library/sections/1/all?country=798", 64 | "key": "798", 65 | "title": "Canada" 66 | }, 67 | { 68 | "fastKey": "/library/sections/1/all?country=82655", 69 | "key": "82655", 70 | "title": "Colombia" 71 | }, 72 | { 73 | "fastKey": "/library/sections/1/all?country=8934", 74 | "key": "8934", 75 | "title": "Croatia" 76 | }, 77 | { 78 | "fastKey": "/library/sections/1/all?country=4180", 79 | "key": "4180", 80 | "title": "Cuba" 81 | }, 82 | { 83 | "fastKey": "/library/sections/1/all?country=5135", 84 | "key": "5135", 85 | "title": "Denmark" 86 | }, 87 | { 88 | "fastKey": "/library/sections/1/all?country=7686", 89 | "key": "7686", 90 | "title": "Finland" 91 | }, 92 | { 93 | "fastKey": "/library/sections/1/all?country=659", 94 | "key": "659", 95 | "title": "France" 96 | }, 97 | { 98 | "fastKey": "/library/sections/1/all?country=968", 99 | "key": "968", 100 | "title": "Germany" 101 | }, 102 | { 103 | "fastKey": "/library/sections/1/all?country=8470", 104 | "key": "8470", 105 | "title": "Greece" 106 | }, 107 | { 108 | "fastKey": "/library/sections/1/all?country=89133", 109 | "key": "89133", 110 | "title": "Haiti" 111 | }, 112 | { 113 | "fastKey": "/library/sections/1/all?country=3478", 114 | "key": "3478", 115 | "title": "Iceland" 116 | }, 117 | { 118 | "fastKey": "/library/sections/1/all?country=8698", 119 | "key": "8698", 120 | "title": "India" 121 | }, 122 | { 123 | "fastKey": "/library/sections/1/all?country=7157", 124 | "key": "7157", 125 | "title": "Iran" 126 | }, 127 | { 128 | "fastKey": "/library/sections/1/all?country=1042", 129 | "key": "1042", 130 | "title": "Ireland" 131 | }, 132 | { 133 | "fastKey": "/library/sections/1/all?country=9635", 134 | "key": "9635", 135 | "title": "Israel" 136 | }, 137 | { 138 | "fastKey": "/library/sections/1/all?country=2256", 139 | "key": "2256", 140 | "title": "Italy" 141 | }, 142 | { 143 | "fastKey": "/library/sections/1/all?country=2028", 144 | "key": "2028", 145 | "title": "Jamaica" 146 | }, 147 | { 148 | "fastKey": "/library/sections/1/all?country=6308", 149 | "key": "6308", 150 | "title": "Japan" 151 | }, 152 | { 153 | "fastKey": "/library/sections/1/all?country=121000", 154 | "key": "121000", 155 | "title": "Mali" 156 | }, 157 | { 158 | "fastKey": "/library/sections/1/all?country=5028", 159 | "key": "5028", 160 | "title": "Mexico" 161 | }, 162 | { 163 | "fastKey": "/library/sections/1/all?country=1352", 164 | "key": "1352", 165 | "title": "Netherlands" 166 | }, 167 | { 168 | "fastKey": "/library/sections/1/all?country=2184", 169 | "key": "2184", 170 | "title": "New Zealand" 171 | }, 172 | { 173 | "fastKey": "/library/sections/1/all?country=96760", 174 | "key": "96760", 175 | "title": "Nigeria" 176 | }, 177 | { 178 | "fastKey": "/library/sections/1/all?country=30503", 179 | "key": "30503", 180 | "title": "North America" 181 | }, 182 | { 183 | "fastKey": "/library/sections/1/all?country=243", 184 | "key": "243", 185 | "title": "Norway" 186 | }, 187 | { 188 | "fastKey": "/library/sections/1/all?country=134870", 189 | "key": "134870", 190 | "title": "Puerto Rico" 191 | }, 192 | { 193 | "fastKey": "/library/sections/1/all?country=89072", 194 | "key": "89072", 195 | "title": "Romania" 196 | }, 197 | { 198 | "fastKey": "/library/sections/1/all?country=66311", 199 | "key": "66311", 200 | "title": "Senegal" 201 | }, 202 | { 203 | "fastKey": "/library/sections/1/all?country=73044", 204 | "key": "73044", 205 | "title": "South Africa" 206 | }, 207 | { 208 | "fastKey": "/library/sections/1/all?country=80890", 209 | "key": "80890", 210 | "title": "South Korea" 211 | }, 212 | { 213 | "fastKey": "/library/sections/1/all?country=7786", 214 | "key": "7786", 215 | "title": "Spain" 216 | }, 217 | { 218 | "fastKey": "/library/sections/1/all?country=1630", 219 | "key": "1630", 220 | "title": "Sweden" 221 | }, 222 | { 223 | "fastKey": "/library/sections/1/all?country=90498", 224 | "key": "90498", 225 | "title": "Switzerland" 226 | }, 227 | { 228 | "fastKey": "/library/sections/1/all?country=5058", 229 | "key": "5058", 230 | "title": "Ukraine" 231 | }, 232 | { 233 | "fastKey": "/library/sections/1/all?country=287", 234 | "key": "287", 235 | "title": "United Kingdom" 236 | }, 237 | { 238 | "fastKey": "/library/sections/1/all?country=147", 239 | "key": "147", 240 | "title": "United States" 241 | }, 242 | { 243 | "fastKey": "/library/sections/1/all?country=103234", 244 | "key": "103234", 245 | "title": "Venezuela" 246 | }, 247 | { 248 | "fastKey": "/library/sections/1/all?country=75548", 249 | "key": "75548", 250 | "title": "West Africa" 251 | } 252 | ] 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /testHelpers/fixtures/library/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": true, 5 | "identifier": "com.plexapp.plugins.library", 6 | "librarySectionID": 1, 7 | "librarySectionTitle": "Music", 8 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 9 | "mediaTagPrefix": "/system/bundle/media/flags/", 10 | "mediaTagVersion": 1481160546, 11 | "Metadata": [ 12 | { 13 | "ratingKey": "40812", 14 | "key": "/library/metadata/40812/children", 15 | "parentRatingKey": "40811", 16 | "guid": "com.plexapp.agents.plexmusic://gracenote/album/0556B27724C2CFD0/616746960-56B2773DE8F6B29DD4EA1DD8C5C3979B?lang=en", 17 | "librarySectionID": 1, 18 | "studio": "Kobalt", 19 | "type": "album", 20 | "title": "Echo of Love", 21 | "parentKey": "/library/metadata/40811", 22 | "parentTitle": "The Shutes", 23 | "summary": "", 24 | "index": 1, 25 | "viewCount": 16, 26 | "lastViewedAt": 1481962116, 27 | "year": 2012, 28 | "thumb": "/library/metadata/40812/thumb/1481013384", 29 | "parentThumb": "/library/metadata/40811/thumb/1482333809", 30 | "originallyAvailableAt": "2012-01-01", 31 | "leafCount": 5, 32 | "viewedLeafCount": 5, 33 | "addedAt": 1481013349, 34 | "updatedAt": 1481013384, 35 | "Genre": [ 36 | { 37 | "id": 1980, 38 | "tag": "General Pop" 39 | }, 40 | { 41 | "id": 1981, 42 | "tag": "Other Pop" 43 | }, 44 | { 45 | "id": 76, 46 | "tag": "Pop" 47 | } 48 | ] 49 | } 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /testHelpers/fixtures/library/playlist.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "Metadata": [ 5 | { 6 | "content": "library://5085a855-78e9-4dd7-9d70-a4ac296e28ee/directory/%2Flibrary%2Fsections%2F1%2Fall%3FuserRating%3D-1%26sort%3DaddedAt%253Adesc%26sourceType%3D10", 7 | "ratingKey": "45606", 8 | "key": "/playlists/45606/items", 9 | "guid": "com.plexapp.agents.none://1aaf0373-6e9f-4e92-912e-27e00fb65869", 10 | "type": "playlist", 11 | "title": "Unrated", 12 | "summary": "", 13 | "smart": 1, 14 | "playlistType": "audio", 15 | "composite": "/playlists/45606/composite/1484646242", 16 | "duration": 1768857000, 17 | "leafCount": 7252, 18 | "addedAt": 1484087118, 19 | "updatedAt": 1484646242 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /testHelpers/fixtures/library/section.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 11, 4 | "allowSync": false, 5 | "art": "/:/resources/artist-fanart.jpg", 6 | "content": "secondary", 7 | "identifier": "com.plexapp.plugins.library", 8 | "librarySectionID": 1, 9 | "mediaTagPrefix": "/system/bundle/media/flags/", 10 | "mediaTagVersion": 1481160546, 11 | "nocache": true, 12 | "thumb": "/:/resources/artist.png", 13 | "title1": "Music", 14 | "viewGroup": "secondary", 15 | "viewMode": 65592, 16 | "Directory": [ 17 | { 18 | "key": "all", 19 | "title": "All Artists" 20 | }, 21 | { 22 | "key": "albums", 23 | "title": "By Album" 24 | }, 25 | { 26 | "secondary": true, 27 | "key": "genre", 28 | "title": "By Genre" 29 | }, 30 | { 31 | "secondary": true, 32 | "key": "decade", 33 | "title": "By Decade" 34 | }, 35 | { 36 | "secondary": true, 37 | "key": "year", 38 | "title": "By Year" 39 | }, 40 | { 41 | "secondary": true, 42 | "key": "collection", 43 | "title": "By Collection" 44 | }, 45 | { 46 | "key": "recentlyAdded", 47 | "title": "Recently Added" 48 | }, 49 | { 50 | "key": "folder", 51 | "title": "By Folder" 52 | }, 53 | { 54 | "prompt": "Search for Artists", 55 | "search": true, 56 | "key": "search?type=8", 57 | "title": "Search Artists..." 58 | }, 59 | { 60 | "prompt": "Search for Albums", 61 | "search": true, 62 | "key": "search?type=9", 63 | "title": "Search Albums..." 64 | }, 65 | { 66 | "prompt": "Search for Tracks", 67 | "search": true, 68 | "key": "search?type=10", 69 | "title": "Search Tracks..." 70 | } 71 | ] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /testHelpers/fixtures/library/sections.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": false, 5 | "identifier": "com.plexapp.plugins.library", 6 | "mediaTagPrefix": "/system/bundle/media/flags/", 7 | "mediaTagVersion": 1481160546, 8 | "title1": "Plex Library", 9 | "Directory": [ 10 | { 11 | "allowSync": true, 12 | "art": "/:/resources/artist-fanart.jpg", 13 | "composite": "/library/sections/1/composite/1465382234", 14 | "filters": true, 15 | "refreshing": false, 16 | "thumb": "/:/resources/artist.png", 17 | "key": "1", 18 | "type": "artist", 19 | "title": "Music", 20 | "agent": "com.plexapp.agents.plexmusic", 21 | "scanner": "Plex Premium Music Scanner", 22 | "language": "en", 23 | "uuid": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 24 | "updatedAt": 1465382234, 25 | "createdAt": 1465382234, 26 | "Location": [ 27 | { 28 | "id": 1, 29 | "path": "/mnt/data/music" 30 | } 31 | ] 32 | } 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /testHelpers/fixtures/library/track.json: -------------------------------------------------------------------------------- 1 | { 2 | "MediaContainer": { 3 | "size": 1, 4 | "allowSync": true, 5 | "identifier": "com.plexapp.plugins.library", 6 | "librarySectionID": 1, 7 | "librarySectionTitle": "Music", 8 | "librarySectionUUID": "5085a855-78e9-4dd7-9d70-a4ac296e28ee", 9 | "mediaTagPrefix": "/system/bundle/media/flags/", 10 | "mediaTagVersion": 1481160546, 11 | "Metadata": [ 12 | { 13 | "ratingKey": "35341", 14 | "key": "/library/metadata/35341", 15 | "parentRatingKey": "35340", 16 | "grandparentRatingKey": "35339", 17 | "guid": "com.plexapp.agents.plexmusic://gracenote/track/382423778-038B27E8A8D214DA255CC25B1B41AB1C/382423779-E927213289AA4219AD6EB9C8481F06D1?lang=en", 18 | "librarySectionID": 1, 19 | "type": "track", 20 | "title": "Sorry", 21 | "grandparentKey": "/library/metadata/35339", 22 | "parentKey": "/library/metadata/35340", 23 | "grandparentTitle": "The Moth & The Flame", 24 | "parentTitle": "&", 25 | "originalTitle": "The Moth & The Flame", 26 | "summary": "", 27 | "index": 1, 28 | "parentIndex": 1, 29 | "ratingCount": 11, 30 | "userRating": 7, 31 | "viewCount": 2, 32 | "lastViewedAt": 1478405744, 33 | "thumb": "/library/metadata/35340/thumb/1473509934", 34 | "parentThumb": "/library/metadata/35340/thumb/1473509934", 35 | "grandparentThumb": "/library/metadata/35339/thumb/1482677884", 36 | "duration": 210495, 37 | "addedAt": 1473509786, 38 | "updatedAt": 1473509933, 39 | "Media": [ 40 | { 41 | "id": 49448, 42 | "duration": 210495, 43 | "bitrate": 320, 44 | "audioChannels": 2, 45 | "audioCodec": "mp3", 46 | "container": "mp3", 47 | "Part": [ 48 | { 49 | "id": 49448, 50 | "key": "/library/parts/49448/1473509724/file.mp3", 51 | "duration": 210495, 52 | "file": "/mnt/data/music/albums/The Moth & The Flame/&/01 Sorry.mp3", 53 | "size": 9999313, 54 | "container": "mp3", 55 | "hasThumbnail": "1", 56 | "Stream": { 57 | "id": 92913, 58 | "streamType": 2, 59 | "selected": true, 60 | "codec": "mp3", 61 | "index": 0, 62 | "channels": 2, 63 | "bitrate": 320, 64 | "bitrateMode": "cbr", 65 | "duration": 210677, 66 | "samplingRate": 44100 67 | } 68 | } 69 | ] 70 | } 71 | ], 72 | "Mood": [ 73 | { 74 | "id": 56, 75 | "tag": "Dreamy Brooding" 76 | } 77 | ] 78 | } 79 | ] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /testHelpers/fixtures/resources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /testHelpers/fixtures/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1212121, 3 | "uuid": "xxxxxxxxxxxxxxxx", 4 | "username": "xxxxxxxxxxxx", 5 | "title": "xxxxxxxxxxxx", 6 | "email": "xxxxxxxxxxxxxxxxxxx", 7 | "locale": "en", 8 | "emailOnlyAuth": false, 9 | "hasPassword": true, 10 | "protected": false, 11 | "thumb": "https://plex.tv/users/xxxxxxxxxxxxxxxx/avatar?c=1563901808", 12 | "authToken": "xxxxxxxxxxxxxxxxxxxx", 13 | "mailingListStatus": "active", 14 | "mailingListActive": true, 15 | "scrobbleTypes": "", 16 | "country": "NZ", 17 | "subscription": { 18 | "active": true, 19 | "subscribedAt": "2016-01-01T01:00:00Z", 20 | "status": "Active", 21 | "paymentService": "braintree", 22 | "plan": "lifetime", 23 | "features": [ 24 | "webhooks", 25 | "content_filter", 26 | "camera_upload", 27 | "home", 28 | "music_videos", 29 | "trailers", 30 | "pass", 31 | "dvr", 32 | "session_bandwidth_restrictions", 33 | "adaptive_bitrate", 34 | "collections", 35 | "photos-metadata-edition", 36 | "premium_music_metadata", 37 | "cloudsync", 38 | "lyrics", 39 | "sync", 40 | "hardware_transcoding", 41 | "session_kick", 42 | "radio", 43 | "tuner-sharing", 44 | "hwtranscode", 45 | "photos-favorites", 46 | "photosV6-tv-albums", 47 | "photosV6-edit", 48 | "federated-auth", 49 | "item_clusters", 50 | "livetv", 51 | "Android - PiP", 52 | "publishing_platform", 53 | "news", 54 | "photos-v5", 55 | "unsupportedtuners", 56 | "kevin-bacon", 57 | "client-radio-stations", 58 | "imagga-v2", 59 | "silence-removal", 60 | "boost-voices", 61 | "volume-leveling", 62 | "sweet-fades", 63 | "sleep-timer", 64 | "TREBLE-show-features", 65 | "web_server_dashboard", 66 | "visualizers", 67 | "premium-dashboard", 68 | "conan_redirect_qa", 69 | "conan_redirect_alpha", 70 | "conan_redirect_beta", 71 | "conan_redirect_public", 72 | "news_local_now", 73 | "nominatim", 74 | "transcoder_cache", 75 | "live-tv-support-incomplete-segments", 76 | "dvr-block-unsupported-countries", 77 | "companions_sonos", 78 | "allow_dvr", 79 | "signin_notification", 80 | "drm_support", 81 | "epg-recent-channels", 82 | "spring_serve_ad_provider", 83 | "conan_redirect_nightlies", 84 | "conan_redirect_nightly" 85 | ] 86 | }, 87 | "subscriptionDescription": "Lifetime Plex Pass", 88 | "restricted": false, 89 | "home": false, 90 | "guest": false, 91 | "homeSize": 1, 92 | "homeAdmin": false, 93 | "maxHomeSize": 15, 94 | "certificateVersion": 2, 95 | "rememberExpiresAt": 1580113467, 96 | "profile": { 97 | "autoSelectAudio": false, 98 | "defaultAudioLanguage": "en", 99 | "defaultSubtitleLanguage": "en", 100 | "autoSelectSubtitle": 0, 101 | "defaultSubtitleAccessibility": 0, 102 | "defaultSubtitleForced": 0 103 | }, 104 | "entitlements": [ 105 | "ios", 106 | "all", 107 | "roku", 108 | "android", 109 | "xbox_one", 110 | "xbox_360", 111 | "windows", 112 | "windows_phone" 113 | ], 114 | "roles": [ 115 | "plexpass" 116 | ], 117 | "subscriptions": [ 118 | { 119 | "id": null, 120 | "mode": "lifetime", 121 | "renewsAt": null, 122 | "endsAt": null, 123 | "type": "plexpass", 124 | "transfer": false, 125 | "state": "active" 126 | } 127 | ], 128 | "trials": [], 129 | "services": [ 130 | { 131 | "identifier": "epg", 132 | "endpoint": "https://epg.provider.plex.tv", 133 | "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 134 | "status": "online" 135 | }, 136 | { 137 | "identifier": "epg-dev", 138 | "endpoint": "https://epg-dev.provider.plex.tv", 139 | "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 140 | "status": "online" 141 | }, 142 | { 143 | "identifier": "eyeq", 144 | "endpoint": "https://c4412416.ipg.web.cddbp.net/webapi/xml/1.0/", 145 | "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 146 | "status": "online" 147 | }, 148 | { 149 | "identifier": "eyeq-channel-icons", 150 | "endpoint": "http://akamai-b.cdn.cddbp.net/cds/2.0/image", 151 | "status": "online" 152 | }, 153 | { 154 | "identifier": "imagga-v2", 155 | "endpoint": "https://api.imagga.com/v2", 156 | "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 157 | "secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 158 | "status": "online" 159 | }, 160 | { 161 | "identifier": "nominatim", 162 | "endpoint": "https://locationiq.org/v1", 163 | "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 164 | "status": "online" 165 | }, 166 | { 167 | "identifier": "metadata", 168 | "endpoint": "https://metadata.provider.plex.tv", 169 | "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 170 | "status": "online" 171 | }, 172 | { 173 | "identifier": "metadata-dev", 174 | "endpoint": "https://metadata-dev.provider.plex.tv", 175 | "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 176 | "status": "online" 177 | }, 178 | { 179 | "identifier": "metadata-provider", 180 | "endpoint": "https://mpm.plex.tv/", 181 | "status": "online" 182 | }, 183 | { 184 | "identifier": "tmsapi", 185 | "endpoint": "https://tmsapi.plex.tv/v1.1/", 186 | "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 187 | "status": "online" 188 | }, 189 | { 190 | "identifier": "subtitles-search", 191 | "endpoint": "https://metadata.provider.plex.tv/library/metadata/matches", 192 | "token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 193 | "status": "online" 194 | }, 195 | { 196 | "identifier": "acoustid", 197 | "endpoint": "https://acoustid.plex.tv/", 198 | "token": "xxxxxxxxxxxxxxxxxxxxxxxx", 199 | "status": "online" 200 | } 201 | ], 202 | "adsConsent": null, 203 | "adsConsentSetAt": null, 204 | "adsConsentReminderAt": null, 205 | "queueEmail": "queue+xxxxxxxxxxxxxxxxxxxx@save.plex.tv", 206 | "queueUid": {} 207 | } 208 | --------------------------------------------------------------------------------