plex-mcp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +18 -0
- package/README.md +52 -166
- package/index.js +1336 -179
- package/package.json +4 -4
- package/smithery.yaml +29 -0
package/index.js
CHANGED
|
@@ -8,12 +8,60 @@ const {
|
|
|
8
8
|
} = require("@modelcontextprotocol/sdk/types.js");
|
|
9
9
|
const axios = require('axios');
|
|
10
10
|
const { PlexOauth } = require('plex-oauth');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
11
14
|
|
|
12
15
|
class PlexAuthManager {
|
|
13
16
|
constructor() {
|
|
14
17
|
this.authToken = null;
|
|
15
18
|
this.plexOauth = null;
|
|
16
19
|
this.currentPinId = null;
|
|
20
|
+
this.tokenFilePath = path.join(os.homedir(), '.plex-mcp-token');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async loadPersistedToken() {
|
|
24
|
+
try {
|
|
25
|
+
if (fs.existsSync(this.tokenFilePath)) {
|
|
26
|
+
const tokenData = fs.readFileSync(this.tokenFilePath, 'utf8');
|
|
27
|
+
const parsed = JSON.parse(tokenData);
|
|
28
|
+
if (parsed.token && parsed.timestamp) {
|
|
29
|
+
// Check if token is less than 1 year old
|
|
30
|
+
const tokenAge = Date.now() - parsed.timestamp;
|
|
31
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
32
|
+
if (tokenAge < oneYear) {
|
|
33
|
+
this.authToken = parsed.token;
|
|
34
|
+
return parsed.token;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// If there's any error reading the token, just continue without it
|
|
40
|
+
console.error('Error loading persisted token:', error.message);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async saveToken(token) {
|
|
46
|
+
try {
|
|
47
|
+
const tokenData = {
|
|
48
|
+
token: token,
|
|
49
|
+
timestamp: Date.now()
|
|
50
|
+
};
|
|
51
|
+
fs.writeFileSync(this.tokenFilePath, JSON.stringify(tokenData, null, 2), 'utf8');
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('Error saving token:', error.message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async clearPersistedToken() {
|
|
58
|
+
try {
|
|
59
|
+
if (fs.existsSync(this.tokenFilePath)) {
|
|
60
|
+
fs.unlinkSync(this.tokenFilePath);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('Error clearing persisted token:', error.message);
|
|
64
|
+
}
|
|
17
65
|
}
|
|
18
66
|
|
|
19
67
|
async getAuthToken() {
|
|
@@ -28,6 +76,12 @@ class PlexAuthManager {
|
|
|
28
76
|
return this.authToken;
|
|
29
77
|
}
|
|
30
78
|
|
|
79
|
+
// Try to load persisted token
|
|
80
|
+
const persistedToken = await this.loadPersistedToken();
|
|
81
|
+
if (persistedToken) {
|
|
82
|
+
return persistedToken;
|
|
83
|
+
}
|
|
84
|
+
|
|
31
85
|
throw new Error('No authentication token available. Please authenticate first using the authenticate_plex tool or set PLEX_TOKEN environment variable.');
|
|
32
86
|
}
|
|
33
87
|
|
|
@@ -38,8 +92,8 @@ class PlexAuthManager {
|
|
|
38
92
|
|
|
39
93
|
const clientInfo = {
|
|
40
94
|
clientIdentifier: process.env.PLEX_CLIENT_ID || 'plex-mcp-client',
|
|
41
|
-
product: process.env.PLEX_PRODUCT || '
|
|
42
|
-
device: process.env.PLEX_DEVICE || '
|
|
95
|
+
product: process.env.PLEX_PRODUCT || 'PlexMCP',
|
|
96
|
+
device: process.env.PLEX_DEVICE || 'PlexMCP',
|
|
43
97
|
version: process.env.PLEX_VERSION || '1.0.0',
|
|
44
98
|
forwardUrl: process.env.PLEX_REDIRECT_URL || 'https://app.plex.tv/auth#!',
|
|
45
99
|
platform: process.env.PLEX_PLATFORM || 'Web'
|
|
@@ -72,6 +126,7 @@ class PlexAuthManager {
|
|
|
72
126
|
const authToken = await oauth.checkForAuthToken(pin);
|
|
73
127
|
if (authToken) {
|
|
74
128
|
this.authToken = authToken;
|
|
129
|
+
await this.saveToken(authToken);
|
|
75
130
|
return authToken;
|
|
76
131
|
}
|
|
77
132
|
return null;
|
|
@@ -80,9 +135,10 @@ class PlexAuthManager {
|
|
|
80
135
|
}
|
|
81
136
|
}
|
|
82
137
|
|
|
83
|
-
clearAuth() {
|
|
138
|
+
async clearAuth() {
|
|
84
139
|
this.authToken = null;
|
|
85
140
|
this.currentPinId = null;
|
|
141
|
+
await this.clearPersistedToken();
|
|
86
142
|
}
|
|
87
143
|
}
|
|
88
144
|
|
|
@@ -112,6 +168,155 @@ class PlexMCPServer {
|
|
|
112
168
|
});
|
|
113
169
|
}
|
|
114
170
|
|
|
171
|
+
// ===========================
|
|
172
|
+
// RANDOMIZATION HELPER METHODS
|
|
173
|
+
// ===========================
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Detect if a query suggests randomization is needed
|
|
177
|
+
* @param {string} query - The search query to analyze
|
|
178
|
+
* @returns {boolean} - True if randomization patterns detected
|
|
179
|
+
*/
|
|
180
|
+
detectRandomizationIntent(query) {
|
|
181
|
+
if (!query || typeof query !== 'string') return false;
|
|
182
|
+
|
|
183
|
+
const randomPatterns = [
|
|
184
|
+
// Direct randomization requests
|
|
185
|
+
/\b(some|random|variety|mix|selection|surprise)\s+(songs?|tracks?|albums?|movies?|shows?|episodes?|music)/i,
|
|
186
|
+
/\b(surprise\s+me|shuffle|mixed\s+bag|something\s+different)/i,
|
|
187
|
+
/\b(pick|choose|select)\s+(some|a\s+few|several)/i,
|
|
188
|
+
|
|
189
|
+
// Indefinite quantities suggesting variety
|
|
190
|
+
/\b(some|any|various|assorted|different)\s+(songs?|tracks?|albums?|movies?|shows?|artists?)/i,
|
|
191
|
+
/\b(give\s+me\s+)?(some|a\s+few|several)\b/i,
|
|
192
|
+
|
|
193
|
+
// Discovery patterns
|
|
194
|
+
/\b(discover|explore|find\s+me)\s+(new|different)/i,
|
|
195
|
+
/\b(what|show\s+me)\s+(some|random)/i
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
return randomPatterns.some(pattern => pattern.test(query));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Determine appropriate randomization settings based on query and content type
|
|
203
|
+
* @param {string} query - The search query
|
|
204
|
+
* @param {string} type - Content type (movie, show, track, etc.)
|
|
205
|
+
* @param {Object} existingParams - Existing search parameters
|
|
206
|
+
* @returns {Object} - Modified parameters with randomization settings
|
|
207
|
+
*/
|
|
208
|
+
applyRandomizationSettings(query, type = null, existingParams = {}) {
|
|
209
|
+
if (!this.detectRandomizationIntent(query)) {
|
|
210
|
+
return existingParams;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const params = { ...existingParams };
|
|
214
|
+
|
|
215
|
+
// Always use random sort when randomization is detected
|
|
216
|
+
params.sort = 'random';
|
|
217
|
+
|
|
218
|
+
// Adjust default limits for variety (unless user specified a specific limit)
|
|
219
|
+
if (!params.limit || params.limit === 10) { // Default limits
|
|
220
|
+
switch (type) {
|
|
221
|
+
case 'track':
|
|
222
|
+
case 'music':
|
|
223
|
+
params.limit = Math.min(25, params.limit || 25); // More songs for variety
|
|
224
|
+
break;
|
|
225
|
+
case 'movie':
|
|
226
|
+
case 'show':
|
|
227
|
+
params.limit = Math.min(15, params.limit || 15); // Moderate for viewing
|
|
228
|
+
break;
|
|
229
|
+
case 'album':
|
|
230
|
+
case 'artist':
|
|
231
|
+
params.limit = Math.min(12, params.limit || 12); // Good album variety
|
|
232
|
+
break;
|
|
233
|
+
default:
|
|
234
|
+
params.limit = Math.min(20, params.limit || 20); // General variety
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// For randomization, prefer to start from beginning (no offset)
|
|
239
|
+
if (params.offset && params.offset > 0) {
|
|
240
|
+
params.offset = 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return params;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Apply client-side randomization when server-side isn't sufficient
|
|
248
|
+
* @param {Array} items - Array of items to randomize
|
|
249
|
+
* @param {number} maxItems - Maximum number of items to return
|
|
250
|
+
* @returns {Array} - Shuffled subset of items
|
|
251
|
+
*/
|
|
252
|
+
applyClientSideRandomization(items, maxItems = null) {
|
|
253
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
254
|
+
return items;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Simple Fisher-Yates shuffle implementation
|
|
258
|
+
const shuffled = [...items];
|
|
259
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
260
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
261
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Return subset if maxItems specified
|
|
265
|
+
if (maxItems && maxItems < shuffled.length) {
|
|
266
|
+
return shuffled.slice(0, maxItems);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return shuffled;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Create a randomized subset from multiple categories
|
|
274
|
+
* @param {Object} categorizedItems - Object with category keys and item arrays
|
|
275
|
+
* @param {number} totalLimit - Total number of items to return
|
|
276
|
+
* @returns {Array} - Mixed randomized results
|
|
277
|
+
*/
|
|
278
|
+
createRandomizedMix(categorizedItems, totalLimit = 20) {
|
|
279
|
+
const categories = Object.keys(categorizedItems);
|
|
280
|
+
if (categories.length === 0) return [];
|
|
281
|
+
|
|
282
|
+
const result = [];
|
|
283
|
+
const itemsPerCategory = Math.floor(totalLimit / categories.length);
|
|
284
|
+
const remainder = totalLimit % categories.length;
|
|
285
|
+
|
|
286
|
+
// Get items from each category
|
|
287
|
+
categories.forEach((category, index) => {
|
|
288
|
+
const items = categorizedItems[category] || [];
|
|
289
|
+
const categoryLimit = itemsPerCategory + (index < remainder ? 1 : 0);
|
|
290
|
+
const randomItems = this.applyClientSideRandomization(items, categoryLimit);
|
|
291
|
+
result.push(...randomItems);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Final shuffle of the mixed results
|
|
295
|
+
return this.applyClientSideRandomization(result);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Generate random discovery suggestions when no specific query provided
|
|
300
|
+
* @param {Array} libraries - Available libraries
|
|
301
|
+
* @returns {Object} - Random discovery parameters
|
|
302
|
+
*/
|
|
303
|
+
generateRandomDiscoveryParams(libraries = []) {
|
|
304
|
+
const currentYear = new Date().getFullYear();
|
|
305
|
+
const decades = ['1970s', '1980s', '1990s', '2000s', '2010s', '2020s'];
|
|
306
|
+
const randomDecade = decades[Math.floor(Math.random() * decades.length)];
|
|
307
|
+
|
|
308
|
+
const discoveryPatterns = [
|
|
309
|
+
{ query: `music from the ${randomDecade}`, limit: 15 },
|
|
310
|
+
{ query: 'highly rated albums', rating_min: 8, limit: 12 },
|
|
311
|
+
{ query: 'unheard songs', never_played: true, limit: 20 },
|
|
312
|
+
{ query: 'recent additions', sort: 'addedAt', limit: 15 },
|
|
313
|
+
{ query: 'forgotten favorites', play_count_min: 1, last_played_before: '2023-01-01', limit: 10 }
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
const randomPattern = discoveryPatterns[Math.floor(Math.random() * discoveryPatterns.length)];
|
|
317
|
+
return { ...randomPattern, sort: 'random' };
|
|
318
|
+
}
|
|
319
|
+
|
|
115
320
|
setupToolHandlers() {
|
|
116
321
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
117
322
|
return {
|
|
@@ -488,7 +693,7 @@ class PlexMCPServer {
|
|
|
488
693
|
},
|
|
489
694
|
{
|
|
490
695
|
name: "create_playlist",
|
|
491
|
-
description: "Create a new playlist on the Plex server.
|
|
696
|
+
description: "Create a new regular playlist on the Plex server. Requires an initial item (item_key parameter) to be created successfully. Smart playlists are not supported due to their complex filter requirements.",
|
|
492
697
|
inputSchema: {
|
|
493
698
|
type: "object",
|
|
494
699
|
properties: {
|
|
@@ -501,19 +706,76 @@ class PlexMCPServer {
|
|
|
501
706
|
enum: ["audio", "video", "photo"],
|
|
502
707
|
description: "The type of playlist to create",
|
|
503
708
|
},
|
|
504
|
-
smart: {
|
|
505
|
-
type: "boolean",
|
|
506
|
-
description: "Whether to create a smart playlist (default: false)",
|
|
507
|
-
default: false,
|
|
508
|
-
},
|
|
509
709
|
item_key: {
|
|
510
710
|
type: "string",
|
|
511
|
-
description: "The key of an initial item to add to the playlist. Required for
|
|
711
|
+
description: "The key of an initial item to add to the playlist. Required for playlist creation. Get item keys from search_plex or browse_library results.",
|
|
512
712
|
},
|
|
513
713
|
},
|
|
514
|
-
required: ["title", "type"],
|
|
714
|
+
required: ["title", "type", "item_key"],
|
|
515
715
|
},
|
|
516
716
|
},
|
|
717
|
+
// TEMPORARILY DISABLED - Smart playlist filtering is broken
|
|
718
|
+
// {
|
|
719
|
+
// name: "create_smart_playlist",
|
|
720
|
+
// description: "Create a new smart playlist with filter criteria. Smart playlists automatically populate based on specified conditions.",
|
|
721
|
+
// inputSchema: {
|
|
722
|
+
// type: "object",
|
|
723
|
+
// properties: {
|
|
724
|
+
// title: {
|
|
725
|
+
// type: "string",
|
|
726
|
+
// description: "The title/name for the new smart playlist",
|
|
727
|
+
// },
|
|
728
|
+
// type: {
|
|
729
|
+
// type: "string",
|
|
730
|
+
// enum: ["audio", "video", "photo"],
|
|
731
|
+
// description: "The type of content for the smart playlist",
|
|
732
|
+
// },
|
|
733
|
+
// library_id: {
|
|
734
|
+
// type: "string",
|
|
735
|
+
// description: "The library ID to create the smart playlist in. Use browse_libraries to get library IDs.",
|
|
736
|
+
// },
|
|
737
|
+
// filters: {
|
|
738
|
+
// type: "array",
|
|
739
|
+
// description: "Array of filter conditions for the smart playlist",
|
|
740
|
+
// items: {
|
|
741
|
+
// type: "object",
|
|
742
|
+
// properties: {
|
|
743
|
+
// field: {
|
|
744
|
+
// type: "string",
|
|
745
|
+
// enum: ["artist.title", "album.title", "track.title", "genre.tag", "year", "rating", "addedAt", "lastViewedAt", "viewCount"],
|
|
746
|
+
// description: "The field to filter on"
|
|
747
|
+
// },
|
|
748
|
+
// operator: {
|
|
749
|
+
// type: "string",
|
|
750
|
+
// enum: ["is", "isnot", "contains", "doesnotcontain", "beginswith", "endswith", "gt", "gte", "lt", "lte"],
|
|
751
|
+
// description: "The comparison operator"
|
|
752
|
+
// },
|
|
753
|
+
// value: {
|
|
754
|
+
// type: "string",
|
|
755
|
+
// description: "The value to compare against"
|
|
756
|
+
// }
|
|
757
|
+
// },
|
|
758
|
+
// required: ["field", "operator", "value"]
|
|
759
|
+
// },
|
|
760
|
+
// minItems: 1
|
|
761
|
+
// },
|
|
762
|
+
// sort: {
|
|
763
|
+
// type: "string",
|
|
764
|
+
// enum: ["artist.titleSort", "album.titleSort", "track.titleSort", "addedAt", "year", "rating", "lastViewedAt", "random"],
|
|
765
|
+
// description: "How to sort the smart playlist results (optional)",
|
|
766
|
+
// default: "artist.titleSort"
|
|
767
|
+
// },
|
|
768
|
+
// limit: {
|
|
769
|
+
// type: "integer",
|
|
770
|
+
// description: "Maximum number of items in the smart playlist (optional)",
|
|
771
|
+
// minimum: 1,
|
|
772
|
+
// maximum: 1000,
|
|
773
|
+
// default: 100
|
|
774
|
+
// }
|
|
775
|
+
// },
|
|
776
|
+
// required: ["title", "type", "library_id", "filters"],
|
|
777
|
+
// },
|
|
778
|
+
// },
|
|
517
779
|
{
|
|
518
780
|
name: "add_to_playlist",
|
|
519
781
|
description: "Add items to an existing playlist",
|
|
@@ -702,6 +964,29 @@ class PlexMCPServer {
|
|
|
702
964
|
required: [],
|
|
703
965
|
},
|
|
704
966
|
},
|
|
967
|
+
{
|
|
968
|
+
name: "discover_music",
|
|
969
|
+
description: "Natural language music discovery with smart recommendations based on your preferences and library",
|
|
970
|
+
inputSchema: {
|
|
971
|
+
type: "object",
|
|
972
|
+
properties: {
|
|
973
|
+
query: {
|
|
974
|
+
type: "string",
|
|
975
|
+
description: "Natural language query (e.g., 'songs from the 90s', 'rock bands I haven't heard', 'something like Modest Mouse')",
|
|
976
|
+
},
|
|
977
|
+
context: {
|
|
978
|
+
type: "string",
|
|
979
|
+
description: "Additional context for the search (optional)",
|
|
980
|
+
},
|
|
981
|
+
limit: {
|
|
982
|
+
type: "number",
|
|
983
|
+
description: "Maximum number of results to return (default: 10)",
|
|
984
|
+
default: 10,
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
required: ["query"],
|
|
988
|
+
},
|
|
989
|
+
},
|
|
705
990
|
{
|
|
706
991
|
name: "authenticate_plex",
|
|
707
992
|
description: "Initiate Plex OAuth authentication flow to get user login URL",
|
|
@@ -758,6 +1043,9 @@ class PlexMCPServer {
|
|
|
758
1043
|
return await this.handleBrowsePlaylist(request.params.arguments);
|
|
759
1044
|
case "create_playlist":
|
|
760
1045
|
return await this.handleCreatePlaylist(request.params.arguments);
|
|
1046
|
+
// TEMPORARILY DISABLED - Smart playlist filtering is broken
|
|
1047
|
+
// case "create_smart_playlist":
|
|
1048
|
+
// return await this.handleCreateSmartPlaylist(request.params.arguments);
|
|
761
1049
|
case "add_to_playlist":
|
|
762
1050
|
return await this.handleAddToPlaylist(request.params.arguments);
|
|
763
1051
|
// DISABLED: remove_from_playlist - PROBLEMATIC operation
|
|
@@ -777,6 +1065,8 @@ class PlexMCPServer {
|
|
|
777
1065
|
return await this.handleGetLibraryStats(request.params.arguments);
|
|
778
1066
|
case "get_listening_stats":
|
|
779
1067
|
return await this.handleGetListeningStats(request.params.arguments);
|
|
1068
|
+
case "discover_music":
|
|
1069
|
+
return await this.handleDiscoverMusic(request.params.arguments);
|
|
780
1070
|
case "authenticate_plex":
|
|
781
1071
|
return await this.handleAuthenticatePlex(request.params.arguments);
|
|
782
1072
|
case "check_auth_status":
|
|
@@ -800,13 +1090,19 @@ class PlexMCPServer {
|
|
|
800
1090
|
text: `Plex Authentication Started
|
|
801
1091
|
|
|
802
1092
|
**Next Steps:**
|
|
803
|
-
1. Open this URL in your browser:
|
|
804
|
-
|
|
805
|
-
|
|
1093
|
+
1. Open this URL in your browser:
|
|
1094
|
+
|
|
1095
|
+
\`\`\`
|
|
1096
|
+
${loginUrl.replace(/\[/g, '%5B').replace(/\]/g, '%5D').replace(/!/g, '%21')}
|
|
1097
|
+
\`\`\`
|
|
1098
|
+
|
|
1099
|
+
2. Sign into your Plex account when prompted
|
|
1100
|
+
3. **IMPORTANT:** After signing in, you MUST return here and run the \`check_auth_status\` tool to complete the authentication process
|
|
1101
|
+
4. Only after running \`check_auth_status\` will your token be saved and ready for use
|
|
806
1102
|
|
|
807
1103
|
**Pin ID:** ${pinId}
|
|
808
1104
|
|
|
809
|
-
|
|
1105
|
+
⚠️ **Don't forget:** The authentication is not complete until you return and run \`check_auth_status\`!`
|
|
810
1106
|
}
|
|
811
1107
|
]
|
|
812
1108
|
};
|
|
@@ -875,7 +1171,7 @@ You can run check_auth_status again to check if authentication is complete.`
|
|
|
875
1171
|
|
|
876
1172
|
async handleClearAuth(args) {
|
|
877
1173
|
try {
|
|
878
|
-
this.authManager.clearAuth();
|
|
1174
|
+
await this.authManager.clearAuth();
|
|
879
1175
|
|
|
880
1176
|
return {
|
|
881
1177
|
content: [
|
|
@@ -934,16 +1230,20 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
934
1230
|
added_after,
|
|
935
1231
|
added_before
|
|
936
1232
|
} = args;
|
|
1233
|
+
|
|
1234
|
+
// Apply randomization settings if detected
|
|
1235
|
+
const enhancedArgs = this.applyRandomizationSettings(query, type, args);
|
|
1236
|
+
const finalLimit = enhancedArgs.limit || limit;
|
|
937
1237
|
|
|
938
1238
|
try {
|
|
939
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
1239
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
940
1240
|
const plexToken = await this.authManager.getAuthToken();
|
|
941
1241
|
|
|
942
|
-
const searchUrl = `${plexUrl}/search`;
|
|
1242
|
+
const searchUrl = `${plexUrl}/hubs/search`;
|
|
943
1243
|
const params = {
|
|
944
1244
|
query: query,
|
|
945
1245
|
'X-Plex-Token': plexToken,
|
|
946
|
-
limit:
|
|
1246
|
+
limit: finalLimit
|
|
947
1247
|
};
|
|
948
1248
|
|
|
949
1249
|
if (type) {
|
|
@@ -994,11 +1294,21 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
994
1294
|
file_size_max
|
|
995
1295
|
});
|
|
996
1296
|
|
|
1297
|
+
// Apply client-side randomization if detected and we have more results than requested
|
|
1298
|
+
const shouldRandomize = this.detectRandomizationIntent(query);
|
|
1299
|
+
if (shouldRandomize && results.length > limit) {
|
|
1300
|
+
results = this.applyClientSideRandomization(results, limit);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const resultText = shouldRandomize && results.length > 0
|
|
1304
|
+
? `Found ${results.length} randomized results for "${query}":\n\n${this.formatResults(results)}`
|
|
1305
|
+
: `Found ${results.length} results for "${query}":\n\n${this.formatResults(results)}`;
|
|
1306
|
+
|
|
997
1307
|
return {
|
|
998
1308
|
content: [
|
|
999
1309
|
{
|
|
1000
1310
|
type: "text",
|
|
1001
|
-
text:
|
|
1311
|
+
text: resultText,
|
|
1002
1312
|
},
|
|
1003
1313
|
],
|
|
1004
1314
|
};
|
|
@@ -1028,11 +1338,30 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1028
1338
|
}
|
|
1029
1339
|
|
|
1030
1340
|
parseSearchResults(data) {
|
|
1031
|
-
if (!data.MediaContainer
|
|
1341
|
+
if (!data.MediaContainer) {
|
|
1032
1342
|
return [];
|
|
1033
1343
|
}
|
|
1034
1344
|
|
|
1035
|
-
|
|
1345
|
+
// Handle both /search and /hubs/search response formats
|
|
1346
|
+
let allResults = [];
|
|
1347
|
+
|
|
1348
|
+
// For /hubs/search response format (contains Hub elements)
|
|
1349
|
+
if (data.MediaContainer.Hub) {
|
|
1350
|
+
const hubs = Array.isArray(data.MediaContainer.Hub) ? data.MediaContainer.Hub : [data.MediaContainer.Hub];
|
|
1351
|
+
|
|
1352
|
+
for (const hub of hubs) {
|
|
1353
|
+
if (hub.Metadata) {
|
|
1354
|
+
const hubResults = Array.isArray(hub.Metadata) ? hub.Metadata : [hub.Metadata];
|
|
1355
|
+
allResults = allResults.concat(hubResults);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
// For /search response format (direct Metadata array)
|
|
1360
|
+
else if (data.MediaContainer.Metadata) {
|
|
1361
|
+
allResults = Array.isArray(data.MediaContainer.Metadata) ? data.MediaContainer.Metadata : [data.MediaContainer.Metadata];
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return allResults.map(item => ({
|
|
1036
1365
|
title: item.title,
|
|
1037
1366
|
type: item.type,
|
|
1038
1367
|
year: item.year,
|
|
@@ -1117,7 +1446,7 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1117
1446
|
|
|
1118
1447
|
async handleBrowseLibraries(args) {
|
|
1119
1448
|
try {
|
|
1120
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
1449
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
1121
1450
|
const plexToken = await this.authManager.getAuthToken();
|
|
1122
1451
|
|
|
1123
1452
|
const librariesUrl = `${plexUrl}/library/sections`;
|
|
@@ -1227,17 +1556,24 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1227
1556
|
added_after,
|
|
1228
1557
|
added_before
|
|
1229
1558
|
} = args;
|
|
1559
|
+
|
|
1560
|
+
// Apply randomization settings if detected (for browse library, check genre as potential query)
|
|
1561
|
+
const searchQuery = genre || year || 'browse';
|
|
1562
|
+
const enhancedArgs = this.applyRandomizationSettings(searchQuery, null, args);
|
|
1563
|
+
const finalSort = enhancedArgs.sort || sort;
|
|
1564
|
+
const finalLimit = enhancedArgs.limit || limit;
|
|
1565
|
+
const finalOffset = enhancedArgs.offset !== undefined ? enhancedArgs.offset : offset;
|
|
1230
1566
|
|
|
1231
1567
|
try {
|
|
1232
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
1568
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
1233
1569
|
const plexToken = await this.authManager.getAuthToken();
|
|
1234
1570
|
|
|
1235
1571
|
const libraryUrl = `${plexUrl}/library/sections/${library_id}/all`;
|
|
1236
1572
|
const params = {
|
|
1237
1573
|
'X-Plex-Token': plexToken,
|
|
1238
|
-
sort:
|
|
1239
|
-
'X-Plex-Container-Start':
|
|
1240
|
-
'X-Plex-Container-Size':
|
|
1574
|
+
sort: finalSort,
|
|
1575
|
+
'X-Plex-Container-Start': finalOffset,
|
|
1576
|
+
'X-Plex-Container-Size': finalLimit
|
|
1241
1577
|
};
|
|
1242
1578
|
|
|
1243
1579
|
if (genre) {
|
|
@@ -1292,12 +1628,20 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1292
1628
|
file_size_max
|
|
1293
1629
|
});
|
|
1294
1630
|
|
|
1631
|
+
// Apply client-side randomization if detected and using random sort
|
|
1632
|
+
const shouldRandomize = this.detectRandomizationIntent(searchQuery);
|
|
1633
|
+
if (shouldRandomize && finalSort === 'random' && results.length > limit) {
|
|
1634
|
+
results = this.applyClientSideRandomization(results, limit);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1295
1637
|
const totalSize = response.data.MediaContainer?.totalSize || results.length;
|
|
1296
1638
|
|
|
1297
|
-
let resultText =
|
|
1639
|
+
let resultText = shouldRandomize && finalSort === 'random'
|
|
1640
|
+
? `Randomized library content (${results.length} items)`
|
|
1641
|
+
: `Library content (${finalOffset + 1}-${Math.min(finalOffset + finalLimit, totalSize)} of ${totalSize})`;
|
|
1298
1642
|
if (genre) resultText += ` | Genre: ${genre}`;
|
|
1299
1643
|
if (year) resultText += ` | Year: ${year}`;
|
|
1300
|
-
if (
|
|
1644
|
+
if (finalSort !== "titleSort") resultText += ` | Sorted by: ${finalSort}`;
|
|
1301
1645
|
resultText += `:\n\n${this.formatResults(results)}`;
|
|
1302
1646
|
|
|
1303
1647
|
return {
|
|
@@ -1351,7 +1695,7 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1351
1695
|
const { library_id, limit = 15, chunk_size = 10, chunk_offset = 0 } = args;
|
|
1352
1696
|
|
|
1353
1697
|
try {
|
|
1354
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
1698
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
1355
1699
|
const plexToken = await this.authManager.getAuthToken();
|
|
1356
1700
|
|
|
1357
1701
|
let recentUrl;
|
|
@@ -1447,7 +1791,7 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1447
1791
|
const { limit = 20, account_id, chunk_size = 10, chunk_offset = 0 } = args;
|
|
1448
1792
|
|
|
1449
1793
|
try {
|
|
1450
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
1794
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
1451
1795
|
const plexToken = await this.authManager.getAuthToken();
|
|
1452
1796
|
|
|
1453
1797
|
const historyUrl = `${plexUrl}/status/sessions/history/all`;
|
|
@@ -1567,7 +1911,7 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1567
1911
|
const { limit = 15 } = args;
|
|
1568
1912
|
|
|
1569
1913
|
try {
|
|
1570
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
1914
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
1571
1915
|
const plexToken = await this.authManager.getAuthToken();
|
|
1572
1916
|
|
|
1573
1917
|
const onDeckUrl = `${plexUrl}/library/onDeck`;
|
|
@@ -1666,7 +2010,7 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1666
2010
|
const { playlist_type } = args;
|
|
1667
2011
|
|
|
1668
2012
|
try {
|
|
1669
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
2013
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
1670
2014
|
const plexToken = await this.authManager.getAuthToken();
|
|
1671
2015
|
|
|
1672
2016
|
const playlistsUrl = `${plexUrl}/playlists`;
|
|
@@ -1714,7 +2058,7 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1714
2058
|
const { playlist_id, limit = 50 } = args;
|
|
1715
2059
|
|
|
1716
2060
|
try {
|
|
1717
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
2061
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
1718
2062
|
const plexToken = await this.authManager.getAuthToken();
|
|
1719
2063
|
|
|
1720
2064
|
// First get playlist info
|
|
@@ -1912,23 +2256,10 @@ All stored authentication credentials have been cleared. To use Plex tools again
|
|
|
1912
2256
|
}
|
|
1913
2257
|
|
|
1914
2258
|
async handleCreatePlaylist(args) {
|
|
1915
|
-
const { title, type, smart = false
|
|
1916
|
-
|
|
1917
|
-
// Validate that item_key is provided for non-smart playlists
|
|
1918
|
-
if (!smart && !item_key) {
|
|
1919
|
-
return {
|
|
1920
|
-
content: [
|
|
1921
|
-
{
|
|
1922
|
-
type: "text",
|
|
1923
|
-
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.`,
|
|
1924
|
-
},
|
|
1925
|
-
],
|
|
1926
|
-
isError: true,
|
|
1927
|
-
};
|
|
1928
|
-
}
|
|
2259
|
+
const { title, type, item_key, smart = false } = args;
|
|
1929
2260
|
|
|
1930
2261
|
try {
|
|
1931
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
2262
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
1932
2263
|
const plexToken = await this.authManager.getAuthToken();
|
|
1933
2264
|
|
|
1934
2265
|
// First get server info to get machine identifier
|
|
@@ -2018,138 +2349,106 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2018
2349
|
}
|
|
2019
2350
|
}
|
|
2020
2351
|
|
|
2021
|
-
async
|
|
2022
|
-
const {
|
|
2023
|
-
|
|
2024
|
-
// Input validation
|
|
2025
|
-
if (!playlist_id || typeof playlist_id !== 'string') {
|
|
2026
|
-
throw new Error('Valid playlist_id is required');
|
|
2027
|
-
}
|
|
2028
|
-
if (!item_keys || !Array.isArray(item_keys) || item_keys.length === 0) {
|
|
2029
|
-
throw new Error('item_keys must be a non-empty array');
|
|
2030
|
-
}
|
|
2031
|
-
if (item_keys.some(key => !key || typeof key !== 'string')) {
|
|
2032
|
-
throw new Error('All item_keys must be non-empty strings');
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2352
|
+
async handleCreateSmartPlaylist(args) {
|
|
2353
|
+
const { title, type, library_id, filters, sort = "artist.titleSort", limit = 100 } = args;
|
|
2354
|
+
|
|
2035
2355
|
try {
|
|
2036
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
2356
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
2037
2357
|
const plexToken = await this.authManager.getAuthToken();
|
|
2038
2358
|
|
|
2039
|
-
// Get
|
|
2040
|
-
const
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
const playlistInfoResponse = await axios.get(playlistInfoUrl, {
|
|
2045
|
-
params: {
|
|
2046
|
-
'X-Plex-Token': plexToken
|
|
2359
|
+
// Get server machine identifier
|
|
2360
|
+
const serverResponse = await axios.get(`${plexUrl}`, {
|
|
2361
|
+
headers: {
|
|
2362
|
+
'X-Plex-Token': plexToken,
|
|
2363
|
+
'Accept': 'application/json'
|
|
2047
2364
|
},
|
|
2048
2365
|
httpsAgent: this.getHttpsAgent()
|
|
2049
2366
|
});
|
|
2050
|
-
|
|
2051
|
-
const playlistInfo = playlistInfoResponse.data.MediaContainer;
|
|
2052
|
-
const playlistTitle = playlistInfo.Metadata && playlistInfo.Metadata[0]
|
|
2053
|
-
? playlistInfo.Metadata[0].title : `Playlist ${playlist_id}`;
|
|
2054
|
-
|
|
2055
|
-
// Get current playlist items count
|
|
2056
|
-
let beforeCount = 0;
|
|
2057
|
-
try {
|
|
2058
|
-
const beforeResponse = await axios.get(playlistItemsUrl, {
|
|
2059
|
-
params: {
|
|
2060
|
-
'X-Plex-Token': plexToken
|
|
2061
|
-
},
|
|
2062
|
-
httpsAgent: this.getHttpsAgent()
|
|
2063
|
-
});
|
|
2064
|
-
beforeCount = beforeResponse.data.MediaContainer?.totalSize || 0;
|
|
2065
|
-
} catch (error) {
|
|
2066
|
-
// If items endpoint fails, playlist might be empty
|
|
2067
|
-
beforeCount = 0;
|
|
2068
|
-
}
|
|
2069
2367
|
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2368
|
+
const machineId = serverResponse.data.MediaContainer.machineIdentifier;
|
|
2369
|
+
|
|
2370
|
+
// Build query parameters from filters manually to avoid double encoding
|
|
2371
|
+
const queryParts = [`type=${type === 'audio' ? '10' : '1'}`];
|
|
2372
|
+
|
|
2373
|
+
filters.forEach(filter => {
|
|
2374
|
+
const field = this.mapFilterField(filter.field);
|
|
2375
|
+
const operator = this.mapFilterOperator(filter.operator);
|
|
2376
|
+
|
|
2377
|
+
if (operator === '=') {
|
|
2378
|
+
// Plex expects triple-encoded format: field%253D%3Dvalue
|
|
2379
|
+
const encodedField = encodeURIComponent(encodeURIComponent(field));
|
|
2380
|
+
const encodedValue = encodeURIComponent(encodeURIComponent(filter.value));
|
|
2381
|
+
queryParts.push(`${encodedField}%253D%3D${encodedValue}`);
|
|
2382
|
+
} else {
|
|
2383
|
+
queryParts.push(`${encodeURIComponent(field)}${operator}${encodeURIComponent(filter.value)}`);
|
|
2384
|
+
}
|
|
2074
2385
|
});
|
|
2075
|
-
|
|
2076
|
-
const machineIdentifier = serverResponse.data?.MediaContainer?.machineIdentifier;
|
|
2077
|
-
if (!machineIdentifier) {
|
|
2078
|
-
throw new Error('Could not get server machine identifier');
|
|
2079
|
-
}
|
|
2080
2386
|
|
|
2081
|
-
|
|
2082
|
-
const
|
|
2083
|
-
'X-Plex-Token': plexToken,
|
|
2084
|
-
uri: item_keys.map(key => `server://${machineIdentifier}/com.plexapp.plugins.library/library/metadata/${key}`).join(',')
|
|
2085
|
-
};
|
|
2387
|
+
// Build the URI in the format Plex expects
|
|
2388
|
+
const uri = `server://${machineId}/com.plexapp.plugins.library/library/sections/${library_id}/all?${queryParts.join('&')}`;
|
|
2086
2389
|
|
|
2087
|
-
|
|
2088
|
-
|
|
2390
|
+
// Debug logging
|
|
2391
|
+
console.log('DEBUG: Generated URI:', uri);
|
|
2392
|
+
console.log('DEBUG: Query parts:', queryParts);
|
|
2393
|
+
|
|
2394
|
+
// Create smart playlist using POST to /playlists
|
|
2395
|
+
const createParams = new URLSearchParams();
|
|
2396
|
+
createParams.append('type', type);
|
|
2397
|
+
createParams.append('title', title);
|
|
2398
|
+
createParams.append('smart', '1');
|
|
2399
|
+
createParams.append('uri', uri);
|
|
2400
|
+
|
|
2401
|
+
const response = await axios.post(`${plexUrl}/playlists?${createParams.toString()}`, null, {
|
|
2402
|
+
headers: {
|
|
2403
|
+
'X-Plex-Token': plexToken,
|
|
2404
|
+
'Accept': 'application/json'
|
|
2405
|
+
},
|
|
2089
2406
|
httpsAgent: this.getHttpsAgent()
|
|
2090
2407
|
});
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
const putSuccessful = response.status >= 200 && response.status < 300;
|
|
2094
|
-
|
|
2095
|
-
// Small delay to allow Plex server to update
|
|
2096
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
2097
|
-
|
|
2098
|
-
// Verify the addition by checking the playlist items again
|
|
2099
|
-
let afterCount = 0;
|
|
2100
|
-
try {
|
|
2101
|
-
const afterResponse = await axios.get(playlistItemsUrl, {
|
|
2102
|
-
params: {
|
|
2103
|
-
'X-Plex-Token': plexToken
|
|
2104
|
-
},
|
|
2105
|
-
httpsAgent: this.getHttpsAgent()
|
|
2106
|
-
});
|
|
2107
|
-
afterCount = afterResponse.data.MediaContainer?.totalSize || 0;
|
|
2108
|
-
} catch (error) {
|
|
2109
|
-
// If items endpoint fails, playlist might be empty
|
|
2110
|
-
afterCount = 0;
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
const actualAdded = afterCount - beforeCount;
|
|
2114
|
-
const attempted = item_keys.length;
|
|
2115
|
-
|
|
2116
|
-
let resultText = `Playlist "${playlistTitle}" update:\n`;
|
|
2117
|
-
resultText += `• Attempted to add: ${attempted} item(s)\n`;
|
|
2118
|
-
resultText += `• Actually added: ${actualAdded} item(s)\n`;
|
|
2119
|
-
resultText += `• Playlist size: ${beforeCount} → ${afterCount} items\n`;
|
|
2120
|
-
|
|
2121
|
-
// If HTTP request was successful but count didn't change,
|
|
2122
|
-
// it's likely the items already exist or are duplicates
|
|
2123
|
-
if (actualAdded === attempted) {
|
|
2124
|
-
resultText += `✅ All items added successfully!`;
|
|
2125
|
-
} else if (actualAdded > 0) {
|
|
2126
|
-
resultText += `⚠️ Partial success: ${attempted - actualAdded} item(s) may have been duplicates or invalid`;
|
|
2127
|
-
} else if (putSuccessful) {
|
|
2128
|
-
resultText += `✅ API request successful! Items may already exist in playlist or were duplicates.\n`;
|
|
2129
|
-
resultText += `ℹ️ This is normal behavior - Plex doesn't add duplicate items.`;
|
|
2130
|
-
} else {
|
|
2131
|
-
resultText += `❌ No items were added. This may indicate:\n`;
|
|
2132
|
-
resultText += ` - Invalid item IDs (use ratingKey from search results)\n`;
|
|
2133
|
-
resultText += ` - Items already exist in playlist\n`;
|
|
2134
|
-
resultText += ` - Permission issues`;
|
|
2135
|
-
}
|
|
2408
|
+
|
|
2409
|
+
const playlistData = response.data.MediaContainer.Metadata[0];
|
|
2136
2410
|
|
|
2137
2411
|
return {
|
|
2138
2412
|
content: [
|
|
2139
2413
|
{
|
|
2140
2414
|
type: "text",
|
|
2141
|
-
text:
|
|
2415
|
+
text: `✅ **Smart Playlist Created Successfully**
|
|
2416
|
+
|
|
2417
|
+
**Playlist Details:**
|
|
2418
|
+
• **Name:** ${playlistData.title}
|
|
2419
|
+
• **Type:** ${playlistData.playlistType}
|
|
2420
|
+
• **Tracks:** ${playlistData.leafCount || 0}
|
|
2421
|
+
• **Duration:** ${playlistData.duration ? Math.round(playlistData.duration / 60000) + ' minutes' : 'Unknown'}
|
|
2422
|
+
• **ID:** ${playlistData.ratingKey}
|
|
2423
|
+
|
|
2424
|
+
**Filters Applied:**
|
|
2425
|
+
${filters.map(f => `• ${f.field} ${f.operator} "${f.value}"`).join('\n')}
|
|
2426
|
+
|
|
2427
|
+
The smart playlist has been created and is now available in your Plex library!`,
|
|
2142
2428
|
},
|
|
2143
2429
|
],
|
|
2144
2430
|
};
|
|
2145
2431
|
} catch (error) {
|
|
2146
|
-
// Enhanced error handling
|
|
2147
|
-
let errorMessage = `Error
|
|
2432
|
+
// Enhanced error handling for smart playlists
|
|
2433
|
+
let errorMessage = `Error creating smart playlist: ${error.message}`;
|
|
2148
2434
|
|
|
2149
2435
|
if (error.response) {
|
|
2150
2436
|
const status = error.response.status;
|
|
2151
|
-
if (status ===
|
|
2152
|
-
errorMessage =
|
|
2437
|
+
if (status === 400) {
|
|
2438
|
+
errorMessage = `❌ **Smart Playlist Creation Failed (400 Bad Request)**
|
|
2439
|
+
|
|
2440
|
+
**Possible issues:**
|
|
2441
|
+
• Invalid filter criteria or field names
|
|
2442
|
+
• Unsupported operator for the field type
|
|
2443
|
+
• Library ID "${library_id}" not found or inaccessible
|
|
2444
|
+
• Filter values in wrong format
|
|
2445
|
+
|
|
2446
|
+
**Debug info:**
|
|
2447
|
+
• Status: ${status}
|
|
2448
|
+
• Filters attempted: ${filters.length}
|
|
2449
|
+
• Library ID: ${library_id}
|
|
2450
|
+
|
|
2451
|
+
**Suggestion:** Try with simpler filters first, or verify library_id with \`browse_libraries\`.`;
|
|
2153
2452
|
} else if (status === 401 || status === 403) {
|
|
2154
2453
|
errorMessage = `Permission denied: Check your Plex token and server access`;
|
|
2155
2454
|
} else if (status >= 500) {
|
|
@@ -2171,11 +2470,43 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2171
2470
|
}
|
|
2172
2471
|
}
|
|
2173
2472
|
|
|
2174
|
-
//
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2473
|
+
// Helper functions for smart playlist field/operator mapping
|
|
2474
|
+
mapFilterField(field) {
|
|
2475
|
+
// Return the field as-is since Plex expects the full dotted notation
|
|
2476
|
+
return field;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
mapFilterOperator(operator) {
|
|
2480
|
+
const operatorMap = {
|
|
2481
|
+
'is': '=',
|
|
2482
|
+
'isnot': '!=',
|
|
2483
|
+
'contains': '=', // Plex uses = for contains on text fields
|
|
2484
|
+
'doesnotcontain': '!=',
|
|
2485
|
+
'beginswith': '=', // Plex uses = for text matching
|
|
2486
|
+
'endswith': '=',
|
|
2487
|
+
'gt': '>',
|
|
2488
|
+
'gte': '>=',
|
|
2489
|
+
'lt': '<',
|
|
2490
|
+
'lte': '<='
|
|
2491
|
+
};
|
|
2492
|
+
return operatorMap[operator] || operator;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
mapSortField(sort) {
|
|
2496
|
+
const sortMap = {
|
|
2497
|
+
'artist.titleSort': 'artist',
|
|
2498
|
+
'album.titleSort': 'album',
|
|
2499
|
+
'track.titleSort': 'title',
|
|
2500
|
+
'addedAt': 'addedAt',
|
|
2501
|
+
'year': 'year',
|
|
2502
|
+
'rating': 'userRating',
|
|
2503
|
+
'lastViewedAt': 'lastViewedAt',
|
|
2504
|
+
'random': 'random'
|
|
2505
|
+
};
|
|
2506
|
+
return sortMap[sort] || sort;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
async handleAddToPlaylist(args) {
|
|
2179
2510
|
const { playlist_id, item_keys } = args;
|
|
2180
2511
|
|
|
2181
2512
|
// Input validation
|
|
@@ -2190,10 +2521,10 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2190
2521
|
}
|
|
2191
2522
|
|
|
2192
2523
|
try {
|
|
2193
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
2524
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
2194
2525
|
const plexToken = await this.authManager.getAuthToken();
|
|
2195
2526
|
|
|
2196
|
-
// Get playlist info before
|
|
2527
|
+
// Get playlist info before adding items
|
|
2197
2528
|
const playlistInfoUrl = `${plexUrl}/playlists/${playlist_id}`;
|
|
2198
2529
|
const playlistItemsUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
|
2199
2530
|
|
|
@@ -2209,9 +2540,306 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2209
2540
|
const playlistTitle = playlistInfo.Metadata && playlistInfo.Metadata[0]
|
|
2210
2541
|
? playlistInfo.Metadata[0].title : `Playlist ${playlist_id}`;
|
|
2211
2542
|
|
|
2212
|
-
// Get current playlist items
|
|
2543
|
+
// Get current playlist items count
|
|
2213
2544
|
let beforeCount = 0;
|
|
2214
|
-
|
|
2545
|
+
try {
|
|
2546
|
+
const beforeResponse = await axios.get(playlistItemsUrl, {
|
|
2547
|
+
params: {
|
|
2548
|
+
'X-Plex-Token': plexToken
|
|
2549
|
+
},
|
|
2550
|
+
httpsAgent: this.getHttpsAgent()
|
|
2551
|
+
});
|
|
2552
|
+
const beforeItems = beforeResponse.data.MediaContainer?.Metadata || [];
|
|
2553
|
+
beforeCount = beforeItems.length; // Use actual count of items instead of totalSize
|
|
2554
|
+
} catch (error) {
|
|
2555
|
+
// If items endpoint fails, playlist might be empty
|
|
2556
|
+
beforeCount = 0;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// Get server machine identifier for proper URI format
|
|
2560
|
+
const serverResponse = await axios.get(`${plexUrl}/`, {
|
|
2561
|
+
headers: { 'X-Plex-Token': plexToken },
|
|
2562
|
+
httpsAgent: this.getHttpsAgent()
|
|
2563
|
+
});
|
|
2564
|
+
|
|
2565
|
+
const machineIdentifier = serverResponse.data?.MediaContainer?.machineIdentifier;
|
|
2566
|
+
if (!machineIdentifier) {
|
|
2567
|
+
throw new Error('Could not get server machine identifier');
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
const addUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
|
2571
|
+
|
|
2572
|
+
// Try different batch approaches for multiple items
|
|
2573
|
+
let response;
|
|
2574
|
+
let batchMethod = '';
|
|
2575
|
+
|
|
2576
|
+
if (item_keys.length === 1) {
|
|
2577
|
+
// Single item - use existing proven method
|
|
2578
|
+
const params = {
|
|
2579
|
+
'X-Plex-Token': plexToken,
|
|
2580
|
+
uri: `server://${machineIdentifier}/com.plexapp.plugins.library/library/metadata/${item_keys[0]}`
|
|
2581
|
+
};
|
|
2582
|
+
response = await axios.put(addUrl, null, { params, httpsAgent: this.getHttpsAgent() });
|
|
2583
|
+
batchMethod = 'single';
|
|
2584
|
+
|
|
2585
|
+
} else {
|
|
2586
|
+
// Multiple items - use sequential individual adds (only reliable method)
|
|
2587
|
+
console.log(`Adding ${item_keys.length} items sequentially (batch operations are unreliable)...`);
|
|
2588
|
+
batchMethod = 'sequential-reliable';
|
|
2589
|
+
let sequentialCount = 0;
|
|
2590
|
+
const sequentialResults = [];
|
|
2591
|
+
|
|
2592
|
+
for (const itemKey of item_keys) {
|
|
2593
|
+
try {
|
|
2594
|
+
const singleParams = {
|
|
2595
|
+
'X-Plex-Token': plexToken,
|
|
2596
|
+
uri: `server://${machineIdentifier}/com.plexapp.plugins.library/library/metadata/${itemKey}`
|
|
2597
|
+
};
|
|
2598
|
+
|
|
2599
|
+
if (process.env.DEBUG_PLAYLISTS) {
|
|
2600
|
+
console.log(`Adding item ${itemKey} individually...`);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
const singleResponse = await axios.put(addUrl, null, {
|
|
2604
|
+
params: singleParams,
|
|
2605
|
+
httpsAgent: this.getHttpsAgent(),
|
|
2606
|
+
timeout: 10000, // 10 second timeout
|
|
2607
|
+
validateStatus: function (status) {
|
|
2608
|
+
return status >= 200 && status < 300; // Only accept 2xx status codes
|
|
2609
|
+
}
|
|
2610
|
+
});
|
|
2611
|
+
|
|
2612
|
+
if (singleResponse.status >= 200 && singleResponse.status < 300) {
|
|
2613
|
+
sequentialCount++;
|
|
2614
|
+
sequentialResults.push({ itemKey, success: true });
|
|
2615
|
+
if (process.env.DEBUG_PLAYLISTS) {
|
|
2616
|
+
console.log(`✅ Successfully added item ${itemKey}`);
|
|
2617
|
+
}
|
|
2618
|
+
} else {
|
|
2619
|
+
sequentialResults.push({ itemKey, success: false, status: singleResponse.status });
|
|
2620
|
+
console.warn(`❌ Failed to add item ${itemKey}, status: ${singleResponse.status}`);
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// Small delay between sequential operations for API stability
|
|
2624
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
2625
|
+
|
|
2626
|
+
} catch (seqError) {
|
|
2627
|
+
sequentialResults.push({ itemKey, success: false, error: seqError.message });
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// Create response for sequential operations
|
|
2632
|
+
response = {
|
|
2633
|
+
status: sequentialCount > 0 ? 200 : 400,
|
|
2634
|
+
data: {
|
|
2635
|
+
sequentialAdded: sequentialCount,
|
|
2636
|
+
sequentialResults: sequentialResults,
|
|
2637
|
+
totalRequested: item_keys.length
|
|
2638
|
+
}
|
|
2639
|
+
};
|
|
2640
|
+
|
|
2641
|
+
if (process.env.DEBUG_PLAYLISTS) {
|
|
2642
|
+
console.log(`Sequential operation complete: ${sequentialCount}/${item_keys.length} items added successfully`);
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// Check if the PUT request was successful based on HTTP status
|
|
2647
|
+
const putSuccessful = response.status >= 200 && response.status < 300;
|
|
2648
|
+
|
|
2649
|
+
// Verify the addition with retries due to Plex API reliability issues
|
|
2650
|
+
let afterCount = 0;
|
|
2651
|
+
let retryCount = 0;
|
|
2652
|
+
const maxRetries = 3;
|
|
2653
|
+
|
|
2654
|
+
while (retryCount <= maxRetries) {
|
|
2655
|
+
await new Promise(resolve => setTimeout(resolve, 300 * (retryCount + 1))); // Increasing delay
|
|
2656
|
+
|
|
2657
|
+
try {
|
|
2658
|
+
// Try both the items endpoint and playlist metadata endpoint
|
|
2659
|
+
const [itemsResponse, playlistResponse] = await Promise.allSettled([
|
|
2660
|
+
axios.get(playlistItemsUrl, {
|
|
2661
|
+
params: { 'X-Plex-Token': plexToken },
|
|
2662
|
+
httpsAgent: this.getHttpsAgent()
|
|
2663
|
+
}),
|
|
2664
|
+
axios.get(playlistInfoUrl, {
|
|
2665
|
+
params: { 'X-Plex-Token': plexToken },
|
|
2666
|
+
httpsAgent: this.getHttpsAgent()
|
|
2667
|
+
})
|
|
2668
|
+
]);
|
|
2669
|
+
|
|
2670
|
+
// Try to get count from items endpoint first
|
|
2671
|
+
if (itemsResponse.status === 'fulfilled' && itemsResponse.value?.data) {
|
|
2672
|
+
try {
|
|
2673
|
+
const items = itemsResponse.value.data.MediaContainer?.Metadata || [];
|
|
2674
|
+
afterCount = items.length;
|
|
2675
|
+
break; // Success, exit retry loop
|
|
2676
|
+
} catch (parseError) {
|
|
2677
|
+
console.warn('Error parsing items response:', parseError.message);
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// Fall back to playlist metadata if items endpoint failed
|
|
2682
|
+
if (playlistResponse.status === 'fulfilled' && playlistResponse.value?.data) {
|
|
2683
|
+
try {
|
|
2684
|
+
const metadata = playlistResponse.value.data.MediaContainer?.Metadata?.[0];
|
|
2685
|
+
afterCount = parseInt(metadata?.leafCount || 0, 10) || 0;
|
|
2686
|
+
break; // Success, exit retry loop
|
|
2687
|
+
} catch (parseError) {
|
|
2688
|
+
console.warn('Error parsing playlist metadata:', parseError.message);
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
} catch (error) {
|
|
2693
|
+
retryCount++;
|
|
2694
|
+
if (retryCount > maxRetries) {
|
|
2695
|
+
// If all retries failed, fall back to optimistic counting
|
|
2696
|
+
afterCount = beforeCount + (putSuccessful ? item_keys.length : 0);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
const actualAdded = afterCount - beforeCount;
|
|
2702
|
+
const attempted = item_keys.length;
|
|
2703
|
+
|
|
2704
|
+
let resultText = `Playlist "${playlistTitle}" update:\n`;
|
|
2705
|
+
resultText += `• Attempted to add: ${attempted} item(s)\n`;
|
|
2706
|
+
resultText += `• Actually added: ${actualAdded} item(s)\n`;
|
|
2707
|
+
resultText += `• Playlist size: ${beforeCount} → ${afterCount} items\n`;
|
|
2708
|
+
|
|
2709
|
+
// Show batch method for multiple items
|
|
2710
|
+
if (item_keys.length > 1) {
|
|
2711
|
+
const methodDescription = {
|
|
2712
|
+
'sequential-reliable': 'sequential individual adds (only reliable method for multiple items)'
|
|
2713
|
+
};
|
|
2714
|
+
resultText += `• Method used: ${methodDescription[batchMethod] || batchMethod}\n`;
|
|
2715
|
+
|
|
2716
|
+
// Show success summary for sequential operations
|
|
2717
|
+
if (response.data?.sequentialAdded !== undefined) {
|
|
2718
|
+
const successRate = ((response.data.sequentialAdded / item_keys.length) * 100).toFixed(0);
|
|
2719
|
+
resultText += `• Success rate: ${response.data.sequentialAdded}/${item_keys.length} items (${successRate}%)\n`;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// Show individual results in debug mode
|
|
2723
|
+
if (response.data?.sequentialResults && process.env.DEBUG_PLAYLISTS) {
|
|
2724
|
+
resultText += `• Individual results:\n`;
|
|
2725
|
+
response.data.sequentialResults.forEach(result => {
|
|
2726
|
+
const status = result.success ? '✅' : '❌';
|
|
2727
|
+
const detail = result.error ? ` (${result.error})` : result.status ? ` (HTTP ${result.status})` : '';
|
|
2728
|
+
resultText += ` ${status} ${result.itemKey}${detail}\n`;
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
// Debug information
|
|
2734
|
+
if (process.env.DEBUG_PLAYLISTS) {
|
|
2735
|
+
resultText += `\nDEBUG INFO:\n`;
|
|
2736
|
+
resultText += `• Batch method used: ${batchMethod}\n`;
|
|
2737
|
+
resultText += `• PUT request status: ${response.status}\n`;
|
|
2738
|
+
resultText += `• PUT successful: ${putSuccessful}\n`;
|
|
2739
|
+
resultText += `• Before count: ${beforeCount}\n`;
|
|
2740
|
+
resultText += `• After count: ${afterCount}\n`;
|
|
2741
|
+
resultText += `• Retries needed: ${retryCount}\n`;
|
|
2742
|
+
resultText += `• Count verification method: ${retryCount > maxRetries ? 'fallback' : 'API'}\n`;
|
|
2743
|
+
resultText += `• Items requested: [${item_keys.join(', ')}]\n`;
|
|
2744
|
+
if (response.data?.sequentialAdded !== undefined) {
|
|
2745
|
+
resultText += `• Sequential adds successful: ${response.data.sequentialAdded}/${item_keys.length}\n`;
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// If HTTP request was successful but count didn't change,
|
|
2750
|
+
// it's likely the items already exist or are duplicates
|
|
2751
|
+
if (actualAdded === attempted) {
|
|
2752
|
+
resultText += `✅ All items added successfully!`;
|
|
2753
|
+
} else if (actualAdded > 0) {
|
|
2754
|
+
resultText += `⚠️ Partial success: ${attempted - actualAdded} item(s) may have been duplicates or invalid`;
|
|
2755
|
+
} else if (putSuccessful) {
|
|
2756
|
+
resultText += `✅ API request successful! Items may already exist in playlist or were duplicates.\n`;
|
|
2757
|
+
resultText += `ℹ️ This is normal behavior - Plex doesn't add duplicate items.`;
|
|
2758
|
+
} else {
|
|
2759
|
+
resultText += `❌ No items were added. This may indicate:\n`;
|
|
2760
|
+
resultText += ` - Invalid item IDs (use ratingKey from search results)\n`;
|
|
2761
|
+
resultText += ` - Items already exist in playlist\n`;
|
|
2762
|
+
resultText += ` - Permission issues`;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
return {
|
|
2766
|
+
content: [
|
|
2767
|
+
{
|
|
2768
|
+
type: "text",
|
|
2769
|
+
text: resultText,
|
|
2770
|
+
},
|
|
2771
|
+
],
|
|
2772
|
+
};
|
|
2773
|
+
} catch (error) {
|
|
2774
|
+
// Enhanced error handling with specific error types
|
|
2775
|
+
let errorMessage = `Error adding items to playlist: ${error.message}`;
|
|
2776
|
+
|
|
2777
|
+
if (error.response) {
|
|
2778
|
+
const status = error.response.status;
|
|
2779
|
+
if (status === 404) {
|
|
2780
|
+
errorMessage = `Playlist with ID ${playlist_id} not found`;
|
|
2781
|
+
} else if (status === 401 || status === 403) {
|
|
2782
|
+
errorMessage = `Permission denied: Check your Plex token and server access`;
|
|
2783
|
+
} else if (status >= 500) {
|
|
2784
|
+
errorMessage = `Plex server error (${status}): ${error.message}`;
|
|
2785
|
+
}
|
|
2786
|
+
} else if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
|
|
2787
|
+
errorMessage = `Cannot connect to Plex server: Check PLEX_URL configuration`;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
return {
|
|
2791
|
+
content: [
|
|
2792
|
+
{
|
|
2793
|
+
type: "text",
|
|
2794
|
+
text: errorMessage,
|
|
2795
|
+
},
|
|
2796
|
+
],
|
|
2797
|
+
isError: true,
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
// DISABLED METHOD - PROBLEMATIC OPERATION
|
|
2803
|
+
// This method is currently disabled due to destructive Plex API behavior
|
|
2804
|
+
// It removes ALL instances of matching items, not just one instance
|
|
2805
|
+
// Use with extreme caution - consider implementing safer alternatives
|
|
2806
|
+
async handleRemoveFromPlaylist(args) {
|
|
2807
|
+
const { playlist_id, item_keys } = args;
|
|
2808
|
+
|
|
2809
|
+
// Input validation
|
|
2810
|
+
if (!playlist_id || typeof playlist_id !== 'string') {
|
|
2811
|
+
throw new Error('Valid playlist_id is required');
|
|
2812
|
+
}
|
|
2813
|
+
if (!item_keys || !Array.isArray(item_keys) || item_keys.length === 0) {
|
|
2814
|
+
throw new Error('item_keys must be a non-empty array');
|
|
2815
|
+
}
|
|
2816
|
+
if (item_keys.some(key => !key || typeof key !== 'string')) {
|
|
2817
|
+
throw new Error('All item_keys must be non-empty strings');
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
try {
|
|
2821
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
2822
|
+
const plexToken = await this.authManager.getAuthToken();
|
|
2823
|
+
|
|
2824
|
+
// Get playlist info before removing items
|
|
2825
|
+
const playlistInfoUrl = `${plexUrl}/playlists/${playlist_id}`;
|
|
2826
|
+
const playlistItemsUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
|
2827
|
+
|
|
2828
|
+
// Get playlist metadata (title, etc.)
|
|
2829
|
+
const playlistInfoResponse = await axios.get(playlistInfoUrl, {
|
|
2830
|
+
params: {
|
|
2831
|
+
'X-Plex-Token': plexToken
|
|
2832
|
+
},
|
|
2833
|
+
httpsAgent: this.getHttpsAgent()
|
|
2834
|
+
});
|
|
2835
|
+
|
|
2836
|
+
const playlistInfo = playlistInfoResponse.data.MediaContainer;
|
|
2837
|
+
const playlistTitle = playlistInfo.Metadata && playlistInfo.Metadata[0]
|
|
2838
|
+
? playlistInfo.Metadata[0].title : `Playlist ${playlist_id}`;
|
|
2839
|
+
|
|
2840
|
+
// Get current playlist items with their detailed information
|
|
2841
|
+
let beforeCount = 0;
|
|
2842
|
+
let playlistItems = [];
|
|
2215
2843
|
try {
|
|
2216
2844
|
const beforeResponse = await axios.get(playlistItemsUrl, {
|
|
2217
2845
|
params: {
|
|
@@ -2370,7 +2998,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2370
2998
|
const { playlist_id } = args;
|
|
2371
2999
|
|
|
2372
3000
|
try {
|
|
2373
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
3001
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
2374
3002
|
const plexToken = await this.authManager.getAuthToken();
|
|
2375
3003
|
|
|
2376
3004
|
const deleteUrl = `${plexUrl}/playlists/${playlist_id}`;
|
|
@@ -2410,7 +3038,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2410
3038
|
const { item_keys, account_id } = args;
|
|
2411
3039
|
|
|
2412
3040
|
try {
|
|
2413
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
3041
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
2414
3042
|
const plexToken = await this.authManager.getAuthToken();
|
|
2415
3043
|
|
|
2416
3044
|
const statusResults = [];
|
|
@@ -2627,7 +3255,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2627
3255
|
let height = 0;
|
|
2628
3256
|
|
|
2629
3257
|
if (media.height) {
|
|
2630
|
-
height = parseInt(media.height);
|
|
3258
|
+
height = parseInt(media.height, 10) || 0;
|
|
2631
3259
|
} else if (media.videoResolution) {
|
|
2632
3260
|
// Convert videoResolution string to height for comparison
|
|
2633
3261
|
switch (media.videoResolution) {
|
|
@@ -2712,7 +3340,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2712
3340
|
const totalSize = item.Media.reduce((total, media) => {
|
|
2713
3341
|
if (media.Part) {
|
|
2714
3342
|
return total + media.Part.reduce((partTotal, part) => {
|
|
2715
|
-
return partTotal + (part.size ? parseInt(part.size) / (1024 * 1024) : 0); // Convert to MB
|
|
3343
|
+
return partTotal + (part.size ? (parseInt(part.size, 10) || 0) / (1024 * 1024) : 0); // Convert to MB
|
|
2716
3344
|
}, 0);
|
|
2717
3345
|
}
|
|
2718
3346
|
return total;
|
|
@@ -2853,7 +3481,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2853
3481
|
const { library_id } = args;
|
|
2854
3482
|
|
|
2855
3483
|
try {
|
|
2856
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
3484
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
2857
3485
|
const plexToken = await this.authManager.getAuthToken();
|
|
2858
3486
|
|
|
2859
3487
|
let collectionsUrl;
|
|
@@ -2903,7 +3531,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
2903
3531
|
const { collection_id, sort = "titleSort", limit = 20, offset = 0 } = args;
|
|
2904
3532
|
|
|
2905
3533
|
try {
|
|
2906
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
3534
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
2907
3535
|
const plexToken = await this.authManager.getAuthToken();
|
|
2908
3536
|
|
|
2909
3537
|
const collectionUrl = `${plexUrl}/library/collections/${collection_id}/children`;
|
|
@@ -3002,7 +3630,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
3002
3630
|
const { item_key } = args;
|
|
3003
3631
|
|
|
3004
3632
|
try {
|
|
3005
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
3633
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
3006
3634
|
const plexToken = await this.authManager.getAuthToken();
|
|
3007
3635
|
|
|
3008
3636
|
const mediaUrl = `${plexUrl}/library/metadata/${item_key}`;
|
|
@@ -3221,7 +3849,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
3221
3849
|
}
|
|
3222
3850
|
|
|
3223
3851
|
if (part.size) {
|
|
3224
|
-
const sizeMB = Math.round(parseInt(part.size) / (1024 * 1024));
|
|
3852
|
+
const sizeMB = Math.round((parseInt(part.size, 10) || 0) / (1024 * 1024));
|
|
3225
3853
|
const sizeGB = (sizeMB / 1024).toFixed(2);
|
|
3226
3854
|
if (sizeMB > 1024) {
|
|
3227
3855
|
formatted += `\\n File Size: ${sizeGB} GB`;
|
|
@@ -3291,7 +3919,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
3291
3919
|
const { library_id, include_details = false } = args;
|
|
3292
3920
|
|
|
3293
3921
|
try {
|
|
3294
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
3922
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
3295
3923
|
const plexToken = await this.authManager.getAuthToken();
|
|
3296
3924
|
|
|
3297
3925
|
// Get library information first
|
|
@@ -3437,7 +4065,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
3437
4065
|
if (media.Part) {
|
|
3438
4066
|
for (const part of media.Part) {
|
|
3439
4067
|
if (part.size) {
|
|
3440
|
-
const sizeBytes = parseInt(part.size);
|
|
4068
|
+
const sizeBytes = parseInt(part.size, 10) || 0;
|
|
3441
4069
|
libraryStats.totalSize += sizeBytes;
|
|
3442
4070
|
|
|
3443
4071
|
if (includeDetails) {
|
|
@@ -3642,7 +4270,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
3642
4270
|
} = args;
|
|
3643
4271
|
|
|
3644
4272
|
try {
|
|
3645
|
-
const plexUrl = process.env.PLEX_URL || '
|
|
4273
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
3646
4274
|
const plexToken = await this.authManager.getAuthToken();
|
|
3647
4275
|
|
|
3648
4276
|
// Auto-detect music libraries if not specified
|
|
@@ -3699,6 +4327,97 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
3699
4327
|
}
|
|
3700
4328
|
}
|
|
3701
4329
|
|
|
4330
|
+
async handleDiscoverMusic(args) {
|
|
4331
|
+
const { query, context, limit = 10 } = args;
|
|
4332
|
+
|
|
4333
|
+
// Apply randomization settings for music discovery
|
|
4334
|
+
const enhancedArgs = this.applyRandomizationSettings(query, 'track', args);
|
|
4335
|
+
const finalLimit = enhancedArgs.limit || limit;
|
|
4336
|
+
|
|
4337
|
+
try {
|
|
4338
|
+
const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
|
|
4339
|
+
const plexToken = await this.authManager.getAuthToken();
|
|
4340
|
+
|
|
4341
|
+
// Get music libraries
|
|
4342
|
+
const librariesResponse = await axios.get(`${plexUrl}/library/sections`, {
|
|
4343
|
+
params: { 'X-Plex-Token': plexToken },
|
|
4344
|
+
httpsAgent: this.getHttpsAgent()
|
|
4345
|
+
});
|
|
4346
|
+
|
|
4347
|
+
const allLibraries = this.parseLibraries(librariesResponse.data);
|
|
4348
|
+
const musicLibraries = allLibraries.filter(lib => lib.type === 'artist');
|
|
4349
|
+
|
|
4350
|
+
if (musicLibraries.length === 0) {
|
|
4351
|
+
throw new Error('No music libraries found.');
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
// Get user's listening stats for context
|
|
4355
|
+
const stats = await this.calculateListeningStats(
|
|
4356
|
+
musicLibraries,
|
|
4357
|
+
null,
|
|
4358
|
+
"month",
|
|
4359
|
+
false, // Don't need recommendations for this
|
|
4360
|
+
plexUrl,
|
|
4361
|
+
plexToken
|
|
4362
|
+
);
|
|
4363
|
+
|
|
4364
|
+
// Parse the natural language query
|
|
4365
|
+
const discovery = await this.processNaturalLanguageQuery(
|
|
4366
|
+
query,
|
|
4367
|
+
context,
|
|
4368
|
+
stats,
|
|
4369
|
+
musicLibraries,
|
|
4370
|
+
plexUrl,
|
|
4371
|
+
plexToken,
|
|
4372
|
+
finalLimit
|
|
4373
|
+
);
|
|
4374
|
+
|
|
4375
|
+
// Apply additional randomization if needed and we have more results than requested
|
|
4376
|
+
const shouldRandomize = this.detectRandomizationIntent(query);
|
|
4377
|
+
if (shouldRandomize && discovery.results.length > limit) {
|
|
4378
|
+
discovery.results = this.applyClientSideRandomization(discovery.results, limit);
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
let resultText = `🎵 **Music Discovery Results**\\n\\n`;
|
|
4382
|
+
resultText += `Query: "${query}"\\n\\n`;
|
|
4383
|
+
|
|
4384
|
+
if (discovery.analysis) {
|
|
4385
|
+
resultText += `**What I found:** ${discovery.analysis}\\n\\n`;
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
if (discovery.results.length > 0) {
|
|
4389
|
+
resultText += `**Recommendations:**\\n`;
|
|
4390
|
+
discovery.results.forEach((item, index) => {
|
|
4391
|
+
resultText += `${index + 1}. **${item.title}** by ${item.artist}\\n`;
|
|
4392
|
+
if (item.album) resultText += ` Album: ${item.album}\\n`;
|
|
4393
|
+
if (item.reason) resultText += ` ${item.reason}\\n`;
|
|
4394
|
+
resultText += `\\n`;
|
|
4395
|
+
});
|
|
4396
|
+
} else {
|
|
4397
|
+
resultText += `No results found that match your query. Your library might not have what you're looking for, or try a different search.\\n`;
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
return {
|
|
4401
|
+
content: [
|
|
4402
|
+
{
|
|
4403
|
+
type: "text",
|
|
4404
|
+
text: resultText,
|
|
4405
|
+
},
|
|
4406
|
+
],
|
|
4407
|
+
};
|
|
4408
|
+
} catch (error) {
|
|
4409
|
+
return {
|
|
4410
|
+
content: [
|
|
4411
|
+
{
|
|
4412
|
+
type: "text",
|
|
4413
|
+
text: `Error with music discovery: ${error.message}`,
|
|
4414
|
+
},
|
|
4415
|
+
],
|
|
4416
|
+
isError: true,
|
|
4417
|
+
};
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4420
|
+
|
|
3702
4421
|
async calculateListeningStats(musicLibraries, accountId, timePeriod, includeRecommendations, plexUrl, plexToken) {
|
|
3703
4422
|
const stats = {
|
|
3704
4423
|
timePeriod,
|
|
@@ -3862,7 +4581,6 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
3862
4581
|
}
|
|
3863
4582
|
}
|
|
3864
4583
|
} catch (libraryError) {
|
|
3865
|
-
console.error(`Error enriching stats for library ${library.key}:`, libraryError.message);
|
|
3866
4584
|
}
|
|
3867
4585
|
}
|
|
3868
4586
|
}
|
|
@@ -3876,6 +4594,9 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
3876
4594
|
|
|
3877
4595
|
const topArtists = Object.keys(stats.topArtists).slice(0, 5);
|
|
3878
4596
|
|
|
4597
|
+
// Track artists we've already recommended to avoid duplicates
|
|
4598
|
+
const recommendedArtists = new Set(topArtists.map(a => a.toLowerCase()));
|
|
4599
|
+
|
|
3879
4600
|
for (const library of musicLibraries) {
|
|
3880
4601
|
try {
|
|
3881
4602
|
// Find new tracks in favorite genres
|
|
@@ -3950,13 +4671,449 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
|
3950
4671
|
continue;
|
|
3951
4672
|
}
|
|
3952
4673
|
}
|
|
4674
|
+
|
|
4675
|
+
// Find similar artists based on genre overlap
|
|
4676
|
+
await this.findSimilarArtistRecommendations(
|
|
4677
|
+
stats,
|
|
4678
|
+
library,
|
|
4679
|
+
topGenres,
|
|
4680
|
+
topArtists,
|
|
4681
|
+
recommendedArtists,
|
|
4682
|
+
plexUrl,
|
|
4683
|
+
plexToken
|
|
4684
|
+
);
|
|
4685
|
+
|
|
3953
4686
|
} catch (libraryError) {
|
|
3954
4687
|
continue;
|
|
3955
4688
|
}
|
|
3956
4689
|
}
|
|
3957
4690
|
|
|
3958
4691
|
// Limit recommendations to avoid overwhelming output
|
|
3959
|
-
stats.recommendations = stats.recommendations.slice(0,
|
|
4692
|
+
stats.recommendations = stats.recommendations.slice(0, 12);
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4695
|
+
async findSimilarArtistRecommendations(stats, library, topGenres, topArtists, recommendedArtists, plexUrl, plexToken) {
|
|
4696
|
+
try {
|
|
4697
|
+
// Find artists in your top genres that you don't already listen to
|
|
4698
|
+
for (const genre of topGenres.slice(0, 2)) {
|
|
4699
|
+
try {
|
|
4700
|
+
const genreArtistsResponse = await axios.get(`${plexUrl}/library/sections/${library.key}/all`, {
|
|
4701
|
+
params: {
|
|
4702
|
+
'X-Plex-Token': plexToken,
|
|
4703
|
+
genre: genre,
|
|
4704
|
+
type: 8, // Artist type
|
|
4705
|
+
'X-Plex-Container-Size': 15,
|
|
4706
|
+
sort: 'titleSort'
|
|
4707
|
+
},
|
|
4708
|
+
httpsAgent: this.getHttpsAgent()
|
|
4709
|
+
});
|
|
4710
|
+
|
|
4711
|
+
const artists = this.parseLibraryContent(genreArtistsResponse.data);
|
|
4712
|
+
|
|
4713
|
+
// Find artists in this genre that aren't in your top artists
|
|
4714
|
+
const similarArtists = artists.filter(artist =>
|
|
4715
|
+
!recommendedArtists.has(artist.title.toLowerCase()) &&
|
|
4716
|
+
!topArtists.some(topArtist => topArtist.toLowerCase() === artist.title.toLowerCase())
|
|
4717
|
+
);
|
|
4718
|
+
|
|
4719
|
+
// Get tracks from similar artists you haven't discovered yet
|
|
4720
|
+
for (const artist of similarArtists.slice(0, 2)) {
|
|
4721
|
+
try {
|
|
4722
|
+
const artistTracksResponse = await axios.get(`${plexUrl}${artist.key}`, {
|
|
4723
|
+
params: {
|
|
4724
|
+
'X-Plex-Token': plexToken,
|
|
4725
|
+
'X-Plex-Container-Size': 5
|
|
4726
|
+
},
|
|
4727
|
+
httpsAgent: this.getHttpsAgent()
|
|
4728
|
+
});
|
|
4729
|
+
|
|
4730
|
+
const tracks = this.parseLibraryContent(artistTracksResponse.data);
|
|
4731
|
+
const unplayedTracks = tracks.filter(track => !stats.topTracks[track.title]);
|
|
4732
|
+
|
|
4733
|
+
if (unplayedTracks.length > 0) {
|
|
4734
|
+
const recommendTrack = unplayedTracks[0];
|
|
4735
|
+
|
|
4736
|
+
// Add some personality
|
|
4737
|
+
let reason = `You might dig ${artist.title} - they're in the ${genre} scene`;
|
|
4738
|
+
if (artist.title.toLowerCase().includes('nickelback')) {
|
|
4739
|
+
reason = `Found Nickelback in your library. We're not judging... much.`;
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
stats.recommendations.push({
|
|
4743
|
+
title: recommendTrack.title,
|
|
4744
|
+
artist: artist.title,
|
|
4745
|
+
album: recommendTrack.parentTitle || 'Unknown Album',
|
|
4746
|
+
reason: reason,
|
|
4747
|
+
type: 'similar-artist',
|
|
4748
|
+
key: recommendTrack.key
|
|
4749
|
+
});
|
|
4750
|
+
|
|
4751
|
+
recommendedArtists.add(artist.title.toLowerCase());
|
|
4752
|
+
|
|
4753
|
+
// Stop if we have enough recommendations
|
|
4754
|
+
if (stats.recommendations.length >= 12) {
|
|
4755
|
+
return;
|
|
4756
|
+
}
|
|
4757
|
+
}
|
|
4758
|
+
} catch (trackError) {
|
|
4759
|
+
continue;
|
|
4760
|
+
}
|
|
4761
|
+
}
|
|
4762
|
+
} catch (genreError) {
|
|
4763
|
+
continue;
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4766
|
+
} catch (error) {
|
|
4767
|
+
// Silently continue if similar artist discovery fails
|
|
4768
|
+
}
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4771
|
+
async processNaturalLanguageQuery(query, context, stats, musicLibraries, plexUrl, plexToken, limit) {
|
|
4772
|
+
const queryLower = query.toLowerCase();
|
|
4773
|
+
let results = [];
|
|
4774
|
+
let analysis = "";
|
|
4775
|
+
|
|
4776
|
+
// Decade/year queries (like "90s songs", "music from the 2000s")
|
|
4777
|
+
if (queryLower.match(/\b(90s?|1990s?|2000s?|80s?|1980s?|70s?|1970s?)\b/)) {
|
|
4778
|
+
const yearMatch = queryLower.match(/\b(90s?|1990s?|2000s?|80s?|1980s?|70s?|1970s?)\b/);
|
|
4779
|
+
let yearRange = {};
|
|
4780
|
+
|
|
4781
|
+
if (yearMatch[1].includes('90')) {
|
|
4782
|
+
yearRange = { min: 1989, max: 2000 };
|
|
4783
|
+
analysis = `Looking for tracks from the 90s era...`;
|
|
4784
|
+
} else if (yearMatch[1].includes('2000')) {
|
|
4785
|
+
yearRange = { min: 2000, max: 2009 };
|
|
4786
|
+
analysis = `Searching for 2000s music...`;
|
|
4787
|
+
} else if (yearMatch[1].includes('80')) {
|
|
4788
|
+
yearRange = { min: 1980, max: 1989 };
|
|
4789
|
+
analysis = `Finding 80s classics...`;
|
|
4790
|
+
} else if (yearMatch[1].includes('70')) {
|
|
4791
|
+
yearRange = { min: 1970, max: 1979 };
|
|
4792
|
+
analysis = `Digging up 70s gems...`;
|
|
4793
|
+
}
|
|
4794
|
+
|
|
4795
|
+
results = await this.searchByDecade(musicLibraries, yearRange, stats, plexUrl, plexToken, limit);
|
|
4796
|
+
|
|
4797
|
+
if (results.length > 0) {
|
|
4798
|
+
const tracksWithMeta = results.filter(r => r.hasMetadata).length;
|
|
4799
|
+
const tracksWithoutMeta = results.length - tracksWithMeta;
|
|
4800
|
+
|
|
4801
|
+
analysis += ` Found ${results.length} tracks`;
|
|
4802
|
+
if (tracksWithoutMeta > 0) {
|
|
4803
|
+
analysis += ` (${tracksWithoutMeta} tracks missing year data - they might also be from this era but I can't tell)`;
|
|
4804
|
+
}
|
|
4805
|
+
analysis += `.`;
|
|
4806
|
+
} else {
|
|
4807
|
+
analysis += ` Your library doesn't seem to have much from that decade, or the year metadata is missing.`;
|
|
4808
|
+
}
|
|
4809
|
+
}
|
|
4810
|
+
|
|
4811
|
+
// Similar artist queries ("like X", "similar to Y")
|
|
4812
|
+
else if (queryLower.match(/\b(like|similar to|sounds like)\s+(.+)/)) {
|
|
4813
|
+
const artistMatch = queryLower.match(/\b(like|similar to|sounds like)\s+(.+)/);
|
|
4814
|
+
const targetArtist = artistMatch[2].trim();
|
|
4815
|
+
|
|
4816
|
+
analysis = `Looking for artists similar to ${targetArtist}...`;
|
|
4817
|
+
results = await this.findSimilarTo(targetArtist, musicLibraries, stats, plexUrl, plexToken, limit);
|
|
4818
|
+
|
|
4819
|
+
if (results.length > 0) {
|
|
4820
|
+
analysis += ` Found some artists you might dig based on genre overlap and your listening patterns.`;
|
|
4821
|
+
} else {
|
|
4822
|
+
analysis += ` Couldn't find similar artists. Either ${targetArtist} isn't in your library or there aren't similar artists available.`;
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
|
|
4826
|
+
// Unheard/new discovery queries
|
|
4827
|
+
else if (queryLower.match(/\b(haven't heard|never played|new|discover|unplayed)\b/)) {
|
|
4828
|
+
analysis = `Finding music in your library you haven't explored yet...`;
|
|
4829
|
+
results = await this.findUnheardMusic(musicLibraries, stats, plexUrl, plexToken, limit);
|
|
4830
|
+
|
|
4831
|
+
if (results.length > 0) {
|
|
4832
|
+
analysis += ` Here are some tracks from your collection that you haven't played much (or at all).`;
|
|
4833
|
+
} else {
|
|
4834
|
+
analysis += ` Looks like you've been thorough with your library! Not much unplayed content found.`;
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
|
|
4838
|
+
// Genre-based queries
|
|
4839
|
+
else if (queryLower.match(/\b(rock|jazz|hip hop|electronic|classical|folk|country|pop|metal|punk|indie|alternative)\b/)) {
|
|
4840
|
+
const genreMatch = queryLower.match(/\b(rock|jazz|hip hop|electronic|classical|folk|country|pop|metal|punk|indie|alternative)\b/);
|
|
4841
|
+
const genre = genreMatch[0];
|
|
4842
|
+
|
|
4843
|
+
analysis = `Searching for ${genre} music in your library...`;
|
|
4844
|
+
results = await this.searchByGenre(genre, musicLibraries, stats, plexUrl, plexToken, limit);
|
|
4845
|
+
|
|
4846
|
+
if (results.length > 0) {
|
|
4847
|
+
analysis += ` Found ${results.length} ${genre} tracks.`;
|
|
4848
|
+
|
|
4849
|
+
// Add personality for specific genres
|
|
4850
|
+
if (genre === 'rock' && results.some(r => r.artist.toLowerCase().includes('nickelback'))) {
|
|
4851
|
+
analysis += ` (Yes, that includes your Nickelback collection. We see you.)`;
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
// General/fallback search
|
|
4857
|
+
else {
|
|
4858
|
+
analysis = `Searching your library for "${query}"...`;
|
|
4859
|
+
results = await this.generalSearch(query, musicLibraries, plexUrl, plexToken, limit);
|
|
4860
|
+
}
|
|
4861
|
+
|
|
4862
|
+
return {
|
|
4863
|
+
analysis,
|
|
4864
|
+
results,
|
|
4865
|
+
query: query
|
|
4866
|
+
};
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
async searchByDecade(musicLibraries, yearRange, stats, plexUrl, plexToken, limit) {
|
|
4870
|
+
const results = [];
|
|
4871
|
+
|
|
4872
|
+
for (const library of musicLibraries) {
|
|
4873
|
+
try {
|
|
4874
|
+
const response = await axios.get(`${plexUrl}/library/sections/${library.key}/all`, {
|
|
4875
|
+
params: {
|
|
4876
|
+
'X-Plex-Token': plexToken,
|
|
4877
|
+
type: 10, // Track type
|
|
4878
|
+
'year>': yearRange.min - 1,
|
|
4879
|
+
'year<': yearRange.max + 1,
|
|
4880
|
+
'X-Plex-Container-Size': limit * 2,
|
|
4881
|
+
sort: 'random'
|
|
4882
|
+
},
|
|
4883
|
+
httpsAgent: this.getHttpsAgent()
|
|
4884
|
+
});
|
|
4885
|
+
|
|
4886
|
+
const tracks = this.parseLibraryContent(response.data);
|
|
4887
|
+
|
|
4888
|
+
tracks.forEach(track => {
|
|
4889
|
+
const playCount = stats.topTracks[track.title] || 0;
|
|
4890
|
+
results.push({
|
|
4891
|
+
title: track.title,
|
|
4892
|
+
artist: track.grandparentTitle || 'Unknown Artist',
|
|
4893
|
+
album: track.parentTitle || 'Unknown Album',
|
|
4894
|
+
year: track.year,
|
|
4895
|
+
playCount: playCount,
|
|
4896
|
+
hasMetadata: !!track.year,
|
|
4897
|
+
reason: `From ${yearRange.min === 1989 ? 'the 90s' : yearRange.min + 's'} - played ${playCount} times`,
|
|
4898
|
+
key: track.key
|
|
4899
|
+
});
|
|
4900
|
+
});
|
|
4901
|
+
|
|
4902
|
+
if (results.length >= limit) break;
|
|
4903
|
+
} catch (error) {
|
|
4904
|
+
continue;
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4908
|
+
return results.slice(0, limit);
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
async findSimilarTo(targetArtist, musicLibraries, stats, plexUrl, plexToken, limit) {
|
|
4912
|
+
const results = [];
|
|
4913
|
+
|
|
4914
|
+
// First, find the target artist's genre(s)
|
|
4915
|
+
let targetGenres = [];
|
|
4916
|
+
|
|
4917
|
+
for (const library of musicLibraries) {
|
|
4918
|
+
try {
|
|
4919
|
+
const artistResponse = await axios.get(`${plexUrl}/library/sections/${library.key}/search`, {
|
|
4920
|
+
params: {
|
|
4921
|
+
'X-Plex-Token': plexToken,
|
|
4922
|
+
query: targetArtist,
|
|
4923
|
+
type: 8 // Artist type
|
|
4924
|
+
},
|
|
4925
|
+
httpsAgent: this.getHttpsAgent()
|
|
4926
|
+
});
|
|
4927
|
+
|
|
4928
|
+
const artists = this.parseSearchResults(artistResponse.data);
|
|
4929
|
+
if (artists.length > 0) {
|
|
4930
|
+
// Get artist details to find genres
|
|
4931
|
+
const artistDetailResponse = await axios.get(`${plexUrl}${artists[0].key}`, {
|
|
4932
|
+
params: { 'X-Plex-Token': plexToken },
|
|
4933
|
+
httpsAgent: this.getHttpsAgent()
|
|
4934
|
+
});
|
|
4935
|
+
|
|
4936
|
+
const artistData = this.parseLibraryContent(artistDetailResponse.data);
|
|
4937
|
+
// Extract genres from artist metadata (this might need adjustment based on actual Plex API response)
|
|
4938
|
+
if (artistData.genre) {
|
|
4939
|
+
targetGenres = Array.isArray(artistData.genre) ? artistData.genre : [artistData.genre];
|
|
4940
|
+
}
|
|
4941
|
+
break;
|
|
4942
|
+
}
|
|
4943
|
+
} catch (error) {
|
|
4944
|
+
continue;
|
|
4945
|
+
}
|
|
4946
|
+
}
|
|
4947
|
+
|
|
4948
|
+
// If we found genres, search for other artists in those genres
|
|
4949
|
+
if (targetGenres.length > 0) {
|
|
4950
|
+
return await this.findSimilarArtistsByGenre(targetGenres, targetArtist, musicLibraries, stats, plexUrl, plexToken, limit);
|
|
4951
|
+
}
|
|
4952
|
+
|
|
4953
|
+
return results;
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
async findSimilarArtistsByGenre(genres, excludeArtist, musicLibraries, stats, plexUrl, plexToken, limit) {
|
|
4957
|
+
const results = [];
|
|
4958
|
+
const seenArtists = new Set([excludeArtist.toLowerCase()]);
|
|
4959
|
+
|
|
4960
|
+
for (const genre of genres.slice(0, 2)) {
|
|
4961
|
+
for (const library of musicLibraries) {
|
|
4962
|
+
try {
|
|
4963
|
+
const response = await axios.get(`${plexUrl}/library/sections/${library.key}/all`, {
|
|
4964
|
+
params: {
|
|
4965
|
+
'X-Plex-Token': plexToken,
|
|
4966
|
+
genre: genre,
|
|
4967
|
+
type: 10, // Track type
|
|
4968
|
+
'X-Plex-Container-Size': 20,
|
|
4969
|
+
sort: 'random'
|
|
4970
|
+
},
|
|
4971
|
+
httpsAgent: this.getHttpsAgent()
|
|
4972
|
+
});
|
|
4973
|
+
|
|
4974
|
+
const tracks = this.parseLibraryContent(response.data);
|
|
4975
|
+
|
|
4976
|
+
tracks.forEach(track => {
|
|
4977
|
+
const artist = track.grandparentTitle || 'Unknown Artist';
|
|
4978
|
+
if (!seenArtists.has(artist.toLowerCase())) {
|
|
4979
|
+
const playCount = stats.topTracks[track.title] || 0;
|
|
4980
|
+
|
|
4981
|
+
results.push({
|
|
4982
|
+
title: track.title,
|
|
4983
|
+
artist: artist,
|
|
4984
|
+
album: track.parentTitle || 'Unknown Album',
|
|
4985
|
+
playCount: playCount,
|
|
4986
|
+
reason: `Similar to ${excludeArtist} (both in ${genre})`,
|
|
4987
|
+
key: track.key
|
|
4988
|
+
});
|
|
4989
|
+
|
|
4990
|
+
seenArtists.add(artist.toLowerCase());
|
|
4991
|
+
|
|
4992
|
+
if (results.length >= limit) return;
|
|
4993
|
+
}
|
|
4994
|
+
});
|
|
4995
|
+
} catch (error) {
|
|
4996
|
+
continue;
|
|
4997
|
+
}
|
|
4998
|
+
}
|
|
4999
|
+
}
|
|
5000
|
+
|
|
5001
|
+
return results.slice(0, limit);
|
|
5002
|
+
}
|
|
5003
|
+
|
|
5004
|
+
async findUnheardMusic(musicLibraries, stats, plexUrl, plexToken, limit) {
|
|
5005
|
+
const results = [];
|
|
5006
|
+
|
|
5007
|
+
for (const library of musicLibraries) {
|
|
5008
|
+
try {
|
|
5009
|
+
const response = await axios.get(`${plexUrl}/library/sections/${library.key}/all`, {
|
|
5010
|
+
params: {
|
|
5011
|
+
'X-Plex-Token': plexToken,
|
|
5012
|
+
type: 10, // Track type
|
|
5013
|
+
'X-Plex-Container-Size': limit * 3,
|
|
5014
|
+
sort: 'random'
|
|
5015
|
+
},
|
|
5016
|
+
httpsAgent: this.getHttpsAgent()
|
|
5017
|
+
});
|
|
5018
|
+
|
|
5019
|
+
const tracks = this.parseLibraryContent(response.data);
|
|
5020
|
+
|
|
5021
|
+
tracks.forEach(track => {
|
|
5022
|
+
const playCount = stats.topTracks[track.title] || 0;
|
|
5023
|
+
if (playCount === 0) {
|
|
5024
|
+
results.push({
|
|
5025
|
+
title: track.title,
|
|
5026
|
+
artist: track.grandparentTitle || 'Unknown Artist',
|
|
5027
|
+
album: track.parentTitle || 'Unknown Album',
|
|
5028
|
+
playCount: 0,
|
|
5029
|
+
reason: `Never played - time to discover something new!`,
|
|
5030
|
+
key: track.key
|
|
5031
|
+
});
|
|
5032
|
+
}
|
|
5033
|
+
});
|
|
5034
|
+
|
|
5035
|
+
if (results.length >= limit) break;
|
|
5036
|
+
} catch (error) {
|
|
5037
|
+
continue;
|
|
5038
|
+
}
|
|
5039
|
+
}
|
|
5040
|
+
|
|
5041
|
+
return results.slice(0, limit);
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
async searchByGenre(genre, musicLibraries, stats, plexUrl, plexToken, limit) {
|
|
5045
|
+
const results = [];
|
|
5046
|
+
|
|
5047
|
+
for (const library of musicLibraries) {
|
|
5048
|
+
try {
|
|
5049
|
+
const response = await axios.get(`${plexUrl}/library/sections/${library.key}/all`, {
|
|
5050
|
+
params: {
|
|
5051
|
+
'X-Plex-Token': plexToken,
|
|
5052
|
+
genre: genre,
|
|
5053
|
+
type: 10, // Track type
|
|
5054
|
+
'X-Plex-Container-Size': limit,
|
|
5055
|
+
sort: 'random'
|
|
5056
|
+
},
|
|
5057
|
+
httpsAgent: this.getHttpsAgent()
|
|
5058
|
+
});
|
|
5059
|
+
|
|
5060
|
+
const tracks = this.parseLibraryContent(response.data);
|
|
5061
|
+
|
|
5062
|
+
tracks.forEach(track => {
|
|
5063
|
+
const playCount = stats.topTracks[track.title] || 0;
|
|
5064
|
+
results.push({
|
|
5065
|
+
title: track.title,
|
|
5066
|
+
artist: track.grandparentTitle || 'Unknown Artist',
|
|
5067
|
+
album: track.parentTitle || 'Unknown Album',
|
|
5068
|
+
playCount: playCount,
|
|
5069
|
+
reason: `${genre.charAt(0).toUpperCase() + genre.slice(1)} track - played ${playCount} times`,
|
|
5070
|
+
key: track.key
|
|
5071
|
+
});
|
|
5072
|
+
});
|
|
5073
|
+
|
|
5074
|
+
if (results.length >= limit) break;
|
|
5075
|
+
} catch (error) {
|
|
5076
|
+
continue;
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
|
|
5080
|
+
return results.slice(0, limit);
|
|
5081
|
+
}
|
|
5082
|
+
|
|
5083
|
+
async generalSearch(query, musicLibraries, plexUrl, plexToken, limit) {
|
|
5084
|
+
const results = [];
|
|
5085
|
+
|
|
5086
|
+
for (const library of musicLibraries) {
|
|
5087
|
+
try {
|
|
5088
|
+
const response = await axios.get(`${plexUrl}/library/sections/${library.key}/search`, {
|
|
5089
|
+
params: {
|
|
5090
|
+
'X-Plex-Token': plexToken,
|
|
5091
|
+
query: query,
|
|
5092
|
+
type: 10, // Track type
|
|
5093
|
+
'X-Plex-Container-Size': limit
|
|
5094
|
+
},
|
|
5095
|
+
httpsAgent: this.getHttpsAgent()
|
|
5096
|
+
});
|
|
5097
|
+
|
|
5098
|
+
const tracks = this.parseSearchResults(response.data);
|
|
5099
|
+
|
|
5100
|
+
tracks.forEach(track => {
|
|
5101
|
+
results.push({
|
|
5102
|
+
title: track.title,
|
|
5103
|
+
artist: track.artist || 'Unknown Artist',
|
|
5104
|
+
album: track.album || 'Unknown Album',
|
|
5105
|
+
reason: `Matches "${query}"`,
|
|
5106
|
+
key: track.key
|
|
5107
|
+
});
|
|
5108
|
+
});
|
|
5109
|
+
|
|
5110
|
+
if (results.length >= limit) break;
|
|
5111
|
+
} catch (error) {
|
|
5112
|
+
continue;
|
|
5113
|
+
}
|
|
5114
|
+
}
|
|
5115
|
+
|
|
5116
|
+
return results.slice(0, limit);
|
|
3960
5117
|
}
|
|
3961
5118
|
|
|
3962
5119
|
formatListeningStats(stats, includeRecommendations) {
|