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 +18 -0
- package/README.md +50 -246
- package/index.js +763 -14
- package/package.json +4 -4
- package/smithery.yaml +29 -0
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
|
+
[](https://smithery.ai/server/@vyb1ng/plex-mcp)
|
|
2
3
|
|
|
3
|
-
|
|
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
|
-
##
|
|
6
|
+
## Quick Start
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
### Running Both Versions
|
|
13
|
+
### Manual Setup for Claude Desktop
|
|
81
14
|
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
**Note:** Replace `your-plex-server:32400` with your actual Plex server address and port.
|
|
128
42
|
|
|
129
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
## Status
|
|
155
67
|
|
|
156
|
-
|
|
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
|
-
|
|
70
|
+
**❌ Disabled:** Smart playlists (filter logic broken)
|
|
161
71
|
|
|
162
|
-
|
|
72
|
+
**🚧 Planned:** Remote server browsing
|
|
163
73
|
|
|
164
|
-
|
|
74
|
+
## Development
|
|
165
75
|
|
|
166
|
-
|
|
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
|
-
"
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
1341
|
+
if (!data.MediaContainer) {
|
|
1154
1342
|
return [];
|
|
1155
1343
|
}
|
|
1156
1344
|
|
|
1157
|
-
|
|
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:
|
|
1361
|
-
'X-Plex-Container-Start':
|
|
1362
|
-
'X-Plex-Container-Size':
|
|
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 =
|
|
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 (
|
|
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,
|
|
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
|
+
"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.
|
|
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": "^
|
|
48
|
+
"@types/jest": "^30.0.0",
|
|
49
49
|
"axios-mock-adapter": "^2.1.0",
|
|
50
|
-
"jest": "^30.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
|