@ctrl/plex 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/alert.js CHANGED
@@ -8,7 +8,6 @@ export class AlertListener {
8
8
  async run() {
9
9
  const url = this.server.url(this.key, true).toString().replace('http', 'ws');
10
10
  this._ws = new WebSocket(url);
11
- // eslint-disable-next-line @typescript-eslint/ban-types
12
11
  this._ws.on('message', (buffer) => {
13
12
  try {
14
13
  const data = JSON.parse(buffer.toString());
@@ -1,4 +1,3 @@
1
- /// <reference types="node" resolution-mode="require"/>
2
1
  import { URL } from 'url';
3
2
  import { Player } from './client.types.js';
4
3
  export interface PlexOptions {
@@ -213,7 +213,6 @@ export class Library {
213
213
  const results = [];
214
214
  const sections = await this.sections();
215
215
  for (const section of sections) {
216
- // eslint-disable-next-line no-await-in-loop
217
216
  const items = await section.all();
218
217
  for (const item of items) {
219
218
  results.push(item);
@@ -227,7 +226,6 @@ export class Library {
227
226
  async emptyTrash() {
228
227
  const sections = await this.sections();
229
228
  for (const section of sections) {
230
- // eslint-disable-next-line no-await-in-loop
231
229
  await section.emptyTrash();
232
230
  }
233
231
  }
@@ -200,4 +200,46 @@ export declare class Rating extends PlexObject {
200
200
  value: number;
201
201
  protected _loadData(data: any): void;
202
202
  }
203
+ /**
204
+ * Base class for all Art, Poster, and Theme objects.
205
+ */
206
+ declare abstract class BaseResource extends PlexObject {
207
+ /**
208
+ * The source of the resource. 'local' for local files (e.g. theme.mp3),
209
+ */
210
+ provider: string;
211
+ /**
212
+ * Unique key identifying the resource.
213
+ */
214
+ ratingKey: string;
215
+ /**
216
+ * True if the resource is currently selected.
217
+ */
218
+ selected: boolean;
219
+ /**
220
+ * The URL to retrieve the resource thumbnail.
221
+ */
222
+ thumb: string;
223
+ select(): Promise<any>;
224
+ resourceFilepath(): string;
225
+ protected _loadData(data: any): void;
226
+ }
227
+ /**
228
+ * Represents a single Art object.
229
+ */
230
+ export declare class Art extends BaseResource {
231
+ static TAG: string;
232
+ }
233
+ /**
234
+ * Represents a single Poster object.
235
+ */
236
+ export declare class Poster extends BaseResource {
237
+ static TAG: string;
238
+ }
239
+ /**
240
+ * Represents a single Theme object.
241
+ */
242
+ export declare class Theme extends BaseResource {
243
+ static TAG: string;
244
+ }
203
245
  export {};
package/dist/src/media.js CHANGED
@@ -186,6 +186,7 @@ class GuidTag extends PlexObject {
186
186
  this.id = data.id;
187
187
  }
188
188
  }
189
+ // biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
189
190
  export class Guid extends GuidTag {
190
191
  static { this.TAG = 'Guid'; }
191
192
  }
@@ -200,3 +201,55 @@ export class Rating extends PlexObject {
200
201
  this.value = data.value;
201
202
  }
202
203
  }
204
+ /**
205
+ * Base class for all Art, Poster, and Theme objects.
206
+ */
207
+ class BaseResource extends PlexObject {
208
+ async select() {
209
+ const key = this.key.slice(0, -1);
210
+ const params = new URLSearchParams();
211
+ params.append('url', this.ratingKey);
212
+ return this.server.query(`${key}?${params.toString()}`, 'put');
213
+ }
214
+ resourceFilepath() {
215
+ if (this.ratingKey.startsWith('media://')) {
216
+ return `Media/localhost/${this.ratingKey.split('://')[1]}`;
217
+ }
218
+ const parent = this.parent?.deref();
219
+ if (this.ratingKey.startsWith('metadata://') && parent) {
220
+ return `${parent.metadataDirectory}/Contents/_combined/${this.ratingKey.split('://')[1]}`;
221
+ }
222
+ if (this.ratingKey.startsWith('upload://') && parent) {
223
+ return `${parent.metadataDirectory}/Uploads/${this.ratingKey.split('://')[1]}`;
224
+ }
225
+ return this.ratingKey;
226
+ }
227
+ _loadData(data) {
228
+ this.key = data.key;
229
+ this.provider = data.provider;
230
+ this.ratingKey = data.ratingKey;
231
+ this.selected = data.selected;
232
+ this.thumb = data.thumb;
233
+ }
234
+ }
235
+ /**
236
+ * Represents a single Art object.
237
+ */
238
+ // biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
239
+ export class Art extends BaseResource {
240
+ static { this.TAG = 'Art'; }
241
+ }
242
+ /**
243
+ * Represents a single Poster object.
244
+ */
245
+ // biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
246
+ export class Poster extends BaseResource {
247
+ static { this.TAG = 'Photo'; }
248
+ }
249
+ /**
250
+ * Represents a single Theme object.
251
+ */
252
+ // biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
253
+ export class Theme extends BaseResource {
254
+ static { this.TAG = 'Theme'; }
255
+ }
@@ -1,5 +1,5 @@
1
1
  import { PlexObject } from './base/plexObject.js';
2
- import { Connection, Device, ResourcesResponse } from './myplex.types.js';
2
+ import type { Connection, Device, ResourcesResponse, WebLogin } from './myplex.types.js';
3
3
  import { PlexServer } from './server.js';
4
4
  /**
5
5
  * MyPlex account and profile information. This object represents the data found Account on
@@ -16,6 +16,19 @@ export declare class MyPlexAccount {
16
16
  timeout: number;
17
17
  server?: PlexServer;
18
18
  static key: string;
19
+ /**
20
+ * This follows the outline described in https://forums.plex.tv/t/authenticating-with-plex/609370
21
+ * to fetch a token and potentially compromise username and password. To use first call `getWebLogin()`
22
+ * and present the returned uri to a user to go to, then await `webLoginCheck()`. If you pass in a
23
+ * `forwardUrl`, then send the user to the returned uri, and when a request comes in on the passed in
24
+ * url, then await `webLoginCheck()`.
25
+ */
26
+ static getWebLogin(forwardUrl?: string | null): Promise<WebLogin>;
27
+ /**
28
+ * Pass in the `webLogin` object obtained from `getWebLogin()` and this will poll Plex to see if
29
+ * the user agreed. It returns a connected `MyPlexAccount` or throws an error.
30
+ */
31
+ static webLoginCheck(webLogin: WebLogin, timeoutSeconds?: number): Promise<MyPlexAccount>;
19
32
  FRIENDINVITE: string;
20
33
  HOMEUSERCREATE: string;
21
34
  EXISTINGUSER: string;
@@ -136,7 +149,7 @@ export declare class MyPlexAccount {
136
149
  */
137
150
  export declare class MyPlexResource {
138
151
  readonly account: MyPlexAccount;
139
- private baseUrl;
152
+ private readonly baseUrl;
140
153
  static key: string;
141
154
  TAG: string;
142
155
  /** Descriptive name of this resource */
@@ -1,4 +1,4 @@
1
- import { URLSearchParams } from 'url';
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
2
  import { ofetch } from 'ofetch';
3
3
  import pAny from 'p-any';
4
4
  import { parseStringPromise } from 'xml2js';
@@ -14,6 +14,71 @@ import { PlexServer } from './server.js';
14
14
  */
15
15
  export class MyPlexAccount {
16
16
  static { this.key = 'https://plex.tv/api/v2/user'; }
17
+ /**
18
+ * This follows the outline described in https://forums.plex.tv/t/authenticating-with-plex/609370
19
+ * to fetch a token and potentially compromise username and password. To use first call `getWebLogin()`
20
+ * and present the returned uri to a user to go to, then await `webLoginCheck()`. If you pass in a
21
+ * `forwardUrl`, then send the user to the returned uri, and when a request comes in on the passed in
22
+ * url, then await `webLoginCheck()`.
23
+ */
24
+ static async getWebLogin(forwardUrl = null) {
25
+ const appName = BASE_HEADERS['X-Plex-Product'];
26
+ const clientIdentifier = BASE_HEADERS['X-Plex-Client-Identifier'];
27
+ const pin = await ofetch('https://plex.tv/api/v2/pins', {
28
+ method: 'post',
29
+ headers: {
30
+ Accept: 'application/json',
31
+ },
32
+ query: {
33
+ strong: 'true',
34
+ 'X-Plex-Product': appName,
35
+ 'X-Plex-Client-Identifier': clientIdentifier,
36
+ },
37
+ });
38
+ return {
39
+ ...pin,
40
+ uri: `https://app.plex.tv/auth#?clientID=${encodeURIComponent(clientIdentifier)}&code=${encodeURIComponent(pin.code)}&context%5Bdevice%5D%5Bproduct%5D=${encodeURIComponent(appName)}${forwardUrl ? '&forwardUrl=' + encodeURIComponent(forwardUrl) : ''}`,
41
+ };
42
+ }
43
+ /**
44
+ * Pass in the `webLogin` object obtained from `getWebLogin()` and this will poll Plex to see if
45
+ * the user agreed. It returns a connected `MyPlexAccount` or throws an error.
46
+ */
47
+ static async webLoginCheck(webLogin, timeoutSeconds = 60) {
48
+ const recheckMs = 3000;
49
+ const clientIdentifier = BASE_HEADERS['X-Plex-Client-Identifier'];
50
+ const uri = `https://plex.tv/api/v2/pins/${webLogin.id}`;
51
+ const startTime = Date.now();
52
+ while (Date.now() < startTime + timeoutSeconds * 1000) {
53
+ try {
54
+ const tokenResponse = await ofetch(uri, {
55
+ method: 'GET',
56
+ headers: {
57
+ Accept: 'application/json',
58
+ },
59
+ query: {
60
+ code: webLogin.code,
61
+ 'X-Plex-Client-Identifier': clientIdentifier,
62
+ },
63
+ timeout: recheckMs,
64
+ retry: 5,
65
+ retryDelay: recheckMs,
66
+ });
67
+ if (tokenResponse.authToken) {
68
+ const myPlexAccount = new MyPlexAccount(null, '', '', tokenResponse.authToken);
69
+ return await myPlexAccount.connect();
70
+ }
71
+ await sleep(recheckMs);
72
+ }
73
+ catch (err) {
74
+ if (err.message.includes('aborted')) {
75
+ continue;
76
+ }
77
+ throw err;
78
+ }
79
+ }
80
+ throw new Error('Failed to authenticate before timeout');
81
+ }
17
82
  /**
18
83
  *
19
84
  * @param username Your MyPlex username
@@ -130,3 +130,8 @@ export interface Device {
130
130
  };
131
131
  }>;
132
132
  }
133
+ export interface WebLogin {
134
+ id: number;
135
+ code: string;
136
+ uri: string;
137
+ }
@@ -99,10 +99,8 @@ export class Playlist extends Playable {
99
99
  throw new BadRequest('Cannot remove items to a smart playlist.');
100
100
  }
101
101
  for (const item of items) {
102
- // eslint-disable-next-line no-await-in-loop
103
102
  const playlistItemId = await this._getPlaylistItemID(item);
104
103
  const key = `${this.key}/items/${playlistItemId}`;
105
- // eslint-disable-next-line no-await-in-loop
106
104
  await this.server.query(key, 'delete');
107
105
  }
108
106
  }
@@ -1,4 +1,3 @@
1
- /// <reference types="node" resolution-mode="require"/>
2
1
  import { URL, URLSearchParams } from 'url';
3
2
  import { PlexClient } from './client.js';
4
3
  import { Hub, Library } from './library.js';
@@ -172,7 +171,10 @@ export declare class PlexServer {
172
171
  * @param method
173
172
  * @param headers
174
173
  */
175
- query<T = any>(path: string, method?: 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete', headers?: any, username?: string, password?: string): Promise<T>;
174
+ query<T = any>(path: string, method?: 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete', options?: {
175
+ headers?: Record<string, string>;
176
+ body?: Uint8Array;
177
+ }, username?: string, password?: string): Promise<T>;
176
178
  /**
177
179
  * Returns a list of media items from watched history. If there are many results, they will
178
180
  * be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only
@@ -99,7 +99,7 @@ export class PlexServer {
99
99
  * @param method
100
100
  * @param headers
101
101
  */
102
- async query(path, method = 'get', headers, username, password) {
102
+ async query(path, method = 'get', options = {}, username, password) {
103
103
  const requestHeaders = this._headers();
104
104
  if (username && password) {
105
105
  const credentials = Buffer.from(`${username}:${password}`).toString('base64');
@@ -113,6 +113,7 @@ export class PlexServer {
113
113
  method,
114
114
  headers: requestHeaders,
115
115
  timeout: this.timeout ?? TIMEOUT,
116
+ body: options.body,
116
117
  retry: 0,
117
118
  responseType: 'json',
118
119
  });
@@ -155,7 +156,6 @@ export class PlexServer {
155
156
  maxresults > results.length) {
156
157
  args['X-Plex-Container-Start'] = (Number(args['X-Plex-Container-Start']) + Number(args['X-Plex-Container-Size'])).toString();
157
158
  key = '/status/sessions/history/all?' + new URLSearchParams(args).toString();
158
- // eslint-disable-next-line no-await-in-loop
159
159
  raw = await this.query(key);
160
160
  results = results.concat(raw.MediaContainer.Metadata);
161
161
  }
@@ -184,7 +184,6 @@ export class PlexServer {
184
184
  * you're likley to recieve an authentication error calling this.
185
185
  */
186
186
  myPlexAccount() {
187
- // eslint-disable-next-line logical-assignment-operators
188
187
  if (!this._myPlexAccount) {
189
188
  this._myPlexAccount = new MyPlexAccount(this.baseurl, undefined, undefined, this.token, this.timeout, this);
190
189
  }
@@ -204,7 +203,6 @@ export class PlexServer {
204
203
  }
205
204
  for (const server of response.MediaContainer.Server) {
206
205
  let { port } = server;
207
- // eslint-disable-next-line logical-assignment-operators
208
206
  if (!port) {
209
207
  // TODO: print warning about doing weird port stuff
210
208
  port = Number(ports?.[server.machineIdentifier]);
@@ -55,7 +55,6 @@ export class Settings extends PlexObject {
55
55
  }
56
56
  _loadData(data) {
57
57
  this._data = data;
58
- // eslint-disable-next-line logical-assignment-operators
59
58
  this._settings = this._settings ?? {};
60
59
  for (const elem of data) {
61
60
  const id = lowerFirst(elem.id);
@@ -1,8 +1,7 @@
1
- /// <reference types="node" resolution-mode="require"/>
2
1
  import { URL } from 'url';
3
2
  import { Playable } from './base/playable.js';
4
3
  import { ExtrasData, FullShowData, MovieData, ShowData } from './library.types.js';
5
- import { Chapter, Collection, Country, Director, Genre, Guid, Marker, Media, Producer, Rating, Role, Similar, Writer } from './media.js';
4
+ import { Chapter, Collection, Country, Director, Genre, Guid, Marker, Media, Poster, Producer, Rating, Role, Similar, Writer } from './media.js';
6
5
  import { ChapterSource, EpisodeMetadata, FullMovieResponse } from './video.types.js';
7
6
  export type VideoType = Movie | Show;
8
7
  declare abstract class Video extends Playable {
@@ -52,6 +51,19 @@ declare abstract class Video extends Playable {
52
51
  markUnwatched(): Promise<void>;
53
52
  rate(rate: number): Promise<void>;
54
53
  extras(): Promise<Extra[]>;
54
+ /**
55
+ * Returns list of available Poster objects.
56
+ */
57
+ posters(): Promise<Poster[]>;
58
+ /**
59
+ * Set the poster for a Plex object.
60
+ * @param poster The poster object to select.
61
+ */
62
+ setPoster(poster: Poster): Promise<this>;
63
+ /**
64
+ * I haven't tested this yet. It may not work.
65
+ */
66
+ uploadPoster(url?: string, file?: Uint8Array): Promise<void>;
55
67
  protected _loadData(data: MovieData | ShowData | EpisodeMetadata): void;
56
68
  }
57
69
  /**
package/dist/src/video.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Playable } from './base/playable.js';
2
2
  import { fetchItem, fetchItems, findItems } from './baseFunctionality.js';
3
- import { Chapter, Collection, Country, Director, Genre, Guid, Marker, Media, Producer, Rating, Role, Similar, Writer, } from './media.js';
3
+ import { Chapter, Collection, Country, Director, Genre, Guid, Marker, Media, Poster, Producer, Rating, Role, Similar, Writer, } from './media.js';
4
4
  class Video extends Playable {
5
5
  constructor() {
6
6
  super(...arguments);
@@ -53,6 +53,35 @@ class Video extends Playable {
53
53
  const data = await this.server.query(this._detailsKey);
54
54
  return findItems(data.MediaContainer.Metadata[0].Extras?.Metadata, undefined, Extra, this.server, this);
55
55
  }
56
+ /**
57
+ * Returns list of available Poster objects.
58
+ */
59
+ async posters() {
60
+ return fetchItems(this.server, `/library/metadata/${this.ratingKey}/posters`, undefined, Poster);
61
+ }
62
+ /**
63
+ * Set the poster for a Plex object.
64
+ * @param poster The poster object to select.
65
+ */
66
+ async setPoster(poster) {
67
+ await poster.select();
68
+ return this;
69
+ }
70
+ /**
71
+ * I haven't tested this yet. It may not work.
72
+ */
73
+ async uploadPoster(url, file) {
74
+ if (url) {
75
+ const key = `/library/metadata/${this.ratingKey}/posters?url=${encodeURIComponent(url)}`;
76
+ await this.server.query(key, 'post');
77
+ }
78
+ else if (file) {
79
+ const key = `/library/metadata/${this.ratingKey}/posters`;
80
+ await this.server.query(key, 'post', {
81
+ body: file,
82
+ });
83
+ }
84
+ }
56
85
  _loadData(data) {
57
86
  this.key = data.key;
58
87
  this.ratingKey = data.ratingKey;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl/plex",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "plex api client in typescript",
5
5
  "author": "Scott Cooper <scttcper@gmail.com>",
6
6
  "publishConfig": {
@@ -20,12 +20,12 @@
20
20
  "typescript"
21
21
  ],
22
22
  "scripts": {
23
- "lint": "npm run lint:biome && npm run lint:eslint",
23
+ "lint": "pnpm run '/^(lint:biome|lint:eslint)$/'",
24
24
  "lint:biome": "biome check .",
25
- "lint:eslint": "eslint --ext .ts,.tsx .",
26
- "lint:fix": "npm run lint:biome:fix && npm run lint:eslint:fix",
27
- "lint:eslint:fix": "eslint --ext .ts,.tsx . --fix",
28
- "lint:biome:fix": "biome check . --apply",
25
+ "lint:eslint": "eslint .",
26
+ "lint:fix": "pnpm run '/^(lint:biome|lint:eslint):fix$/'",
27
+ "lint:eslint:fix": "eslint . --fix",
28
+ "lint:biome:fix": "biome check . --write",
29
29
  "prepare": "npm run build",
30
30
  "build": "tsc",
31
31
  "build:docs": "typedoc",
@@ -40,34 +40,29 @@
40
40
  "@ctrl/mac-address": "^3.0.3",
41
41
  "ofetch": "^1.3.4",
42
42
  "p-any": "^4.0.0",
43
- "type-fest": "^4.18.1",
44
- "ws": "^8.17.0",
43
+ "type-fest": "^4.24.0",
44
+ "ws": "^8.18.0",
45
45
  "xml2js": "^0.6.2"
46
46
  },
47
47
  "devDependencies": {
48
- "@biomejs/biome": "1.7.2",
49
- "@ctrl/eslint-config-biome": "2.6.7",
50
- "@sindresorhus/tsconfig": "5.0.0",
51
- "@types/lodash": "4.17.1",
52
- "@types/micromatch": "4.0.7",
53
- "@types/node": "20.12.8",
54
- "@types/ws": "8.5.10",
48
+ "@biomejs/biome": "1.8.3",
49
+ "@ctrl/eslint-config-biome": "4.1.3",
50
+ "@sindresorhus/tsconfig": "6.0.0",
51
+ "@types/node": "22.3.0",
52
+ "@types/ws": "8.5.12",
55
53
  "@types/xml2js": "0.4.14",
56
- "@types/yargs": "17.0.32",
57
- "@vitest/coverage-v8": "1.6.0",
58
- "delay": "6.0.0",
59
- "eslint": "8.57.0",
60
- "execa": "8.0.1",
61
- "glob": "10.3.12",
62
- "globby": "14.0.1",
63
- "lodash": "4.17.21",
54
+ "@types/yargs": "17.0.33",
55
+ "@vitest/coverage-v8": "2.0.5",
56
+ "eslint": "9.9.0",
57
+ "execa": "9.3.1",
58
+ "globby": "14.0.2",
64
59
  "make-dir": "5.0.0",
65
60
  "ora": "8.0.1",
66
61
  "p-retry": "6.2.0",
67
62
  "ts-node": "10.9.2",
68
- "typedoc": "0.25.13",
69
- "typescript": "5.4.5",
70
- "vitest": "1.6.0",
63
+ "typedoc": "0.26.5",
64
+ "typescript": "5.5.4",
65
+ "vitest": "2.0.5",
71
66
  "yargs": "17.7.2"
72
67
  },
73
68
  "release": {