plex-mcp 0.3.1 → 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 ADDED
@@ -0,0 +1,18 @@
1
+ # Generated by https://smithery.ai. See: https://smithery.ai/docs/build/project-config
2
+ # Auto-generated Dockerfile for Plex MCP Server
3
+ FROM node:lts-alpine AS base
4
+ WORKDIR /app
5
+
6
+ # Install dependencies
7
+ COPY package.json package-lock.json ./
8
+ RUN npm ci --ignore-scripts
9
+
10
+ # Copy source
11
+ COPY . .
12
+
13
+ # Default verify SSL
14
+ ENV PLEX_VERIFY_SSL=true
15
+
16
+ # Start the MCP server
17
+ USER non-root
18
+ CMD ["node", "index.js"]
package/README.md CHANGED
@@ -1,85 +1,18 @@
1
1
  # Plex MCP Server
2
+ [![smithery badge](https://smithery.ai/badge/@vyb1ng/plex-mcp)](https://smithery.ai/server/@vyb1ng/plex-mcp)
2
3
 
3
- A Model Context Protocol (MCP) server for searching Plex media libraries using Claude.
4
+ Search and manage your Plex media libraries with Claude. Actively developed by Claude and a nerdy human who mostly uses Plex for auditory delights and wanted to see how much could be accomplished without knowing much about what they're doing. Results may vary, but probably in a good way.
4
5
 
5
- ## Features
6
+ ## Quick Start
6
7
 
7
- - Search movies, TV shows, episodes, music, and other content in your Plex libraries
8
- - Filter by content type
9
- - Configurable result limits
10
- - Rich formatted results with metadata
11
- - **Direct Plex authentication with OAuth flow**
12
- - Support for both static tokens and interactive authentication
13
-
14
- ## Setup
15
-
16
- 1. Install dependencies:
17
- ```bash
18
- npm install
19
- ```
20
-
21
- 2. Configure your Plex connection (two options):
22
-
23
- **Option A: Interactive Authentication (Recommended)**
24
- - Set your Plex server URL:
25
- ```
26
- PLEX_URL=http://your-plex-server:32400
27
- ```
28
- - Use the `authenticate_plex` tool for OAuth login (see Authentication section below)
29
-
30
- **Option B: Static Token**
31
- - Set your Plex server URL and token:
32
- ```
33
- PLEX_URL=http://your-plex-server:32400
34
- PLEX_TOKEN=your_plex_token
35
- ```
36
- - Get your Plex token by visiting [Plex Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)
37
-
38
- ## Claude Desktop Configuration
39
-
40
- ### Option 1: Production (Using npx - Recommended)
41
-
42
- Add this configuration to your Claude Desktop settings for the stable published version:
43
-
44
- ```json
45
- {
46
- "mcpServers": {
47
- "plex": {
48
- "command": "npx",
49
- "args": ["plex-mcp"],
50
- "env": {
51
- "PLEX_URL": "http://your-plex-server:32400",
52
- "PLEX_TOKEN": "your_plex_token"
53
- }
54
- }
55
- }
56
- }
57
- ```
58
-
59
- ### Option 2: Development (Local)
60
-
61
- For development with your local code changes, add this configuration:
62
-
63
- ```json
64
- {
65
- "mcpServers": {
66
- "plex-dev": {
67
- "command": "node",
68
- "args": ["/path/to/your/plex-mcp/index.js"],
69
- "env": {
70
- "PLEX_URL": "http://your-plex-server:32400",
71
- "PLEX_TOKEN": "your_plex_token"
72
- }
73
- }
74
- }
75
- }
8
+ ### Install via Smithery (Recommended)
9
+ ```bash
10
+ npx -y @smithery/cli install @vyb1ng/plex-mcp --client claude
76
11
  ```
77
12
 
78
- Replace `/path/to/your/plex-mcp/` with the actual path to this project directory.
79
-
80
- ### Running Both Versions
13
+ ### Manual Setup for Claude Desktop
81
14
 
82
- You can configure both versions simultaneously by using different server names (`plex` and `plex-dev`):
15
+ Add to your Claude Desktop MCP settings:
83
16
 
84
17
  ```json
85
18
  {
@@ -88,201 +21,72 @@ You can configure both versions simultaneously by using different server names (
88
21
  "command": "npx",
89
22
  "args": ["plex-mcp"],
90
23
  "env": {
91
- "PLEX_URL": "http://your-plex-server:32400",
92
- "PLEX_TOKEN": "your_plex_token"
93
- }
94
- },
95
- "plex-dev": {
96
- "command": "node",
97
- "args": ["/path/to/your/plex-mcp/index.js"],
98
- "env": {
99
- "PLEX_URL": "http://your-plex-server:32400",
100
- "PLEX_TOKEN": "your_plex_token"
24
+ "PLEX_URL": "http://your-plex-server:32400"
101
25
  }
102
26
  }
103
27
  }
104
28
  }
105
29
  ```
106
30
 
107
- **Configuration Steps:**
108
- 1. Open Claude Desktop settings (Cmd/Ctrl + ,)
109
- 2. Navigate to the "MCP Servers" tab
110
- 3. Add the configuration above
111
- 4. Update `PLEX_URL` and `PLEX_TOKEN` with your Plex server details
112
- 5. Restart Claude Desktop
113
-
114
- ## Usage
115
-
116
- Run the MCP server standalone:
117
- ```bash
118
- node index.js
119
- ```
120
-
121
31
  ## Authentication
122
32
 
123
- The Plex MCP server supports two authentication methods:
33
+ **Option 1: OAuth (Recommended)**
34
+ - Use the `authenticate_plex` tool to get a login URL
35
+ - Sign in through your browser
124
36
 
125
- ### 1. Interactive OAuth Authentication (Recommended)
37
+ **Option 2: Static Token**
38
+ - Add `"PLEX_TOKEN": "your_token"` to the env section
39
+ - Get your token from [Plex Support](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)
126
40
 
127
- Use the built-in OAuth flow for secure, interactive authentication:
41
+ **Note:** Replace `your-plex-server:32400` with your actual Plex server address and port.
128
42
 
129
- 1. **Start Authentication:**
130
- ```
131
- Use the authenticate_plex tool
132
- ```
133
- This will provide you with a Plex login URL and pin ID.
43
+ ## What You Can Do
134
44
 
135
- 2. **Complete Login:**
136
- - Open the provided URL in your browser
137
- - Sign into your Plex account
138
- - Grant access to the MCP application
45
+ **Search & Browse**
46
+ - Search movies, TV shows, music, and other content
47
+ - Browse libraries and collections
48
+ - View recently added content and watch history
139
49
 
140
- 3. **Check Authentication Status:**
141
- ```
142
- Use the check_auth_status tool
143
- ```
144
- This confirms authentication completion and stores your token.
50
+ **Music Discovery**
51
+ - Natural language music discovery ("songs from the 90s", "rock bands I haven't heard")
52
+ - Smart recommendations based on listening patterns
53
+ - Intelligent randomization for variety and surprise
54
+ - Similar artist discovery and genre exploration
145
55
 
146
- 4. **Clear Authentication (Optional):**
147
- ```
148
- Use the clear_auth tool
149
- ```
150
- This removes stored credentials if needed.
56
+ **Playlists**
57
+ - Create and manage playlists
58
+ - Add items to existing playlists
59
+ - Browse playlist contents
151
60
 
152
- ### 2. Static Token Authentication
61
+ **Media Info**
62
+ - Get detailed media information (codecs, bitrates, file sizes)
63
+ - Check watch status and progress
64
+ - View library statistics and listening stats
153
65
 
154
- For automated setups or if you prefer manual token management:
66
+ ## Status
155
67
 
156
- 1. Obtain your Plex token from [Plex Support](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)
157
- 2. Set the `PLEX_TOKEN` environment variable
158
- 3. All tools will automatically use this token
68
+ **✅ Working:** Search, browse, playlists, media info, library stats, watch history, collections, music discovery
159
69
 
160
- **Note:** The OAuth method takes precedence - if both are available, static tokens are used as fallback.
70
+ **❌ Disabled:** Smart playlists (filter logic broken)
161
71
 
162
- ## MCP Tools
72
+ **🚧 Planned:** Remote server browsing
163
73
 
164
- ### 🔴 Known Issues
74
+ ## Development
165
75
 
166
- **⚠️ Smart Playlist Creation (TEMPORARILY DISABLED)**
167
- - The `create_smart_playlist` tool is currently disabled due to filter logic bugs
168
- - Smart playlists were being created but with incorrect content and inflated metadata
169
- - Use the regular `create_playlist` tool as an alternative
170
- - Issue under investigation - will be re-enabled once fixed
76
+ Want to contribute? Point Claude at your local version:
171
77
 
172
- ### ✅ Working Tools
173
-
174
- ### Authentication Tools
175
-
176
- #### authenticate_plex
177
- Start the Plex OAuth authentication flow.
178
-
179
- **Parameters:** None
180
-
181
- **Returns:** Login URL and pin ID for browser authentication.
182
-
183
- #### check_auth_status
184
- Check if OAuth authentication is complete and retrieve the token.
185
-
186
- **Parameters:**
187
- - `pin_id` (string, optional): Specific pin ID to check
188
-
189
- **Returns:** Authentication status and success confirmation.
190
-
191
- #### clear_auth
192
- Clear stored authentication credentials.
193
-
194
- **Parameters:** None
195
-
196
- **Returns:** Confirmation of credential removal.
197
-
198
- ### Content Tools
199
-
200
- #### search_plex
201
-
202
- Search for content in your Plex libraries.
203
-
204
- **Parameters:**
205
- - `query` (string, required): Search query
206
- - `type` (string, optional): Content type ("movie", "show", "episode", "artist", "album", "track")
207
- - `limit` (number, optional): Maximum results (default: 10)
208
-
209
- **Example:**
210
78
  ```json
211
79
  {
212
- "query": "Star Wars",
213
- "type": "movie",
214
- "limit": 5
80
+ "mcpServers": {
81
+ "plex-dev": {
82
+ "command": "node",
83
+ "args": ["/path/to/plex-mcp/index.js"],
84
+ "env": {
85
+ "PLEX_URL": "http://your-plex-server:32400"
86
+ }
87
+ }
88
+ }
215
89
  }
216
90
  ```
217
91
 
218
- #### browse_libraries
219
- List all available Plex libraries (Movies, TV Shows, Music, etc.)
220
-
221
- #### browse_library
222
- Browse content within a specific library with filtering and sorting options
223
-
224
- #### get_recently_added
225
- Get recently added content from Plex libraries
226
-
227
- #### get_watch_history
228
- Get playback history for the Plex server
229
-
230
- #### get_on_deck
231
- Get 'On Deck' items (continue watching) for users
232
-
233
- ### Playlist Tools
234
-
235
- #### list_playlists
236
- List all playlists on the Plex server
237
-
238
- #### browse_playlist
239
- Browse and view the contents of a specific playlist
240
-
241
- #### create_playlist ✅
242
- Create a new regular playlist (requires an initial item)
243
-
244
- #### ~~create_smart_playlist~~ ❌ **DISABLED**
245
- ~~Create smart playlists with filter criteria~~ - Currently disabled due to filter logic bugs
246
-
247
- #### add_to_playlist
248
- Add items to an existing playlist
249
-
250
- #### delete_playlist
251
- Delete an existing playlist
252
-
253
- ### Media Information Tools
254
-
255
- #### get_watched_status
256
- Check watch status and progress for specific content items
257
-
258
- #### get_collections
259
- List all collections available on the Plex server
260
-
261
- #### browse_collection
262
- Browse content within a specific collection
263
-
264
- #### get_media_info
265
- Get detailed technical information about media files (codecs, bitrates, file sizes, etc.)
266
-
267
- #### get_library_stats
268
- Get comprehensive statistics about Plex libraries (storage usage, file counts, content breakdown, etc.)
269
-
270
- #### get_listening_stats
271
- Get detailed listening statistics and music recommendations based on play history
272
-
273
- ## Tool Status Summary
274
-
275
- ### ✅ Fully Working
276
- - All authentication tools (`authenticate_plex`, `check_auth_status`, `clear_auth`)
277
- - All search and browse tools (`search_plex`, `browse_libraries`, `browse_library`)
278
- - All activity tools (`get_recently_added`, `get_watch_history`, `get_on_deck`)
279
- - Regular playlist tools (`list_playlists`, `browse_playlist`, `create_playlist`, `add_to_playlist`, `delete_playlist`)
280
- - All information tools (`get_watched_status`, `get_collections`, `browse_collection`, `get_media_info`, `get_library_stats`, `get_listening_stats`)
281
-
282
- ### ❌ Temporarily Disabled
283
- - `create_smart_playlist` - Filter logic is broken, returns incorrect content with inflated metadata
284
-
285
- ### ⚠️ Known Limitations
286
- - Smart playlist filtering system needs complete rework
287
- - Some advanced filter combinations may not work as expected
288
- - SSL certificate validation can be disabled with `PLEX_VERIFY_SSL=false` environment variable
92
+ It works for us. If it doesn't work for you, well we tried. Hit us up, we don't bite. Much.
package/index.js CHANGED
@@ -168,6 +168,155 @@ class PlexMCPServer {
168
168
  });
169
169
  }
170
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
+
171
320
  setupToolHandlers() {
172
321
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
173
322
  return {
@@ -815,6 +964,29 @@ class PlexMCPServer {
815
964
  required: [],
816
965
  },
817
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
+ },
818
990
  {
819
991
  name: "authenticate_plex",
820
992
  description: "Initiate Plex OAuth authentication flow to get user login URL",
@@ -893,6 +1065,8 @@ class PlexMCPServer {
893
1065
  return await this.handleGetLibraryStats(request.params.arguments);
894
1066
  case "get_listening_stats":
895
1067
  return await this.handleGetListeningStats(request.params.arguments);
1068
+ case "discover_music":
1069
+ return await this.handleDiscoverMusic(request.params.arguments);
896
1070
  case "authenticate_plex":
897
1071
  return await this.handleAuthenticatePlex(request.params.arguments);
898
1072
  case "check_auth_status":
@@ -1056,16 +1230,20 @@ All stored authentication credentials have been cleared. To use Plex tools again
1056
1230
  added_after,
1057
1231
  added_before
1058
1232
  } = args;
1233
+
1234
+ // Apply randomization settings if detected
1235
+ const enhancedArgs = this.applyRandomizationSettings(query, type, args);
1236
+ const finalLimit = enhancedArgs.limit || limit;
1059
1237
 
1060
1238
  try {
1061
1239
  const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
1062
1240
  const plexToken = await this.authManager.getAuthToken();
1063
1241
 
1064
- const searchUrl = `${plexUrl}/search`;
1242
+ const searchUrl = `${plexUrl}/hubs/search`;
1065
1243
  const params = {
1066
1244
  query: query,
1067
1245
  'X-Plex-Token': plexToken,
1068
- limit: limit
1246
+ limit: finalLimit
1069
1247
  };
1070
1248
 
1071
1249
  if (type) {
@@ -1116,11 +1294,21 @@ All stored authentication credentials have been cleared. To use Plex tools again
1116
1294
  file_size_max
1117
1295
  });
1118
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
+
1119
1307
  return {
1120
1308
  content: [
1121
1309
  {
1122
1310
  type: "text",
1123
- text: `Found ${results.length} results for "${query}":\n\n${this.formatResults(results)}`,
1311
+ text: resultText,
1124
1312
  },
1125
1313
  ],
1126
1314
  };
@@ -1150,11 +1338,30 @@ All stored authentication credentials have been cleared. To use Plex tools again
1150
1338
  }
1151
1339
 
1152
1340
  parseSearchResults(data) {
1153
- if (!data.MediaContainer || !data.MediaContainer.Metadata) {
1341
+ if (!data.MediaContainer) {
1154
1342
  return [];
1155
1343
  }
1156
1344
 
1157
- 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 => ({
1158
1365
  title: item.title,
1159
1366
  type: item.type,
1160
1367
  year: item.year,
@@ -1349,6 +1556,13 @@ All stored authentication credentials have been cleared. To use Plex tools again
1349
1556
  added_after,
1350
1557
  added_before
1351
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;
1352
1566
 
1353
1567
  try {
1354
1568
  const plexUrl = process.env.PLEX_URL || 'https://app.plex.tv';
@@ -1357,9 +1571,9 @@ All stored authentication credentials have been cleared. To use Plex tools again
1357
1571
  const libraryUrl = `${plexUrl}/library/sections/${library_id}/all`;
1358
1572
  const params = {
1359
1573
  'X-Plex-Token': plexToken,
1360
- sort: sort,
1361
- 'X-Plex-Container-Start': offset,
1362
- 'X-Plex-Container-Size': limit
1574
+ sort: finalSort,
1575
+ 'X-Plex-Container-Start': finalOffset,
1576
+ 'X-Plex-Container-Size': finalLimit
1363
1577
  };
1364
1578
 
1365
1579
  if (genre) {
@@ -1414,12 +1628,20 @@ All stored authentication credentials have been cleared. To use Plex tools again
1414
1628
  file_size_max
1415
1629
  });
1416
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
+
1417
1637
  const totalSize = response.data.MediaContainer?.totalSize || results.length;
1418
1638
 
1419
- 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})`;
1420
1642
  if (genre) resultText += ` | Genre: ${genre}`;
1421
1643
  if (year) resultText += ` | Year: ${year}`;
1422
- if (sort !== "titleSort") resultText += ` | Sorted by: ${sort}`;
1644
+ if (finalSort !== "titleSort") resultText += ` | Sorted by: ${finalSort}`;
1423
1645
  resultText += `:\n\n${this.formatResults(results)}`;
1424
1646
 
1425
1647
  return {
@@ -2402,7 +2624,6 @@ The smart playlist has been created and is now available in your Plex library!`,
2402
2624
  await new Promise(resolve => setTimeout(resolve, 200));
2403
2625
 
2404
2626
  } catch (seqError) {
2405
- console.warn(`❌ Sequential add failed for item ${itemKey}:`, seqError.message);
2406
2627
  sequentialResults.push({ itemKey, success: false, error: seqError.message });
2407
2628
  }
2408
2629
  }
@@ -2471,7 +2692,6 @@ The smart playlist has been created and is now available in your Plex library!`,
2471
2692
  } catch (error) {
2472
2693
  retryCount++;
2473
2694
  if (retryCount > maxRetries) {
2474
- console.warn(`Failed to get playlist count after ${maxRetries} retries:`, error.message);
2475
2695
  // If all retries failed, fall back to optimistic counting
2476
2696
  afterCount = beforeCount + (putSuccessful ? item_keys.length : 0);
2477
2697
  }
@@ -4107,6 +4327,97 @@ The smart playlist has been created and is now available in your Plex library!`,
4107
4327
  }
4108
4328
  }
4109
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
+
4110
4421
  async calculateListeningStats(musicLibraries, accountId, timePeriod, includeRecommendations, plexUrl, plexToken) {
4111
4422
  const stats = {
4112
4423
  timePeriod,
@@ -4270,7 +4581,6 @@ The smart playlist has been created and is now available in your Plex library!`,
4270
4581
  }
4271
4582
  }
4272
4583
  } catch (libraryError) {
4273
- console.error(`Error enriching stats for library ${library.key}:`, libraryError.message);
4274
4584
  }
4275
4585
  }
4276
4586
  }
@@ -4284,6 +4594,9 @@ The smart playlist has been created and is now available in your Plex library!`,
4284
4594
 
4285
4595
  const topArtists = Object.keys(stats.topArtists).slice(0, 5);
4286
4596
 
4597
+ // Track artists we've already recommended to avoid duplicates
4598
+ const recommendedArtists = new Set(topArtists.map(a => a.toLowerCase()));
4599
+
4287
4600
  for (const library of musicLibraries) {
4288
4601
  try {
4289
4602
  // Find new tracks in favorite genres
@@ -4358,13 +4671,449 @@ The smart playlist has been created and is now available in your Plex library!`,
4358
4671
  continue;
4359
4672
  }
4360
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
+
4361
4686
  } catch (libraryError) {
4362
4687
  continue;
4363
4688
  }
4364
4689
  }
4365
4690
 
4366
4691
  // Limit recommendations to avoid overwhelming output
4367
- 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);
4368
5117
  }
4369
5118
 
4370
5119
  formatListeningStats(stats, includeRecommendations) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plex-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "A Model Context Protocol (MCP) server that enables Claude to query and manage Plex media libraries.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -40,14 +40,14 @@
40
40
  "homepage": "https://github.com/vyb1ng/plex-mcp#readme",
41
41
  "type": "commonjs",
42
42
  "dependencies": {
43
- "@modelcontextprotocol/sdk": "^1.12.3",
43
+ "@modelcontextprotocol/sdk": "^1.13.0",
44
44
  "axios": "^1.10.0",
45
45
  "plex-oauth": "^2.1.0"
46
46
  },
47
47
  "devDependencies": {
48
- "@types/jest": "^29.5.14",
48
+ "@types/jest": "^30.0.0",
49
49
  "axios-mock-adapter": "^2.1.0",
50
- "jest": "^30.0.0"
50
+ "jest": "^30.0.2"
51
51
  },
52
52
  "overrides": {
53
53
  "plex-oauth": {
package/smithery.yaml ADDED
@@ -0,0 +1,29 @@
1
+ # Smithery configuration file: https://smithery.ai/docs/build/project-config
2
+
3
+ startCommand:
4
+ type: stdio
5
+ commandFunction:
6
+ # A JS function that produces the CLI command based on the given config to start the MCP on stdio.
7
+ |-
8
+ (config) => ({command: 'node', args: ['index.js'], env: {PLEX_URL: config.plexUrl, PLEX_TOKEN: config.plexToken, PLEX_VERIFY_SSL: config.plexVerifySsl.toString()}})
9
+ configSchema:
10
+ # JSON Schema defining the configuration options for the MCP.
11
+ type: object
12
+ required:
13
+ - plexUrl
14
+ - plexToken
15
+ properties:
16
+ plexUrl:
17
+ type: string
18
+ description: URL of the Plex server
19
+ plexToken:
20
+ type: string
21
+ description: Plex authentication token
22
+ plexVerifySsl:
23
+ type: boolean
24
+ default: true
25
+ description: Whether to verify Plex server SSL certificate
26
+ exampleConfig:
27
+ plexUrl: http://your-plex-server:32400
28
+ plexToken: abcdef123456
29
+ plexVerifySsl: true