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/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;