@ctrl/plex 3.9.2 → 3.10.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/README.md CHANGED
@@ -84,31 +84,37 @@ export PLEX_USERNAME=email
84
84
  export PLEX_PASSWORD=password
85
85
  ```
86
86
 
87
- Claim server and setup test content (once)
87
+ Setup test content (once)
88
88
 
89
89
  ```sh
90
- npm run claim-server && npm run add-media
90
+ pnpm run add-media
91
91
  ```
92
92
 
93
93
  Run tests
94
94
 
95
95
  ```sh
96
- npm test
96
+ pnpm test
97
97
  ```
98
98
 
99
99
  Post testing, remove plex server from account. Warning this is destructive. Do not use this on a real plex account.
100
100
 
101
101
  ```sh
102
- npm run test-cleanup
102
+ pnpm run test-cleanup
103
103
  ```
104
104
 
105
- ### Running tests locally (mostly for myself)
105
+ ### Running tests locally
106
+
107
+ #### Step 1
106
108
 
107
109
  get a claim token from https://www.plex.tv/claim/
108
110
  export PLEX_CLAIM_TOKEN=claim-token
109
111
 
110
112
  Start plex container for testing. Replace `/Users/scooper/gh/plex` with the path to this repo's directory.
111
113
 
114
+ #### Step 2
115
+
116
+ Replace `/Users/scooper/gh/plex` with the path to this repo's directory.
117
+
112
118
  ```console
113
119
  docker run -d \
114
120
  --name=plex \
@@ -1,4 +1,4 @@
1
- import { PartialPlexObject } from './base/partialPlexObject.js';
1
+ import { Playable } from './base/playable.js';
2
2
  import { PlexObject } from './base/plexObject.js';
3
3
  import type { AlbumData, ArtistData, TrackData } from './audio.types.js';
4
4
  import { Chapter, Collection, Country, Field, Format, Genre, Guid, Image, Label, Media, Mood, Similar, Style, Subformat } from './media.js';
@@ -6,7 +6,7 @@ import type { PlexServer } from './server.js';
6
6
  /**
7
7
  * Base class for all audio objects including Artist, Album, and Track.
8
8
  */
9
- export declare class Audio extends PartialPlexObject {
9
+ export declare class Audio extends Playable {
10
10
  /** Default metadata type for audio sync items. */
11
11
  static METADATA_TYPE: string;
12
12
  /** Hardcoded list type for filtering. */
package/dist/src/audio.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { URLSearchParams } from 'url';
2
- import { PartialPlexObject } from './base/partialPlexObject.js';
2
+ import { Playable } from './base/playable.js';
3
3
  import { fetchItem, fetchItems } from './baseFunctionality.js';
4
4
  import { Chapter, Collection, Country, Field, Format, Genre, Guid, Image, Label, Media, Mood, Similar, Style, Subformat, } from './media.js';
5
5
  /**
6
6
  * Base class for all audio objects including Artist, Album, and Track.
7
7
  */
8
- export class Audio extends PartialPlexObject {
8
+ export class Audio extends Playable {
9
9
  /** Default metadata type for audio sync items. */
10
10
  static { this.METADATA_TYPE = 'track'; }
11
11
  /** Hardcoded list type for filtering. */
@@ -114,6 +114,8 @@ export class Audio extends PartialPlexObject {
114
114
  this.musicAnalysisVersion = isNaN(musicAnalysisVersionInt)
115
115
  ? undefined
116
116
  : musicAnalysisVersionInt;
117
+ this.playlistItemID = data.playlistItemID;
118
+ this.ratingKey = data.ratingKey;
117
119
  const ratingKeyInt = data.ratingKey ? parseInt(data.ratingKey, 10) : NaN;
118
120
  this.ratingKey = isNaN(ratingKeyInt) ? this.ratingKey : ratingKeyInt.toString();
119
121
  this.summary = data.summary;
@@ -383,7 +385,6 @@ export class Artist extends Audio {
383
385
  if (e.constructor.name === 'NotFound') {
384
386
  return undefined;
385
387
  }
386
- // eslint-disable-next-line @typescript-eslint/only-throw-error
387
388
  throw e;
388
389
  }
389
390
  }
@@ -100,7 +100,7 @@ export declare abstract class PartialPlexObject extends PlexObject {
100
100
  * @param maxresults Only return the specified number of results (optional).
101
101
  * @param mindate Min datetime to return results from.
102
102
  */
103
- history(maxresults?: number, mindate?: Date): Promise<import("../server.types.js").HistoryMetadatum[]>;
103
+ history(maxresults?: number, mindate?: Date): Promise<import("../server.types.js").HistoryResult[]>;
104
104
  section(): Promise<Section>;
105
105
  /**
106
106
  * Delete a media element. This has to be enabled under settings > server > library in plex webui.
@@ -8,6 +8,7 @@ export * from './myplex.js';
8
8
  export * from './playlist.js';
9
9
  export * from './playqueue.js';
10
10
  export * from './server.js';
11
+ export type { HistoryResult } from './server.types.js';
11
12
  export * from './video.js';
12
13
  export * from './audio.js';
13
14
  export { X_PLEX_IDENTIFIER } from './config.js';
@@ -5,7 +5,7 @@ import { Album, Artist, Track } from './audio.js';
5
5
  import type { CollectionData, LibraryRootResponse, Location, SectionsDirectory } from './library.types.js';
6
6
  import { Playlist } from './playlist.js';
7
7
  import { type Agent, type SEARCHTYPES } from './search.js';
8
- import type { SearchResult } from './search.types.js';
8
+ import type { SearchResultContainer } from './search.types.js';
9
9
  import type { PlexServer } from './server.js';
10
10
  import { Movie, Show } from './video.js';
11
11
  export type Section = MovieSection | ShowSection | MusicSection;
@@ -26,7 +26,7 @@ export declare class Library {
26
26
  */
27
27
  sections(): Promise<Section[]>;
28
28
  section<T extends Section = Section>(title: string): Promise<T>;
29
- sectionByID(sectionId: string | number): Promise<Section>;
29
+ sectionByID<T extends Section = Section>(sectionId: string | number): Promise<T>;
30
30
  /**
31
31
  * Simplified add for the most common options.
32
32
  *
@@ -468,9 +468,9 @@ export declare class Hub extends PlexObject {
468
468
  size: number;
469
469
  title: string;
470
470
  type: string;
471
- Directory: SearchResult['Directory'];
472
- Metadata: SearchResult['Metadata'];
473
- protected _loadData(data: SearchResult): void;
471
+ Directory: SearchResultContainer['Directory'];
472
+ Metadata: SearchResultContainer['Metadata'];
473
+ protected _loadData(data: SearchResultContainer): void;
474
474
  }
475
475
  /**
476
476
  * Represents a Folder inside a library.
@@ -26,7 +26,6 @@ export class Library {
26
26
  for (const elem of elems.MediaContainer.Directory) {
27
27
  for (const cls of [MovieSection, ShowSection, MusicSection]) {
28
28
  if (cls.TYPE === elem.type) {
29
- // eslint-disable-next-line new-cap
30
29
  const instance = new cls(this.server, elem, key);
31
30
  sections.push(instance);
32
31
  }
@@ -1,4 +1,5 @@
1
1
  import { Playable } from './base/playable.js';
2
+ import { Album, Artist, Track } from './audio.js';
2
3
  import type { Section, SectionType } from './library.js';
3
4
  import type { PlaylistResponse } from './playlist.types.js';
4
5
  import type { PlexServer } from './server.js';
@@ -29,10 +30,32 @@ interface CreateSmartPlaylistOptions {
29
30
  filters?: Record<string, any>;
30
31
  }
31
32
  type CreatePlaylistOptions = CreateRegularPlaylistOptions | CreateSmartPlaylistOptions;
32
- type PlaylistContent = Episode | Movie;
33
+ type PlaylistContent = Episode | Movie | Track | Album | Artist;
34
+ export interface UpdatePlaylistOptions {
35
+ /** New title for the playlist */
36
+ title?: string;
37
+ /** New summary/description for the playlist */
38
+ summary?: string;
39
+ }
33
40
  export declare class Playlist extends Playable {
34
41
  static TAG: string;
35
42
  static create(server: PlexServer, title: string, options: CreatePlaylistOptions): Promise<Playlist>;
43
+ /**
44
+ * Update a playlist's metadata by ratingKey without fetching the full playlist first.
45
+ *
46
+ * @param server - The Plex server instance
47
+ * @param ratingKey - The playlist's ratingKey
48
+ * @param options - Fields to update (title and/or summary)
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * await Playlist.update(server, '12345', {
53
+ * title: 'My Updated Playlist',
54
+ * summary: 'A new description'
55
+ * });
56
+ * ```
57
+ */
58
+ static update(server: PlexServer, ratingKey: string, options: UpdatePlaylistOptions): Promise<void>;
36
59
  /** Create a smart playlist. */
37
60
  private static _create;
38
61
  TYPE: string;
@@ -61,7 +84,7 @@ export declare class Playlist extends Playable {
61
84
  * @returns the item in the playlist that matches the specified title.
62
85
  */
63
86
  item(title: string): Promise<PlaylistContent | null>;
64
- items(): Promise<PlaylistContent[]>;
87
+ items<T extends PlaylistContent>(): Promise<T[]>;
65
88
  /** Add items to a playlist. */
66
89
  addItems(items: PlaylistContent[]): Promise<void>;
67
90
  /** Remove an item from a playlist. */
@@ -1,5 +1,6 @@
1
1
  import { URLSearchParams } from 'url';
2
2
  import { Playable } from './base/playable.js';
3
+ import { Album, Artist, Track } from './audio.js';
3
4
  import { fetchItems } from './baseFunctionality.js';
4
5
  import { BadRequest, NotFound } from './exceptions.js';
5
6
  import { Episode, Movie } from './video.js';
@@ -12,8 +13,14 @@ function contentClass(data) {
12
13
  return Episode;
13
14
  case 'movie':
14
15
  return Movie;
16
+ case 'track':
17
+ return Track;
18
+ case 'album':
19
+ return Album;
20
+ case 'artist':
21
+ return Artist;
15
22
  default:
16
- throw new Error('Media type not implemented');
23
+ throw new Error(`Media type '${data.type}' not implemented`);
17
24
  }
18
25
  }
19
26
  export class Playlist extends Playable {
@@ -31,6 +38,35 @@ export class Playlist extends Playable {
31
38
  }
32
39
  return this._create(server, title, options.items);
33
40
  }
41
+ /**
42
+ * Update a playlist's metadata by ratingKey without fetching the full playlist first.
43
+ *
44
+ * @param server - The Plex server instance
45
+ * @param ratingKey - The playlist's ratingKey
46
+ * @param options - Fields to update (title and/or summary)
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * await Playlist.update(server, '12345', {
51
+ * title: 'My Updated Playlist',
52
+ * summary: 'A new description'
53
+ * });
54
+ * ```
55
+ */
56
+ static async update(server, ratingKey, options) {
57
+ if (!options.title && !options.summary) {
58
+ return;
59
+ }
60
+ const params = new URLSearchParams();
61
+ if (options.title) {
62
+ params.set('title', options.title);
63
+ }
64
+ if (options.summary) {
65
+ params.set('summary', options.summary);
66
+ }
67
+ const key = `/playlists/${ratingKey}?${params.toString()}`;
68
+ await server.query(key, 'put');
69
+ }
34
70
  /** Create a smart playlist. */
35
71
  // private static _createSmart(server: PlexServer, title: string, options: CreatePlaylistOptions) {}
36
72
  static async _create(server, title, items) {
@@ -135,6 +171,7 @@ export class Playlist extends Playable {
135
171
  if (!playlistItem) {
136
172
  throw new NotFound(`Item with title "${item.title}" not found in the playlist`);
137
173
  }
174
+ // playlistItemID is added dynamically by Plex API for playlist items
138
175
  return playlistItem.playlistItemID;
139
176
  }
140
177
  }
@@ -6,7 +6,7 @@ export interface MatchSearchResult {
6
6
  year: number;
7
7
  lifespanEnded: boolean;
8
8
  }
9
- export interface SearchResult {
9
+ export interface SearchResultContainer {
10
10
  title: string;
11
11
  type: string;
12
12
  hubIdentifier: string;
@@ -15,7 +15,7 @@ export interface SearchResult {
15
15
  more: boolean;
16
16
  style: string;
17
17
  Directory?: Directory[];
18
- Metadata?: Metadatum[];
18
+ Metadata?: SearchResult[];
19
19
  }
20
20
  export interface Directory {
21
21
  key: string;
@@ -34,7 +34,7 @@ export interface Directory {
34
34
  count: number;
35
35
  thumb?: string;
36
36
  }
37
- interface Metadatum {
37
+ interface SearchResult {
38
38
  librarySectionTitle: string;
39
39
  ratingKey: string;
40
40
  key: string;
@@ -8,7 +8,7 @@ import type { Playlist } from './playlist.js';
8
8
  import { PlayQueue } from './playqueue.js';
9
9
  import type { CreatePlayQueueOptions } from './playqueue.types.js';
10
10
  import { Agent, SEARCHTYPES } from './search.js';
11
- import type { HistoryMetadatum } from './server.types.js';
11
+ import type { HistoryResult } from './server.types.js';
12
12
  import { Settings } from './settings.js';
13
13
  /**
14
14
  * This is the main entry point to interacting with a Plex server. It allows you to
@@ -190,7 +190,7 @@ export declare class PlexServer {
190
190
  * @param accountId request history for a specific account ID.
191
191
  * @param librarySectionId request history for a specific library section ID.
192
192
  */
193
- history(maxresults?: number, mindate?: Date, ratingKey?: number | string, accountId?: number | string, librarySectionId?: number | string): Promise<HistoryMetadatum[]>;
193
+ history(maxresults?: number, mindate?: Date, ratingKey?: number | string, accountId?: number | string, librarySectionId?: number | string): Promise<HistoryResult[]>;
194
194
  settings(): Promise<Settings>;
195
195
  /**
196
196
  * Creates and returns a new PlayQueue.
@@ -143,7 +143,7 @@ export class PlexServer {
143
143
  args.librarySectionID = librarySectionId.toString();
144
144
  }
145
145
  if (mindate !== undefined) {
146
- args['viewedAt>'] = mindate.getTime().toString();
146
+ args['viewedAt>'] = Math.floor(mindate.getTime() / 1000).toString();
147
147
  }
148
148
  args['X-Plex-Container-Start'] = '0';
149
149
  args['X-Plex-Container-Size'] = Math.min(X_PLEX_CONTAINER_SIZE, maxresults).toString();
@@ -151,14 +151,18 @@ export class PlexServer {
151
151
  let key = '/status/sessions/history/all?' + new URLSearchParams(args).toString();
152
152
  let raw = await this.query(key);
153
153
  const totalResults = raw.MediaContainer.totalSize;
154
- results = results.concat(raw.MediaContainer.Metadata);
154
+ // Filter out null/undefined items from the metadata
155
+ const validMetadata = raw.MediaContainer.Metadata?.filter(Boolean) ?? [];
156
+ results = results.concat(validMetadata);
155
157
  while (results.length <= totalResults &&
156
158
  X_PLEX_CONTAINER_SIZE === raw.MediaContainer.size &&
157
159
  maxresults > results.length) {
158
160
  args['X-Plex-Container-Start'] = (Number(args['X-Plex-Container-Start']) + Number(args['X-Plex-Container-Size'])).toString();
159
161
  key = '/status/sessions/history/all?' + new URLSearchParams(args).toString();
160
162
  raw = await this.query(key);
161
- results = results.concat(raw.MediaContainer.Metadata);
163
+ // Filter out null/undefined items from the metadata
164
+ const validMetadata = raw.MediaContainer.Metadata?.filter(item => item != null) ?? [];
165
+ results = results.concat(validMetadata);
162
166
  }
163
167
  return results;
164
168
  }
@@ -65,10 +65,12 @@ export interface HistoryMediaContainer {
65
65
  size: number;
66
66
  totalSize: number;
67
67
  offset: number;
68
- Metadata: HistoryMetadatum[];
68
+ Metadata?: Array<HistoryResult | null | undefined>;
69
69
  }
70
- export interface HistoryMetadatum {
70
+ export interface HistoryResult {
71
71
  key: string;
72
+ ratingKey: string;
73
+ historyKey: string;
72
74
  parentKey?: string;
73
75
  grandparentKey?: string;
74
76
  title: string;
@@ -84,6 +86,7 @@ export interface HistoryMetadatum {
84
86
  viewedAt: number;
85
87
  accountID: number;
86
88
  deviceID: number;
89
+ librarySectionID: string;
87
90
  }
88
91
  export interface PlaylistMediaContainer {
89
92
  size: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl/plex",
3
- "version": "3.9.2",
3
+ "version": "3.10.0",
4
4
  "description": "plex api client in typescript using ofetch",
5
5
  "author": "Scott Cooper <scttcper@gmail.com>",
6
6
  "publishConfig": {
@@ -41,25 +41,25 @@
41
41
  "xml2js": "^0.6.2"
42
42
  },
43
43
  "devDependencies": {
44
- "@ctrl/oxlint-config": "1.2.6",
44
+ "@ctrl/oxlint-config": "1.2.8",
45
45
  "@ctrl/video-filename-parser": "5.4.1",
46
46
  "@sindresorhus/tsconfig": "8.0.1",
47
47
  "@trivago/prettier-plugin-sort-imports": "5.2.2",
48
- "@types/node": "24.5.2",
48
+ "@types/node": "24.7.2",
49
49
  "@types/ws": "8.18.1",
50
50
  "@types/xml2js": "0.4.14",
51
51
  "@types/yargs": "17.0.33",
52
52
  "@vitest/coverage-v8": "3.2.4",
53
53
  "execa": "9.6.0",
54
- "globby": "14.1.0",
54
+ "globby": "15.0.0",
55
55
  "make-dir": "5.1.0",
56
56
  "ora": "9.0.0",
57
- "oxlint": "1.18.0",
58
- "p-retry": "7.0.0",
57
+ "oxlint": "1.22.0",
58
+ "p-retry": "7.1.0",
59
59
  "prettier": "3.6.2",
60
60
  "tsx": "4.20.6",
61
- "typedoc": "0.28.13",
62
- "typescript": "5.9.2",
61
+ "typedoc": "0.28.14",
62
+ "typescript": "5.9.3",
63
63
  "vitest": "3.2.4",
64
64
  "yargs": "18.0.0"
65
65
  },
@@ -91,5 +91,5 @@
91
91
  "engines": {
92
92
  "node": ">=18"
93
93
  },
94
- "packageManager": "pnpm@10.17.1"
94
+ "packageManager": "pnpm@10.18.2"
95
95
  }