plex-mcp 0.0.1
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/.claude/settings.local.json +17 -0
- package/.github/workflows/ci.yml +85 -0
- package/.github/workflows/pr.yml +107 -0
- package/.github/workflows/release.yml +30 -0
- package/LICENSE +46 -0
- package/README.md +58 -0
- package/README.txt +198 -0
- package/README.txt~ +198 -0
- package/index.js +3506 -0
- package/package-lock.json~ +4715 -0
- package/package.json +41 -0
- package/package.json~ +25 -0
package/index.js
ADDED
@@ -0,0 +1,3506 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
|
3
|
+
const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
|
4
|
+
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
5
|
+
const {
|
6
|
+
CallToolRequestSchema,
|
7
|
+
ListToolsRequestSchema,
|
8
|
+
} = require("@modelcontextprotocol/sdk/types.js");
|
9
|
+
const axios = require('axios');
|
10
|
+
|
11
|
+
class PlexMCPServer {
|
12
|
+
constructor() {
|
13
|
+
this.server = new Server(
|
14
|
+
{
|
15
|
+
name: "plex-search-server",
|
16
|
+
version: "0.1.0",
|
17
|
+
},
|
18
|
+
{
|
19
|
+
capabilities: {
|
20
|
+
tools: {},
|
21
|
+
},
|
22
|
+
}
|
23
|
+
);
|
24
|
+
|
25
|
+
this.setupToolHandlers();
|
26
|
+
}
|
27
|
+
|
28
|
+
setupToolHandlers() {
|
29
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
30
|
+
return {
|
31
|
+
tools: [
|
32
|
+
{
|
33
|
+
name: "search_plex",
|
34
|
+
description: "Search for movies, TV shows, and other content in Plex libraries",
|
35
|
+
inputSchema: {
|
36
|
+
type: "object",
|
37
|
+
properties: {
|
38
|
+
query: {
|
39
|
+
type: "string",
|
40
|
+
description: "The search query (movie title, show name, etc.)",
|
41
|
+
},
|
42
|
+
type: {
|
43
|
+
type: "string",
|
44
|
+
enum: ["movie", "show", "episode", "artist", "album", "track"],
|
45
|
+
description: "Type of content to search for (optional)",
|
46
|
+
},
|
47
|
+
limit: {
|
48
|
+
type: "number",
|
49
|
+
description: "Maximum number of results to return (default: 10)",
|
50
|
+
default: 10,
|
51
|
+
},
|
52
|
+
play_count_min: {
|
53
|
+
type: "number",
|
54
|
+
description: "Minimum play count for results",
|
55
|
+
},
|
56
|
+
play_count_max: {
|
57
|
+
type: "number",
|
58
|
+
description: "Maximum play count for results",
|
59
|
+
},
|
60
|
+
last_played_after: {
|
61
|
+
type: "string",
|
62
|
+
description: "Filter items played after this date (YYYY-MM-DD format)",
|
63
|
+
},
|
64
|
+
last_played_before: {
|
65
|
+
type: "string",
|
66
|
+
description: "Filter items played before this date (YYYY-MM-DD format)",
|
67
|
+
},
|
68
|
+
played_in_last_days: {
|
69
|
+
type: "number",
|
70
|
+
description: "Filter items played in the last N days",
|
71
|
+
},
|
72
|
+
never_played: {
|
73
|
+
type: "boolean",
|
74
|
+
description: "Filter to only show never played items",
|
75
|
+
},
|
76
|
+
content_rating: {
|
77
|
+
type: "string",
|
78
|
+
description: "Filter by content rating (G, PG, PG-13, R, etc.)",
|
79
|
+
},
|
80
|
+
resolution: {
|
81
|
+
type: "string",
|
82
|
+
enum: ["4k", "1080", "720", "480", "sd"],
|
83
|
+
description: "Filter by video resolution",
|
84
|
+
},
|
85
|
+
audio_format: {
|
86
|
+
type: "string",
|
87
|
+
enum: ["lossless", "lossy", "mp3", "flac", "aac"],
|
88
|
+
description: "Filter by audio format (for music)",
|
89
|
+
},
|
90
|
+
file_size_min: {
|
91
|
+
type: "number",
|
92
|
+
description: "Minimum file size in MB",
|
93
|
+
},
|
94
|
+
file_size_max: {
|
95
|
+
type: "number",
|
96
|
+
description: "Maximum file size in MB",
|
97
|
+
},
|
98
|
+
genre: {
|
99
|
+
type: "string",
|
100
|
+
description: "Filter by genre (e.g., Action, Comedy, Rock, Jazz)",
|
101
|
+
},
|
102
|
+
year: {
|
103
|
+
type: "number",
|
104
|
+
description: "Filter by release year",
|
105
|
+
},
|
106
|
+
year_min: {
|
107
|
+
type: "number",
|
108
|
+
description: "Filter by minimum release year",
|
109
|
+
},
|
110
|
+
year_max: {
|
111
|
+
type: "number",
|
112
|
+
description: "Filter by maximum release year",
|
113
|
+
},
|
114
|
+
studio: {
|
115
|
+
type: "string",
|
116
|
+
description: "Filter by studio/label (e.g., Warner Bros, Sony Music)",
|
117
|
+
},
|
118
|
+
director: {
|
119
|
+
type: "string",
|
120
|
+
description: "Filter by director name",
|
121
|
+
},
|
122
|
+
writer: {
|
123
|
+
type: "string",
|
124
|
+
description: "Filter by writer name",
|
125
|
+
},
|
126
|
+
actor: {
|
127
|
+
type: "string",
|
128
|
+
description: "Filter by actor/cast member name",
|
129
|
+
},
|
130
|
+
rating_min: {
|
131
|
+
type: "number",
|
132
|
+
description: "Minimum rating (0-10 scale)",
|
133
|
+
},
|
134
|
+
rating_max: {
|
135
|
+
type: "number",
|
136
|
+
description: "Maximum rating (0-10 scale)",
|
137
|
+
},
|
138
|
+
duration_min: {
|
139
|
+
type: "number",
|
140
|
+
description: "Minimum duration in minutes",
|
141
|
+
},
|
142
|
+
duration_max: {
|
143
|
+
type: "number",
|
144
|
+
description: "Maximum duration in minutes",
|
145
|
+
},
|
146
|
+
added_after: {
|
147
|
+
type: "string",
|
148
|
+
description: "Filter items added to library after this date (YYYY-MM-DD format)",
|
149
|
+
},
|
150
|
+
added_before: {
|
151
|
+
type: "string",
|
152
|
+
description: "Filter items added to library before this date (YYYY-MM-DD format)",
|
153
|
+
},
|
154
|
+
},
|
155
|
+
required: ["query"],
|
156
|
+
},
|
157
|
+
},
|
158
|
+
{
|
159
|
+
name: "browse_libraries",
|
160
|
+
description: "List all available Plex libraries (Movies, TV Shows, Music, etc.)",
|
161
|
+
inputSchema: {
|
162
|
+
type: "object",
|
163
|
+
properties: {},
|
164
|
+
required: [],
|
165
|
+
},
|
166
|
+
},
|
167
|
+
{
|
168
|
+
name: "browse_library",
|
169
|
+
description: "Browse content within a specific Plex library with filtering and sorting options",
|
170
|
+
inputSchema: {
|
171
|
+
type: "object",
|
172
|
+
properties: {
|
173
|
+
library_id: {
|
174
|
+
type: "string",
|
175
|
+
description: "The library ID (key) to browse",
|
176
|
+
},
|
177
|
+
sort: {
|
178
|
+
type: "string",
|
179
|
+
enum: ["titleSort", "addedAt", "originallyAvailableAt", "rating", "viewCount", "lastViewedAt"],
|
180
|
+
description: "Sort order (default: titleSort)",
|
181
|
+
default: "titleSort",
|
182
|
+
},
|
183
|
+
genre: {
|
184
|
+
type: "string",
|
185
|
+
description: "Filter by genre",
|
186
|
+
},
|
187
|
+
year: {
|
188
|
+
type: "number",
|
189
|
+
description: "Filter by release year",
|
190
|
+
},
|
191
|
+
limit: {
|
192
|
+
type: "number",
|
193
|
+
description: "Maximum number of results to return (default: 20)",
|
194
|
+
default: 20,
|
195
|
+
},
|
196
|
+
offset: {
|
197
|
+
type: "number",
|
198
|
+
description: "Number of results to skip (for pagination, default: 0)",
|
199
|
+
default: 0,
|
200
|
+
},
|
201
|
+
play_count_min: {
|
202
|
+
type: "number",
|
203
|
+
description: "Minimum play count for results",
|
204
|
+
},
|
205
|
+
play_count_max: {
|
206
|
+
type: "number",
|
207
|
+
description: "Maximum play count for results",
|
208
|
+
},
|
209
|
+
last_played_after: {
|
210
|
+
type: "string",
|
211
|
+
description: "Filter items played after this date (YYYY-MM-DD format)",
|
212
|
+
},
|
213
|
+
last_played_before: {
|
214
|
+
type: "string",
|
215
|
+
description: "Filter items played before this date (YYYY-MM-DD format)",
|
216
|
+
},
|
217
|
+
played_in_last_days: {
|
218
|
+
type: "number",
|
219
|
+
description: "Filter items played in the last N days",
|
220
|
+
},
|
221
|
+
never_played: {
|
222
|
+
type: "boolean",
|
223
|
+
description: "Filter to only show never played items",
|
224
|
+
},
|
225
|
+
content_rating: {
|
226
|
+
type: "string",
|
227
|
+
description: "Filter by content rating (G, PG, PG-13, R, etc.)",
|
228
|
+
},
|
229
|
+
resolution: {
|
230
|
+
type: "string",
|
231
|
+
enum: ["4k", "1080", "720", "480", "sd"],
|
232
|
+
description: "Filter by video resolution",
|
233
|
+
},
|
234
|
+
audio_format: {
|
235
|
+
type: "string",
|
236
|
+
enum: ["lossless", "lossy", "mp3", "flac", "aac"],
|
237
|
+
description: "Filter by audio format (for music)",
|
238
|
+
},
|
239
|
+
file_size_min: {
|
240
|
+
type: "number",
|
241
|
+
description: "Minimum file size in MB",
|
242
|
+
},
|
243
|
+
file_size_max: {
|
244
|
+
type: "number",
|
245
|
+
description: "Maximum file size in MB",
|
246
|
+
},
|
247
|
+
year_min: {
|
248
|
+
type: "number",
|
249
|
+
description: "Filter by minimum release year",
|
250
|
+
},
|
251
|
+
year_max: {
|
252
|
+
type: "number",
|
253
|
+
description: "Filter by maximum release year",
|
254
|
+
},
|
255
|
+
studio: {
|
256
|
+
type: "string",
|
257
|
+
description: "Filter by studio/label (e.g., Warner Bros, Sony Music)",
|
258
|
+
},
|
259
|
+
director: {
|
260
|
+
type: "string",
|
261
|
+
description: "Filter by director name",
|
262
|
+
},
|
263
|
+
writer: {
|
264
|
+
type: "string",
|
265
|
+
description: "Filter by writer name",
|
266
|
+
},
|
267
|
+
actor: {
|
268
|
+
type: "string",
|
269
|
+
description: "Filter by actor/cast member name",
|
270
|
+
},
|
271
|
+
rating_min: {
|
272
|
+
type: "number",
|
273
|
+
description: "Minimum rating (0-10 scale)",
|
274
|
+
},
|
275
|
+
rating_max: {
|
276
|
+
type: "number",
|
277
|
+
description: "Maximum rating (0-10 scale)",
|
278
|
+
},
|
279
|
+
duration_min: {
|
280
|
+
type: "number",
|
281
|
+
description: "Minimum duration in minutes",
|
282
|
+
},
|
283
|
+
duration_max: {
|
284
|
+
type: "number",
|
285
|
+
description: "Maximum duration in minutes",
|
286
|
+
},
|
287
|
+
added_after: {
|
288
|
+
type: "string",
|
289
|
+
description: "Filter items added to library after this date (YYYY-MM-DD format)",
|
290
|
+
},
|
291
|
+
added_before: {
|
292
|
+
type: "string",
|
293
|
+
description: "Filter items added to library before this date (YYYY-MM-DD format)",
|
294
|
+
},
|
295
|
+
},
|
296
|
+
required: ["library_id"],
|
297
|
+
},
|
298
|
+
},
|
299
|
+
{
|
300
|
+
name: "get_recently_added",
|
301
|
+
description: "Get recently added content from Plex libraries",
|
302
|
+
inputSchema: {
|
303
|
+
type: "object",
|
304
|
+
properties: {
|
305
|
+
library_id: {
|
306
|
+
type: "string",
|
307
|
+
description: "Specific library ID to get recent content from (optional, defaults to all libraries)",
|
308
|
+
},
|
309
|
+
limit: {
|
310
|
+
type: "number",
|
311
|
+
description: "Maximum number of results to return (default: 15)",
|
312
|
+
default: 15,
|
313
|
+
},
|
314
|
+
chunk_size: {
|
315
|
+
type: "number",
|
316
|
+
description: "Number of items to return per chunk for pagination (optional)",
|
317
|
+
},
|
318
|
+
chunk_offset: {
|
319
|
+
type: "number",
|
320
|
+
description: "Offset for pagination, number of items to skip (optional)",
|
321
|
+
},
|
322
|
+
},
|
323
|
+
required: [],
|
324
|
+
},
|
325
|
+
},
|
326
|
+
{
|
327
|
+
name: "get_watch_history",
|
328
|
+
description: "Get playback history for the Plex server",
|
329
|
+
inputSchema: {
|
330
|
+
type: "object",
|
331
|
+
properties: {
|
332
|
+
limit: {
|
333
|
+
type: "number",
|
334
|
+
description: "Maximum number of history items to return (default: 20)",
|
335
|
+
default: 20,
|
336
|
+
},
|
337
|
+
account_id: {
|
338
|
+
type: "string",
|
339
|
+
description: "Filter by specific account/user ID (optional)",
|
340
|
+
},
|
341
|
+
chunk_size: {
|
342
|
+
type: "number",
|
343
|
+
description: "Number of items to return per chunk for pagination (optional)",
|
344
|
+
},
|
345
|
+
chunk_offset: {
|
346
|
+
type: "number",
|
347
|
+
description: "Offset for pagination, number of items to skip (optional)",
|
348
|
+
},
|
349
|
+
},
|
350
|
+
required: [],
|
351
|
+
},
|
352
|
+
},
|
353
|
+
{
|
354
|
+
name: "get_on_deck",
|
355
|
+
description: "Get 'On Deck' items (continue watching) for users",
|
356
|
+
inputSchema: {
|
357
|
+
type: "object",
|
358
|
+
properties: {
|
359
|
+
limit: {
|
360
|
+
type: "number",
|
361
|
+
description: "Maximum number of items to return (default: 15)",
|
362
|
+
default: 15,
|
363
|
+
},
|
364
|
+
},
|
365
|
+
required: [],
|
366
|
+
},
|
367
|
+
},
|
368
|
+
{
|
369
|
+
name: "list_playlists",
|
370
|
+
description: "List all playlists on the Plex server",
|
371
|
+
inputSchema: {
|
372
|
+
type: "object",
|
373
|
+
properties: {
|
374
|
+
playlist_type: {
|
375
|
+
type: "string",
|
376
|
+
enum: ["audio", "video", "photo"],
|
377
|
+
description: "Filter by playlist type (optional)",
|
378
|
+
},
|
379
|
+
},
|
380
|
+
required: [],
|
381
|
+
},
|
382
|
+
},
|
383
|
+
{
|
384
|
+
name: "create_playlist",
|
385
|
+
description: "Create a new playlist on the Plex server. Note: Non-smart playlists require an initial item (item_key parameter) to be created successfully.",
|
386
|
+
inputSchema: {
|
387
|
+
type: "object",
|
388
|
+
properties: {
|
389
|
+
title: {
|
390
|
+
type: "string",
|
391
|
+
description: "The title/name for the new playlist",
|
392
|
+
},
|
393
|
+
type: {
|
394
|
+
type: "string",
|
395
|
+
enum: ["audio", "video", "photo"],
|
396
|
+
description: "The type of playlist to create",
|
397
|
+
},
|
398
|
+
smart: {
|
399
|
+
type: "boolean",
|
400
|
+
description: "Whether to create a smart playlist (default: false)",
|
401
|
+
default: false,
|
402
|
+
},
|
403
|
+
item_key: {
|
404
|
+
type: "string",
|
405
|
+
description: "The key of an initial item to add to the playlist. Required for non-smart playlists, optional for smart playlists.",
|
406
|
+
},
|
407
|
+
},
|
408
|
+
required: ["title", "type"],
|
409
|
+
},
|
410
|
+
},
|
411
|
+
{
|
412
|
+
name: "add_to_playlist",
|
413
|
+
description: "Add items to an existing playlist",
|
414
|
+
inputSchema: {
|
415
|
+
type: "object",
|
416
|
+
properties: {
|
417
|
+
playlist_id: {
|
418
|
+
type: "string",
|
419
|
+
description: "The playlist ID (ratingKey) to add items to",
|
420
|
+
},
|
421
|
+
item_keys: {
|
422
|
+
type: "array",
|
423
|
+
items: {
|
424
|
+
type: "string"
|
425
|
+
},
|
426
|
+
description: "Array of item keys (ratingKey) to add to the playlist",
|
427
|
+
},
|
428
|
+
},
|
429
|
+
required: ["playlist_id", "item_keys"],
|
430
|
+
},
|
431
|
+
},
|
432
|
+
{
|
433
|
+
name: "remove_from_playlist",
|
434
|
+
description: "Remove items from an existing playlist",
|
435
|
+
inputSchema: {
|
436
|
+
type: "object",
|
437
|
+
properties: {
|
438
|
+
playlist_id: {
|
439
|
+
type: "string",
|
440
|
+
description: "The playlist ID (ratingKey) to remove items from",
|
441
|
+
},
|
442
|
+
item_keys: {
|
443
|
+
type: "array",
|
444
|
+
items: {
|
445
|
+
type: "string"
|
446
|
+
},
|
447
|
+
description: "Array of item keys (ratingKey) to remove from the playlist",
|
448
|
+
},
|
449
|
+
},
|
450
|
+
required: ["playlist_id", "item_keys"],
|
451
|
+
},
|
452
|
+
},
|
453
|
+
{
|
454
|
+
name: "delete_playlist",
|
455
|
+
description: "Delete an existing playlist",
|
456
|
+
inputSchema: {
|
457
|
+
type: "object",
|
458
|
+
properties: {
|
459
|
+
playlist_id: {
|
460
|
+
type: "string",
|
461
|
+
description: "The playlist ID (ratingKey) to delete",
|
462
|
+
},
|
463
|
+
},
|
464
|
+
required: ["playlist_id"],
|
465
|
+
},
|
466
|
+
},
|
467
|
+
{
|
468
|
+
name: "get_watched_status",
|
469
|
+
description: "Check watch status and progress for specific content items",
|
470
|
+
inputSchema: {
|
471
|
+
type: "object",
|
472
|
+
properties: {
|
473
|
+
item_keys: {
|
474
|
+
type: "array",
|
475
|
+
items: {
|
476
|
+
type: "string"
|
477
|
+
},
|
478
|
+
description: "Array of item keys (ratingKey) to check watch status for",
|
479
|
+
},
|
480
|
+
account_id: {
|
481
|
+
type: "string",
|
482
|
+
description: "Specific account/user ID to check status for (optional)",
|
483
|
+
},
|
484
|
+
},
|
485
|
+
required: ["item_keys"],
|
486
|
+
},
|
487
|
+
},
|
488
|
+
{
|
489
|
+
name: "get_collections",
|
490
|
+
description: "List all collections available on the Plex server",
|
491
|
+
inputSchema: {
|
492
|
+
type: "object",
|
493
|
+
properties: {
|
494
|
+
library_id: {
|
495
|
+
type: "string",
|
496
|
+
description: "Filter collections by specific library ID (optional)",
|
497
|
+
},
|
498
|
+
},
|
499
|
+
required: [],
|
500
|
+
},
|
501
|
+
},
|
502
|
+
{
|
503
|
+
name: "browse_collection",
|
504
|
+
description: "Browse content within a specific collection",
|
505
|
+
inputSchema: {
|
506
|
+
type: "object",
|
507
|
+
properties: {
|
508
|
+
collection_id: {
|
509
|
+
type: "string",
|
510
|
+
description: "The collection ID (ratingKey) to browse",
|
511
|
+
},
|
512
|
+
sort: {
|
513
|
+
type: "string",
|
514
|
+
enum: ["titleSort", "addedAt", "originallyAvailableAt", "rating", "viewCount", "lastViewedAt"],
|
515
|
+
description: "Sort order (default: titleSort)",
|
516
|
+
default: "titleSort",
|
517
|
+
},
|
518
|
+
limit: {
|
519
|
+
type: "number",
|
520
|
+
description: "Maximum number of results to return (default: 20)",
|
521
|
+
default: 20,
|
522
|
+
},
|
523
|
+
offset: {
|
524
|
+
type: "number",
|
525
|
+
description: "Number of results to skip (for pagination, default: 0)",
|
526
|
+
default: 0,
|
527
|
+
},
|
528
|
+
},
|
529
|
+
required: ["collection_id"],
|
530
|
+
},
|
531
|
+
},
|
532
|
+
{
|
533
|
+
name: "get_media_info",
|
534
|
+
description: "Get detailed technical information about media files (codecs, bitrates, file sizes, etc.)",
|
535
|
+
inputSchema: {
|
536
|
+
type: "object",
|
537
|
+
properties: {
|
538
|
+
item_key: {
|
539
|
+
type: "string",
|
540
|
+
description: "The item key (ratingKey) to get media information for",
|
541
|
+
},
|
542
|
+
},
|
543
|
+
required: ["item_key"],
|
544
|
+
},
|
545
|
+
},
|
546
|
+
{
|
547
|
+
name: "get_library_stats",
|
548
|
+
description: "Get comprehensive statistics about Plex libraries (storage usage, file counts, content breakdown, etc.)",
|
549
|
+
inputSchema: {
|
550
|
+
type: "object",
|
551
|
+
properties: {
|
552
|
+
library_id: {
|
553
|
+
type: "string",
|
554
|
+
description: "Specific library ID to get stats for (optional, defaults to all libraries)",
|
555
|
+
},
|
556
|
+
include_details: {
|
557
|
+
type: "boolean",
|
558
|
+
description: "Include detailed breakdowns by file type, resolution, codec, etc. (default: false)",
|
559
|
+
default: false,
|
560
|
+
},
|
561
|
+
},
|
562
|
+
required: [],
|
563
|
+
},
|
564
|
+
},
|
565
|
+
{
|
566
|
+
name: "get_listening_stats",
|
567
|
+
description: "Get detailed listening statistics and music recommendations based on play history and patterns",
|
568
|
+
inputSchema: {
|
569
|
+
type: "object",
|
570
|
+
properties: {
|
571
|
+
account_id: {
|
572
|
+
type: "string",
|
573
|
+
description: "Specific account/user ID to analyze (optional, defaults to all users)",
|
574
|
+
},
|
575
|
+
time_period: {
|
576
|
+
type: "string",
|
577
|
+
enum: ["week", "month", "quarter", "year", "all"],
|
578
|
+
description: "Time period to analyze (default: month)",
|
579
|
+
default: "month",
|
580
|
+
},
|
581
|
+
include_recommendations: {
|
582
|
+
type: "boolean",
|
583
|
+
description: "Include music recommendations based on listening patterns (default: true)",
|
584
|
+
default: true,
|
585
|
+
},
|
586
|
+
music_library_id: {
|
587
|
+
type: "string",
|
588
|
+
description: "Specific music library ID to analyze (optional, auto-detects music libraries)",
|
589
|
+
},
|
590
|
+
},
|
591
|
+
required: [],
|
592
|
+
},
|
593
|
+
},
|
594
|
+
],
|
595
|
+
};
|
596
|
+
});
|
597
|
+
|
598
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
599
|
+
switch (request.params.name) {
|
600
|
+
case "search_plex":
|
601
|
+
return await this.handlePlexSearch(request.params.arguments);
|
602
|
+
case "browse_libraries":
|
603
|
+
return await this.handleBrowseLibraries(request.params.arguments);
|
604
|
+
case "browse_library":
|
605
|
+
return await this.handleBrowseLibrary(request.params.arguments);
|
606
|
+
case "get_recently_added":
|
607
|
+
return await this.handleRecentlyAdded(request.params.arguments);
|
608
|
+
case "get_watch_history":
|
609
|
+
return await this.handleWatchHistory(request.params.arguments);
|
610
|
+
case "get_on_deck":
|
611
|
+
return await this.handleOnDeck(request.params.arguments);
|
612
|
+
case "list_playlists":
|
613
|
+
return await this.handleListPlaylists(request.params.arguments);
|
614
|
+
case "create_playlist":
|
615
|
+
return await this.handleCreatePlaylist(request.params.arguments);
|
616
|
+
case "add_to_playlist":
|
617
|
+
return await this.handleAddToPlaylist(request.params.arguments);
|
618
|
+
case "remove_from_playlist":
|
619
|
+
return await this.handleRemoveFromPlaylist(request.params.arguments);
|
620
|
+
case "delete_playlist":
|
621
|
+
return await this.handleDeletePlaylist(request.params.arguments);
|
622
|
+
case "get_watched_status":
|
623
|
+
return await this.handleWatchedStatus(request.params.arguments);
|
624
|
+
case "get_collections":
|
625
|
+
return await this.handleGetCollections(request.params.arguments);
|
626
|
+
case "browse_collection":
|
627
|
+
return await this.handleBrowseCollection(request.params.arguments);
|
628
|
+
case "get_media_info":
|
629
|
+
return await this.handleGetMediaInfo(request.params.arguments);
|
630
|
+
case "get_library_stats":
|
631
|
+
return await this.handleGetLibraryStats(request.params.arguments);
|
632
|
+
case "get_listening_stats":
|
633
|
+
return await this.handleGetListeningStats(request.params.arguments);
|
634
|
+
default:
|
635
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
636
|
+
}
|
637
|
+
});
|
638
|
+
}
|
639
|
+
|
640
|
+
async handlePlexSearch(args) {
|
641
|
+
const {
|
642
|
+
query,
|
643
|
+
type,
|
644
|
+
limit = 10,
|
645
|
+
play_count_min,
|
646
|
+
play_count_max,
|
647
|
+
last_played_after,
|
648
|
+
last_played_before,
|
649
|
+
played_in_last_days,
|
650
|
+
never_played,
|
651
|
+
content_rating,
|
652
|
+
resolution,
|
653
|
+
audio_format,
|
654
|
+
file_size_min,
|
655
|
+
file_size_max,
|
656
|
+
genre,
|
657
|
+
year,
|
658
|
+
year_min,
|
659
|
+
year_max,
|
660
|
+
studio,
|
661
|
+
director,
|
662
|
+
writer,
|
663
|
+
actor,
|
664
|
+
rating_min,
|
665
|
+
rating_max,
|
666
|
+
duration_min,
|
667
|
+
duration_max,
|
668
|
+
added_after,
|
669
|
+
added_before
|
670
|
+
} = args;
|
671
|
+
|
672
|
+
try {
|
673
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
674
|
+
const plexToken = process.env.PLEX_TOKEN;
|
675
|
+
|
676
|
+
if (!plexToken) {
|
677
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
678
|
+
}
|
679
|
+
|
680
|
+
const searchUrl = `${plexUrl}/search`;
|
681
|
+
const params = {
|
682
|
+
query: query,
|
683
|
+
'X-Plex-Token': plexToken,
|
684
|
+
limit: limit
|
685
|
+
};
|
686
|
+
|
687
|
+
if (type) {
|
688
|
+
params.type = this.getPlexTypeNumber(type);
|
689
|
+
}
|
690
|
+
|
691
|
+
const response = await axios.get(searchUrl, {
|
692
|
+
params,
|
693
|
+
httpsAgent: new (require('https').Agent)({
|
694
|
+
rejectUnauthorized: false,
|
695
|
+
minVersion: 'TLSv1.2'
|
696
|
+
})
|
697
|
+
});
|
698
|
+
|
699
|
+
let results = this.parseSearchResults(response.data);
|
700
|
+
|
701
|
+
// Apply activity filters
|
702
|
+
results = this.applyActivityFilters(results, {
|
703
|
+
play_count_min,
|
704
|
+
play_count_max,
|
705
|
+
last_played_after,
|
706
|
+
last_played_before,
|
707
|
+
played_in_last_days,
|
708
|
+
never_played
|
709
|
+
});
|
710
|
+
|
711
|
+
// Apply basic content filters
|
712
|
+
results = this.applyBasicFilters(results, {
|
713
|
+
genre,
|
714
|
+
year,
|
715
|
+
year_min,
|
716
|
+
year_max,
|
717
|
+
studio,
|
718
|
+
director,
|
719
|
+
writer,
|
720
|
+
actor,
|
721
|
+
rating_min,
|
722
|
+
rating_max,
|
723
|
+
duration_min,
|
724
|
+
duration_max,
|
725
|
+
added_after,
|
726
|
+
added_before
|
727
|
+
});
|
728
|
+
|
729
|
+
// Apply advanced filters
|
730
|
+
results = this.applyAdvancedFilters(results, {
|
731
|
+
content_rating,
|
732
|
+
resolution,
|
733
|
+
audio_format,
|
734
|
+
file_size_min,
|
735
|
+
file_size_max
|
736
|
+
});
|
737
|
+
|
738
|
+
return {
|
739
|
+
content: [
|
740
|
+
{
|
741
|
+
type: "text",
|
742
|
+
text: `Found ${results.length} results for "${query}":\n\n${this.formatResults(results)}`,
|
743
|
+
},
|
744
|
+
],
|
745
|
+
};
|
746
|
+
} catch (error) {
|
747
|
+
return {
|
748
|
+
content: [
|
749
|
+
{
|
750
|
+
type: "text",
|
751
|
+
text: `Error searching Plex: ${error.message}`,
|
752
|
+
},
|
753
|
+
],
|
754
|
+
isError: true,
|
755
|
+
};
|
756
|
+
}
|
757
|
+
}
|
758
|
+
|
759
|
+
getPlexTypeNumber(type) {
|
760
|
+
const typeMap = {
|
761
|
+
movie: 1,
|
762
|
+
show: 2,
|
763
|
+
episode: 4,
|
764
|
+
artist: 8,
|
765
|
+
album: 9,
|
766
|
+
track: 10
|
767
|
+
};
|
768
|
+
return typeMap[type] || null;
|
769
|
+
}
|
770
|
+
|
771
|
+
parseSearchResults(data) {
|
772
|
+
if (!data.MediaContainer || !data.MediaContainer.Metadata) {
|
773
|
+
return [];
|
774
|
+
}
|
775
|
+
|
776
|
+
return data.MediaContainer.Metadata.map(item => ({
|
777
|
+
title: item.title,
|
778
|
+
type: item.type,
|
779
|
+
year: item.year,
|
780
|
+
summary: item.summary,
|
781
|
+
rating: item.rating,
|
782
|
+
duration: item.duration,
|
783
|
+
addedAt: item.addedAt,
|
784
|
+
viewCount: item.viewCount,
|
785
|
+
lastViewedAt: item.lastViewedAt,
|
786
|
+
contentRating: item.contentRating,
|
787
|
+
Media: item.Media,
|
788
|
+
key: item.key,
|
789
|
+
// Additional metadata for basic filters
|
790
|
+
studio: item.studio,
|
791
|
+
genres: item.Genre ? item.Genre.map(g => g.tag) : [],
|
792
|
+
directors: item.Director ? item.Director.map(d => d.tag) : [],
|
793
|
+
writers: item.Writer ? item.Writer.map(w => w.tag) : [],
|
794
|
+
actors: item.Role ? item.Role.map(r => r.tag) : []
|
795
|
+
}));
|
796
|
+
}
|
797
|
+
|
798
|
+
formatResults(results) {
|
799
|
+
return results.map((item, index) => {
|
800
|
+
let formatted = `${index + 1}. **${item.title}**`;
|
801
|
+
|
802
|
+
if (item.year) {
|
803
|
+
formatted += ` (${item.year})`;
|
804
|
+
}
|
805
|
+
|
806
|
+
if (item.type) {
|
807
|
+
formatted += ` - ${item.type}`;
|
808
|
+
}
|
809
|
+
|
810
|
+
if (item.rating) {
|
811
|
+
formatted += ` - Rating: ${item.rating}`;
|
812
|
+
}
|
813
|
+
|
814
|
+
if (item.summary) {
|
815
|
+
formatted += `\n ${item.summary.substring(0, 150)}${item.summary.length > 150 ? '...' : ''}`;
|
816
|
+
}
|
817
|
+
|
818
|
+
return formatted;
|
819
|
+
}).join('\n\n');
|
820
|
+
}
|
821
|
+
|
822
|
+
async handleBrowseLibraries(args) {
|
823
|
+
try {
|
824
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
825
|
+
const plexToken = process.env.PLEX_TOKEN;
|
826
|
+
|
827
|
+
if (!plexToken) {
|
828
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
829
|
+
}
|
830
|
+
|
831
|
+
const librariesUrl = `${plexUrl}/library/sections`;
|
832
|
+
const params = {
|
833
|
+
'X-Plex-Token': plexToken
|
834
|
+
};
|
835
|
+
|
836
|
+
const response = await axios.get(librariesUrl, {
|
837
|
+
params,
|
838
|
+
httpsAgent: new (require('https').Agent)({
|
839
|
+
rejectUnauthorized: false,
|
840
|
+
minVersion: 'TLSv1.2'
|
841
|
+
})
|
842
|
+
});
|
843
|
+
|
844
|
+
const libraries = this.parseLibraries(response.data);
|
845
|
+
|
846
|
+
return {
|
847
|
+
content: [
|
848
|
+
{
|
849
|
+
type: "text",
|
850
|
+
text: `Available Plex Libraries:\n\n${this.formatLibraries(libraries)}`,
|
851
|
+
},
|
852
|
+
],
|
853
|
+
};
|
854
|
+
} catch (error) {
|
855
|
+
return {
|
856
|
+
content: [
|
857
|
+
{
|
858
|
+
type: "text",
|
859
|
+
text: `Error browsing libraries: ${error.message}`,
|
860
|
+
},
|
861
|
+
],
|
862
|
+
isError: true,
|
863
|
+
};
|
864
|
+
}
|
865
|
+
}
|
866
|
+
|
867
|
+
parseLibraries(data) {
|
868
|
+
if (!data.MediaContainer || !data.MediaContainer.Directory) {
|
869
|
+
return [];
|
870
|
+
}
|
871
|
+
|
872
|
+
return data.MediaContainer.Directory.map(library => ({
|
873
|
+
key: library.key,
|
874
|
+
title: library.title,
|
875
|
+
type: library.type,
|
876
|
+
agent: library.agent,
|
877
|
+
scanner: library.scanner,
|
878
|
+
language: library.language,
|
879
|
+
refreshing: library.refreshing,
|
880
|
+
createdAt: library.createdAt,
|
881
|
+
updatedAt: library.updatedAt,
|
882
|
+
scannedAt: library.scannedAt
|
883
|
+
}));
|
884
|
+
}
|
885
|
+
|
886
|
+
formatLibraries(libraries) {
|
887
|
+
return libraries.map((library, index) => {
|
888
|
+
let formatted = `${index + 1}. **${library.title}** (${library.type})`;
|
889
|
+
|
890
|
+
if (library.agent) {
|
891
|
+
formatted += `\n Agent: ${library.agent}`;
|
892
|
+
}
|
893
|
+
|
894
|
+
if (library.language) {
|
895
|
+
formatted += ` | Language: ${library.language}`;
|
896
|
+
}
|
897
|
+
|
898
|
+
if (library.scannedAt) {
|
899
|
+
const scannedDate = new Date(library.scannedAt * 1000).toLocaleDateString();
|
900
|
+
formatted += `\n Last scanned: ${scannedDate}`;
|
901
|
+
}
|
902
|
+
|
903
|
+
formatted += `\n Library ID: ${library.key}`;
|
904
|
+
|
905
|
+
return formatted;
|
906
|
+
}).join('\n\n');
|
907
|
+
}
|
908
|
+
|
909
|
+
async handleBrowseLibrary(args) {
|
910
|
+
const {
|
911
|
+
library_id,
|
912
|
+
sort = "titleSort",
|
913
|
+
genre,
|
914
|
+
year,
|
915
|
+
limit = 20,
|
916
|
+
offset = 0,
|
917
|
+
play_count_min,
|
918
|
+
play_count_max,
|
919
|
+
last_played_after,
|
920
|
+
last_played_before,
|
921
|
+
played_in_last_days,
|
922
|
+
never_played,
|
923
|
+
content_rating,
|
924
|
+
resolution,
|
925
|
+
audio_format,
|
926
|
+
file_size_min,
|
927
|
+
file_size_max,
|
928
|
+
year_min,
|
929
|
+
year_max,
|
930
|
+
studio,
|
931
|
+
director,
|
932
|
+
writer,
|
933
|
+
actor,
|
934
|
+
rating_min,
|
935
|
+
rating_max,
|
936
|
+
duration_min,
|
937
|
+
duration_max,
|
938
|
+
added_after,
|
939
|
+
added_before
|
940
|
+
} = args;
|
941
|
+
|
942
|
+
try {
|
943
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
944
|
+
const plexToken = process.env.PLEX_TOKEN;
|
945
|
+
|
946
|
+
if (!plexToken) {
|
947
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
948
|
+
}
|
949
|
+
|
950
|
+
const libraryUrl = `${plexUrl}/library/sections/${library_id}/all`;
|
951
|
+
const params = {
|
952
|
+
'X-Plex-Token': plexToken,
|
953
|
+
sort: sort,
|
954
|
+
'X-Plex-Container-Start': offset,
|
955
|
+
'X-Plex-Container-Size': limit
|
956
|
+
};
|
957
|
+
|
958
|
+
if (genre) {
|
959
|
+
params.genre = genre;
|
960
|
+
}
|
961
|
+
|
962
|
+
if (year) {
|
963
|
+
params.year = year;
|
964
|
+
}
|
965
|
+
|
966
|
+
const response = await axios.get(libraryUrl, {
|
967
|
+
params,
|
968
|
+
httpsAgent: new (require('https').Agent)({
|
969
|
+
rejectUnauthorized: false,
|
970
|
+
minVersion: 'TLSv1.2'
|
971
|
+
})
|
972
|
+
});
|
973
|
+
|
974
|
+
let results = this.parseLibraryContent(response.data);
|
975
|
+
|
976
|
+
// Apply activity filters
|
977
|
+
results = this.applyActivityFilters(results, {
|
978
|
+
play_count_min,
|
979
|
+
play_count_max,
|
980
|
+
last_played_after,
|
981
|
+
last_played_before,
|
982
|
+
played_in_last_days,
|
983
|
+
never_played
|
984
|
+
});
|
985
|
+
|
986
|
+
// Apply basic content filters
|
987
|
+
results = this.applyBasicFilters(results, {
|
988
|
+
genre,
|
989
|
+
year,
|
990
|
+
year_min,
|
991
|
+
year_max,
|
992
|
+
studio,
|
993
|
+
director,
|
994
|
+
writer,
|
995
|
+
actor,
|
996
|
+
rating_min,
|
997
|
+
rating_max,
|
998
|
+
duration_min,
|
999
|
+
duration_max,
|
1000
|
+
added_after,
|
1001
|
+
added_before
|
1002
|
+
});
|
1003
|
+
|
1004
|
+
// Apply advanced filters
|
1005
|
+
results = this.applyAdvancedFilters(results, {
|
1006
|
+
content_rating,
|
1007
|
+
resolution,
|
1008
|
+
audio_format,
|
1009
|
+
file_size_min,
|
1010
|
+
file_size_max
|
1011
|
+
});
|
1012
|
+
|
1013
|
+
const totalSize = response.data.MediaContainer?.totalSize || results.length;
|
1014
|
+
|
1015
|
+
let resultText = `Library content (${offset + 1}-${Math.min(offset + limit, totalSize)} of ${totalSize})`;
|
1016
|
+
if (genre) resultText += ` | Genre: ${genre}`;
|
1017
|
+
if (year) resultText += ` | Year: ${year}`;
|
1018
|
+
if (sort !== "titleSort") resultText += ` | Sorted by: ${sort}`;
|
1019
|
+
resultText += `:\n\n${this.formatResults(results)}`;
|
1020
|
+
|
1021
|
+
return {
|
1022
|
+
content: [
|
1023
|
+
{
|
1024
|
+
type: "text",
|
1025
|
+
text: resultText,
|
1026
|
+
},
|
1027
|
+
],
|
1028
|
+
};
|
1029
|
+
} catch (error) {
|
1030
|
+
return {
|
1031
|
+
content: [
|
1032
|
+
{
|
1033
|
+
type: "text",
|
1034
|
+
text: `Error browsing library: ${error.message}`,
|
1035
|
+
},
|
1036
|
+
],
|
1037
|
+
isError: true,
|
1038
|
+
};
|
1039
|
+
}
|
1040
|
+
}
|
1041
|
+
|
1042
|
+
parseLibraryContent(data) {
|
1043
|
+
if (!data.MediaContainer || !data.MediaContainer.Metadata) {
|
1044
|
+
return [];
|
1045
|
+
}
|
1046
|
+
|
1047
|
+
return data.MediaContainer.Metadata.map(item => ({
|
1048
|
+
title: item.title,
|
1049
|
+
type: item.type,
|
1050
|
+
year: item.year,
|
1051
|
+
summary: item.summary,
|
1052
|
+
rating: item.rating,
|
1053
|
+
duration: item.duration,
|
1054
|
+
addedAt: item.addedAt,
|
1055
|
+
originallyAvailableAt: item.originallyAvailableAt,
|
1056
|
+
viewCount: item.viewCount,
|
1057
|
+
lastViewedAt: item.lastViewedAt,
|
1058
|
+
genres: item.Genre?.map(g => g.tag) || [],
|
1059
|
+
key: item.key,
|
1060
|
+
// Additional metadata for basic filters
|
1061
|
+
studio: item.studio,
|
1062
|
+
directors: item.Director ? item.Director.map(d => d.tag) : [],
|
1063
|
+
writers: item.Writer ? item.Writer.map(w => w.tag) : [],
|
1064
|
+
actors: item.Role ? item.Role.map(r => r.tag) : []
|
1065
|
+
}));
|
1066
|
+
}
|
1067
|
+
|
1068
|
+
async handleRecentlyAdded(args) {
|
1069
|
+
const { library_id, limit = 15, chunk_size = 10, chunk_offset = 0 } = args;
|
1070
|
+
|
1071
|
+
try {
|
1072
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1073
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1074
|
+
|
1075
|
+
if (!plexToken) {
|
1076
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1077
|
+
}
|
1078
|
+
|
1079
|
+
let recentUrl;
|
1080
|
+
if (library_id) {
|
1081
|
+
recentUrl = `${plexUrl}/library/sections/${library_id}/recentlyAdded`;
|
1082
|
+
} else {
|
1083
|
+
recentUrl = `${plexUrl}/library/recentlyAdded`;
|
1084
|
+
}
|
1085
|
+
|
1086
|
+
const params = {
|
1087
|
+
'X-Plex-Token': plexToken,
|
1088
|
+
'X-Plex-Container-Size': limit
|
1089
|
+
};
|
1090
|
+
|
1091
|
+
const response = await axios.get(recentUrl, {
|
1092
|
+
params,
|
1093
|
+
httpsAgent: new (require('https').Agent)({
|
1094
|
+
rejectUnauthorized: false,
|
1095
|
+
minVersion: 'TLSv1.2'
|
1096
|
+
})
|
1097
|
+
});
|
1098
|
+
|
1099
|
+
const results = this.parseLibraryContent(response.data);
|
1100
|
+
|
1101
|
+
// Apply chunking
|
1102
|
+
const totalResults = results.length;
|
1103
|
+
const start = chunk_offset;
|
1104
|
+
const end = Math.min(start + chunk_size, totalResults);
|
1105
|
+
const chunkedResults = results.slice(start, end);
|
1106
|
+
|
1107
|
+
let resultText = `Recently added content`;
|
1108
|
+
if (library_id) resultText += ` from library ${library_id}`;
|
1109
|
+
resultText += ` (showing ${start + 1}-${end} of ${totalResults} items)`;
|
1110
|
+
|
1111
|
+
if (totalResults > chunk_size) {
|
1112
|
+
const hasMore = end < totalResults;
|
1113
|
+
const hasPrevious = start > 0;
|
1114
|
+
resultText += `\n📄 Pagination: `;
|
1115
|
+
if (hasPrevious) resultText += `Previous available (offset: ${Math.max(0, start - chunk_size)}) | `;
|
1116
|
+
if (hasMore) resultText += `Next available (offset: ${end})`;
|
1117
|
+
}
|
1118
|
+
|
1119
|
+
resultText += `:\n\n${this.formatRecentlyAdded(chunkedResults)}`;
|
1120
|
+
|
1121
|
+
return {
|
1122
|
+
content: [
|
1123
|
+
{
|
1124
|
+
type: "text",
|
1125
|
+
text: resultText,
|
1126
|
+
},
|
1127
|
+
],
|
1128
|
+
};
|
1129
|
+
} catch (error) {
|
1130
|
+
return {
|
1131
|
+
content: [
|
1132
|
+
{
|
1133
|
+
type: "text",
|
1134
|
+
text: `Error getting recently added content: ${error.message}`,
|
1135
|
+
},
|
1136
|
+
],
|
1137
|
+
isError: true,
|
1138
|
+
};
|
1139
|
+
}
|
1140
|
+
}
|
1141
|
+
|
1142
|
+
formatRecentlyAdded(results) {
|
1143
|
+
return results.map((item, index) => {
|
1144
|
+
let formatted = `${index + 1}. **${item.title}**`;
|
1145
|
+
|
1146
|
+
if (item.year) {
|
1147
|
+
formatted += ` (${item.year})`;
|
1148
|
+
}
|
1149
|
+
|
1150
|
+
if (item.type) {
|
1151
|
+
formatted += ` - ${item.type}`;
|
1152
|
+
}
|
1153
|
+
|
1154
|
+
if (item.addedAt) {
|
1155
|
+
const addedDate = new Date(item.addedAt * 1000).toLocaleDateString();
|
1156
|
+
formatted += ` - Added: ${addedDate}`;
|
1157
|
+
}
|
1158
|
+
|
1159
|
+
if (item.genres && item.genres.length > 0) {
|
1160
|
+
formatted += `\n Genres: ${item.genres.slice(0, 3).join(', ')}`;
|
1161
|
+
}
|
1162
|
+
|
1163
|
+
if (item.summary) {
|
1164
|
+
formatted += `\n ${item.summary.substring(0, 120)}${item.summary.length > 120 ? '...' : ''}`;
|
1165
|
+
}
|
1166
|
+
|
1167
|
+
return formatted;
|
1168
|
+
}).join('\n\n');
|
1169
|
+
}
|
1170
|
+
|
1171
|
+
async handleWatchHistory(args) {
|
1172
|
+
const { limit = 20, account_id, chunk_size = 10, chunk_offset = 0 } = args;
|
1173
|
+
|
1174
|
+
try {
|
1175
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1176
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1177
|
+
|
1178
|
+
if (!plexToken) {
|
1179
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1180
|
+
}
|
1181
|
+
|
1182
|
+
const historyUrl = `${plexUrl}/status/sessions/history/all`;
|
1183
|
+
const params = {
|
1184
|
+
'X-Plex-Token': plexToken,
|
1185
|
+
'X-Plex-Container-Size': limit
|
1186
|
+
};
|
1187
|
+
|
1188
|
+
if (account_id) {
|
1189
|
+
params.accountID = account_id;
|
1190
|
+
}
|
1191
|
+
|
1192
|
+
const response = await axios.get(historyUrl, {
|
1193
|
+
params,
|
1194
|
+
httpsAgent: new (require('https').Agent)({
|
1195
|
+
rejectUnauthorized: false,
|
1196
|
+
minVersion: 'TLSv1.2'
|
1197
|
+
})
|
1198
|
+
});
|
1199
|
+
|
1200
|
+
const results = this.parseWatchHistory(response.data);
|
1201
|
+
|
1202
|
+
// Apply chunking
|
1203
|
+
const totalResults = results.length;
|
1204
|
+
const start = chunk_offset;
|
1205
|
+
const end = Math.min(start + chunk_size, totalResults);
|
1206
|
+
const chunkedResults = results.slice(start, end);
|
1207
|
+
|
1208
|
+
let resultText = `Watch history`;
|
1209
|
+
if (account_id) resultText += ` for account ${account_id}`;
|
1210
|
+
resultText += ` (showing ${start + 1}-${end} of ${totalResults} items)`;
|
1211
|
+
|
1212
|
+
if (totalResults > chunk_size) {
|
1213
|
+
const hasMore = end < totalResults;
|
1214
|
+
const hasPrevious = start > 0;
|
1215
|
+
resultText += `\n📄 Pagination: `;
|
1216
|
+
if (hasPrevious) resultText += `Previous available (offset: ${Math.max(0, start - chunk_size)}) | `;
|
1217
|
+
if (hasMore) resultText += `Next available (offset: ${end})`;
|
1218
|
+
}
|
1219
|
+
|
1220
|
+
resultText += `:\n\n${this.formatWatchHistory(chunkedResults)}`;
|
1221
|
+
|
1222
|
+
return {
|
1223
|
+
content: [
|
1224
|
+
{
|
1225
|
+
type: "text",
|
1226
|
+
text: resultText,
|
1227
|
+
},
|
1228
|
+
],
|
1229
|
+
};
|
1230
|
+
} catch (error) {
|
1231
|
+
return {
|
1232
|
+
content: [
|
1233
|
+
{
|
1234
|
+
type: "text",
|
1235
|
+
text: `Error getting watch history: ${error.message}`,
|
1236
|
+
},
|
1237
|
+
],
|
1238
|
+
isError: true,
|
1239
|
+
};
|
1240
|
+
}
|
1241
|
+
}
|
1242
|
+
|
1243
|
+
parseWatchHistory(data) {
|
1244
|
+
if (!data.MediaContainer || !data.MediaContainer.Metadata) {
|
1245
|
+
return [];
|
1246
|
+
}
|
1247
|
+
|
1248
|
+
return data.MediaContainer.Metadata.map(item => ({
|
1249
|
+
title: item.title,
|
1250
|
+
type: item.type,
|
1251
|
+
year: item.year,
|
1252
|
+
viewedAt: item.viewedAt,
|
1253
|
+
accountID: item.accountID,
|
1254
|
+
deviceID: item.deviceID,
|
1255
|
+
viewOffset: item.viewOffset,
|
1256
|
+
duration: item.duration,
|
1257
|
+
grandparentTitle: item.grandparentTitle, // TV show name
|
1258
|
+
parentTitle: item.parentTitle, // Season name
|
1259
|
+
index: item.index, // Episode number
|
1260
|
+
parentIndex: item.parentIndex, // Season number
|
1261
|
+
key: item.key
|
1262
|
+
}));
|
1263
|
+
}
|
1264
|
+
|
1265
|
+
formatWatchHistory(results) {
|
1266
|
+
return results.map((item, index) => {
|
1267
|
+
let formatted = `${index + 1}. **${item.title}**`;
|
1268
|
+
|
1269
|
+
if (item.grandparentTitle) {
|
1270
|
+
formatted = `${index + 1}. **${item.grandparentTitle}**`;
|
1271
|
+
if (item.parentIndex) formatted += ` S${item.parentIndex}`;
|
1272
|
+
if (item.index) formatted += `E${item.index}`;
|
1273
|
+
formatted += ` - ${item.title}`;
|
1274
|
+
}
|
1275
|
+
|
1276
|
+
if (item.year) {
|
1277
|
+
formatted += ` (${item.year})`;
|
1278
|
+
}
|
1279
|
+
|
1280
|
+
if (item.viewedAt) {
|
1281
|
+
const viewedDate = new Date(item.viewedAt * 1000);
|
1282
|
+
formatted += `\n Watched: ${viewedDate.toLocaleString()}`;
|
1283
|
+
}
|
1284
|
+
|
1285
|
+
if (item.viewOffset && item.duration) {
|
1286
|
+
const progress = Math.round((item.viewOffset / item.duration) * 100);
|
1287
|
+
formatted += ` | Progress: ${progress}%`;
|
1288
|
+
}
|
1289
|
+
|
1290
|
+
if (item.deviceID) {
|
1291
|
+
formatted += `\n Device: ${item.deviceID}`;
|
1292
|
+
}
|
1293
|
+
|
1294
|
+
return formatted;
|
1295
|
+
}).join('\n\n');
|
1296
|
+
}
|
1297
|
+
|
1298
|
+
async handleOnDeck(args) {
|
1299
|
+
const { limit = 15 } = args;
|
1300
|
+
|
1301
|
+
try {
|
1302
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1303
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1304
|
+
|
1305
|
+
if (!plexToken) {
|
1306
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1307
|
+
}
|
1308
|
+
|
1309
|
+
const onDeckUrl = `${plexUrl}/library/onDeck`;
|
1310
|
+
const params = {
|
1311
|
+
'X-Plex-Token': plexToken,
|
1312
|
+
'X-Plex-Container-Size': limit
|
1313
|
+
};
|
1314
|
+
|
1315
|
+
const response = await axios.get(onDeckUrl, {
|
1316
|
+
params,
|
1317
|
+
httpsAgent: new (require('https').Agent)({
|
1318
|
+
rejectUnauthorized: false,
|
1319
|
+
minVersion: 'TLSv1.2'
|
1320
|
+
})
|
1321
|
+
});
|
1322
|
+
|
1323
|
+
const results = this.parseOnDeck(response.data);
|
1324
|
+
|
1325
|
+
const resultText = `On Deck (Continue Watching) - ${results.length} items:\n\n${this.formatOnDeck(results)}`;
|
1326
|
+
|
1327
|
+
return {
|
1328
|
+
content: [
|
1329
|
+
{
|
1330
|
+
type: "text",
|
1331
|
+
text: resultText,
|
1332
|
+
},
|
1333
|
+
],
|
1334
|
+
};
|
1335
|
+
} catch (error) {
|
1336
|
+
return {
|
1337
|
+
content: [
|
1338
|
+
{
|
1339
|
+
type: "text",
|
1340
|
+
text: `Error getting On Deck items: ${error.message}`,
|
1341
|
+
},
|
1342
|
+
],
|
1343
|
+
isError: true,
|
1344
|
+
};
|
1345
|
+
}
|
1346
|
+
}
|
1347
|
+
|
1348
|
+
parseOnDeck(data) {
|
1349
|
+
if (!data.MediaContainer || !data.MediaContainer.Metadata) {
|
1350
|
+
return [];
|
1351
|
+
}
|
1352
|
+
|
1353
|
+
return data.MediaContainer.Metadata.map(item => ({
|
1354
|
+
title: item.title,
|
1355
|
+
type: item.type,
|
1356
|
+
year: item.year,
|
1357
|
+
viewOffset: item.viewOffset,
|
1358
|
+
duration: item.duration,
|
1359
|
+
lastViewedAt: item.lastViewedAt,
|
1360
|
+
grandparentTitle: item.grandparentTitle,
|
1361
|
+
parentTitle: item.parentTitle,
|
1362
|
+
index: item.index,
|
1363
|
+
parentIndex: item.parentIndex,
|
1364
|
+
summary: item.summary,
|
1365
|
+
rating: item.rating,
|
1366
|
+
key: item.key
|
1367
|
+
}));
|
1368
|
+
}
|
1369
|
+
|
1370
|
+
formatOnDeck(results) {
|
1371
|
+
return results.map((item, index) => {
|
1372
|
+
let formatted = `${index + 1}. **${item.title}**`;
|
1373
|
+
|
1374
|
+
if (item.grandparentTitle) {
|
1375
|
+
formatted = `${index + 1}. **${item.grandparentTitle}**`;
|
1376
|
+
if (item.parentIndex) formatted += ` S${item.parentIndex}`;
|
1377
|
+
if (item.index) formatted += `E${item.index}`;
|
1378
|
+
formatted += ` - ${item.title}`;
|
1379
|
+
}
|
1380
|
+
|
1381
|
+
if (item.year) {
|
1382
|
+
formatted += ` (${item.year})`;
|
1383
|
+
}
|
1384
|
+
|
1385
|
+
if (item.viewOffset && item.duration) {
|
1386
|
+
const progress = Math.round((item.viewOffset / item.duration) * 100);
|
1387
|
+
const remainingMinutes = Math.round((item.duration - item.viewOffset) / 60000);
|
1388
|
+
formatted += `\n Progress: ${progress}% | ${remainingMinutes} min remaining`;
|
1389
|
+
}
|
1390
|
+
|
1391
|
+
if (item.lastViewedAt) {
|
1392
|
+
const lastViewed = new Date(item.lastViewedAt * 1000);
|
1393
|
+
formatted += `\n Last watched: ${lastViewed.toLocaleDateString()}`;
|
1394
|
+
}
|
1395
|
+
|
1396
|
+
if (item.summary) {
|
1397
|
+
formatted += `\n ${item.summary.substring(0, 100)}${item.summary.length > 100 ? '...' : ''}`;
|
1398
|
+
}
|
1399
|
+
|
1400
|
+
return formatted;
|
1401
|
+
}).join('\n\n');
|
1402
|
+
}
|
1403
|
+
|
1404
|
+
async handleListPlaylists(args) {
|
1405
|
+
const { playlist_type } = args;
|
1406
|
+
|
1407
|
+
try {
|
1408
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1409
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1410
|
+
|
1411
|
+
if (!plexToken) {
|
1412
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1413
|
+
}
|
1414
|
+
|
1415
|
+
const playlistsUrl = `${plexUrl}/playlists`;
|
1416
|
+
const params = {
|
1417
|
+
'X-Plex-Token': plexToken
|
1418
|
+
};
|
1419
|
+
|
1420
|
+
if (playlist_type) {
|
1421
|
+
params.playlistType = playlist_type;
|
1422
|
+
}
|
1423
|
+
|
1424
|
+
const response = await axios.get(playlistsUrl, {
|
1425
|
+
params,
|
1426
|
+
httpsAgent: new (require('https').Agent)({
|
1427
|
+
rejectUnauthorized: false,
|
1428
|
+
minVersion: 'TLSv1.2'
|
1429
|
+
})
|
1430
|
+
});
|
1431
|
+
|
1432
|
+
const playlists = this.parsePlaylists(response.data);
|
1433
|
+
|
1434
|
+
let resultText = `Playlists`;
|
1435
|
+
if (playlist_type) resultText += ` (${playlist_type})`;
|
1436
|
+
resultText += ` - ${playlists.length} found:\n\n${this.formatPlaylists(playlists)}`;
|
1437
|
+
|
1438
|
+
return {
|
1439
|
+
content: [
|
1440
|
+
{
|
1441
|
+
type: "text",
|
1442
|
+
text: resultText,
|
1443
|
+
},
|
1444
|
+
],
|
1445
|
+
};
|
1446
|
+
} catch (error) {
|
1447
|
+
return {
|
1448
|
+
content: [
|
1449
|
+
{
|
1450
|
+
type: "text",
|
1451
|
+
text: `Error listing playlists: ${error.message}`,
|
1452
|
+
},
|
1453
|
+
],
|
1454
|
+
isError: true,
|
1455
|
+
};
|
1456
|
+
}
|
1457
|
+
}
|
1458
|
+
|
1459
|
+
parsePlaylists(data) {
|
1460
|
+
if (!data.MediaContainer || !data.MediaContainer.Metadata) {
|
1461
|
+
return [];
|
1462
|
+
}
|
1463
|
+
|
1464
|
+
return data.MediaContainer.Metadata.map(playlist => ({
|
1465
|
+
ratingKey: playlist.ratingKey,
|
1466
|
+
key: playlist.key,
|
1467
|
+
title: playlist.title,
|
1468
|
+
type: playlist.type,
|
1469
|
+
playlistType: playlist.playlistType,
|
1470
|
+
smart: playlist.smart,
|
1471
|
+
duration: playlist.duration,
|
1472
|
+
leafCount: playlist.leafCount,
|
1473
|
+
addedAt: playlist.addedAt,
|
1474
|
+
updatedAt: playlist.updatedAt,
|
1475
|
+
composite: playlist.composite
|
1476
|
+
}));
|
1477
|
+
}
|
1478
|
+
|
1479
|
+
formatPlaylists(playlists) {
|
1480
|
+
return playlists.map((playlist, index) => {
|
1481
|
+
let formatted = `${index + 1}. **${playlist.title}**`;
|
1482
|
+
|
1483
|
+
if (playlist.playlistType) {
|
1484
|
+
formatted += ` (${playlist.playlistType})`;
|
1485
|
+
}
|
1486
|
+
|
1487
|
+
if (playlist.smart) {
|
1488
|
+
formatted += ` - Smart Playlist`;
|
1489
|
+
}
|
1490
|
+
|
1491
|
+
if (playlist.leafCount) {
|
1492
|
+
formatted += `\n Items: ${playlist.leafCount}`;
|
1493
|
+
}
|
1494
|
+
|
1495
|
+
if (playlist.duration) {
|
1496
|
+
const hours = Math.floor(playlist.duration / 3600000);
|
1497
|
+
const minutes = Math.floor((playlist.duration % 3600000) / 60000);
|
1498
|
+
if (hours > 0) {
|
1499
|
+
formatted += ` | Duration: ${hours}h ${minutes}m`;
|
1500
|
+
} else {
|
1501
|
+
formatted += ` | Duration: ${minutes}m`;
|
1502
|
+
}
|
1503
|
+
}
|
1504
|
+
|
1505
|
+
if (playlist.updatedAt) {
|
1506
|
+
const updatedDate = new Date(playlist.updatedAt * 1000).toLocaleDateString();
|
1507
|
+
formatted += `\n Last updated: ${updatedDate}`;
|
1508
|
+
}
|
1509
|
+
|
1510
|
+
formatted += `\n Playlist ID: ${playlist.ratingKey}`;
|
1511
|
+
|
1512
|
+
return formatted;
|
1513
|
+
}).join('\n\n');
|
1514
|
+
}
|
1515
|
+
|
1516
|
+
async handleCreatePlaylist(args) {
|
1517
|
+
const { title, type, smart = false, item_key = null } = args;
|
1518
|
+
|
1519
|
+
// Validate that item_key is provided for non-smart playlists
|
1520
|
+
if (!smart && !item_key) {
|
1521
|
+
return {
|
1522
|
+
content: [
|
1523
|
+
{
|
1524
|
+
type: "text",
|
1525
|
+
text: `Error: Non-smart playlists require an initial item. Please provide an item_key parameter with the Plex item key to add to the playlist. You can get item keys by searching or browsing your library first.`,
|
1526
|
+
},
|
1527
|
+
],
|
1528
|
+
isError: true,
|
1529
|
+
};
|
1530
|
+
}
|
1531
|
+
|
1532
|
+
try {
|
1533
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1534
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1535
|
+
|
1536
|
+
if (!plexToken) {
|
1537
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1538
|
+
}
|
1539
|
+
|
1540
|
+
// First get server info to get machine identifier
|
1541
|
+
const serverResponse = await axios.get(`${plexUrl}/`, {
|
1542
|
+
headers: { 'X-Plex-Token': plexToken },
|
1543
|
+
httpsAgent: new (require('https').Agent)({
|
1544
|
+
rejectUnauthorized: false,
|
1545
|
+
minVersion: 'TLSv1.2'
|
1546
|
+
})
|
1547
|
+
});
|
1548
|
+
|
1549
|
+
const machineIdentifier = serverResponse.data?.MediaContainer?.machineIdentifier;
|
1550
|
+
if (!machineIdentifier) {
|
1551
|
+
throw new Error('Could not get server machine identifier');
|
1552
|
+
}
|
1553
|
+
|
1554
|
+
const params = new URLSearchParams({
|
1555
|
+
title: title,
|
1556
|
+
type: type,
|
1557
|
+
smart: smart ? '1' : '0'
|
1558
|
+
});
|
1559
|
+
|
1560
|
+
// Add URI if item_key is provided
|
1561
|
+
if (item_key) {
|
1562
|
+
const uri = `server://${machineIdentifier}/com.plexapp.plugins.library/library/metadata/${item_key}`;
|
1563
|
+
params.append('uri', uri);
|
1564
|
+
}
|
1565
|
+
|
1566
|
+
// Add required Plex headers as query parameters
|
1567
|
+
params.append('X-Plex-Token', plexToken);
|
1568
|
+
params.append('X-Plex-Product', 'Plex MCP');
|
1569
|
+
params.append('X-Plex-Version', '1.0.0');
|
1570
|
+
params.append('X-Plex-Client-Identifier', 'plex-mcp-client');
|
1571
|
+
params.append('X-Plex-Platform', 'Node.js');
|
1572
|
+
|
1573
|
+
const createUrl = `${plexUrl}/playlists?${params.toString()}`;
|
1574
|
+
|
1575
|
+
const response = await axios.post(createUrl, null, {
|
1576
|
+
headers: {
|
1577
|
+
'Content-Length': '0'
|
1578
|
+
},
|
1579
|
+
httpsAgent: new (require('https').Agent)({
|
1580
|
+
rejectUnauthorized: false,
|
1581
|
+
minVersion: 'TLSv1.2'
|
1582
|
+
})
|
1583
|
+
});
|
1584
|
+
|
1585
|
+
// Get the created playlist info from the response
|
1586
|
+
const playlistData = response.data?.MediaContainer?.Metadata?.[0];
|
1587
|
+
|
1588
|
+
let resultText = `Successfully created ${smart ? 'smart ' : ''}playlist: **${title}**`;
|
1589
|
+
if (playlistData) {
|
1590
|
+
resultText += `\n Playlist ID: ${playlistData.ratingKey}`;
|
1591
|
+
resultText += `\n Type: ${type}`;
|
1592
|
+
if (smart) resultText += `\n Smart Playlist: Yes`;
|
1593
|
+
}
|
1594
|
+
|
1595
|
+
return {
|
1596
|
+
content: [
|
1597
|
+
{
|
1598
|
+
type: "text",
|
1599
|
+
text: resultText,
|
1600
|
+
},
|
1601
|
+
],
|
1602
|
+
};
|
1603
|
+
} catch (error) {
|
1604
|
+
let errorMessage = `Error creating playlist: ${error.message}`;
|
1605
|
+
|
1606
|
+
// Check if it's a 400 Bad Request error
|
1607
|
+
if (error.response && error.response.status === 400) {
|
1608
|
+
errorMessage = `Playlist creation failed with 400 Bad Request. This may indicate that:
|
1609
|
+
1. The Plex server doesn't support playlist creation via API
|
1610
|
+
2. Additional parameters are required that aren't documented
|
1611
|
+
3. Playlists may need to be created through the Plex web interface
|
1612
|
+
|
1613
|
+
You can try creating the playlist manually in Plex and then use other MCP tools to manage it.`;
|
1614
|
+
}
|
1615
|
+
|
1616
|
+
return {
|
1617
|
+
content: [
|
1618
|
+
{
|
1619
|
+
type: "text",
|
1620
|
+
text: errorMessage,
|
1621
|
+
},
|
1622
|
+
],
|
1623
|
+
isError: true,
|
1624
|
+
};
|
1625
|
+
}
|
1626
|
+
}
|
1627
|
+
|
1628
|
+
async handleAddToPlaylist(args) {
|
1629
|
+
const { playlist_id, item_keys } = args;
|
1630
|
+
|
1631
|
+
try {
|
1632
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1633
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1634
|
+
|
1635
|
+
if (!plexToken) {
|
1636
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1637
|
+
}
|
1638
|
+
|
1639
|
+
const addUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
1640
|
+
const params = {
|
1641
|
+
'X-Plex-Token': plexToken,
|
1642
|
+
uri: item_keys.map(key => `server://localhost/com.plexapp.plugins.library/library/metadata/${key}`).join(',')
|
1643
|
+
};
|
1644
|
+
|
1645
|
+
const response = await axios.put(addUrl, null, {
|
1646
|
+
params,
|
1647
|
+
httpsAgent: new (require('https').Agent)({
|
1648
|
+
rejectUnauthorized: false,
|
1649
|
+
minVersion: 'TLSv1.2'
|
1650
|
+
})
|
1651
|
+
});
|
1652
|
+
|
1653
|
+
const resultText = `Successfully added ${item_keys.length} item(s) to playlist ${playlist_id}`;
|
1654
|
+
|
1655
|
+
return {
|
1656
|
+
content: [
|
1657
|
+
{
|
1658
|
+
type: "text",
|
1659
|
+
text: resultText,
|
1660
|
+
},
|
1661
|
+
],
|
1662
|
+
};
|
1663
|
+
} catch (error) {
|
1664
|
+
return {
|
1665
|
+
content: [
|
1666
|
+
{
|
1667
|
+
type: "text",
|
1668
|
+
text: `Error adding items to playlist: ${error.message}`,
|
1669
|
+
},
|
1670
|
+
],
|
1671
|
+
isError: true,
|
1672
|
+
};
|
1673
|
+
}
|
1674
|
+
}
|
1675
|
+
|
1676
|
+
async handleRemoveFromPlaylist(args) {
|
1677
|
+
const { playlist_id, item_keys } = args;
|
1678
|
+
|
1679
|
+
try {
|
1680
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1681
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1682
|
+
|
1683
|
+
if (!plexToken) {
|
1684
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1685
|
+
}
|
1686
|
+
|
1687
|
+
const removeUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
1688
|
+
const params = {
|
1689
|
+
'X-Plex-Token': plexToken,
|
1690
|
+
uri: item_keys.map(key => `server://localhost/com.plexapp.plugins.library/library/metadata/${key}`).join(',')
|
1691
|
+
};
|
1692
|
+
|
1693
|
+
const response = await axios.delete(removeUrl, {
|
1694
|
+
params,
|
1695
|
+
httpsAgent: new (require('https').Agent)({
|
1696
|
+
rejectUnauthorized: false,
|
1697
|
+
minVersion: 'TLSv1.2'
|
1698
|
+
})
|
1699
|
+
});
|
1700
|
+
|
1701
|
+
const resultText = `Successfully removed ${item_keys.length} item(s) from playlist ${playlist_id}`;
|
1702
|
+
|
1703
|
+
return {
|
1704
|
+
content: [
|
1705
|
+
{
|
1706
|
+
type: "text",
|
1707
|
+
text: resultText,
|
1708
|
+
},
|
1709
|
+
],
|
1710
|
+
};
|
1711
|
+
} catch (error) {
|
1712
|
+
return {
|
1713
|
+
content: [
|
1714
|
+
{
|
1715
|
+
type: "text",
|
1716
|
+
text: `Error removing items from playlist: ${error.message}`,
|
1717
|
+
},
|
1718
|
+
],
|
1719
|
+
isError: true,
|
1720
|
+
};
|
1721
|
+
}
|
1722
|
+
}
|
1723
|
+
|
1724
|
+
async handleDeletePlaylist(args) {
|
1725
|
+
const { playlist_id } = args;
|
1726
|
+
|
1727
|
+
try {
|
1728
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1729
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1730
|
+
|
1731
|
+
if (!plexToken) {
|
1732
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1733
|
+
}
|
1734
|
+
|
1735
|
+
const deleteUrl = `${plexUrl}/playlists/${playlist_id}`;
|
1736
|
+
const params = {
|
1737
|
+
'X-Plex-Token': plexToken
|
1738
|
+
};
|
1739
|
+
|
1740
|
+
const response = await axios.delete(deleteUrl, {
|
1741
|
+
params,
|
1742
|
+
httpsAgent: new (require('https').Agent)({
|
1743
|
+
rejectUnauthorized: false,
|
1744
|
+
minVersion: 'TLSv1.2'
|
1745
|
+
})
|
1746
|
+
});
|
1747
|
+
|
1748
|
+
const resultText = `Successfully deleted playlist ${playlist_id}`;
|
1749
|
+
|
1750
|
+
return {
|
1751
|
+
content: [
|
1752
|
+
{
|
1753
|
+
type: "text",
|
1754
|
+
text: resultText,
|
1755
|
+
},
|
1756
|
+
],
|
1757
|
+
};
|
1758
|
+
} catch (error) {
|
1759
|
+
return {
|
1760
|
+
content: [
|
1761
|
+
{
|
1762
|
+
type: "text",
|
1763
|
+
text: `Error deleting playlist: ${error.message}`,
|
1764
|
+
},
|
1765
|
+
],
|
1766
|
+
isError: true,
|
1767
|
+
};
|
1768
|
+
}
|
1769
|
+
}
|
1770
|
+
|
1771
|
+
async handleWatchedStatus(args) {
|
1772
|
+
const { item_keys, account_id } = args;
|
1773
|
+
|
1774
|
+
try {
|
1775
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1776
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1777
|
+
|
1778
|
+
if (!plexToken) {
|
1779
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1780
|
+
}
|
1781
|
+
|
1782
|
+
const statusResults = [];
|
1783
|
+
|
1784
|
+
// Check each item individually to get detailed status
|
1785
|
+
for (const itemKey of item_keys) {
|
1786
|
+
try {
|
1787
|
+
const itemUrl = `${plexUrl}/library/metadata/${itemKey}`;
|
1788
|
+
const params = {
|
1789
|
+
'X-Plex-Token': plexToken
|
1790
|
+
};
|
1791
|
+
|
1792
|
+
if (account_id) {
|
1793
|
+
params.accountID = account_id;
|
1794
|
+
}
|
1795
|
+
|
1796
|
+
const response = await axios.get(itemUrl, {
|
1797
|
+
params,
|
1798
|
+
httpsAgent: new (require('https').Agent)({
|
1799
|
+
rejectUnauthorized: false,
|
1800
|
+
minVersion: 'TLSv1.2'
|
1801
|
+
})
|
1802
|
+
});
|
1803
|
+
|
1804
|
+
const item = response.data?.MediaContainer?.Metadata?.[0];
|
1805
|
+
if (item) {
|
1806
|
+
statusResults.push(this.parseWatchedStatus(item));
|
1807
|
+
} else {
|
1808
|
+
statusResults.push({
|
1809
|
+
ratingKey: itemKey,
|
1810
|
+
title: 'Unknown',
|
1811
|
+
error: 'Item not found'
|
1812
|
+
});
|
1813
|
+
}
|
1814
|
+
} catch (error) {
|
1815
|
+
statusResults.push({
|
1816
|
+
ratingKey: itemKey,
|
1817
|
+
title: 'Unknown',
|
1818
|
+
error: error.message
|
1819
|
+
});
|
1820
|
+
}
|
1821
|
+
}
|
1822
|
+
|
1823
|
+
let resultText = `Watch status for ${item_keys.length} item(s)`;
|
1824
|
+
if (account_id) resultText += ` (account: ${account_id})`;
|
1825
|
+
resultText += `:\n\n${this.formatWatchedStatus(statusResults)}`;
|
1826
|
+
|
1827
|
+
return {
|
1828
|
+
content: [
|
1829
|
+
{
|
1830
|
+
type: "text",
|
1831
|
+
text: resultText,
|
1832
|
+
},
|
1833
|
+
],
|
1834
|
+
};
|
1835
|
+
} catch (error) {
|
1836
|
+
return {
|
1837
|
+
content: [
|
1838
|
+
{
|
1839
|
+
type: "text",
|
1840
|
+
text: `Error checking watch status: ${error.message}`,
|
1841
|
+
},
|
1842
|
+
],
|
1843
|
+
isError: true,
|
1844
|
+
};
|
1845
|
+
}
|
1846
|
+
}
|
1847
|
+
|
1848
|
+
parseWatchedStatus(item) {
|
1849
|
+
return {
|
1850
|
+
ratingKey: item.ratingKey,
|
1851
|
+
title: item.title,
|
1852
|
+
type: item.type,
|
1853
|
+
year: item.year,
|
1854
|
+
viewCount: item.viewCount || 0,
|
1855
|
+
lastViewedAt: item.lastViewedAt,
|
1856
|
+
viewOffset: item.viewOffset || 0,
|
1857
|
+
duration: item.duration,
|
1858
|
+
watched: item.viewCount > 0,
|
1859
|
+
partiallyWatched: item.viewOffset > 0 && item.viewOffset < item.duration,
|
1860
|
+
grandparentTitle: item.grandparentTitle,
|
1861
|
+
parentTitle: item.parentTitle,
|
1862
|
+
index: item.index,
|
1863
|
+
parentIndex: item.parentIndex
|
1864
|
+
};
|
1865
|
+
}
|
1866
|
+
|
1867
|
+
formatWatchedStatus(statusResults) {
|
1868
|
+
return statusResults.map((item, index) => {
|
1869
|
+
if (item.error) {
|
1870
|
+
return `${index + 1}. **${item.title}** (ID: ${item.ratingKey})\n Error: ${item.error}`;
|
1871
|
+
}
|
1872
|
+
|
1873
|
+
let formatted = `${index + 1}. **${item.title}**`;
|
1874
|
+
|
1875
|
+
if (item.grandparentTitle) {
|
1876
|
+
formatted = `${index + 1}. **${item.grandparentTitle}**`;
|
1877
|
+
if (item.parentIndex) formatted += ` S${item.parentIndex}`;
|
1878
|
+
if (item.index) formatted += `E${item.index}`;
|
1879
|
+
formatted += ` - ${item.title}`;
|
1880
|
+
}
|
1881
|
+
|
1882
|
+
if (item.year) {
|
1883
|
+
formatted += ` (${item.year})`;
|
1884
|
+
}
|
1885
|
+
|
1886
|
+
// Watch status
|
1887
|
+
if (item.watched) {
|
1888
|
+
formatted += `\n Status: ✅ Watched`;
|
1889
|
+
if (item.viewCount > 1) {
|
1890
|
+
formatted += ` (${item.viewCount} times)`;
|
1891
|
+
}
|
1892
|
+
} else if (item.partiallyWatched) {
|
1893
|
+
const progress = Math.round((item.viewOffset / item.duration) * 100);
|
1894
|
+
const remainingMinutes = Math.round((item.duration - item.viewOffset) / 60000);
|
1895
|
+
formatted += `\n Status: ⏸️ In Progress (${progress}% complete, ${remainingMinutes}m remaining)`;
|
1896
|
+
} else {
|
1897
|
+
formatted += `\n Status: ⬜ Unwatched`;
|
1898
|
+
}
|
1899
|
+
|
1900
|
+
if (item.lastViewedAt) {
|
1901
|
+
const lastViewed = new Date(item.lastViewedAt * 1000);
|
1902
|
+
formatted += `\n Last watched: ${lastViewed.toLocaleString()}`;
|
1903
|
+
}
|
1904
|
+
|
1905
|
+
formatted += `\n Item ID: ${item.ratingKey}`;
|
1906
|
+
|
1907
|
+
return formatted;
|
1908
|
+
}).join('\n\n');
|
1909
|
+
}
|
1910
|
+
|
1911
|
+
applyActivityFilters(results, filters) {
|
1912
|
+
const {
|
1913
|
+
play_count_min,
|
1914
|
+
play_count_max,
|
1915
|
+
last_played_after,
|
1916
|
+
last_played_before,
|
1917
|
+
played_in_last_days,
|
1918
|
+
never_played
|
1919
|
+
} = filters;
|
1920
|
+
|
1921
|
+
return results.filter(item => {
|
1922
|
+
// Play count filters
|
1923
|
+
if (play_count_min !== undefined && (item.viewCount || 0) < play_count_min) {
|
1924
|
+
return false;
|
1925
|
+
}
|
1926
|
+
|
1927
|
+
if (play_count_max !== undefined && (item.viewCount || 0) > play_count_max) {
|
1928
|
+
return false;
|
1929
|
+
}
|
1930
|
+
|
1931
|
+
// Never played filter
|
1932
|
+
if (never_played && (item.viewCount || 0) > 0) {
|
1933
|
+
return false;
|
1934
|
+
}
|
1935
|
+
|
1936
|
+
// Date-based filters
|
1937
|
+
if (item.lastViewedAt) {
|
1938
|
+
const lastViewedDate = new Date(item.lastViewedAt * 1000);
|
1939
|
+
|
1940
|
+
if (last_played_after) {
|
1941
|
+
const afterDate = new Date(last_played_after);
|
1942
|
+
if (lastViewedDate < afterDate) {
|
1943
|
+
return false;
|
1944
|
+
}
|
1945
|
+
}
|
1946
|
+
|
1947
|
+
if (last_played_before) {
|
1948
|
+
const beforeDate = new Date(last_played_before);
|
1949
|
+
if (lastViewedDate > beforeDate) {
|
1950
|
+
return false;
|
1951
|
+
}
|
1952
|
+
}
|
1953
|
+
|
1954
|
+
if (played_in_last_days) {
|
1955
|
+
const cutoffDate = new Date();
|
1956
|
+
cutoffDate.setDate(cutoffDate.getDate() - played_in_last_days);
|
1957
|
+
if (lastViewedDate < cutoffDate) {
|
1958
|
+
return false;
|
1959
|
+
}
|
1960
|
+
}
|
1961
|
+
} else {
|
1962
|
+
// Item has never been played
|
1963
|
+
if (last_played_after || last_played_before || played_in_last_days) {
|
1964
|
+
return false; // Exclude unplayed items when filtering by play dates
|
1965
|
+
}
|
1966
|
+
}
|
1967
|
+
|
1968
|
+
return true;
|
1969
|
+
});
|
1970
|
+
}
|
1971
|
+
|
1972
|
+
applyAdvancedFilters(results, filters) {
|
1973
|
+
const {
|
1974
|
+
content_rating,
|
1975
|
+
resolution,
|
1976
|
+
audio_format,
|
1977
|
+
file_size_min,
|
1978
|
+
file_size_max
|
1979
|
+
} = filters;
|
1980
|
+
|
1981
|
+
return results.filter(item => {
|
1982
|
+
// Content rating filter
|
1983
|
+
if (content_rating && item.contentRating !== content_rating) {
|
1984
|
+
return false;
|
1985
|
+
}
|
1986
|
+
|
1987
|
+
// Resolution filter (requires Media array access)
|
1988
|
+
if (resolution) {
|
1989
|
+
// If resolution filter is applied but item has no Media, exclude it
|
1990
|
+
if (!item.Media || item.Media.length === 0) {
|
1991
|
+
return false;
|
1992
|
+
}
|
1993
|
+
|
1994
|
+
const hasResolution = item.Media.some(media => {
|
1995
|
+
// Get height either from height property or derive from videoResolution
|
1996
|
+
let height = 0;
|
1997
|
+
|
1998
|
+
if (media.height) {
|
1999
|
+
height = parseInt(media.height);
|
2000
|
+
} else if (media.videoResolution) {
|
2001
|
+
// Convert videoResolution string to height for comparison
|
2002
|
+
switch (media.videoResolution) {
|
2003
|
+
case '4k':
|
2004
|
+
height = 2160;
|
2005
|
+
break;
|
2006
|
+
case '1080':
|
2007
|
+
height = 1080;
|
2008
|
+
break;
|
2009
|
+
case '720':
|
2010
|
+
height = 720;
|
2011
|
+
break;
|
2012
|
+
case '480':
|
2013
|
+
case 'sd':
|
2014
|
+
height = 480;
|
2015
|
+
break;
|
2016
|
+
default:
|
2017
|
+
height = 0;
|
2018
|
+
}
|
2019
|
+
}
|
2020
|
+
|
2021
|
+
if (height === 0) return false;
|
2022
|
+
|
2023
|
+
// Apply resolution filter based on height
|
2024
|
+
switch (resolution) {
|
2025
|
+
case '4k':
|
2026
|
+
return height >= 2160;
|
2027
|
+
case '1080':
|
2028
|
+
return height >= 1080;
|
2029
|
+
case '720':
|
2030
|
+
return height >= 720;
|
2031
|
+
case '480':
|
2032
|
+
return height >= 480;
|
2033
|
+
case 'sd':
|
2034
|
+
return height < 720;
|
2035
|
+
default:
|
2036
|
+
return false;
|
2037
|
+
}
|
2038
|
+
});
|
2039
|
+
|
2040
|
+
if (!hasResolution) return false;
|
2041
|
+
}
|
2042
|
+
|
2043
|
+
// Audio format filter (requires Media/Part array access)
|
2044
|
+
if (audio_format) {
|
2045
|
+
// If audio format filter is applied but item has no Media, exclude it
|
2046
|
+
if (!item.Media || item.Media.length === 0) {
|
2047
|
+
return false;
|
2048
|
+
}
|
2049
|
+
|
2050
|
+
const hasAudioFormat = item.Media.some(media =>
|
2051
|
+
media.Part && media.Part.some(part => {
|
2052
|
+
if (!part.audioCodec) return false;
|
2053
|
+
|
2054
|
+
switch (audio_format) {
|
2055
|
+
case 'lossless':
|
2056
|
+
return ['flac', 'alac', 'dts', 'truehd'].includes(part.audioCodec.toLowerCase());
|
2057
|
+
case 'lossy':
|
2058
|
+
return ['mp3', 'aac', 'ogg', 'ac3'].includes(part.audioCodec.toLowerCase());
|
2059
|
+
case 'mp3':
|
2060
|
+
return part.audioCodec.toLowerCase() === 'mp3';
|
2061
|
+
case 'flac':
|
2062
|
+
return part.audioCodec.toLowerCase() === 'flac';
|
2063
|
+
case 'aac':
|
2064
|
+
return part.audioCodec.toLowerCase() === 'aac';
|
2065
|
+
default:
|
2066
|
+
return false;
|
2067
|
+
}
|
2068
|
+
})
|
2069
|
+
);
|
2070
|
+
|
2071
|
+
if (!hasAudioFormat) return false;
|
2072
|
+
}
|
2073
|
+
|
2074
|
+
// File size filters (requires Media/Part array access)
|
2075
|
+
if (file_size_min !== undefined || file_size_max !== undefined) {
|
2076
|
+
// If file size filter is applied but item has no Media, exclude it
|
2077
|
+
if (!item.Media || item.Media.length === 0) {
|
2078
|
+
return false;
|
2079
|
+
}
|
2080
|
+
|
2081
|
+
const totalSize = item.Media.reduce((total, media) => {
|
2082
|
+
if (media.Part) {
|
2083
|
+
return total + media.Part.reduce((partTotal, part) => {
|
2084
|
+
return partTotal + (part.size ? parseInt(part.size) / (1024 * 1024) : 0); // Convert to MB
|
2085
|
+
}, 0);
|
2086
|
+
}
|
2087
|
+
return total;
|
2088
|
+
}, 0);
|
2089
|
+
|
2090
|
+
if (file_size_min !== undefined && totalSize < file_size_min) {
|
2091
|
+
return false;
|
2092
|
+
}
|
2093
|
+
|
2094
|
+
if (file_size_max !== undefined && totalSize > file_size_max) {
|
2095
|
+
return false;
|
2096
|
+
}
|
2097
|
+
}
|
2098
|
+
|
2099
|
+
return true;
|
2100
|
+
});
|
2101
|
+
}
|
2102
|
+
|
2103
|
+
applyBasicFilters(results, filters) {
|
2104
|
+
const {
|
2105
|
+
genre,
|
2106
|
+
year,
|
2107
|
+
year_min,
|
2108
|
+
year_max,
|
2109
|
+
studio,
|
2110
|
+
director,
|
2111
|
+
writer,
|
2112
|
+
actor,
|
2113
|
+
rating_min,
|
2114
|
+
rating_max,
|
2115
|
+
duration_min,
|
2116
|
+
duration_max,
|
2117
|
+
added_after,
|
2118
|
+
added_before
|
2119
|
+
} = filters;
|
2120
|
+
|
2121
|
+
return results.filter(item => {
|
2122
|
+
// Genre filter
|
2123
|
+
if (genre && item.genres) {
|
2124
|
+
const hasGenre = item.genres.some(g =>
|
2125
|
+
g.toLowerCase().includes(genre.toLowerCase())
|
2126
|
+
);
|
2127
|
+
if (!hasGenre) return false;
|
2128
|
+
}
|
2129
|
+
|
2130
|
+
// Year filters
|
2131
|
+
if (year && item.year !== year) {
|
2132
|
+
return false;
|
2133
|
+
}
|
2134
|
+
|
2135
|
+
if (year_min && (!item.year || item.year < year_min)) {
|
2136
|
+
return false;
|
2137
|
+
}
|
2138
|
+
|
2139
|
+
if (year_max && (!item.year || item.year > year_max)) {
|
2140
|
+
return false;
|
2141
|
+
}
|
2142
|
+
|
2143
|
+
// Studio filter
|
2144
|
+
if (studio && item.studio) {
|
2145
|
+
if (!item.studio.toLowerCase().includes(studio.toLowerCase())) {
|
2146
|
+
return false;
|
2147
|
+
}
|
2148
|
+
}
|
2149
|
+
|
2150
|
+
// Director filter (requires detailed metadata)
|
2151
|
+
if (director && item.directors) {
|
2152
|
+
const hasDirector = item.directors.some(d =>
|
2153
|
+
d.toLowerCase().includes(director.toLowerCase())
|
2154
|
+
);
|
2155
|
+
if (!hasDirector) return false;
|
2156
|
+
}
|
2157
|
+
|
2158
|
+
// Writer filter (requires detailed metadata)
|
2159
|
+
if (writer && item.writers) {
|
2160
|
+
const hasWriter = item.writers.some(w =>
|
2161
|
+
w.toLowerCase().includes(writer.toLowerCase())
|
2162
|
+
);
|
2163
|
+
if (!hasWriter) return false;
|
2164
|
+
}
|
2165
|
+
|
2166
|
+
// Actor filter (requires detailed metadata)
|
2167
|
+
if (actor && item.actors) {
|
2168
|
+
const hasActor = item.actors.some(a =>
|
2169
|
+
a.toLowerCase().includes(actor.toLowerCase())
|
2170
|
+
);
|
2171
|
+
if (!hasActor) return false;
|
2172
|
+
}
|
2173
|
+
|
2174
|
+
// Rating filters
|
2175
|
+
if (rating_min !== undefined && (!item.rating || item.rating < rating_min)) {
|
2176
|
+
return false;
|
2177
|
+
}
|
2178
|
+
|
2179
|
+
if (rating_max !== undefined && (!item.rating || item.rating > rating_max)) {
|
2180
|
+
return false;
|
2181
|
+
}
|
2182
|
+
|
2183
|
+
// Duration filters (convert to minutes)
|
2184
|
+
if (duration_min !== undefined && item.duration) {
|
2185
|
+
const durationMinutes = Math.floor(item.duration / 60000);
|
2186
|
+
if (durationMinutes < duration_min) {
|
2187
|
+
return false;
|
2188
|
+
}
|
2189
|
+
}
|
2190
|
+
|
2191
|
+
if (duration_max !== undefined && item.duration) {
|
2192
|
+
const durationMinutes = Math.floor(item.duration / 60000);
|
2193
|
+
if (durationMinutes > duration_max) {
|
2194
|
+
return false;
|
2195
|
+
}
|
2196
|
+
}
|
2197
|
+
|
2198
|
+
// Added date filters
|
2199
|
+
if (item.addedAt) {
|
2200
|
+
const addedDate = new Date(item.addedAt * 1000);
|
2201
|
+
|
2202
|
+
if (added_after) {
|
2203
|
+
const afterDate = new Date(added_after);
|
2204
|
+
if (addedDate < afterDate) {
|
2205
|
+
return false;
|
2206
|
+
}
|
2207
|
+
}
|
2208
|
+
|
2209
|
+
if (added_before) {
|
2210
|
+
const beforeDate = new Date(added_before);
|
2211
|
+
if (addedDate > beforeDate) {
|
2212
|
+
return false;
|
2213
|
+
}
|
2214
|
+
}
|
2215
|
+
}
|
2216
|
+
|
2217
|
+
return true;
|
2218
|
+
});
|
2219
|
+
}
|
2220
|
+
|
2221
|
+
async handleGetCollections(args) {
|
2222
|
+
const { library_id } = args;
|
2223
|
+
|
2224
|
+
try {
|
2225
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
2226
|
+
const plexToken = process.env.PLEX_TOKEN;
|
2227
|
+
|
2228
|
+
if (!plexToken) {
|
2229
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
2230
|
+
}
|
2231
|
+
|
2232
|
+
let collectionsUrl;
|
2233
|
+
if (library_id) {
|
2234
|
+
collectionsUrl = `${plexUrl}/library/sections/${library_id}/collections`;
|
2235
|
+
} else {
|
2236
|
+
collectionsUrl = `${plexUrl}/library/collections`;
|
2237
|
+
}
|
2238
|
+
|
2239
|
+
const params = {
|
2240
|
+
'X-Plex-Token': plexToken
|
2241
|
+
};
|
2242
|
+
|
2243
|
+
const response = await axios.get(collectionsUrl, {
|
2244
|
+
params,
|
2245
|
+
httpsAgent: new (require('https').Agent)({
|
2246
|
+
rejectUnauthorized: false,
|
2247
|
+
minVersion: 'TLSv1.2'
|
2248
|
+
})
|
2249
|
+
});
|
2250
|
+
|
2251
|
+
const collections = this.parseCollections(response.data);
|
2252
|
+
|
2253
|
+
let resultText = `Collections`;
|
2254
|
+
if (library_id) resultText += ` from library ${library_id}`;
|
2255
|
+
resultText += ` - ${collections.length} found:\n\n${this.formatCollections(collections)}`;
|
2256
|
+
|
2257
|
+
return {
|
2258
|
+
content: [
|
2259
|
+
{
|
2260
|
+
type: "text",
|
2261
|
+
text: resultText,
|
2262
|
+
},
|
2263
|
+
],
|
2264
|
+
};
|
2265
|
+
} catch (error) {
|
2266
|
+
return {
|
2267
|
+
content: [
|
2268
|
+
{
|
2269
|
+
type: "text",
|
2270
|
+
text: `Error getting collections: ${error.message}`,
|
2271
|
+
},
|
2272
|
+
],
|
2273
|
+
isError: true,
|
2274
|
+
};
|
2275
|
+
}
|
2276
|
+
}
|
2277
|
+
|
2278
|
+
async handleBrowseCollection(args) {
|
2279
|
+
const { collection_id, sort = "titleSort", limit = 20, offset = 0 } = args;
|
2280
|
+
|
2281
|
+
try {
|
2282
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
2283
|
+
const plexToken = process.env.PLEX_TOKEN;
|
2284
|
+
|
2285
|
+
if (!plexToken) {
|
2286
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
2287
|
+
}
|
2288
|
+
|
2289
|
+
const collectionUrl = `${plexUrl}/library/collections/${collection_id}/children`;
|
2290
|
+
const params = {
|
2291
|
+
'X-Plex-Token': plexToken,
|
2292
|
+
sort: sort,
|
2293
|
+
'X-Plex-Container-Start': offset,
|
2294
|
+
'X-Plex-Container-Size': limit
|
2295
|
+
};
|
2296
|
+
|
2297
|
+
const response = await axios.get(collectionUrl, {
|
2298
|
+
params,
|
2299
|
+
httpsAgent: new (require('https').Agent)({
|
2300
|
+
rejectUnauthorized: false,
|
2301
|
+
minVersion: 'TLSv1.2'
|
2302
|
+
})
|
2303
|
+
});
|
2304
|
+
|
2305
|
+
const results = this.parseLibraryContent(response.data);
|
2306
|
+
const totalSize = response.data.MediaContainer?.totalSize || results.length;
|
2307
|
+
|
2308
|
+
let resultText = `Collection content (${offset + 1}-${Math.min(offset + limit, totalSize)} of ${totalSize})`;
|
2309
|
+
if (sort !== "titleSort") resultText += ` | Sorted by: ${sort}`;
|
2310
|
+
resultText += `:\n\n${this.formatResults(results)}`;
|
2311
|
+
|
2312
|
+
return {
|
2313
|
+
content: [
|
2314
|
+
{
|
2315
|
+
type: "text",
|
2316
|
+
text: resultText,
|
2317
|
+
},
|
2318
|
+
],
|
2319
|
+
};
|
2320
|
+
} catch (error) {
|
2321
|
+
return {
|
2322
|
+
content: [
|
2323
|
+
{
|
2324
|
+
type: "text",
|
2325
|
+
text: `Error browsing collection: ${error.message}`,
|
2326
|
+
},
|
2327
|
+
],
|
2328
|
+
isError: true,
|
2329
|
+
};
|
2330
|
+
}
|
2331
|
+
}
|
2332
|
+
|
2333
|
+
parseCollections(data) {
|
2334
|
+
if (!data.MediaContainer || !data.MediaContainer.Metadata) {
|
2335
|
+
return [];
|
2336
|
+
}
|
2337
|
+
|
2338
|
+
return data.MediaContainer.Metadata.map(collection => ({
|
2339
|
+
ratingKey: collection.ratingKey,
|
2340
|
+
key: collection.key,
|
2341
|
+
title: collection.title,
|
2342
|
+
type: collection.type,
|
2343
|
+
subtype: collection.subtype,
|
2344
|
+
summary: collection.summary,
|
2345
|
+
childCount: collection.childCount,
|
2346
|
+
addedAt: collection.addedAt,
|
2347
|
+
updatedAt: collection.updatedAt,
|
2348
|
+
thumb: collection.thumb,
|
2349
|
+
smart: collection.smart
|
2350
|
+
}));
|
2351
|
+
}
|
2352
|
+
|
2353
|
+
formatCollections(collections) {
|
2354
|
+
return collections.map((collection, index) => {
|
2355
|
+
let formatted = `${index + 1}. **${collection.title}**`;
|
2356
|
+
|
2357
|
+
if (collection.subtype) {
|
2358
|
+
formatted += ` (${collection.subtype})`;
|
2359
|
+
}
|
2360
|
+
|
2361
|
+
if (collection.smart) {
|
2362
|
+
formatted += ` - Smart Collection`;
|
2363
|
+
}
|
2364
|
+
|
2365
|
+
if (collection.childCount) {
|
2366
|
+
formatted += `\n Items: ${collection.childCount}`;
|
2367
|
+
}
|
2368
|
+
|
2369
|
+
if (collection.summary) {
|
2370
|
+
formatted += `\n ${collection.summary.substring(0, 120)}${collection.summary.length > 120 ? '...' : ''}`;
|
2371
|
+
}
|
2372
|
+
|
2373
|
+
if (collection.addedAt) {
|
2374
|
+
const addedDate = new Date(collection.addedAt * 1000).toLocaleDateString();
|
2375
|
+
formatted += `\n Created: ${addedDate}`;
|
2376
|
+
}
|
2377
|
+
|
2378
|
+
formatted += `\n Collection ID: ${collection.ratingKey}`;
|
2379
|
+
|
2380
|
+
return formatted;
|
2381
|
+
}).join('\n\n');
|
2382
|
+
}
|
2383
|
+
|
2384
|
+
async handleGetMediaInfo(args) {
|
2385
|
+
const { item_key } = args;
|
2386
|
+
|
2387
|
+
try {
|
2388
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
2389
|
+
const plexToken = process.env.PLEX_TOKEN;
|
2390
|
+
|
2391
|
+
if (!plexToken) {
|
2392
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
2393
|
+
}
|
2394
|
+
|
2395
|
+
const mediaUrl = `${plexUrl}/library/metadata/${item_key}`;
|
2396
|
+
const params = {
|
2397
|
+
'X-Plex-Token': plexToken
|
2398
|
+
};
|
2399
|
+
|
2400
|
+
const response = await axios.get(mediaUrl, {
|
2401
|
+
params,
|
2402
|
+
httpsAgent: new (require('https').Agent)({
|
2403
|
+
rejectUnauthorized: false,
|
2404
|
+
minVersion: 'TLSv1.2'
|
2405
|
+
})
|
2406
|
+
});
|
2407
|
+
|
2408
|
+
const item = response.data?.MediaContainer?.Metadata?.[0];
|
2409
|
+
if (!item) {
|
2410
|
+
throw new Error('Item not found');
|
2411
|
+
}
|
2412
|
+
|
2413
|
+
const mediaInfo = this.parseMediaInfo(item);
|
2414
|
+
|
2415
|
+
const resultText = `Media Information for "${item.title}":\\n\\n${this.formatMediaInfo(mediaInfo)}`;
|
2416
|
+
|
2417
|
+
return {
|
2418
|
+
content: [
|
2419
|
+
{
|
2420
|
+
type: "text",
|
2421
|
+
text: resultText,
|
2422
|
+
},
|
2423
|
+
],
|
2424
|
+
};
|
2425
|
+
} catch (error) {
|
2426
|
+
return {
|
2427
|
+
content: [
|
2428
|
+
{
|
2429
|
+
type: "text",
|
2430
|
+
text: `Error getting media info: ${error.message}`,
|
2431
|
+
},
|
2432
|
+
],
|
2433
|
+
isError: true,
|
2434
|
+
};
|
2435
|
+
}
|
2436
|
+
}
|
2437
|
+
|
2438
|
+
parseMediaInfo(item) {
|
2439
|
+
const mediaInfo = {
|
2440
|
+
title: item.title,
|
2441
|
+
type: item.type,
|
2442
|
+
year: item.year,
|
2443
|
+
duration: item.duration,
|
2444
|
+
addedAt: item.addedAt,
|
2445
|
+
updatedAt: item.updatedAt,
|
2446
|
+
contentRating: item.contentRating,
|
2447
|
+
studio: item.studio,
|
2448
|
+
originallyAvailableAt: item.originallyAvailableAt,
|
2449
|
+
media: []
|
2450
|
+
};
|
2451
|
+
|
2452
|
+
if (item.Media && item.Media.length > 0) {
|
2453
|
+
mediaInfo.media = item.Media.map(media => ({
|
2454
|
+
id: media.id,
|
2455
|
+
duration: media.duration,
|
2456
|
+
bitrate: media.bitrate,
|
2457
|
+
width: media.width,
|
2458
|
+
height: media.height,
|
2459
|
+
aspectRatio: media.aspectRatio,
|
2460
|
+
audioChannels: media.audioChannels,
|
2461
|
+
audioCodec: media.audioCodec,
|
2462
|
+
videoCodec: media.videoCodec,
|
2463
|
+
videoResolution: media.videoResolution,
|
2464
|
+
container: media.container,
|
2465
|
+
videoFrameRate: media.videoFrameRate,
|
2466
|
+
audioProfile: media.audioProfile,
|
2467
|
+
videoProfile: media.videoProfile,
|
2468
|
+
parts: media.Part ? media.Part.map(part => ({
|
2469
|
+
id: part.id,
|
2470
|
+
key: part.key,
|
2471
|
+
duration: part.duration,
|
2472
|
+
file: part.file,
|
2473
|
+
size: part.size,
|
2474
|
+
audioProfile: part.audioProfile,
|
2475
|
+
container: part.container,
|
2476
|
+
videoProfile: part.videoProfile,
|
2477
|
+
streams: part.Stream ? part.Stream.map(stream => ({
|
2478
|
+
id: stream.id,
|
2479
|
+
streamType: stream.streamType,
|
2480
|
+
default: stream.default,
|
2481
|
+
codec: stream.codec,
|
2482
|
+
index: stream.index,
|
2483
|
+
bitrate: stream.bitrate,
|
2484
|
+
language: stream.language,
|
2485
|
+
languageCode: stream.languageCode,
|
2486
|
+
bitDepth: stream.bitDepth,
|
2487
|
+
chromaLocation: stream.chromaLocation,
|
2488
|
+
chromaSubsampling: stream.chromaSubsampling,
|
2489
|
+
codedHeight: stream.codedHeight,
|
2490
|
+
codedWidth: stream.codedWidth,
|
2491
|
+
colorRange: stream.colorRange,
|
2492
|
+
frameRate: stream.frameRate,
|
2493
|
+
height: stream.height,
|
2494
|
+
width: stream.width,
|
2495
|
+
displayTitle: stream.displayTitle,
|
2496
|
+
extendedDisplayTitle: stream.extendedDisplayTitle,
|
2497
|
+
channels: stream.channels,
|
2498
|
+
audioChannelLayout: stream.audioChannelLayout,
|
2499
|
+
samplingRate: stream.samplingRate,
|
2500
|
+
profile: stream.profile,
|
2501
|
+
refFrames: stream.refFrames,
|
2502
|
+
scanType: stream.scanType,
|
2503
|
+
title: stream.title
|
2504
|
+
})) : []
|
2505
|
+
})) : []
|
2506
|
+
}));
|
2507
|
+
}
|
2508
|
+
|
2509
|
+
return mediaInfo;
|
2510
|
+
}
|
2511
|
+
|
2512
|
+
formatMediaInfo(mediaInfo) {
|
2513
|
+
let formatted = `**${mediaInfo.title}**`;
|
2514
|
+
|
2515
|
+
if (mediaInfo.year) {
|
2516
|
+
formatted += ` (${mediaInfo.year})`;
|
2517
|
+
}
|
2518
|
+
|
2519
|
+
if (mediaInfo.type) {
|
2520
|
+
formatted += ` - ${mediaInfo.type}`;
|
2521
|
+
}
|
2522
|
+
|
2523
|
+
if (mediaInfo.duration) {
|
2524
|
+
const hours = Math.floor(mediaInfo.duration / 3600000);
|
2525
|
+
const minutes = Math.floor((mediaInfo.duration % 3600000) / 60000);
|
2526
|
+
if (hours > 0) {
|
2527
|
+
formatted += `\\n Duration: ${hours}h ${minutes}m`;
|
2528
|
+
} else {
|
2529
|
+
formatted += `\\n Duration: ${minutes}m`;
|
2530
|
+
}
|
2531
|
+
}
|
2532
|
+
|
2533
|
+
if (mediaInfo.contentRating) {
|
2534
|
+
formatted += ` | Rating: ${mediaInfo.contentRating}`;
|
2535
|
+
}
|
2536
|
+
|
2537
|
+
if (mediaInfo.studio) {
|
2538
|
+
formatted += `\\n Studio: ${mediaInfo.studio}`;
|
2539
|
+
}
|
2540
|
+
|
2541
|
+
if (mediaInfo.originallyAvailableAt) {
|
2542
|
+
const releaseDate = new Date(mediaInfo.originallyAvailableAt).toLocaleDateString();
|
2543
|
+
formatted += `\\n Released: ${releaseDate}`;
|
2544
|
+
}
|
2545
|
+
|
2546
|
+
if (mediaInfo.addedAt) {
|
2547
|
+
const addedDate = new Date(mediaInfo.addedAt * 1000).toLocaleDateString();
|
2548
|
+
formatted += `\\n Added to library: ${addedDate}`;
|
2549
|
+
}
|
2550
|
+
|
2551
|
+
// Format media files information
|
2552
|
+
if (mediaInfo.media && mediaInfo.media.length > 0) {
|
2553
|
+
formatted += `\\n\\n**Media Files (${mediaInfo.media.length} version${mediaInfo.media.length > 1 ? 's' : ''}):**`;
|
2554
|
+
|
2555
|
+
mediaInfo.media.forEach((media, index) => {
|
2556
|
+
formatted += `\\n\\n**Version ${index + 1}:**`;
|
2557
|
+
|
2558
|
+
if (media.container) {
|
2559
|
+
formatted += `\\n Container: ${media.container.toUpperCase()}`;
|
2560
|
+
}
|
2561
|
+
|
2562
|
+
if (media.bitrate) {
|
2563
|
+
formatted += ` | Bitrate: ${media.bitrate} kbps`;
|
2564
|
+
}
|
2565
|
+
|
2566
|
+
if (media.width && media.height) {
|
2567
|
+
formatted += `\\n Resolution: ${media.width}×${media.height}`;
|
2568
|
+
if (media.videoResolution) {
|
2569
|
+
formatted += ` (${media.videoResolution})`;
|
2570
|
+
}
|
2571
|
+
}
|
2572
|
+
|
2573
|
+
if (media.aspectRatio) {
|
2574
|
+
formatted += ` | Aspect Ratio: ${media.aspectRatio}`;
|
2575
|
+
}
|
2576
|
+
|
2577
|
+
if (media.videoCodec) {
|
2578
|
+
formatted += `\\n Video Codec: ${media.videoCodec.toUpperCase()}`;
|
2579
|
+
}
|
2580
|
+
|
2581
|
+
if (media.videoFrameRate) {
|
2582
|
+
formatted += ` | Frame Rate: ${media.videoFrameRate} fps`;
|
2583
|
+
}
|
2584
|
+
|
2585
|
+
if (media.videoProfile) {
|
2586
|
+
formatted += ` | Profile: ${media.videoProfile}`;
|
2587
|
+
}
|
2588
|
+
|
2589
|
+
if (media.audioCodec) {
|
2590
|
+
formatted += `\\n Audio Codec: ${media.audioCodec.toUpperCase()}`;
|
2591
|
+
}
|
2592
|
+
|
2593
|
+
if (media.audioChannels) {
|
2594
|
+
formatted += ` | Channels: ${media.audioChannels}`;
|
2595
|
+
}
|
2596
|
+
|
2597
|
+
if (media.audioProfile) {
|
2598
|
+
formatted += ` | Profile: ${media.audioProfile}`;
|
2599
|
+
}
|
2600
|
+
|
2601
|
+
// Format file parts
|
2602
|
+
if (media.parts && media.parts.length > 0) {
|
2603
|
+
formatted += `\\n\\n **Files:**`;
|
2604
|
+
|
2605
|
+
media.parts.forEach((part, partIndex) => {
|
2606
|
+
formatted += `\\n\\n **File ${partIndex + 1}:**`;
|
2607
|
+
|
2608
|
+
if (part.file) {
|
2609
|
+
const fileName = part.file.split('/').pop();
|
2610
|
+
formatted += `\\n Filename: ${fileName}`;
|
2611
|
+
}
|
2612
|
+
|
2613
|
+
if (part.size) {
|
2614
|
+
const sizeMB = Math.round(parseInt(part.size) / (1024 * 1024));
|
2615
|
+
const sizeGB = (sizeMB / 1024).toFixed(2);
|
2616
|
+
if (sizeMB > 1024) {
|
2617
|
+
formatted += `\\n File Size: ${sizeGB} GB`;
|
2618
|
+
} else {
|
2619
|
+
formatted += `\\n File Size: ${sizeMB} MB`;
|
2620
|
+
}
|
2621
|
+
}
|
2622
|
+
|
2623
|
+
if (part.duration) {
|
2624
|
+
const hours = Math.floor(part.duration / 3600000);
|
2625
|
+
const minutes = Math.floor((part.duration % 3600000) / 60000);
|
2626
|
+
if (hours > 0) {
|
2627
|
+
formatted += `\\n Duration: ${hours}h ${minutes}m`;
|
2628
|
+
} else {
|
2629
|
+
formatted += `\\n Duration: ${minutes}m`;
|
2630
|
+
}
|
2631
|
+
}
|
2632
|
+
|
2633
|
+
// Format streams
|
2634
|
+
if (part.streams && part.streams.length > 0) {
|
2635
|
+
const videoStreams = part.streams.filter(s => s.streamType === 1);
|
2636
|
+
const audioStreams = part.streams.filter(s => s.streamType === 2);
|
2637
|
+
const subtitleStreams = part.streams.filter(s => s.streamType === 3);
|
2638
|
+
|
2639
|
+
if (videoStreams.length > 0) {
|
2640
|
+
formatted += `\\n\\n **Video Streams (${videoStreams.length}):**`;
|
2641
|
+
videoStreams.forEach((stream, streamIndex) => {
|
2642
|
+
formatted += `\\n ${streamIndex + 1}. ${stream.displayTitle || stream.codec?.toUpperCase() || 'Unknown'}`;
|
2643
|
+
if (stream.bitrate) formatted += ` | ${stream.bitrate} kbps`;
|
2644
|
+
if (stream.width && stream.height) formatted += ` | ${stream.width}×${stream.height}`;
|
2645
|
+
if (stream.frameRate) formatted += ` | ${stream.frameRate} fps`;
|
2646
|
+
if (stream.profile) formatted += ` | ${stream.profile}`;
|
2647
|
+
});
|
2648
|
+
}
|
2649
|
+
|
2650
|
+
if (audioStreams.length > 0) {
|
2651
|
+
formatted += `\\n\\n **Audio Streams (${audioStreams.length}):**`;
|
2652
|
+
audioStreams.forEach((stream, streamIndex) => {
|
2653
|
+
formatted += `\\n ${streamIndex + 1}. ${stream.displayTitle || stream.codec?.toUpperCase() || 'Unknown'}`;
|
2654
|
+
if (stream.language) formatted += ` | ${stream.language}`;
|
2655
|
+
if (stream.channels) formatted += ` | ${stream.channels} ch`;
|
2656
|
+
if (stream.bitrate) formatted += ` | ${stream.bitrate} kbps`;
|
2657
|
+
if (stream.samplingRate) formatted += ` | ${stream.samplingRate} Hz`;
|
2658
|
+
if (stream.audioChannelLayout) formatted += ` | ${stream.audioChannelLayout}`;
|
2659
|
+
if (stream.default) formatted += ` | Default`;
|
2660
|
+
});
|
2661
|
+
}
|
2662
|
+
|
2663
|
+
if (subtitleStreams.length > 0) {
|
2664
|
+
formatted += `\\n\\n **Subtitle Streams (${subtitleStreams.length}):**`;
|
2665
|
+
subtitleStreams.forEach((stream, streamIndex) => {
|
2666
|
+
formatted += `\\n ${streamIndex + 1}. ${stream.displayTitle || stream.language || 'Unknown'}`;
|
2667
|
+
if (stream.codec) formatted += ` | ${stream.codec.toUpperCase()}`;
|
2668
|
+
if (stream.default) formatted += ` | Default`;
|
2669
|
+
});
|
2670
|
+
}
|
2671
|
+
}
|
2672
|
+
});
|
2673
|
+
}
|
2674
|
+
});
|
2675
|
+
}
|
2676
|
+
|
2677
|
+
return formatted;
|
2678
|
+
}
|
2679
|
+
|
2680
|
+
async handleGetLibraryStats(args) {
|
2681
|
+
const { library_id, include_details = false } = args;
|
2682
|
+
|
2683
|
+
try {
|
2684
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
2685
|
+
const plexToken = process.env.PLEX_TOKEN;
|
2686
|
+
|
2687
|
+
if (!plexToken) {
|
2688
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
2689
|
+
}
|
2690
|
+
|
2691
|
+
// Get library information first
|
2692
|
+
const librariesResponse = await axios.get(`${plexUrl}/library/sections`, {
|
2693
|
+
params: { 'X-Plex-Token': plexToken },
|
2694
|
+
httpsAgent: new (require('https').Agent)({
|
2695
|
+
rejectUnauthorized: false,
|
2696
|
+
minVersion: 'TLSv1.2'
|
2697
|
+
})
|
2698
|
+
});
|
2699
|
+
|
2700
|
+
const libraries = this.parseLibraries(librariesResponse.data);
|
2701
|
+
let targetLibraries = libraries;
|
2702
|
+
|
2703
|
+
if (library_id) {
|
2704
|
+
targetLibraries = libraries.filter(lib => lib.key === library_id);
|
2705
|
+
if (targetLibraries.length === 0) {
|
2706
|
+
throw new Error(`Library with ID ${library_id} not found`);
|
2707
|
+
}
|
2708
|
+
}
|
2709
|
+
|
2710
|
+
const stats = await this.calculateLibraryStats(targetLibraries, include_details, plexUrl, plexToken);
|
2711
|
+
|
2712
|
+
let resultText = library_id
|
2713
|
+
? `Library Statistics for "${targetLibraries[0].title}":`
|
2714
|
+
: `Plex Server Statistics (${targetLibraries.length} libraries):`;
|
2715
|
+
|
2716
|
+
resultText += `\\n\\n${this.formatLibraryStats(stats, include_details)}`;
|
2717
|
+
|
2718
|
+
return {
|
2719
|
+
content: [
|
2720
|
+
{
|
2721
|
+
type: "text",
|
2722
|
+
text: resultText,
|
2723
|
+
},
|
2724
|
+
],
|
2725
|
+
};
|
2726
|
+
} catch (error) {
|
2727
|
+
return {
|
2728
|
+
content: [
|
2729
|
+
{
|
2730
|
+
type: "text",
|
2731
|
+
text: `Error getting library statistics: ${error.message}`,
|
2732
|
+
},
|
2733
|
+
],
|
2734
|
+
isError: true,
|
2735
|
+
};
|
2736
|
+
}
|
2737
|
+
}
|
2738
|
+
|
2739
|
+
async calculateLibraryStats(libraries, includeDetails, plexUrl, plexToken) {
|
2740
|
+
const stats = {
|
2741
|
+
totalLibraries: libraries.length,
|
2742
|
+
totalItems: 0,
|
2743
|
+
totalSize: 0,
|
2744
|
+
totalDuration: 0,
|
2745
|
+
libraries: [],
|
2746
|
+
overview: {
|
2747
|
+
contentTypes: {},
|
2748
|
+
fileFormats: {},
|
2749
|
+
resolutions: {},
|
2750
|
+
audioCodecs: {},
|
2751
|
+
videoCodecs: {}
|
2752
|
+
}
|
2753
|
+
};
|
2754
|
+
|
2755
|
+
for (const library of libraries) {
|
2756
|
+
const libraryStats = {
|
2757
|
+
name: library.title,
|
2758
|
+
type: library.type,
|
2759
|
+
key: library.key,
|
2760
|
+
itemCount: 0,
|
2761
|
+
totalSize: 0,
|
2762
|
+
totalDuration: 0,
|
2763
|
+
contentBreakdown: {},
|
2764
|
+
details: includeDetails ? {
|
2765
|
+
fileFormats: {},
|
2766
|
+
resolutions: {},
|
2767
|
+
audioCodecs: {},
|
2768
|
+
videoCodecs: {},
|
2769
|
+
contentRatings: {},
|
2770
|
+
decades: {}
|
2771
|
+
} : null
|
2772
|
+
};
|
2773
|
+
|
2774
|
+
// Get all content from this library with media information
|
2775
|
+
let offset = 0;
|
2776
|
+
const batchSize = 100;
|
2777
|
+
let hasMore = true;
|
2778
|
+
|
2779
|
+
while (hasMore) {
|
2780
|
+
const contentResponse = await axios.get(`${plexUrl}/library/sections/${library.key}/all`, {
|
2781
|
+
params: {
|
2782
|
+
'X-Plex-Token': plexToken,
|
2783
|
+
'X-Plex-Container-Start': offset,
|
2784
|
+
'X-Plex-Container-Size': batchSize
|
2785
|
+
},
|
2786
|
+
httpsAgent: new (require('https').Agent)({
|
2787
|
+
rejectUnauthorized: false,
|
2788
|
+
minVersion: 'TLSv1.2'
|
2789
|
+
})
|
2790
|
+
});
|
2791
|
+
|
2792
|
+
const content = this.parseLibraryContent(contentResponse.data);
|
2793
|
+
|
2794
|
+
if (content.length === 0) {
|
2795
|
+
hasMore = false;
|
2796
|
+
break;
|
2797
|
+
}
|
2798
|
+
|
2799
|
+
// Process each item
|
2800
|
+
for (const item of content) {
|
2801
|
+
libraryStats.itemCount++;
|
2802
|
+
|
2803
|
+
// Count by content type
|
2804
|
+
const contentType = item.type || 'unknown';
|
2805
|
+
libraryStats.contentBreakdown[contentType] = (libraryStats.contentBreakdown[contentType] || 0) + 1;
|
2806
|
+
stats.overview.contentTypes[contentType] = (stats.overview.contentTypes[contentType] || 0) + 1;
|
2807
|
+
|
2808
|
+
if (item.duration) {
|
2809
|
+
libraryStats.totalDuration += item.duration;
|
2810
|
+
}
|
2811
|
+
|
2812
|
+
if (includeDetails) {
|
2813
|
+
// Add decade information
|
2814
|
+
if (item.year) {
|
2815
|
+
const decade = Math.floor(item.year / 10) * 10;
|
2816
|
+
libraryStats.details.decades[`${decade}s`] = (libraryStats.details.decades[`${decade}s`] || 0) + 1;
|
2817
|
+
}
|
2818
|
+
|
2819
|
+
// Add content rating
|
2820
|
+
if (item.contentRating) {
|
2821
|
+
libraryStats.details.contentRatings[item.contentRating] = (libraryStats.details.contentRatings[item.contentRating] || 0) + 1;
|
2822
|
+
}
|
2823
|
+
}
|
2824
|
+
|
2825
|
+
// Get detailed media information if available
|
2826
|
+
if (item.key) {
|
2827
|
+
try {
|
2828
|
+
const mediaResponse = await axios.get(`${plexUrl}${item.key}`, {
|
2829
|
+
params: { 'X-Plex-Token': plexToken },
|
2830
|
+
httpsAgent: new (require('https').Agent)({
|
2831
|
+
rejectUnauthorized: false,
|
2832
|
+
minVersion: 'TLSv1.2'
|
2833
|
+
})
|
2834
|
+
});
|
2835
|
+
|
2836
|
+
const detailedItem = mediaResponse.data?.MediaContainer?.Metadata?.[0];
|
2837
|
+
if (detailedItem && detailedItem.Media) {
|
2838
|
+
for (const media of detailedItem.Media) {
|
2839
|
+
// Calculate file sizes
|
2840
|
+
if (media.Part) {
|
2841
|
+
for (const part of media.Part) {
|
2842
|
+
if (part.size) {
|
2843
|
+
const sizeBytes = parseInt(part.size);
|
2844
|
+
libraryStats.totalSize += sizeBytes;
|
2845
|
+
|
2846
|
+
if (includeDetails) {
|
2847
|
+
// Track file formats
|
2848
|
+
if (media.container) {
|
2849
|
+
libraryStats.details.fileFormats[media.container.toUpperCase()] =
|
2850
|
+
(libraryStats.details.fileFormats[media.container.toUpperCase()] || 0) + 1;
|
2851
|
+
stats.overview.fileFormats[media.container.toUpperCase()] =
|
2852
|
+
(stats.overview.fileFormats[media.container.toUpperCase()] || 0) + 1;
|
2853
|
+
}
|
2854
|
+
}
|
2855
|
+
}
|
2856
|
+
}
|
2857
|
+
}
|
2858
|
+
|
2859
|
+
if (includeDetails) {
|
2860
|
+
// Track resolutions
|
2861
|
+
if (media.width && media.height) {
|
2862
|
+
const resolution = `${media.width}×${media.height}`;
|
2863
|
+
libraryStats.details.resolutions[resolution] = (libraryStats.details.resolutions[resolution] || 0) + 1;
|
2864
|
+
stats.overview.resolutions[resolution] = (stats.overview.resolutions[resolution] || 0) + 1;
|
2865
|
+
} else if (media.videoResolution) {
|
2866
|
+
libraryStats.details.resolutions[media.videoResolution] = (libraryStats.details.resolutions[media.videoResolution] || 0) + 1;
|
2867
|
+
stats.overview.resolutions[media.videoResolution] = (stats.overview.resolutions[media.videoResolution] || 0) + 1;
|
2868
|
+
}
|
2869
|
+
|
2870
|
+
// Track codecs
|
2871
|
+
if (media.videoCodec) {
|
2872
|
+
libraryStats.details.videoCodecs[media.videoCodec.toUpperCase()] =
|
2873
|
+
(libraryStats.details.videoCodecs[media.videoCodec.toUpperCase()] || 0) + 1;
|
2874
|
+
stats.overview.videoCodecs[media.videoCodec.toUpperCase()] =
|
2875
|
+
(stats.overview.videoCodecs[media.videoCodec.toUpperCase()] || 0) + 1;
|
2876
|
+
}
|
2877
|
+
|
2878
|
+
if (media.audioCodec) {
|
2879
|
+
libraryStats.details.audioCodecs[media.audioCodec.toUpperCase()] =
|
2880
|
+
(libraryStats.details.audioCodecs[media.audioCodec.toUpperCase()] || 0) + 1;
|
2881
|
+
stats.overview.audioCodecs[media.audioCodec.toUpperCase()] =
|
2882
|
+
(stats.overview.audioCodecs[media.audioCodec.toUpperCase()] || 0) + 1;
|
2883
|
+
}
|
2884
|
+
}
|
2885
|
+
}
|
2886
|
+
}
|
2887
|
+
} catch (error) {
|
2888
|
+
// Skip detailed media info if not available
|
2889
|
+
console.error(`Could not get media details for item ${item.key}: ${error.message}`);
|
2890
|
+
}
|
2891
|
+
}
|
2892
|
+
}
|
2893
|
+
|
2894
|
+
offset += batchSize;
|
2895
|
+
if (content.length < batchSize) {
|
2896
|
+
hasMore = false;
|
2897
|
+
}
|
2898
|
+
}
|
2899
|
+
|
2900
|
+
stats.totalItems += libraryStats.itemCount;
|
2901
|
+
stats.totalSize += libraryStats.totalSize;
|
2902
|
+
stats.totalDuration += libraryStats.totalDuration;
|
2903
|
+
stats.libraries.push(libraryStats);
|
2904
|
+
}
|
2905
|
+
|
2906
|
+
return stats;
|
2907
|
+
}
|
2908
|
+
|
2909
|
+
formatLibraryStats(stats, includeDetails) {
|
2910
|
+
let formatted = `**Overview:**\\n`;
|
2911
|
+
formatted += ` Total Libraries: ${stats.totalLibraries}\\n`;
|
2912
|
+
formatted += ` Total Items: ${stats.totalItems.toLocaleString()}\\n`;
|
2913
|
+
|
2914
|
+
if (stats.totalSize > 0) {
|
2915
|
+
const sizeGB = (stats.totalSize / (1024 * 1024 * 1024)).toFixed(2);
|
2916
|
+
const sizeTB = (stats.totalSize / (1024 * 1024 * 1024 * 1024)).toFixed(2);
|
2917
|
+
if (parseFloat(sizeTB) >= 1) {
|
2918
|
+
formatted += ` Total Storage: ${sizeTB} TB\\n`;
|
2919
|
+
} else {
|
2920
|
+
formatted += ` Total Storage: ${sizeGB} GB\\n`;
|
2921
|
+
}
|
2922
|
+
}
|
2923
|
+
|
2924
|
+
if (stats.totalDuration > 0) {
|
2925
|
+
const totalHours = Math.floor(stats.totalDuration / 3600000);
|
2926
|
+
const totalDays = Math.floor(totalHours / 24);
|
2927
|
+
if (totalDays > 1) {
|
2928
|
+
formatted += ` Total Duration: ${totalDays} days (${totalHours.toLocaleString()} hours)\\n`;
|
2929
|
+
} else {
|
2930
|
+
formatted += ` Total Duration: ${totalHours.toLocaleString()} hours\\n`;
|
2931
|
+
}
|
2932
|
+
}
|
2933
|
+
|
2934
|
+
// Content type breakdown
|
2935
|
+
if (Object.keys(stats.overview.contentTypes).length > 0) {
|
2936
|
+
formatted += `\\n**Content Types:**\\n`;
|
2937
|
+
const sortedTypes = Object.entries(stats.overview.contentTypes)
|
2938
|
+
.sort(([,a], [,b]) => b - a);
|
2939
|
+
for (const [type, count] of sortedTypes) {
|
2940
|
+
formatted += ` ${type}: ${count.toLocaleString()} items\\n`;
|
2941
|
+
}
|
2942
|
+
}
|
2943
|
+
|
2944
|
+
// Library breakdown
|
2945
|
+
if (stats.libraries.length > 1) {
|
2946
|
+
formatted += `\\n**Libraries:**\\n`;
|
2947
|
+
for (const library of stats.libraries) {
|
2948
|
+
formatted += `\\n**${library.name}** (${library.type})\\n`;
|
2949
|
+
formatted += ` Items: ${library.itemCount.toLocaleString()}\\n`;
|
2950
|
+
|
2951
|
+
if (library.totalSize > 0) {
|
2952
|
+
const sizeGB = (library.totalSize / (1024 * 1024 * 1024)).toFixed(2);
|
2953
|
+
formatted += ` Storage: ${sizeGB} GB\\n`;
|
2954
|
+
}
|
2955
|
+
|
2956
|
+
if (library.totalDuration > 0) {
|
2957
|
+
const hours = Math.floor(library.totalDuration / 3600000);
|
2958
|
+
formatted += ` Duration: ${hours.toLocaleString()} hours\\n`;
|
2959
|
+
}
|
2960
|
+
|
2961
|
+
// Content breakdown for this library
|
2962
|
+
if (Object.keys(library.contentBreakdown).length > 0) {
|
2963
|
+
const sortedContent = Object.entries(library.contentBreakdown)
|
2964
|
+
.sort(([,a], [,b]) => b - a);
|
2965
|
+
formatted += ` Content: `;
|
2966
|
+
formatted += sortedContent.map(([type, count]) => `${count} ${type}${count > 1 ? 's' : ''}`).join(', ');
|
2967
|
+
formatted += `\\n`;
|
2968
|
+
}
|
2969
|
+
}
|
2970
|
+
}
|
2971
|
+
|
2972
|
+
// Detailed breakdowns
|
2973
|
+
if (includeDetails) {
|
2974
|
+
if (Object.keys(stats.overview.fileFormats).length > 0) {
|
2975
|
+
formatted += `\\n**File Formats:**\\n`;
|
2976
|
+
const sortedFormats = Object.entries(stats.overview.fileFormats)
|
2977
|
+
.sort(([,a], [,b]) => b - a);
|
2978
|
+
for (const [format, count] of sortedFormats) {
|
2979
|
+
formatted += ` ${format}: ${count.toLocaleString()} files\\n`;
|
2980
|
+
}
|
2981
|
+
}
|
2982
|
+
|
2983
|
+
if (Object.keys(stats.overview.resolutions).length > 0) {
|
2984
|
+
formatted += `\\n**Resolutions:**\\n`;
|
2985
|
+
const sortedResolutions = Object.entries(stats.overview.resolutions)
|
2986
|
+
.sort(([,a], [,b]) => b - a);
|
2987
|
+
for (const [resolution, count] of sortedResolutions) {
|
2988
|
+
formatted += ` ${resolution}: ${count.toLocaleString()} items\\n`;
|
2989
|
+
}
|
2990
|
+
}
|
2991
|
+
|
2992
|
+
if (Object.keys(stats.overview.videoCodecs).length > 0) {
|
2993
|
+
formatted += `\\n**Video Codecs:**\\n`;
|
2994
|
+
const sortedCodecs = Object.entries(stats.overview.videoCodecs)
|
2995
|
+
.sort(([,a], [,b]) => b - a);
|
2996
|
+
for (const [codec, count] of sortedCodecs) {
|
2997
|
+
formatted += ` ${codec}: ${count.toLocaleString()} items\\n`;
|
2998
|
+
}
|
2999
|
+
}
|
3000
|
+
|
3001
|
+
if (Object.keys(stats.overview.audioCodecs).length > 0) {
|
3002
|
+
formatted += `\\n**Audio Codecs:**\\n`;
|
3003
|
+
const sortedCodecs = Object.entries(stats.overview.audioCodecs)
|
3004
|
+
.sort(([,a], [,b]) => b - a);
|
3005
|
+
for (const [codec, count] of sortedCodecs) {
|
3006
|
+
formatted += ` ${codec}: ${count.toLocaleString()} items\\n`;
|
3007
|
+
}
|
3008
|
+
}
|
3009
|
+
|
3010
|
+
// Per-library details if multiple libraries
|
3011
|
+
if (stats.libraries.length > 1) {
|
3012
|
+
for (const library of stats.libraries) {
|
3013
|
+
if (library.details) {
|
3014
|
+
formatted += `\\n**${library.name} - Detailed Breakdown:**\\n`;
|
3015
|
+
|
3016
|
+
if (Object.keys(library.details.decades).length > 0) {
|
3017
|
+
formatted += ` Decades: `;
|
3018
|
+
const sortedDecades = Object.entries(library.details.decades)
|
3019
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
3020
|
+
formatted += sortedDecades.map(([decade, count]) => `${decade} (${count})`).join(', ');
|
3021
|
+
formatted += `\\n`;
|
3022
|
+
}
|
3023
|
+
|
3024
|
+
if (Object.keys(library.details.contentRatings).length > 0) {
|
3025
|
+
formatted += ` Ratings: `;
|
3026
|
+
const sortedRatings = Object.entries(library.details.contentRatings)
|
3027
|
+
.sort(([,a], [,b]) => b - a);
|
3028
|
+
formatted += sortedRatings.map(([rating, count]) => `${rating} (${count})`).join(', ');
|
3029
|
+
formatted += `\\n`;
|
3030
|
+
}
|
3031
|
+
}
|
3032
|
+
}
|
3033
|
+
}
|
3034
|
+
}
|
3035
|
+
|
3036
|
+
return formatted;
|
3037
|
+
}
|
3038
|
+
|
3039
|
+
async handleGetListeningStats(args) {
|
3040
|
+
const {
|
3041
|
+
account_id,
|
3042
|
+
time_period = "month",
|
3043
|
+
include_recommendations = true,
|
3044
|
+
music_library_id
|
3045
|
+
} = args;
|
3046
|
+
|
3047
|
+
try {
|
3048
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
3049
|
+
const plexToken = process.env.PLEX_TOKEN;
|
3050
|
+
|
3051
|
+
if (!plexToken) {
|
3052
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
3053
|
+
}
|
3054
|
+
|
3055
|
+
// Auto-detect music libraries if not specified
|
3056
|
+
let musicLibraries = [];
|
3057
|
+
if (music_library_id) {
|
3058
|
+
musicLibraries = [{ key: music_library_id, title: 'Music Library' }];
|
3059
|
+
} else {
|
3060
|
+
const librariesResponse = await axios.get(`${plexUrl}/library/sections`, {
|
3061
|
+
params: { 'X-Plex-Token': plexToken },
|
3062
|
+
httpsAgent: new (require('https').Agent)({
|
3063
|
+
rejectUnauthorized: false,
|
3064
|
+
minVersion: 'TLSv1.2'
|
3065
|
+
})
|
3066
|
+
});
|
3067
|
+
|
3068
|
+
const allLibraries = this.parseLibraries(librariesResponse.data);
|
3069
|
+
musicLibraries = allLibraries.filter(lib => lib.type === 'artist');
|
3070
|
+
|
3071
|
+
if (musicLibraries.length === 0) {
|
3072
|
+
throw new Error('No music libraries found. Please specify a music_library_id.');
|
3073
|
+
}
|
3074
|
+
}
|
3075
|
+
|
3076
|
+
const stats = await this.calculateListeningStats(
|
3077
|
+
musicLibraries,
|
3078
|
+
account_id,
|
3079
|
+
time_period,
|
3080
|
+
include_recommendations,
|
3081
|
+
plexUrl,
|
3082
|
+
plexToken
|
3083
|
+
);
|
3084
|
+
|
3085
|
+
let resultText = account_id
|
3086
|
+
? `Listening Statistics for User ${account_id} (${time_period}):`
|
3087
|
+
: `Music Listening Statistics (${time_period}):`;
|
3088
|
+
|
3089
|
+
resultText += `\\n\\n${this.formatListeningStats(stats, include_recommendations)}`;
|
3090
|
+
|
3091
|
+
return {
|
3092
|
+
content: [
|
3093
|
+
{
|
3094
|
+
type: "text",
|
3095
|
+
text: resultText,
|
3096
|
+
},
|
3097
|
+
],
|
3098
|
+
};
|
3099
|
+
} catch (error) {
|
3100
|
+
return {
|
3101
|
+
content: [
|
3102
|
+
{
|
3103
|
+
type: "text",
|
3104
|
+
text: `Error getting listening statistics: ${error.message}`,
|
3105
|
+
},
|
3106
|
+
],
|
3107
|
+
isError: true,
|
3108
|
+
};
|
3109
|
+
}
|
3110
|
+
}
|
3111
|
+
|
3112
|
+
async calculateListeningStats(musicLibraries, accountId, timePeriod, includeRecommendations, plexUrl, plexToken) {
|
3113
|
+
const stats = {
|
3114
|
+
timePeriod,
|
3115
|
+
totalPlays: 0,
|
3116
|
+
totalListeningTime: 0,
|
3117
|
+
uniqueTracks: new Set(),
|
3118
|
+
uniqueArtists: new Set(),
|
3119
|
+
uniqueAlbums: new Set(),
|
3120
|
+
topTracks: {},
|
3121
|
+
topArtists: {},
|
3122
|
+
topAlbums: {},
|
3123
|
+
topGenres: {},
|
3124
|
+
listeningPatterns: {
|
3125
|
+
byHour: Array(24).fill(0),
|
3126
|
+
byDayOfWeek: Array(7).fill(0),
|
3127
|
+
byMonth: Array(12).fill(0)
|
3128
|
+
},
|
3129
|
+
recentDiscoveries: [],
|
3130
|
+
recommendations: []
|
3131
|
+
};
|
3132
|
+
|
3133
|
+
// Calculate time cutoff based on period
|
3134
|
+
const now = new Date();
|
3135
|
+
let cutoffDate = new Date(0); // Default to beginning of time
|
3136
|
+
|
3137
|
+
switch (timePeriod) {
|
3138
|
+
case 'week':
|
3139
|
+
cutoffDate = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000));
|
3140
|
+
break;
|
3141
|
+
case 'month':
|
3142
|
+
cutoffDate = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000));
|
3143
|
+
break;
|
3144
|
+
case 'quarter':
|
3145
|
+
cutoffDate = new Date(now.getTime() - (90 * 24 * 60 * 60 * 1000));
|
3146
|
+
break;
|
3147
|
+
case 'year':
|
3148
|
+
cutoffDate = new Date(now.getTime() - (365 * 24 * 60 * 60 * 1000));
|
3149
|
+
break;
|
3150
|
+
case 'all':
|
3151
|
+
cutoffDate = new Date(0);
|
3152
|
+
break;
|
3153
|
+
}
|
3154
|
+
|
3155
|
+
// Get listening history from Plex
|
3156
|
+
const historyParams = {
|
3157
|
+
'X-Plex-Token': plexToken,
|
3158
|
+
'X-Plex-Container-Size': 1000 // Get a large batch of history
|
3159
|
+
};
|
3160
|
+
|
3161
|
+
if (accountId) {
|
3162
|
+
historyParams.accountID = accountId;
|
3163
|
+
}
|
3164
|
+
|
3165
|
+
try {
|
3166
|
+
const historyResponse = await axios.get(`${plexUrl}/status/sessions/history/all`, {
|
3167
|
+
params: historyParams,
|
3168
|
+
httpsAgent: new (require('https').Agent)({
|
3169
|
+
rejectUnauthorized: false,
|
3170
|
+
minVersion: 'TLSv1.2'
|
3171
|
+
})
|
3172
|
+
});
|
3173
|
+
|
3174
|
+
const history = this.parseWatchHistory(historyResponse.data);
|
3175
|
+
|
3176
|
+
// Filter for music content and time period
|
3177
|
+
const musicHistory = history.filter(item => {
|
3178
|
+
const isMusic = item.type === 'track';
|
3179
|
+
const viewedDate = new Date(item.viewedAt * 1000);
|
3180
|
+
const inTimePeriod = viewedDate >= cutoffDate;
|
3181
|
+
return isMusic && inTimePeriod;
|
3182
|
+
});
|
3183
|
+
|
3184
|
+
// Process each music play
|
3185
|
+
for (const play of musicHistory) {
|
3186
|
+
stats.totalPlays++;
|
3187
|
+
|
3188
|
+
if (play.duration) {
|
3189
|
+
stats.totalListeningTime += play.duration;
|
3190
|
+
}
|
3191
|
+
|
3192
|
+
// Track unique content
|
3193
|
+
stats.uniqueTracks.add(play.title);
|
3194
|
+
if (play.grandparentTitle) stats.uniqueArtists.add(play.grandparentTitle);
|
3195
|
+
if (play.parentTitle) stats.uniqueAlbums.add(play.parentTitle);
|
3196
|
+
|
3197
|
+
// Count plays for top lists
|
3198
|
+
stats.topTracks[play.title] = (stats.topTracks[play.title] || 0) + 1;
|
3199
|
+
if (play.grandparentTitle) {
|
3200
|
+
stats.topArtists[play.grandparentTitle] = (stats.topArtists[play.grandparentTitle] || 0) + 1;
|
3201
|
+
}
|
3202
|
+
if (play.parentTitle) {
|
3203
|
+
stats.topAlbums[play.parentTitle] = (stats.topAlbums[play.parentTitle] || 0) + 1;
|
3204
|
+
}
|
3205
|
+
|
3206
|
+
// Analyze listening patterns
|
3207
|
+
const playDate = new Date(play.viewedAt * 1000);
|
3208
|
+
const hour = playDate.getHours();
|
3209
|
+
const dayOfWeek = playDate.getDay();
|
3210
|
+
const month = playDate.getMonth();
|
3211
|
+
|
3212
|
+
stats.listeningPatterns.byHour[hour]++;
|
3213
|
+
stats.listeningPatterns.byDayOfWeek[dayOfWeek]++;
|
3214
|
+
stats.listeningPatterns.byMonth[month]++;
|
3215
|
+
}
|
3216
|
+
|
3217
|
+
// Get detailed track information for genre analysis and recommendations
|
3218
|
+
await this.enrichMusicStats(stats, musicLibraries, plexUrl, plexToken);
|
3219
|
+
|
3220
|
+
// Generate recommendations if requested
|
3221
|
+
if (includeRecommendations) {
|
3222
|
+
await this.generateMusicRecommendations(stats, musicLibraries, plexUrl, plexToken);
|
3223
|
+
}
|
3224
|
+
|
3225
|
+
} catch (error) {
|
3226
|
+
console.error('Error getting music history:', error.message);
|
3227
|
+
}
|
3228
|
+
|
3229
|
+
// Convert Sets to counts
|
3230
|
+
stats.uniqueTracks = stats.uniqueTracks.size;
|
3231
|
+
stats.uniqueArtists = stats.uniqueArtists.size;
|
3232
|
+
stats.uniqueAlbums = stats.uniqueAlbums.size;
|
3233
|
+
|
3234
|
+
return stats;
|
3235
|
+
}
|
3236
|
+
|
3237
|
+
async enrichMusicStats(stats, musicLibraries, plexUrl, plexToken) {
|
3238
|
+
// Get genre information from top tracks
|
3239
|
+
const topTrackNames = Object.keys(stats.topTracks).slice(0, 20); // Analyze top 20 tracks
|
3240
|
+
|
3241
|
+
for (const library of musicLibraries) {
|
3242
|
+
try {
|
3243
|
+
// Search for tracks to get genre information
|
3244
|
+
for (const trackName of topTrackNames) {
|
3245
|
+
try {
|
3246
|
+
const searchResponse = await axios.get(`${plexUrl}/library/sections/${library.key}/search`, {
|
3247
|
+
params: {
|
3248
|
+
'X-Plex-Token': plexToken,
|
3249
|
+
query: trackName,
|
3250
|
+
type: 10 // Track type
|
3251
|
+
},
|
3252
|
+
httpsAgent: new (require('https').Agent)({
|
3253
|
+
rejectUnauthorized: false,
|
3254
|
+
minVersion: 'TLSv1.2'
|
3255
|
+
})
|
3256
|
+
});
|
3257
|
+
|
3258
|
+
const tracks = this.parseSearchResults(searchResponse.data);
|
3259
|
+
for (const track of tracks.slice(0, 1)) { // Just take first match
|
3260
|
+
if (track.key) {
|
3261
|
+
const trackDetailResponse = await axios.get(`${plexUrl}${track.key}`, {
|
3262
|
+
params: { 'X-Plex-Token': plexToken },
|
3263
|
+
httpsAgent: new (require('https').Agent)({
|
3264
|
+
rejectUnauthorized: false,
|
3265
|
+
minVersion: 'TLSv1.2'
|
3266
|
+
})
|
3267
|
+
});
|
3268
|
+
|
3269
|
+
const trackDetail = trackDetailResponse.data?.MediaContainer?.Metadata?.[0];
|
3270
|
+
if (trackDetail && trackDetail.Genre) {
|
3271
|
+
for (const genre of trackDetail.Genre) {
|
3272
|
+
const playCount = stats.topTracks[trackName] || 1;
|
3273
|
+
stats.topGenres[genre.tag] = (stats.topGenres[genre.tag] || 0) + playCount;
|
3274
|
+
}
|
3275
|
+
}
|
3276
|
+
}
|
3277
|
+
}
|
3278
|
+
} catch (trackError) {
|
3279
|
+
// Skip individual track errors
|
3280
|
+
continue;
|
3281
|
+
}
|
3282
|
+
}
|
3283
|
+
} catch (libraryError) {
|
3284
|
+
console.error(`Error enriching stats for library ${library.key}:`, libraryError.message);
|
3285
|
+
}
|
3286
|
+
}
|
3287
|
+
}
|
3288
|
+
|
3289
|
+
async generateMusicRecommendations(stats, musicLibraries, plexUrl, plexToken) {
|
3290
|
+
// Generate recommendations based on top genres and artists
|
3291
|
+
const topGenres = Object.entries(stats.topGenres)
|
3292
|
+
.sort(([,a], [,b]) => b - a)
|
3293
|
+
.slice(0, 3)
|
3294
|
+
.map(([genre]) => genre);
|
3295
|
+
|
3296
|
+
const topArtists = Object.keys(stats.topArtists).slice(0, 5);
|
3297
|
+
|
3298
|
+
for (const library of musicLibraries) {
|
3299
|
+
try {
|
3300
|
+
// Find new tracks in favorite genres
|
3301
|
+
for (const genre of topGenres.slice(0, 2)) {
|
3302
|
+
try {
|
3303
|
+
const genreSearchResponse = await axios.get(`${plexUrl}/library/sections/${library.key}/all`, {
|
3304
|
+
params: {
|
3305
|
+
'X-Plex-Token': plexToken,
|
3306
|
+
genre: genre,
|
3307
|
+
type: 10, // Track type
|
3308
|
+
'X-Plex-Container-Size': 10,
|
3309
|
+
sort: 'addedAt:desc' // Recently added first
|
3310
|
+
},
|
3311
|
+
httpsAgent: new (require('https').Agent)({
|
3312
|
+
rejectUnauthorized: false,
|
3313
|
+
minVersion: 'TLSv1.2'
|
3314
|
+
})
|
3315
|
+
});
|
3316
|
+
|
3317
|
+
const tracks = this.parseLibraryContent(genreSearchResponse.data);
|
3318
|
+
for (const track of tracks.slice(0, 3)) {
|
3319
|
+
// Only recommend if not already in top tracks
|
3320
|
+
if (!stats.topTracks[track.title]) {
|
3321
|
+
stats.recommendations.push({
|
3322
|
+
title: track.title,
|
3323
|
+
artist: track.grandparentTitle || 'Unknown Artist',
|
3324
|
+
album: track.parentTitle || 'Unknown Album',
|
3325
|
+
reason: `Based on your interest in ${genre}`,
|
3326
|
+
type: 'genre-based',
|
3327
|
+
key: track.key
|
3328
|
+
});
|
3329
|
+
}
|
3330
|
+
}
|
3331
|
+
} catch (genreError) {
|
3332
|
+
continue;
|
3333
|
+
}
|
3334
|
+
}
|
3335
|
+
|
3336
|
+
// Find tracks by similar artists
|
3337
|
+
for (const artist of topArtists.slice(0, 2)) {
|
3338
|
+
try {
|
3339
|
+
const artistSearchResponse = await axios.get(`${plexUrl}/library/sections/${library.key}/search`, {
|
3340
|
+
params: {
|
3341
|
+
'X-Plex-Token': plexToken,
|
3342
|
+
query: artist,
|
3343
|
+
type: 8 // Artist type
|
3344
|
+
},
|
3345
|
+
httpsAgent: new (require('https').Agent)({
|
3346
|
+
rejectUnauthorized: false,
|
3347
|
+
minVersion: 'TLSv1.2'
|
3348
|
+
})
|
3349
|
+
});
|
3350
|
+
|
3351
|
+
const artists = this.parseSearchResults(artistSearchResponse.data);
|
3352
|
+
for (const foundArtist of artists.slice(0, 1)) {
|
3353
|
+
if (foundArtist.key) {
|
3354
|
+
const artistDetailResponse = await axios.get(`${plexUrl}${foundArtist.key}`, {
|
3355
|
+
params: { 'X-Plex-Token': plexToken },
|
3356
|
+
httpsAgent: new (require('https').Agent)({
|
3357
|
+
rejectUnauthorized: false,
|
3358
|
+
minVersion: 'TLSv1.2'
|
3359
|
+
})
|
3360
|
+
});
|
3361
|
+
|
3362
|
+
const artistTracks = this.parseLibraryContent(artistDetailResponse.data);
|
3363
|
+
for (const track of artistTracks.slice(0, 2)) {
|
3364
|
+
if (!stats.topTracks[track.title]) {
|
3365
|
+
stats.recommendations.push({
|
3366
|
+
title: track.title,
|
3367
|
+
artist: artist,
|
3368
|
+
album: track.parentTitle || 'Unknown Album',
|
3369
|
+
reason: `More from ${artist}`,
|
3370
|
+
type: 'artist-based',
|
3371
|
+
key: track.key
|
3372
|
+
});
|
3373
|
+
}
|
3374
|
+
}
|
3375
|
+
}
|
3376
|
+
}
|
3377
|
+
} catch (artistError) {
|
3378
|
+
continue;
|
3379
|
+
}
|
3380
|
+
}
|
3381
|
+
} catch (libraryError) {
|
3382
|
+
continue;
|
3383
|
+
}
|
3384
|
+
}
|
3385
|
+
|
3386
|
+
// Limit recommendations to avoid overwhelming output
|
3387
|
+
stats.recommendations = stats.recommendations.slice(0, 10);
|
3388
|
+
}
|
3389
|
+
|
3390
|
+
formatListeningStats(stats, includeRecommendations) {
|
3391
|
+
let formatted = `**Overview:**\\n`;
|
3392
|
+
formatted += ` Total Plays: ${stats.totalPlays.toLocaleString()}\\n`;
|
3393
|
+
|
3394
|
+
if (stats.totalListeningTime > 0) {
|
3395
|
+
const totalHours = Math.floor(stats.totalListeningTime / 3600000);
|
3396
|
+
const totalDays = Math.floor(totalHours / 24);
|
3397
|
+
if (totalDays > 1) {
|
3398
|
+
formatted += ` Listening Time: ${totalDays} days (${totalHours.toLocaleString()} hours)\\n`;
|
3399
|
+
} else {
|
3400
|
+
formatted += ` Listening Time: ${totalHours.toLocaleString()} hours\\n`;
|
3401
|
+
}
|
3402
|
+
}
|
3403
|
+
|
3404
|
+
formatted += ` Unique Tracks: ${stats.uniqueTracks.toLocaleString()}\\n`;
|
3405
|
+
formatted += ` Unique Artists: ${stats.uniqueArtists.toLocaleString()}\\n`;
|
3406
|
+
formatted += ` Unique Albums: ${stats.uniqueAlbums.toLocaleString()}\\n`;
|
3407
|
+
|
3408
|
+
// Top tracks
|
3409
|
+
if (Object.keys(stats.topTracks).length > 0) {
|
3410
|
+
formatted += `\\n**Top Tracks:**\\n`;
|
3411
|
+
const sortedTracks = Object.entries(stats.topTracks)
|
3412
|
+
.sort(([,a], [,b]) => b - a)
|
3413
|
+
.slice(0, 10);
|
3414
|
+
|
3415
|
+
sortedTracks.forEach(([track, count], index) => {
|
3416
|
+
formatted += ` ${index + 1}. ${track} (${count} plays)\\n`;
|
3417
|
+
});
|
3418
|
+
}
|
3419
|
+
|
3420
|
+
// Top artists
|
3421
|
+
if (Object.keys(stats.topArtists).length > 0) {
|
3422
|
+
formatted += `\\n**Top Artists:**\\n`;
|
3423
|
+
const sortedArtists = Object.entries(stats.topArtists)
|
3424
|
+
.sort(([,a], [,b]) => b - a)
|
3425
|
+
.slice(0, 10);
|
3426
|
+
|
3427
|
+
sortedArtists.forEach(([artist, count], index) => {
|
3428
|
+
formatted += ` ${index + 1}. ${artist} (${count} plays)\\n`;
|
3429
|
+
});
|
3430
|
+
}
|
3431
|
+
|
3432
|
+
// Top albums
|
3433
|
+
if (Object.keys(stats.topAlbums).length > 0) {
|
3434
|
+
formatted += `\\n**Top Albums:**\\n`;
|
3435
|
+
const sortedAlbums = Object.entries(stats.topAlbums)
|
3436
|
+
.sort(([,a], [,b]) => b - a)
|
3437
|
+
.slice(0, 5);
|
3438
|
+
|
3439
|
+
sortedAlbums.forEach(([album, count], index) => {
|
3440
|
+
formatted += ` ${index + 1}. ${album} (${count} plays)\\n`;
|
3441
|
+
});
|
3442
|
+
}
|
3443
|
+
|
3444
|
+
// Top genres
|
3445
|
+
if (Object.keys(stats.topGenres).length > 0) {
|
3446
|
+
formatted += `\\n**Top Genres:**\\n`;
|
3447
|
+
const sortedGenres = Object.entries(stats.topGenres)
|
3448
|
+
.sort(([,a], [,b]) => b - a)
|
3449
|
+
.slice(0, 8);
|
3450
|
+
|
3451
|
+
sortedGenres.forEach(([genre, count], index) => {
|
3452
|
+
formatted += ` ${index + 1}. ${genre} (${count} plays)\\n`;
|
3453
|
+
});
|
3454
|
+
}
|
3455
|
+
|
3456
|
+
// Listening patterns
|
3457
|
+
formatted += `\\n**Listening Patterns:**\\n`;
|
3458
|
+
|
3459
|
+
// Peak listening hour
|
3460
|
+
const peakHourIndex = stats.listeningPatterns.byHour.indexOf(Math.max(...stats.listeningPatterns.byHour));
|
3461
|
+
const peakHour = peakHourIndex === 0 ? '12 AM' :
|
3462
|
+
peakHourIndex < 12 ? `${peakHourIndex} AM` :
|
3463
|
+
peakHourIndex === 12 ? '12 PM' : `${peakHourIndex - 12} PM`;
|
3464
|
+
formatted += ` Peak listening hour: ${peakHour}\\n`;
|
3465
|
+
|
3466
|
+
// Most active day
|
3467
|
+
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
3468
|
+
const peakDayIndex = stats.listeningPatterns.byDayOfWeek.indexOf(Math.max(...stats.listeningPatterns.byDayOfWeek));
|
3469
|
+
formatted += ` Most active day: ${dayNames[peakDayIndex]}\\n`;
|
3470
|
+
|
3471
|
+
// Peak month (if data spans multiple months)
|
3472
|
+
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
3473
|
+
const monthsWithData = stats.listeningPatterns.byMonth.filter(count => count > 0).length;
|
3474
|
+
if (monthsWithData > 1) {
|
3475
|
+
const peakMonthIndex = stats.listeningPatterns.byMonth.indexOf(Math.max(...stats.listeningPatterns.byMonth));
|
3476
|
+
formatted += ` Peak month: ${monthNames[peakMonthIndex]}\\n`;
|
3477
|
+
}
|
3478
|
+
|
3479
|
+
// Recommendations
|
3480
|
+
if (includeRecommendations && stats.recommendations.length > 0) {
|
3481
|
+
formatted += `\\n**Recommendations for You:**\\n`;
|
3482
|
+
stats.recommendations.forEach((rec, index) => {
|
3483
|
+
formatted += ` ${index + 1}. **${rec.title}** by ${rec.artist}\\n`;
|
3484
|
+
formatted += ` ${rec.reason}\\n`;
|
3485
|
+
if (rec.album && rec.album !== 'Unknown Album') {
|
3486
|
+
formatted += ` Album: ${rec.album}\\n`;
|
3487
|
+
}
|
3488
|
+
});
|
3489
|
+
}
|
3490
|
+
|
3491
|
+
return formatted;
|
3492
|
+
}
|
3493
|
+
|
3494
|
+
async run() {
|
3495
|
+
const transport = new StdioServerTransport();
|
3496
|
+
await this.server.connect(transport);
|
3497
|
+
console.error("Plex MCP server running on stdio");
|
3498
|
+
}
|
3499
|
+
}
|
3500
|
+
|
3501
|
+
if (require.main === module) {
|
3502
|
+
const server = new PlexMCPServer();
|
3503
|
+
server.run().catch(console.error);
|
3504
|
+
}
|
3505
|
+
|
3506
|
+
module.exports = PlexMCPServer;
|