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/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 || 'Plex MCP Server',
42
- device: process.env.PLEX_DEVICE || 'MCP Server',
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. Note: Non-smart playlists require an initial item (item_key parameter) to be created successfully.",
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 non-smart playlists, optional for smart playlists.",
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: ${loginUrl}
804
- 2. Sign into your Plex account
805
- 3. After signing in, run the check_auth_status tool to complete authentication
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
- Note: Keep this pin ID if you want to check auth status manually later.`
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 || 'http://localhost:32400';
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: 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: `Found ${results.length} results for "${query}":\n\n${this.formatResults(results)}`,
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 || !data.MediaContainer.Metadata) {
1341
+ if (!data.MediaContainer) {
1032
1342
  return [];
1033
1343
  }
1034
1344
 
1035
- return data.MediaContainer.Metadata.map(item => ({
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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: sort,
1239
- 'X-Plex-Container-Start': offset,
1240
- 'X-Plex-Container-Size': limit
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 = `Library content (${offset + 1}-${Math.min(offset + limit, totalSize)} of ${totalSize})`;
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 (sort !== "titleSort") resultText += ` | Sorted by: ${sort}`;
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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, item_key = null } = args;
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 || 'http://localhost:32400';
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 handleAddToPlaylist(args) {
2022
- const { playlist_id, item_keys } = args;
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 || 'http://localhost:32400';
2356
+ const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
2037
2357
  const plexToken = await this.authManager.getAuthToken();
2038
2358
 
2039
- // Get playlist info before adding items
2040
- const playlistInfoUrl = `${plexUrl}/playlists/${playlist_id}`;
2041
- const playlistItemsUrl = `${plexUrl}/playlists/${playlist_id}/items`;
2042
-
2043
- // Get playlist metadata (title, etc.)
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
- // Get server machine identifier for proper URI format
2071
- const serverResponse = await axios.get(`${plexUrl}/`, {
2072
- headers: { 'X-Plex-Token': plexToken },
2073
- httpsAgent: this.getHttpsAgent()
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
- const addUrl = `${plexUrl}/playlists/${playlist_id}/items`;
2082
- const params = {
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
- const response = await axios.put(addUrl, null, {
2088
- params,
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
- // Check if the PUT request was successful based on HTTP status
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: resultText,
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 with specific error types
2147
- let errorMessage = `Error adding items to playlist: ${error.message}`;
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 === 404) {
2152
- errorMessage = `Playlist with ID ${playlist_id} not found`;
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
- // DISABLED METHOD - PROBLEMATIC OPERATION
2175
- // This method is currently disabled due to destructive Plex API behavior
2176
- // It removes ALL instances of matching items, not just one instance
2177
- // Use with extreme caution - consider implementing safer alternatives
2178
- async handleRemoveFromPlaylist(args) {
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 || 'http://localhost:32400';
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 removing items
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 with their detailed information
2543
+ // Get current playlist items count
2213
2544
  let beforeCount = 0;
2214
- let playlistItems = [];
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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 || 'http://localhost:32400';
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, 10);
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) {