plex-mcp 0.0.2 → 0.1.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/README.md +32 -5
- package/TODO.md +272 -0
- package/index.js +528 -115
- package/package.json +7 -4
package/README.md
CHANGED
@@ -32,9 +32,9 @@ A Model Context Protocol (MCP) server for searching Plex media libraries using C
|
|
32
32
|
|
33
33
|
## Claude Desktop Configuration
|
34
34
|
|
35
|
-
### Option 1: Using npx
|
35
|
+
### Option 1: Production (Using npx - Recommended)
|
36
36
|
|
37
|
-
Add this configuration to your Claude Desktop settings:
|
37
|
+
Add this configuration to your Claude Desktop settings for the stable published version:
|
38
38
|
|
39
39
|
```json
|
40
40
|
{
|
@@ -51,14 +51,14 @@ Add this configuration to your Claude Desktop settings:
|
|
51
51
|
}
|
52
52
|
```
|
53
53
|
|
54
|
-
### Option 2: Local
|
54
|
+
### Option 2: Development (Local)
|
55
55
|
|
56
|
-
For local
|
56
|
+
For development with your local code changes, add this configuration:
|
57
57
|
|
58
58
|
```json
|
59
59
|
{
|
60
60
|
"mcpServers": {
|
61
|
-
"plex": {
|
61
|
+
"plex-dev": {
|
62
62
|
"command": "node",
|
63
63
|
"args": ["/path/to/your/plex-mcp/index.js"],
|
64
64
|
"env": {
|
@@ -72,6 +72,33 @@ For local development, add this configuration:
|
|
72
72
|
|
73
73
|
Replace `/path/to/your/plex-mcp/` with the actual path to this project directory.
|
74
74
|
|
75
|
+
### Running Both Versions
|
76
|
+
|
77
|
+
You can configure both versions simultaneously by using different server names (`plex` and `plex-dev`):
|
78
|
+
|
79
|
+
```json
|
80
|
+
{
|
81
|
+
"mcpServers": {
|
82
|
+
"plex": {
|
83
|
+
"command": "npx",
|
84
|
+
"args": ["plex-mcp"],
|
85
|
+
"env": {
|
86
|
+
"PLEX_URL": "http://your-plex-server:32400",
|
87
|
+
"PLEX_TOKEN": "your_plex_token"
|
88
|
+
}
|
89
|
+
},
|
90
|
+
"plex-dev": {
|
91
|
+
"command": "node",
|
92
|
+
"args": ["/path/to/your/plex-mcp/index.js"],
|
93
|
+
"env": {
|
94
|
+
"PLEX_URL": "http://your-plex-server:32400",
|
95
|
+
"PLEX_TOKEN": "your_plex_token"
|
96
|
+
}
|
97
|
+
}
|
98
|
+
}
|
99
|
+
}
|
100
|
+
```
|
101
|
+
|
75
102
|
**Configuration Steps:**
|
76
103
|
1. Open Claude Desktop settings (Cmd/Ctrl + ,)
|
77
104
|
2. Navigate to the "MCP Servers" tab
|
package/TODO.md
ADDED
@@ -0,0 +1,272 @@
|
|
1
|
+
# Plex MCP Server - TODO List
|
2
|
+
|
3
|
+
## Recent Completions
|
4
|
+
|
5
|
+
### ✅ Completed Items
|
6
|
+
- **Plex Item ID Investigation** - Identified correct field usage for playlist operations
|
7
|
+
- Confirmed `ratingKey` is the primary identifier for all playlist operations
|
8
|
+
- Documented ID field relationships (`ratingKey`, `key`, `machineIdentifier`)
|
9
|
+
- Found URI format conversion pattern for server operations
|
10
|
+
- **Playlist Operation Parameter Analysis** - Verified correct API parameter formats
|
11
|
+
- Confirmed `item_keys` array expects `ratingKey` values from search results
|
12
|
+
- Documented URI conversion: `ratingKey` → `server://localhost/com.plexapp.plugins.library/library/metadata/{key}`
|
13
|
+
- **Live Playlist Test Implementation** - Created comprehensive E2E test sequence
|
14
|
+
- Added playlist creation test with dynamic item search
|
15
|
+
- Added playlist item addition test with multiple items
|
16
|
+
- Added playlist browsing verification test
|
17
|
+
- Tests extract `ratingKey` from search results using `**ID: (\d+)**` pattern
|
18
|
+
- **Browse Playlist Bug Fix** - Fixed empty results issue in `handleBrowsePlaylist`
|
19
|
+
- Added missing pagination parameters (`X-Plex-Container-Start`, `X-Plex-Container-Size`)
|
20
|
+
- Added fallback to `/playlists/{id}/items` endpoint when main endpoint returns empty
|
21
|
+
- Added `leafCount` check to detect playlists that should have items
|
22
|
+
|
23
|
+
## Unimplemented Plex API Features
|
24
|
+
|
25
|
+
### Critical Missing APIs (High Priority)
|
26
|
+
|
27
|
+
#### Session Management
|
28
|
+
- [ ] **get_active_sessions** - List current "Now Playing" sessions
|
29
|
+
- Endpoint: `/status/sessions`
|
30
|
+
- Returns: Active playback sessions with user, client, media info
|
31
|
+
- [ ] **get_transcode_sessions** - List active transcoding operations
|
32
|
+
- Endpoint: `/transcode/sessions`
|
33
|
+
- Returns: Transcoding progress, quality settings, resource usage
|
34
|
+
- [ ] **terminate_session** - Stop/kill active playback sessions
|
35
|
+
- Endpoint: `/status/sessions/terminate`
|
36
|
+
- Action: Force stop sessions by session ID
|
37
|
+
|
38
|
+
#### Playback Control
|
39
|
+
- [ ] **control_playback** - Control playback (play/pause/stop/seek)
|
40
|
+
- Endpoint: `/player/playback/{command}`
|
41
|
+
- Commands: play, pause, stop, stepForward, stepBack, seekTo
|
42
|
+
- [ ] **start_playback** - Initiate playback on specific clients
|
43
|
+
- Endpoint: `/player/playback/playMedia`
|
44
|
+
- Parameters: media key, client ID, resume offset
|
45
|
+
- [ ] **remote_control** - Advanced remote control operations
|
46
|
+
- Endpoint: `/player/navigation/{command}`
|
47
|
+
- Commands: moveUp, moveDown, select, back, home
|
48
|
+
|
49
|
+
#### Client & Device Management
|
50
|
+
- [ ] **get_clients** - List available Plex clients/players
|
51
|
+
- Endpoint: `/clients`
|
52
|
+
- Returns: Client names, IDs, capabilities, online status
|
53
|
+
- [ ] **get_devices** - List all registered devices
|
54
|
+
- Endpoint: `/devices`
|
55
|
+
- Returns: Device info, last seen, platform details
|
56
|
+
- [ ] **get_servers** - List available Plex servers
|
57
|
+
- Endpoint: `/servers`
|
58
|
+
- Returns: Server list for multi-server setups
|
59
|
+
|
60
|
+
### Server Administration (Medium Priority)
|
61
|
+
|
62
|
+
#### Server Info & Management
|
63
|
+
- [ ] **get_server_info** - Server capabilities and status
|
64
|
+
- Endpoint: `/`
|
65
|
+
- Returns: Version, capabilities, transcoder info, platform
|
66
|
+
- [ ] **get_server_preferences** - Server configuration settings
|
67
|
+
- Endpoint: `/:/prefs`
|
68
|
+
- Returns: All server preferences and settings
|
69
|
+
- [ ] **scan_library** - Trigger library content scan
|
70
|
+
- Endpoint: `/library/sections/{id}/refresh`
|
71
|
+
- Action: Force library scan for new content
|
72
|
+
- [ ] **refresh_metadata** - Force metadata refresh for items
|
73
|
+
- Endpoint: `/library/metadata/{id}/refresh`
|
74
|
+
- Action: Re-download metadata, artwork, etc.
|
75
|
+
|
76
|
+
#### User Management
|
77
|
+
- [ ] **get_users** - List server users and accounts
|
78
|
+
- Endpoint: `/accounts`
|
79
|
+
- Returns: User list, permissions, sharing status
|
80
|
+
- [ ] **get_user_activity** - User-specific activity logs
|
81
|
+
- Endpoint: `/status/sessions/history/all?accountID={id}`
|
82
|
+
- Returns: Per-user watch history and statistics
|
83
|
+
|
84
|
+
### Content Discovery & Recommendations (Medium Priority)
|
85
|
+
|
86
|
+
#### Advanced Discovery
|
87
|
+
- [ ] **get_content_hubs** - Plex's recommendation engine
|
88
|
+
- Endpoint: `/hubs`
|
89
|
+
- Returns: Curated content recommendations, trending
|
90
|
+
- [ ] **get_discover_content** - Discover new content across libraries
|
91
|
+
- Endpoint: `/library/sections/all?discover=1`
|
92
|
+
- Returns: Cross-library content discovery
|
93
|
+
- [ ] **get_trending** - Trending content on Plex platform
|
94
|
+
- Endpoint: `/hubs/trending`
|
95
|
+
- Returns: Popular content across Plex network
|
96
|
+
|
97
|
+
#### Metadata Enhancement
|
98
|
+
- [ ] **get_genres** - Available genres across libraries
|
99
|
+
- Endpoint: `/library/sections/{id}/genre`
|
100
|
+
- Returns: Genre list with item counts
|
101
|
+
- [ ] **get_years** - Available years across libraries
|
102
|
+
- Endpoint: `/library/sections/{id}/year`
|
103
|
+
- Returns: Year list with item counts
|
104
|
+
- [ ] **get_studios** - Available studios/networks
|
105
|
+
- Endpoint: `/library/sections/{id}/studio`
|
106
|
+
- Returns: Studio/network list with item counts
|
107
|
+
- [ ] **get_directors** - Available directors/actors
|
108
|
+
- Endpoint: `/library/sections/{id}/director`
|
109
|
+
- Returns: People list with filmography counts
|
110
|
+
|
111
|
+
### Media Management (Medium Priority)
|
112
|
+
|
113
|
+
#### Watch Status Management
|
114
|
+
- [ ] **mark_watched** - Mark items as watched
|
115
|
+
- Endpoint: `/:/scrobble?key={id}&identifier=com.plexapp.plugins.library`
|
116
|
+
- Action: Set watch status, update play count
|
117
|
+
- [ ] **mark_unwatched** - Mark items as unwatched
|
118
|
+
- Endpoint: `/:/unscrobble?key={id}&identifier=com.plexapp.plugins.library`
|
119
|
+
- Action: Remove watch status, reset play count
|
120
|
+
- [ ] **set_rating** - Rate content (stars/thumbs)
|
121
|
+
- Endpoint: `/:/rate?key={id}&rating={rating}&identifier=com.plexapp.plugins.library`
|
122
|
+
- Action: Set user rating for content
|
123
|
+
|
124
|
+
#### Library Maintenance
|
125
|
+
- [ ] **optimize_library** - Database optimization operations
|
126
|
+
- Endpoint: `/library/optimize`
|
127
|
+
- Action: Clean up database, optimize indexes
|
128
|
+
- [ ] **empty_trash** - Empty library trash/deleted items
|
129
|
+
- Endpoint: `/library/sections/{id}/emptyTrash`
|
130
|
+
- Action: Permanently delete trashed items
|
131
|
+
- [ ] **update_library** - Update library metadata
|
132
|
+
- Endpoint: `/library/sections/{id}/update`
|
133
|
+
- Action: Update library without full scan
|
134
|
+
|
135
|
+
### Advanced Features (Low Priority)
|
136
|
+
|
137
|
+
#### Transcoding Management
|
138
|
+
- [ ] **get_transcoding_settings** - Transcoding preferences
|
139
|
+
- Endpoint: `/:/prefs?group=transcoder`
|
140
|
+
- Returns: Quality settings, codec preferences
|
141
|
+
- [ ] **optimize_content** - Content optimization for devices
|
142
|
+
- Endpoint: `/library/metadata/{id}/optimize`
|
143
|
+
- Action: Pre-transcode content for specific devices
|
144
|
+
|
145
|
+
#### Sync & Download
|
146
|
+
- [ ] **get_sync_items** - Sync queue and downloaded items
|
147
|
+
- Endpoint: `/sync/items`
|
148
|
+
- Returns: Sync status, download queue
|
149
|
+
- [ ] **download_media** - Download content for offline viewing
|
150
|
+
- Endpoint: `/sync/items`
|
151
|
+
- Action: Queue content for download/sync
|
152
|
+
|
153
|
+
#### Webhooks & Events
|
154
|
+
- [ ] **get_webhooks** - List configured webhooks
|
155
|
+
- Endpoint: `/:/webhooks`
|
156
|
+
- Returns: Webhook URLs and trigger events
|
157
|
+
- [ ] **listen_events** - Real-time event streaming
|
158
|
+
- Endpoint: `/:/events` (WebSocket/SSE)
|
159
|
+
- Returns: Live server events, playback updates
|
160
|
+
|
161
|
+
## Missing Test Coverage
|
162
|
+
|
163
|
+
### Unit Tests
|
164
|
+
- [ ] **Session management handlers** - Tests for new session control APIs
|
165
|
+
- [ ] **Playback control handlers** - Tests for media control functionality
|
166
|
+
- [ ] **Client management handlers** - Tests for device/client discovery
|
167
|
+
- [ ] **Server admin handlers** - Tests for administrative functions
|
168
|
+
- [ ] **User management handlers** - Tests for multi-user functionality
|
169
|
+
- [ ] **Content discovery handlers** - Tests for recommendation features
|
170
|
+
- [ ] **Watch status handlers** - Tests for marking watched/unwatched
|
171
|
+
- [ ] **Library management handlers** - Tests for scan/refresh operations
|
172
|
+
|
173
|
+
### Integration Tests
|
174
|
+
- [ ] **Multi-library operations** - Cross-library search and discovery
|
175
|
+
- [ ] **Playlist management with new content** - Advanced playlist operations
|
176
|
+
- [ ] **User permission scenarios** - Multi-user access control
|
177
|
+
- [ ] **Server configuration changes** - Settings modification testing
|
178
|
+
- [ ] **Transcoding workflow** - End-to-end transcoding scenarios
|
179
|
+
- [ ] **Sync/download workflows** - Offline content management
|
180
|
+
|
181
|
+
### Mock Data Enhancement
|
182
|
+
- [ ] **Session data mocks** - Active session responses
|
183
|
+
- [ ] **Client data mocks** - Available client/device responses
|
184
|
+
- [ ] **Server info mocks** - Server capabilities and status
|
185
|
+
- [ ] **User data mocks** - Multi-user account responses
|
186
|
+
- [ ] **Transcoding mocks** - Transcoding session responses
|
187
|
+
- [ ] **Webhook mocks** - Event and webhook responses
|
188
|
+
|
189
|
+
## E2E Tests to Add
|
190
|
+
|
191
|
+
### Real Server Integration
|
192
|
+
- [ ] **Live session monitoring** - Connect to real server, monitor active sessions
|
193
|
+
- Test with actual playback sessions
|
194
|
+
- Verify session data accuracy
|
195
|
+
- Test session termination
|
196
|
+
- [ ] **Live playback control** - Control actual Plex clients
|
197
|
+
- Test play/pause/stop commands
|
198
|
+
- Test seek functionality
|
199
|
+
- Test client discovery and selection
|
200
|
+
- [ ] **Live library management** - Real library operations
|
201
|
+
- Test library scanning
|
202
|
+
- Test metadata refresh
|
203
|
+
- Test library optimization
|
204
|
+
- [ ] **Live user scenarios** - Multi-user testing
|
205
|
+
- Test shared library access
|
206
|
+
- Test user-specific history
|
207
|
+
- Test permission boundaries
|
208
|
+
|
209
|
+
### Cross-Platform Testing
|
210
|
+
- [ ] **Multiple client types** - Test with various Plex clients
|
211
|
+
- Desktop app, mobile app, web player
|
212
|
+
- Smart TV apps, streaming devices
|
213
|
+
- Verify control compatibility
|
214
|
+
- [ ] **Multiple server versions** - Test against different Plex server versions
|
215
|
+
- Latest stable, previous versions
|
216
|
+
- PlexPass vs free features
|
217
|
+
- API compatibility testing
|
218
|
+
|
219
|
+
### Network Scenarios
|
220
|
+
- [ ] **SSL/TLS configurations** - Various security setups
|
221
|
+
- Self-signed certificates
|
222
|
+
- Valid SSL certificates
|
223
|
+
- Mixed HTTP/HTTPS environments
|
224
|
+
- [ ] **Remote access testing** - Plex relay and direct connections
|
225
|
+
- Remote server access via Plex.tv
|
226
|
+
- Direct IP connections
|
227
|
+
- VPN/tunnel scenarios
|
228
|
+
- [ ] **Performance testing** - Large library scenarios
|
229
|
+
- Libraries with 10k+ items
|
230
|
+
- Multiple concurrent operations
|
231
|
+
- Memory and CPU usage monitoring
|
232
|
+
|
233
|
+
## Code Quality & Architecture
|
234
|
+
|
235
|
+
### Error Handling Improvements
|
236
|
+
- [ ] **Granular error types** - Specific error classes for different failure modes
|
237
|
+
- [ ] **Retry mechanisms** - Automatic retry for transient failures
|
238
|
+
- [ ] **Circuit breaker pattern** - Fail-fast for consistently failing operations
|
239
|
+
- [ ] **Rate limiting** - Respect Plex server rate limits
|
240
|
+
|
241
|
+
### Performance Optimizations
|
242
|
+
- [ ] **Response caching** - Cache frequently accessed data
|
243
|
+
- [ ] **Batch operations** - Combine multiple API calls when possible
|
244
|
+
- [ ] **Streaming responses** - Handle large datasets efficiently
|
245
|
+
- [ ] **Connection pooling** - Reuse HTTP connections
|
246
|
+
|
247
|
+
### Documentation
|
248
|
+
- [ ] **API reference docs** - Complete documentation for all tools
|
249
|
+
- [ ] **Usage examples** - Real-world scenarios and code examples
|
250
|
+
- [ ] **Troubleshooting guide** - Common issues and solutions
|
251
|
+
- [ ] **Performance tuning** - Optimization recommendations
|
252
|
+
|
253
|
+
### Security Enhancements
|
254
|
+
- [ ] **Token validation** - Verify Plex token format and permissions
|
255
|
+
- [ ] **SSL certificate validation** - Proper certificate handling
|
256
|
+
- [ ] **Input sanitization** - Validate all user inputs
|
257
|
+
- [ ] **Audit logging** - Log all administrative operations
|
258
|
+
|
259
|
+
## Development Infrastructure
|
260
|
+
|
261
|
+
### CI/CD Improvements
|
262
|
+
- [ ] **Automated testing** - Run full test suite on every commit
|
263
|
+
- [ ] **Code coverage tracking** - Monitor and improve test coverage
|
264
|
+
- [ ] **Performance benchmarks** - Track performance regressions
|
265
|
+
- [ ] **Security scanning** - Automated vulnerability detection
|
266
|
+
|
267
|
+
### Development Tools
|
268
|
+
- [ ] **Mock Plex server** - Local development server for testing
|
269
|
+
- [ ] **Test data generator** - Generate realistic test datasets
|
270
|
+
- [ ] **Performance profiler** - Identify bottlenecks and optimize
|
271
|
+
- [ ] **Documentation generator** - Auto-generate API docs from code
|
272
|
+
|
package/index.js
CHANGED
@@ -25,6 +25,14 @@ class PlexMCPServer {
|
|
25
25
|
this.setupToolHandlers();
|
26
26
|
}
|
27
27
|
|
28
|
+
getHttpsAgent() {
|
29
|
+
const verifySSL = process.env.PLEX_VERIFY_SSL !== 'false';
|
30
|
+
return new (require('https').Agent)({
|
31
|
+
rejectUnauthorized: verifySSL,
|
32
|
+
minVersion: 'TLSv1.2'
|
33
|
+
});
|
34
|
+
}
|
35
|
+
|
28
36
|
setupToolHandlers() {
|
29
37
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
30
38
|
return {
|
@@ -380,6 +388,25 @@ class PlexMCPServer {
|
|
380
388
|
required: [],
|
381
389
|
},
|
382
390
|
},
|
391
|
+
{
|
392
|
+
name: "browse_playlist",
|
393
|
+
description: "Browse and view the contents of a specific playlist with full track metadata",
|
394
|
+
inputSchema: {
|
395
|
+
type: "object",
|
396
|
+
properties: {
|
397
|
+
playlist_id: {
|
398
|
+
type: "string",
|
399
|
+
description: "The ID of the playlist to browse",
|
400
|
+
},
|
401
|
+
limit: {
|
402
|
+
type: "number",
|
403
|
+
description: "Maximum number of items to return (default: 50)",
|
404
|
+
default: 50,
|
405
|
+
},
|
406
|
+
},
|
407
|
+
required: ["playlist_id"],
|
408
|
+
},
|
409
|
+
},
|
383
410
|
{
|
384
411
|
name: "create_playlist",
|
385
412
|
description: "Create a new playlist on the Plex server. Note: Non-smart playlists require an initial item (item_key parameter) to be created successfully.",
|
@@ -429,6 +456,10 @@ class PlexMCPServer {
|
|
429
456
|
required: ["playlist_id", "item_keys"],
|
430
457
|
},
|
431
458
|
},
|
459
|
+
// DISABLED: remove_from_playlist - PROBLEMATIC due to Plex API limitations
|
460
|
+
// This operation removes ALL instances of matching items, not just one
|
461
|
+
// Uncomment only after implementing safer removal patterns
|
462
|
+
/*
|
432
463
|
{
|
433
464
|
name: "remove_from_playlist",
|
434
465
|
description: "Remove items from an existing playlist",
|
@@ -450,6 +481,7 @@ class PlexMCPServer {
|
|
450
481
|
required: ["playlist_id", "item_keys"],
|
451
482
|
},
|
452
483
|
},
|
484
|
+
*/
|
453
485
|
{
|
454
486
|
name: "delete_playlist",
|
455
487
|
description: "Delete an existing playlist",
|
@@ -611,12 +643,15 @@ class PlexMCPServer {
|
|
611
643
|
return await this.handleOnDeck(request.params.arguments);
|
612
644
|
case "list_playlists":
|
613
645
|
return await this.handleListPlaylists(request.params.arguments);
|
646
|
+
case "browse_playlist":
|
647
|
+
return await this.handleBrowsePlaylist(request.params.arguments);
|
614
648
|
case "create_playlist":
|
615
649
|
return await this.handleCreatePlaylist(request.params.arguments);
|
616
650
|
case "add_to_playlist":
|
617
651
|
return await this.handleAddToPlaylist(request.params.arguments);
|
618
|
-
|
619
|
-
|
652
|
+
// DISABLED: remove_from_playlist - PROBLEMATIC operation
|
653
|
+
// case "remove_from_playlist":
|
654
|
+
// return await this.handleRemoveFromPlaylist(request.params.arguments);
|
620
655
|
case "delete_playlist":
|
621
656
|
return await this.handleDeletePlaylist(request.params.arguments);
|
622
657
|
case "get_watched_status":
|
@@ -690,10 +725,7 @@ class PlexMCPServer {
|
|
690
725
|
|
691
726
|
const response = await axios.get(searchUrl, {
|
692
727
|
params,
|
693
|
-
httpsAgent:
|
694
|
-
rejectUnauthorized: false,
|
695
|
-
minVersion: 'TLSv1.2'
|
696
|
-
})
|
728
|
+
httpsAgent: this.getHttpsAgent()
|
697
729
|
});
|
698
730
|
|
699
731
|
let results = this.parseSearchResults(response.data);
|
@@ -786,6 +818,12 @@ class PlexMCPServer {
|
|
786
818
|
contentRating: item.contentRating,
|
787
819
|
Media: item.Media,
|
788
820
|
key: item.key,
|
821
|
+
ratingKey: item.ratingKey, // Critical: the unique identifier for playlist operations
|
822
|
+
// Additional hierarchical info for music tracks
|
823
|
+
parentTitle: item.parentTitle, // Album name
|
824
|
+
grandparentTitle: item.grandparentTitle, // Artist name
|
825
|
+
parentRatingKey: item.parentRatingKey, // Album ID
|
826
|
+
grandparentRatingKey: item.grandparentRatingKey, // Artist ID
|
789
827
|
// Additional metadata for basic filters
|
790
828
|
studio: item.studio,
|
791
829
|
genres: item.Genre ? item.Genre.map(g => g.tag) : [],
|
@@ -807,8 +845,24 @@ class PlexMCPServer {
|
|
807
845
|
formatted += ` - ${item.type}`;
|
808
846
|
}
|
809
847
|
|
848
|
+
// Add artist/album info for music tracks
|
849
|
+
if (item.grandparentTitle && item.parentTitle) {
|
850
|
+
formatted += `\n Artist: ${item.grandparentTitle} | Album: ${item.parentTitle}`;
|
851
|
+
} else if (item.parentTitle) {
|
852
|
+
formatted += `\n Album/Show: ${item.parentTitle}`;
|
853
|
+
}
|
854
|
+
|
810
855
|
if (item.rating) {
|
811
|
-
formatted +=
|
856
|
+
formatted += `\n Rating: ${item.rating}`;
|
857
|
+
}
|
858
|
+
|
859
|
+
if (item.duration) {
|
860
|
+
formatted += `\n Duration: ${this.formatDuration(item.duration)}`;
|
861
|
+
}
|
862
|
+
|
863
|
+
// CRITICAL: Show the ratingKey for playlist operations
|
864
|
+
if (item.ratingKey) {
|
865
|
+
formatted += `\n **ID: ${item.ratingKey}** (use this for playlists)`;
|
812
866
|
}
|
813
867
|
|
814
868
|
if (item.summary) {
|
@@ -819,6 +873,21 @@ class PlexMCPServer {
|
|
819
873
|
}).join('\n\n');
|
820
874
|
}
|
821
875
|
|
876
|
+
formatDuration(milliseconds) {
|
877
|
+
if (!milliseconds || milliseconds === 0) return 'Unknown';
|
878
|
+
|
879
|
+
const seconds = Math.floor(milliseconds / 1000);
|
880
|
+
const minutes = Math.floor(seconds / 60);
|
881
|
+
const hours = Math.floor(minutes / 60);
|
882
|
+
|
883
|
+
if (hours > 0) {
|
884
|
+
const remainingMinutes = minutes % 60;
|
885
|
+
return `${hours}h ${remainingMinutes}m`;
|
886
|
+
} else {
|
887
|
+
return `${minutes}m`;
|
888
|
+
}
|
889
|
+
}
|
890
|
+
|
822
891
|
async handleBrowseLibraries(args) {
|
823
892
|
try {
|
824
893
|
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
@@ -835,10 +904,7 @@ class PlexMCPServer {
|
|
835
904
|
|
836
905
|
const response = await axios.get(librariesUrl, {
|
837
906
|
params,
|
838
|
-
httpsAgent:
|
839
|
-
rejectUnauthorized: false,
|
840
|
-
minVersion: 'TLSv1.2'
|
841
|
-
})
|
907
|
+
httpsAgent: this.getHttpsAgent()
|
842
908
|
});
|
843
909
|
|
844
910
|
const libraries = this.parseLibraries(response.data);
|
@@ -965,10 +1031,7 @@ class PlexMCPServer {
|
|
965
1031
|
|
966
1032
|
const response = await axios.get(libraryUrl, {
|
967
1033
|
params,
|
968
|
-
httpsAgent:
|
969
|
-
rejectUnauthorized: false,
|
970
|
-
minVersion: 'TLSv1.2'
|
971
|
-
})
|
1034
|
+
httpsAgent: this.getHttpsAgent()
|
972
1035
|
});
|
973
1036
|
|
974
1037
|
let results = this.parseLibraryContent(response.data);
|
@@ -1090,10 +1153,7 @@ class PlexMCPServer {
|
|
1090
1153
|
|
1091
1154
|
const response = await axios.get(recentUrl, {
|
1092
1155
|
params,
|
1093
|
-
httpsAgent:
|
1094
|
-
rejectUnauthorized: false,
|
1095
|
-
minVersion: 'TLSv1.2'
|
1096
|
-
})
|
1156
|
+
httpsAgent: this.getHttpsAgent()
|
1097
1157
|
});
|
1098
1158
|
|
1099
1159
|
const results = this.parseLibraryContent(response.data);
|
@@ -1191,10 +1251,7 @@ class PlexMCPServer {
|
|
1191
1251
|
|
1192
1252
|
const response = await axios.get(historyUrl, {
|
1193
1253
|
params,
|
1194
|
-
httpsAgent:
|
1195
|
-
rejectUnauthorized: false,
|
1196
|
-
minVersion: 'TLSv1.2'
|
1197
|
-
})
|
1254
|
+
httpsAgent: this.getHttpsAgent()
|
1198
1255
|
});
|
1199
1256
|
|
1200
1257
|
const results = this.parseWatchHistory(response.data);
|
@@ -1314,10 +1371,7 @@ class PlexMCPServer {
|
|
1314
1371
|
|
1315
1372
|
const response = await axios.get(onDeckUrl, {
|
1316
1373
|
params,
|
1317
|
-
httpsAgent:
|
1318
|
-
rejectUnauthorized: false,
|
1319
|
-
minVersion: 'TLSv1.2'
|
1320
|
-
})
|
1374
|
+
httpsAgent: this.getHttpsAgent()
|
1321
1375
|
});
|
1322
1376
|
|
1323
1377
|
const results = this.parseOnDeck(response.data);
|
@@ -1423,10 +1477,7 @@ class PlexMCPServer {
|
|
1423
1477
|
|
1424
1478
|
const response = await axios.get(playlistsUrl, {
|
1425
1479
|
params,
|
1426
|
-
httpsAgent:
|
1427
|
-
rejectUnauthorized: false,
|
1428
|
-
minVersion: 'TLSv1.2'
|
1429
|
-
})
|
1480
|
+
httpsAgent: this.getHttpsAgent()
|
1430
1481
|
});
|
1431
1482
|
|
1432
1483
|
const playlists = this.parsePlaylists(response.data);
|
@@ -1456,6 +1507,154 @@ class PlexMCPServer {
|
|
1456
1507
|
}
|
1457
1508
|
}
|
1458
1509
|
|
1510
|
+
async handleBrowsePlaylist(args) {
|
1511
|
+
const { playlist_id, limit = 50 } = args;
|
1512
|
+
|
1513
|
+
try {
|
1514
|
+
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1515
|
+
const plexToken = process.env.PLEX_TOKEN;
|
1516
|
+
|
1517
|
+
if (!plexToken) {
|
1518
|
+
throw new Error('PLEX_TOKEN environment variable is required');
|
1519
|
+
}
|
1520
|
+
|
1521
|
+
// First get playlist info
|
1522
|
+
const playlistUrl = `${plexUrl}/playlists/${playlist_id}`;
|
1523
|
+
const response = await axios.get(playlistUrl, {
|
1524
|
+
params: {
|
1525
|
+
'X-Plex-Token': plexToken,
|
1526
|
+
'X-Plex-Container-Start': 0,
|
1527
|
+
'X-Plex-Container-Size': limit || 50
|
1528
|
+
},
|
1529
|
+
httpsAgent: this.getHttpsAgent()
|
1530
|
+
});
|
1531
|
+
|
1532
|
+
const playlistData = response.data.MediaContainer;
|
1533
|
+
if (!playlistData || !playlistData.Metadata || playlistData.Metadata.length === 0) {
|
1534
|
+
return {
|
1535
|
+
content: [
|
1536
|
+
{
|
1537
|
+
type: "text",
|
1538
|
+
text: `Playlist with ID ${playlist_id} not found`,
|
1539
|
+
},
|
1540
|
+
],
|
1541
|
+
};
|
1542
|
+
}
|
1543
|
+
|
1544
|
+
const playlist = playlistData.Metadata[0];
|
1545
|
+
|
1546
|
+
// Try to get items from the current response first
|
1547
|
+
let items = playlistData.Metadata[0].Metadata || [];
|
1548
|
+
|
1549
|
+
// If no items found, try the /items endpoint specifically
|
1550
|
+
if (items.length === 0 && playlist.leafCount && playlist.leafCount > 0) {
|
1551
|
+
try {
|
1552
|
+
const itemsUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
1553
|
+
const itemsResponse = await axios.get(itemsUrl, {
|
1554
|
+
params: {
|
1555
|
+
'X-Plex-Token': plexToken,
|
1556
|
+
'X-Plex-Container-Start': 0,
|
1557
|
+
'X-Plex-Container-Size': limit || 50
|
1558
|
+
},
|
1559
|
+
httpsAgent: this.getHttpsAgent()
|
1560
|
+
});
|
1561
|
+
|
1562
|
+
const itemsData = itemsResponse.data.MediaContainer;
|
1563
|
+
if (itemsData && itemsData.Metadata) {
|
1564
|
+
items = itemsData.Metadata;
|
1565
|
+
}
|
1566
|
+
} catch (itemsError) {
|
1567
|
+
console.error(`Failed to fetch playlist items via /items endpoint: ${itemsError.message}`);
|
1568
|
+
return {
|
1569
|
+
content: [
|
1570
|
+
{
|
1571
|
+
type: "text",
|
1572
|
+
text: `Error retrieving playlist items: ${itemsError.message}`,
|
1573
|
+
},
|
1574
|
+
],
|
1575
|
+
};
|
1576
|
+
}
|
1577
|
+
}
|
1578
|
+
|
1579
|
+
// Limit results if specified
|
1580
|
+
const limitedItems = limit ? items.slice(0, limit) : items;
|
1581
|
+
|
1582
|
+
let resultText = `**${playlist.title}**`;
|
1583
|
+
if (playlist.smart) {
|
1584
|
+
resultText += ` (Smart Playlist)`;
|
1585
|
+
}
|
1586
|
+
resultText += `\n`;
|
1587
|
+
if (playlist.summary) {
|
1588
|
+
resultText += `${playlist.summary}\n`;
|
1589
|
+
}
|
1590
|
+
resultText += `Duration: ${this.formatDuration(playlist.duration || 0)}\n`;
|
1591
|
+
resultText += `Items: ${items.length}`;
|
1592
|
+
if (limit && items.length > limit) {
|
1593
|
+
resultText += ` (showing first ${limit})`;
|
1594
|
+
}
|
1595
|
+
resultText += `\n\n`;
|
1596
|
+
|
1597
|
+
if (limitedItems.length === 0) {
|
1598
|
+
resultText += `This playlist appears to be empty or items could not be retrieved.`;
|
1599
|
+
} else {
|
1600
|
+
resultText += limitedItems.map((item, index) => {
|
1601
|
+
let itemText = `${index + 1}. **${item.title}**`;
|
1602
|
+
|
1603
|
+
// Add artist/album info for music
|
1604
|
+
if (item.grandparentTitle && item.parentTitle) {
|
1605
|
+
itemText += `\n Artist: ${item.grandparentTitle}\n Album: ${item.parentTitle}`;
|
1606
|
+
} else if (item.parentTitle) {
|
1607
|
+
itemText += `\n Album/Show: ${item.parentTitle}`;
|
1608
|
+
}
|
1609
|
+
|
1610
|
+
// Add duration
|
1611
|
+
if (item.duration) {
|
1612
|
+
itemText += `\n Duration: ${this.formatDuration(item.duration)}`;
|
1613
|
+
}
|
1614
|
+
|
1615
|
+
// Add rating key for identification
|
1616
|
+
itemText += `\n ID: ${item.ratingKey}`;
|
1617
|
+
|
1618
|
+
// Add media type
|
1619
|
+
const mediaType = this.getMediaTypeFromItem(item);
|
1620
|
+
if (mediaType) {
|
1621
|
+
itemText += `\n Type: ${mediaType}`;
|
1622
|
+
}
|
1623
|
+
|
1624
|
+
return itemText;
|
1625
|
+
}).join('\n\n');
|
1626
|
+
}
|
1627
|
+
|
1628
|
+
return {
|
1629
|
+
content: [
|
1630
|
+
{
|
1631
|
+
type: "text",
|
1632
|
+
text: resultText,
|
1633
|
+
},
|
1634
|
+
],
|
1635
|
+
};
|
1636
|
+
} catch (error) {
|
1637
|
+
return {
|
1638
|
+
content: [
|
1639
|
+
{
|
1640
|
+
type: "text",
|
1641
|
+
text: `Error browsing playlist: ${error.message}`,
|
1642
|
+
},
|
1643
|
+
],
|
1644
|
+
isError: true,
|
1645
|
+
};
|
1646
|
+
}
|
1647
|
+
}
|
1648
|
+
|
1649
|
+
getMediaTypeFromItem(item) {
|
1650
|
+
if (item.type === 'track') return 'Music Track';
|
1651
|
+
if (item.type === 'episode') return 'TV Episode';
|
1652
|
+
if (item.type === 'movie') return 'Movie';
|
1653
|
+
if (item.type === 'artist') return 'Artist';
|
1654
|
+
if (item.type === 'album') return 'Album';
|
1655
|
+
return item.type || 'Unknown';
|
1656
|
+
}
|
1657
|
+
|
1459
1658
|
parsePlaylists(data) {
|
1460
1659
|
if (!data.MediaContainer || !data.MediaContainer.Metadata) {
|
1461
1660
|
return [];
|
@@ -1540,10 +1739,7 @@ class PlexMCPServer {
|
|
1540
1739
|
// First get server info to get machine identifier
|
1541
1740
|
const serverResponse = await axios.get(`${plexUrl}/`, {
|
1542
1741
|
headers: { 'X-Plex-Token': plexToken },
|
1543
|
-
httpsAgent:
|
1544
|
-
rejectUnauthorized: false,
|
1545
|
-
minVersion: 'TLSv1.2'
|
1546
|
-
})
|
1742
|
+
httpsAgent: this.getHttpsAgent()
|
1547
1743
|
});
|
1548
1744
|
|
1549
1745
|
const machineIdentifier = serverResponse.data?.MediaContainer?.machineIdentifier;
|
@@ -1576,20 +1772,22 @@ class PlexMCPServer {
|
|
1576
1772
|
headers: {
|
1577
1773
|
'Content-Length': '0'
|
1578
1774
|
},
|
1579
|
-
httpsAgent:
|
1580
|
-
rejectUnauthorized: false,
|
1581
|
-
minVersion: 'TLSv1.2'
|
1582
|
-
})
|
1775
|
+
httpsAgent: this.getHttpsAgent()
|
1583
1776
|
});
|
1584
1777
|
|
1585
1778
|
// Get the created playlist info from the response
|
1586
1779
|
const playlistData = response.data?.MediaContainer?.Metadata?.[0];
|
1587
1780
|
|
1588
|
-
let resultText =
|
1781
|
+
let resultText = `✅ Successfully created ${smart ? 'smart ' : ''}playlist: **${title}**`;
|
1589
1782
|
if (playlistData) {
|
1590
|
-
resultText += `\n Playlist ID: ${playlistData.ratingKey}`;
|
1783
|
+
resultText += `\n **Playlist ID: ${playlistData.ratingKey}** (use this ID for future operations)`;
|
1591
1784
|
resultText += `\n Type: ${type}`;
|
1592
1785
|
if (smart) resultText += `\n Smart Playlist: Yes`;
|
1786
|
+
if (item_key && !smart) {
|
1787
|
+
resultText += `\n Initial item added: ${item_key}`;
|
1788
|
+
}
|
1789
|
+
} else {
|
1790
|
+
resultText += `\n ⚠️ Playlist created but details not available - check your playlists`;
|
1593
1791
|
}
|
1594
1792
|
|
1595
1793
|
return {
|
@@ -1628,6 +1826,17 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1628
1826
|
async handleAddToPlaylist(args) {
|
1629
1827
|
const { playlist_id, item_keys } = args;
|
1630
1828
|
|
1829
|
+
// Input validation
|
1830
|
+
if (!playlist_id || typeof playlist_id !== 'string') {
|
1831
|
+
throw new Error('Valid playlist_id is required');
|
1832
|
+
}
|
1833
|
+
if (!item_keys || !Array.isArray(item_keys) || item_keys.length === 0) {
|
1834
|
+
throw new Error('item_keys must be a non-empty array');
|
1835
|
+
}
|
1836
|
+
if (item_keys.some(key => !key || typeof key !== 'string')) {
|
1837
|
+
throw new Error('All item_keys must be non-empty strings');
|
1838
|
+
}
|
1839
|
+
|
1631
1840
|
try {
|
1632
1841
|
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1633
1842
|
const plexToken = process.env.PLEX_TOKEN;
|
@@ -1636,21 +1845,103 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1636
1845
|
throw new Error('PLEX_TOKEN environment variable is required');
|
1637
1846
|
}
|
1638
1847
|
|
1848
|
+
// Get playlist info before adding items
|
1849
|
+
const playlistInfoUrl = `${plexUrl}/playlists/${playlist_id}`;
|
1850
|
+
const playlistItemsUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
1851
|
+
|
1852
|
+
// Get playlist metadata (title, etc.)
|
1853
|
+
const playlistInfoResponse = await axios.get(playlistInfoUrl, {
|
1854
|
+
params: {
|
1855
|
+
'X-Plex-Token': plexToken
|
1856
|
+
},
|
1857
|
+
httpsAgent: this.getHttpsAgent()
|
1858
|
+
});
|
1859
|
+
|
1860
|
+
const playlistInfo = playlistInfoResponse.data.MediaContainer;
|
1861
|
+
const playlistTitle = playlistInfo.Metadata && playlistInfo.Metadata[0]
|
1862
|
+
? playlistInfo.Metadata[0].title : `Playlist ${playlist_id}`;
|
1863
|
+
|
1864
|
+
// Get current playlist items count
|
1865
|
+
let beforeCount = 0;
|
1866
|
+
try {
|
1867
|
+
const beforeResponse = await axios.get(playlistItemsUrl, {
|
1868
|
+
params: {
|
1869
|
+
'X-Plex-Token': plexToken
|
1870
|
+
},
|
1871
|
+
httpsAgent: this.getHttpsAgent()
|
1872
|
+
});
|
1873
|
+
beforeCount = beforeResponse.data.MediaContainer?.totalSize || 0;
|
1874
|
+
} catch (error) {
|
1875
|
+
// If items endpoint fails, playlist might be empty
|
1876
|
+
beforeCount = 0;
|
1877
|
+
}
|
1878
|
+
|
1879
|
+
// Get server machine identifier for proper URI format
|
1880
|
+
const serverResponse = await axios.get(`${plexUrl}/`, {
|
1881
|
+
headers: { 'X-Plex-Token': plexToken },
|
1882
|
+
httpsAgent: this.getHttpsAgent()
|
1883
|
+
});
|
1884
|
+
|
1885
|
+
const machineIdentifier = serverResponse.data?.MediaContainer?.machineIdentifier;
|
1886
|
+
if (!machineIdentifier) {
|
1887
|
+
throw new Error('Could not get server machine identifier');
|
1888
|
+
}
|
1889
|
+
|
1639
1890
|
const addUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
1640
1891
|
const params = {
|
1641
1892
|
'X-Plex-Token': plexToken,
|
1642
|
-
uri: item_keys.map(key => `server
|
1893
|
+
uri: item_keys.map(key => `server://${machineIdentifier}/com.plexapp.plugins.library/library/metadata/${key}`).join(',')
|
1643
1894
|
};
|
1644
1895
|
|
1645
1896
|
const response = await axios.put(addUrl, null, {
|
1646
1897
|
params,
|
1647
|
-
httpsAgent:
|
1648
|
-
rejectUnauthorized: false,
|
1649
|
-
minVersion: 'TLSv1.2'
|
1650
|
-
})
|
1898
|
+
httpsAgent: this.getHttpsAgent()
|
1651
1899
|
});
|
1652
1900
|
|
1653
|
-
|
1901
|
+
// Check if the PUT request was successful based on HTTP status
|
1902
|
+
const putSuccessful = response.status >= 200 && response.status < 300;
|
1903
|
+
|
1904
|
+
// Small delay to allow Plex server to update
|
1905
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
1906
|
+
|
1907
|
+
// Verify the addition by checking the playlist items again
|
1908
|
+
let afterCount = 0;
|
1909
|
+
try {
|
1910
|
+
const afterResponse = await axios.get(playlistItemsUrl, {
|
1911
|
+
params: {
|
1912
|
+
'X-Plex-Token': plexToken
|
1913
|
+
},
|
1914
|
+
httpsAgent: this.getHttpsAgent()
|
1915
|
+
});
|
1916
|
+
afterCount = afterResponse.data.MediaContainer?.totalSize || 0;
|
1917
|
+
} catch (error) {
|
1918
|
+
// If items endpoint fails, playlist might be empty
|
1919
|
+
afterCount = 0;
|
1920
|
+
}
|
1921
|
+
|
1922
|
+
const actualAdded = afterCount - beforeCount;
|
1923
|
+
const attempted = item_keys.length;
|
1924
|
+
|
1925
|
+
let resultText = `Playlist "${playlistTitle}" update:\n`;
|
1926
|
+
resultText += `• Attempted to add: ${attempted} item(s)\n`;
|
1927
|
+
resultText += `• Actually added: ${actualAdded} item(s)\n`;
|
1928
|
+
resultText += `• Playlist size: ${beforeCount} → ${afterCount} items\n`;
|
1929
|
+
|
1930
|
+
// If HTTP request was successful but count didn't change,
|
1931
|
+
// it's likely the items already exist or are duplicates
|
1932
|
+
if (actualAdded === attempted) {
|
1933
|
+
resultText += `✅ All items added successfully!`;
|
1934
|
+
} else if (actualAdded > 0) {
|
1935
|
+
resultText += `⚠️ Partial success: ${attempted - actualAdded} item(s) may have been duplicates or invalid`;
|
1936
|
+
} else if (putSuccessful) {
|
1937
|
+
resultText += `✅ API request successful! Items may already exist in playlist or were duplicates.\n`;
|
1938
|
+
resultText += `ℹ️ This is normal behavior - Plex doesn't add duplicate items.`;
|
1939
|
+
} else {
|
1940
|
+
resultText += `❌ No items were added. This may indicate:\n`;
|
1941
|
+
resultText += ` - Invalid item IDs (use ratingKey from search results)\n`;
|
1942
|
+
resultText += ` - Items already exist in playlist\n`;
|
1943
|
+
resultText += ` - Permission issues`;
|
1944
|
+
}
|
1654
1945
|
|
1655
1946
|
return {
|
1656
1947
|
content: [
|
@@ -1661,11 +1952,27 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1661
1952
|
],
|
1662
1953
|
};
|
1663
1954
|
} catch (error) {
|
1955
|
+
// Enhanced error handling with specific error types
|
1956
|
+
let errorMessage = `Error adding items to playlist: ${error.message}`;
|
1957
|
+
|
1958
|
+
if (error.response) {
|
1959
|
+
const status = error.response.status;
|
1960
|
+
if (status === 404) {
|
1961
|
+
errorMessage = `Playlist with ID ${playlist_id} not found`;
|
1962
|
+
} else if (status === 401 || status === 403) {
|
1963
|
+
errorMessage = `Permission denied: Check your Plex token and server access`;
|
1964
|
+
} else if (status >= 500) {
|
1965
|
+
errorMessage = `Plex server error (${status}): ${error.message}`;
|
1966
|
+
}
|
1967
|
+
} else if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
|
1968
|
+
errorMessage = `Cannot connect to Plex server: Check PLEX_URL configuration`;
|
1969
|
+
}
|
1970
|
+
|
1664
1971
|
return {
|
1665
1972
|
content: [
|
1666
1973
|
{
|
1667
1974
|
type: "text",
|
1668
|
-
text:
|
1975
|
+
text: errorMessage,
|
1669
1976
|
},
|
1670
1977
|
],
|
1671
1978
|
isError: true,
|
@@ -1673,9 +1980,24 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1673
1980
|
}
|
1674
1981
|
}
|
1675
1982
|
|
1983
|
+
// DISABLED METHOD - PROBLEMATIC OPERATION
|
1984
|
+
// This method is currently disabled due to destructive Plex API behavior
|
1985
|
+
// It removes ALL instances of matching items, not just one instance
|
1986
|
+
// Use with extreme caution - consider implementing safer alternatives
|
1676
1987
|
async handleRemoveFromPlaylist(args) {
|
1677
1988
|
const { playlist_id, item_keys } = args;
|
1678
1989
|
|
1990
|
+
// Input validation
|
1991
|
+
if (!playlist_id || typeof playlist_id !== 'string') {
|
1992
|
+
throw new Error('Valid playlist_id is required');
|
1993
|
+
}
|
1994
|
+
if (!item_keys || !Array.isArray(item_keys) || item_keys.length === 0) {
|
1995
|
+
throw new Error('item_keys must be a non-empty array');
|
1996
|
+
}
|
1997
|
+
if (item_keys.some(key => !key || typeof key !== 'string')) {
|
1998
|
+
throw new Error('All item_keys must be non-empty strings');
|
1999
|
+
}
|
2000
|
+
|
1679
2001
|
try {
|
1680
2002
|
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1681
2003
|
const plexToken = process.env.PLEX_TOKEN;
|
@@ -1684,21 +2006,141 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1684
2006
|
throw new Error('PLEX_TOKEN environment variable is required');
|
1685
2007
|
}
|
1686
2008
|
|
2009
|
+
// Get playlist info before removing items
|
2010
|
+
const playlistInfoUrl = `${plexUrl}/playlists/${playlist_id}`;
|
2011
|
+
const playlistItemsUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
2012
|
+
|
2013
|
+
// Get playlist metadata (title, etc.)
|
2014
|
+
const playlistInfoResponse = await axios.get(playlistInfoUrl, {
|
2015
|
+
params: {
|
2016
|
+
'X-Plex-Token': plexToken
|
2017
|
+
},
|
2018
|
+
httpsAgent: this.getHttpsAgent()
|
2019
|
+
});
|
2020
|
+
|
2021
|
+
const playlistInfo = playlistInfoResponse.data.MediaContainer;
|
2022
|
+
const playlistTitle = playlistInfo.Metadata && playlistInfo.Metadata[0]
|
2023
|
+
? playlistInfo.Metadata[0].title : `Playlist ${playlist_id}`;
|
2024
|
+
|
2025
|
+
// Get current playlist items with their detailed information
|
2026
|
+
let beforeCount = 0;
|
2027
|
+
let playlistItems = [];
|
2028
|
+
try {
|
2029
|
+
const beforeResponse = await axios.get(playlistItemsUrl, {
|
2030
|
+
params: {
|
2031
|
+
'X-Plex-Token': plexToken
|
2032
|
+
},
|
2033
|
+
httpsAgent: this.getHttpsAgent()
|
2034
|
+
});
|
2035
|
+
beforeCount = beforeResponse.data.MediaContainer?.totalSize || 0;
|
2036
|
+
playlistItems = beforeResponse.data.MediaContainer?.Metadata || [];
|
2037
|
+
} catch (error) {
|
2038
|
+
// If items endpoint fails, playlist might be empty
|
2039
|
+
beforeCount = 0;
|
2040
|
+
playlistItems = [];
|
2041
|
+
}
|
2042
|
+
|
2043
|
+
// Get server machine identifier for proper URI format
|
2044
|
+
const serverResponse = await axios.get(`${plexUrl}/`, {
|
2045
|
+
headers: { 'X-Plex-Token': plexToken },
|
2046
|
+
httpsAgent: this.getHttpsAgent()
|
2047
|
+
});
|
2048
|
+
|
2049
|
+
const machineIdentifier = serverResponse.data?.MediaContainer?.machineIdentifier;
|
2050
|
+
if (!machineIdentifier) {
|
2051
|
+
throw new Error('Could not get server machine identifier');
|
2052
|
+
}
|
2053
|
+
|
2054
|
+
// Find items to remove by matching ratingKeys to actual playlist positions
|
2055
|
+
const itemsToRemove = [];
|
2056
|
+
const itemKeysSet = new Set(item_keys);
|
2057
|
+
|
2058
|
+
playlistItems.forEach((item, index) => {
|
2059
|
+
if (itemKeysSet.has(item.ratingKey)) {
|
2060
|
+
itemsToRemove.push({
|
2061
|
+
ratingKey: item.ratingKey,
|
2062
|
+
position: index,
|
2063
|
+
title: item.title || 'Unknown'
|
2064
|
+
});
|
2065
|
+
}
|
2066
|
+
});
|
2067
|
+
|
2068
|
+
if (itemsToRemove.length === 0) {
|
2069
|
+
return {
|
2070
|
+
content: [
|
2071
|
+
{
|
2072
|
+
type: "text",
|
2073
|
+
text: `No matching items found in playlist "${playlistTitle}".\\nSpecified items may not exist in this playlist.`,
|
2074
|
+
},
|
2075
|
+
],
|
2076
|
+
};
|
2077
|
+
}
|
2078
|
+
|
2079
|
+
// WARNING: Current Plex API behavior - this removes ALL instances of matching items
|
2080
|
+
// This is a limitation of the Plex API - there's no way to remove just specific instances
|
1687
2081
|
const removeUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
1688
2082
|
const params = {
|
1689
2083
|
'X-Plex-Token': plexToken,
|
1690
|
-
uri:
|
2084
|
+
uri: itemsToRemove.map(item => `server://${machineIdentifier}/com.plexapp.plugins.library/library/metadata/${item.ratingKey}`).join(',')
|
1691
2085
|
};
|
1692
2086
|
|
1693
2087
|
const response = await axios.delete(removeUrl, {
|
1694
2088
|
params,
|
1695
|
-
httpsAgent:
|
1696
|
-
rejectUnauthorized: false,
|
1697
|
-
minVersion: 'TLSv1.2'
|
1698
|
-
})
|
2089
|
+
httpsAgent: this.getHttpsAgent()
|
1699
2090
|
});
|
1700
2091
|
|
1701
|
-
|
2092
|
+
// Check if the DELETE request was successful based on HTTP status
|
2093
|
+
const deleteSuccessful = response.status >= 200 && response.status < 300;
|
2094
|
+
|
2095
|
+
// Small delay to allow Plex server to update
|
2096
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
2097
|
+
|
2098
|
+
// Verify the removal by checking the playlist items again
|
2099
|
+
let afterCount = 0;
|
2100
|
+
try {
|
2101
|
+
const afterResponse = await axios.get(playlistItemsUrl, {
|
2102
|
+
params: {
|
2103
|
+
'X-Plex-Token': plexToken
|
2104
|
+
},
|
2105
|
+
httpsAgent: this.getHttpsAgent()
|
2106
|
+
});
|
2107
|
+
afterCount = afterResponse.data.MediaContainer?.totalSize || 0;
|
2108
|
+
} catch (error) {
|
2109
|
+
// If items endpoint fails, playlist might be empty
|
2110
|
+
afterCount = 0;
|
2111
|
+
}
|
2112
|
+
|
2113
|
+
const actualRemoved = beforeCount - afterCount;
|
2114
|
+
const attempted = item_keys.length;
|
2115
|
+
|
2116
|
+
let resultText = `Playlist "${playlistTitle}" update:\n`;
|
2117
|
+
resultText += `• Attempted to remove: ${attempted} item(s)\n`;
|
2118
|
+
resultText += `• Found in playlist: ${itemsToRemove.length} item(s)\n`;
|
2119
|
+
resultText += `• Actually removed: ${actualRemoved} item(s)\n`;
|
2120
|
+
resultText += `• Playlist size: ${beforeCount} → ${afterCount} items\n\n`;
|
2121
|
+
|
2122
|
+
// Add warning about Plex behavior
|
2123
|
+
if (itemsToRemove.length > 0) {
|
2124
|
+
resultText += `⚠️ **Important**: Plex removes ALL instances of matching items from playlists.\n`;
|
2125
|
+
resultText += `If you had duplicate tracks, all copies were removed.\n\n`;
|
2126
|
+
}
|
2127
|
+
|
2128
|
+
if (actualRemoved === attempted) {
|
2129
|
+
resultText += `✅ All items removed successfully!`;
|
2130
|
+
} else if (actualRemoved > 0) {
|
2131
|
+
resultText += `⚠️ Partial success: ${attempted - actualRemoved} item(s) were not found in the playlist`;
|
2132
|
+
} else if (deleteSuccessful && itemsToRemove.length > 0) {
|
2133
|
+
resultText += `✅ API request successful! Items were processed.\n`;
|
2134
|
+
resultText += `ℹ️ If count didn't change, items may have already been removed previously.`;
|
2135
|
+
} else if (deleteSuccessful) {
|
2136
|
+
resultText += `✅ API request successful! Items may not have been in the playlist.\n`;
|
2137
|
+
resultText += `ℹ️ This is normal behavior - Plex ignores requests to remove non-existent items.`;
|
2138
|
+
} else {
|
2139
|
+
resultText += `❌ No items were removed. This may indicate:\n`;
|
2140
|
+
resultText += ` - Invalid item IDs\n`;
|
2141
|
+
resultText += ` - Items not present in playlist\n`;
|
2142
|
+
resultText += ` - Permission issues`;
|
2143
|
+
}
|
1702
2144
|
|
1703
2145
|
return {
|
1704
2146
|
content: [
|
@@ -1709,11 +2151,27 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1709
2151
|
],
|
1710
2152
|
};
|
1711
2153
|
} catch (error) {
|
2154
|
+
// Enhanced error handling with specific error types
|
2155
|
+
let errorMessage = `Error removing items from playlist: ${error.message}`;
|
2156
|
+
|
2157
|
+
if (error.response) {
|
2158
|
+
const status = error.response.status;
|
2159
|
+
if (status === 404) {
|
2160
|
+
errorMessage = `Playlist with ID ${playlist_id} not found`;
|
2161
|
+
} else if (status === 401 || status === 403) {
|
2162
|
+
errorMessage = `Permission denied: Check your Plex token and server access`;
|
2163
|
+
} else if (status >= 500) {
|
2164
|
+
errorMessage = `Plex server error (${status}): ${error.message}`;
|
2165
|
+
}
|
2166
|
+
} else if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
|
2167
|
+
errorMessage = `Cannot connect to Plex server: Check PLEX_URL configuration`;
|
2168
|
+
}
|
2169
|
+
|
1712
2170
|
return {
|
1713
2171
|
content: [
|
1714
2172
|
{
|
1715
2173
|
type: "text",
|
1716
|
-
text:
|
2174
|
+
text: errorMessage,
|
1717
2175
|
},
|
1718
2176
|
],
|
1719
2177
|
isError: true,
|
@@ -1739,10 +2197,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1739
2197
|
|
1740
2198
|
const response = await axios.delete(deleteUrl, {
|
1741
2199
|
params,
|
1742
|
-
httpsAgent:
|
1743
|
-
rejectUnauthorized: false,
|
1744
|
-
minVersion: 'TLSv1.2'
|
1745
|
-
})
|
2200
|
+
httpsAgent: this.getHttpsAgent()
|
1746
2201
|
});
|
1747
2202
|
|
1748
2203
|
const resultText = `Successfully deleted playlist ${playlist_id}`;
|
@@ -1795,10 +2250,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1795
2250
|
|
1796
2251
|
const response = await axios.get(itemUrl, {
|
1797
2252
|
params,
|
1798
|
-
httpsAgent:
|
1799
|
-
rejectUnauthorized: false,
|
1800
|
-
minVersion: 'TLSv1.2'
|
1801
|
-
})
|
2253
|
+
httpsAgent: this.getHttpsAgent()
|
1802
2254
|
});
|
1803
2255
|
|
1804
2256
|
const item = response.data?.MediaContainer?.Metadata?.[0];
|
@@ -2242,10 +2694,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
2242
2694
|
|
2243
2695
|
const response = await axios.get(collectionsUrl, {
|
2244
2696
|
params,
|
2245
|
-
httpsAgent:
|
2246
|
-
rejectUnauthorized: false,
|
2247
|
-
minVersion: 'TLSv1.2'
|
2248
|
-
})
|
2697
|
+
httpsAgent: this.getHttpsAgent()
|
2249
2698
|
});
|
2250
2699
|
|
2251
2700
|
const collections = this.parseCollections(response.data);
|
@@ -2296,10 +2745,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
2296
2745
|
|
2297
2746
|
const response = await axios.get(collectionUrl, {
|
2298
2747
|
params,
|
2299
|
-
httpsAgent:
|
2300
|
-
rejectUnauthorized: false,
|
2301
|
-
minVersion: 'TLSv1.2'
|
2302
|
-
})
|
2748
|
+
httpsAgent: this.getHttpsAgent()
|
2303
2749
|
});
|
2304
2750
|
|
2305
2751
|
const results = this.parseLibraryContent(response.data);
|
@@ -2399,10 +2845,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
2399
2845
|
|
2400
2846
|
const response = await axios.get(mediaUrl, {
|
2401
2847
|
params,
|
2402
|
-
httpsAgent:
|
2403
|
-
rejectUnauthorized: false,
|
2404
|
-
minVersion: 'TLSv1.2'
|
2405
|
-
})
|
2848
|
+
httpsAgent: this.getHttpsAgent()
|
2406
2849
|
});
|
2407
2850
|
|
2408
2851
|
const item = response.data?.MediaContainer?.Metadata?.[0];
|
@@ -2691,10 +3134,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
2691
3134
|
// Get library information first
|
2692
3135
|
const librariesResponse = await axios.get(`${plexUrl}/library/sections`, {
|
2693
3136
|
params: { 'X-Plex-Token': plexToken },
|
2694
|
-
httpsAgent:
|
2695
|
-
rejectUnauthorized: false,
|
2696
|
-
minVersion: 'TLSv1.2'
|
2697
|
-
})
|
3137
|
+
httpsAgent: this.getHttpsAgent()
|
2698
3138
|
});
|
2699
3139
|
|
2700
3140
|
const libraries = this.parseLibraries(librariesResponse.data);
|
@@ -2783,10 +3223,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
2783
3223
|
'X-Plex-Container-Start': offset,
|
2784
3224
|
'X-Plex-Container-Size': batchSize
|
2785
3225
|
},
|
2786
|
-
httpsAgent:
|
2787
|
-
rejectUnauthorized: false,
|
2788
|
-
minVersion: 'TLSv1.2'
|
2789
|
-
})
|
3226
|
+
httpsAgent: this.getHttpsAgent()
|
2790
3227
|
});
|
2791
3228
|
|
2792
3229
|
const content = this.parseLibraryContent(contentResponse.data);
|
@@ -2827,10 +3264,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
2827
3264
|
try {
|
2828
3265
|
const mediaResponse = await axios.get(`${plexUrl}${item.key}`, {
|
2829
3266
|
params: { 'X-Plex-Token': plexToken },
|
2830
|
-
httpsAgent:
|
2831
|
-
rejectUnauthorized: false,
|
2832
|
-
minVersion: 'TLSv1.2'
|
2833
|
-
})
|
3267
|
+
httpsAgent: this.getHttpsAgent()
|
2834
3268
|
});
|
2835
3269
|
|
2836
3270
|
const detailedItem = mediaResponse.data?.MediaContainer?.Metadata?.[0];
|
@@ -3059,10 +3493,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
3059
3493
|
} else {
|
3060
3494
|
const librariesResponse = await axios.get(`${plexUrl}/library/sections`, {
|
3061
3495
|
params: { 'X-Plex-Token': plexToken },
|
3062
|
-
httpsAgent:
|
3063
|
-
rejectUnauthorized: false,
|
3064
|
-
minVersion: 'TLSv1.2'
|
3065
|
-
})
|
3496
|
+
httpsAgent: this.getHttpsAgent()
|
3066
3497
|
});
|
3067
3498
|
|
3068
3499
|
const allLibraries = this.parseLibraries(librariesResponse.data);
|
@@ -3165,10 +3596,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
3165
3596
|
try {
|
3166
3597
|
const historyResponse = await axios.get(`${plexUrl}/status/sessions/history/all`, {
|
3167
3598
|
params: historyParams,
|
3168
|
-
httpsAgent:
|
3169
|
-
rejectUnauthorized: false,
|
3170
|
-
minVersion: 'TLSv1.2'
|
3171
|
-
})
|
3599
|
+
httpsAgent: this.getHttpsAgent()
|
3172
3600
|
});
|
3173
3601
|
|
3174
3602
|
const history = this.parseWatchHistory(historyResponse.data);
|
@@ -3249,10 +3677,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
3249
3677
|
query: trackName,
|
3250
3678
|
type: 10 // Track type
|
3251
3679
|
},
|
3252
|
-
httpsAgent:
|
3253
|
-
rejectUnauthorized: false,
|
3254
|
-
minVersion: 'TLSv1.2'
|
3255
|
-
})
|
3680
|
+
httpsAgent: this.getHttpsAgent()
|
3256
3681
|
});
|
3257
3682
|
|
3258
3683
|
const tracks = this.parseSearchResults(searchResponse.data);
|
@@ -3260,10 +3685,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
3260
3685
|
if (track.key) {
|
3261
3686
|
const trackDetailResponse = await axios.get(`${plexUrl}${track.key}`, {
|
3262
3687
|
params: { 'X-Plex-Token': plexToken },
|
3263
|
-
httpsAgent:
|
3264
|
-
rejectUnauthorized: false,
|
3265
|
-
minVersion: 'TLSv1.2'
|
3266
|
-
})
|
3688
|
+
httpsAgent: this.getHttpsAgent()
|
3267
3689
|
});
|
3268
3690
|
|
3269
3691
|
const trackDetail = trackDetailResponse.data?.MediaContainer?.Metadata?.[0];
|
@@ -3308,10 +3730,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
3308
3730
|
'X-Plex-Container-Size': 10,
|
3309
3731
|
sort: 'addedAt:desc' // Recently added first
|
3310
3732
|
},
|
3311
|
-
httpsAgent:
|
3312
|
-
rejectUnauthorized: false,
|
3313
|
-
minVersion: 'TLSv1.2'
|
3314
|
-
})
|
3733
|
+
httpsAgent: this.getHttpsAgent()
|
3315
3734
|
});
|
3316
3735
|
|
3317
3736
|
const tracks = this.parseLibraryContent(genreSearchResponse.data);
|
@@ -3342,10 +3761,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
3342
3761
|
query: artist,
|
3343
3762
|
type: 8 // Artist type
|
3344
3763
|
},
|
3345
|
-
httpsAgent:
|
3346
|
-
rejectUnauthorized: false,
|
3347
|
-
minVersion: 'TLSv1.2'
|
3348
|
-
})
|
3764
|
+
httpsAgent: this.getHttpsAgent()
|
3349
3765
|
});
|
3350
3766
|
|
3351
3767
|
const artists = this.parseSearchResults(artistSearchResponse.data);
|
@@ -3353,10 +3769,7 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
3353
3769
|
if (foundArtist.key) {
|
3354
3770
|
const artistDetailResponse = await axios.get(`${plexUrl}${foundArtist.key}`, {
|
3355
3771
|
params: { 'X-Plex-Token': plexToken },
|
3356
|
-
httpsAgent:
|
3357
|
-
rejectUnauthorized: false,
|
3358
|
-
minVersion: 'TLSv1.2'
|
3359
|
-
})
|
3772
|
+
httpsAgent: this.getHttpsAgent()
|
3360
3773
|
});
|
3361
3774
|
|
3362
3775
|
const artistTracks = this.parseLibraryContent(artistDetailResponse.data);
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "plex-mcp",
|
3
|
-
"version": "0.0
|
3
|
+
"version": "0.1.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": {
|
@@ -8,9 +8,12 @@
|
|
8
8
|
},
|
9
9
|
"scripts": {
|
10
10
|
"start": "node index.js",
|
11
|
-
"test": "jest",
|
12
|
-
"test:
|
13
|
-
"test:
|
11
|
+
"test": "jest --testPathIgnorePatterns=/tests/e2e/",
|
12
|
+
"test:e2e": "jest --testPathPattern=e2e",
|
13
|
+
"test:e2e:analysis": "jest --testPathPattern=e2e --testNamePattern=\"(Playlist Behavior Analysis|Critical Remove Bug Investigation|Multiple Item Addition Analysis)\"",
|
14
|
+
"test:all": "jest",
|
15
|
+
"test:watch": "jest --watch --testPathIgnorePatterns=/tests/e2e/",
|
16
|
+
"test:coverage": "jest --coverage --testPathIgnorePatterns=/tests/e2e/",
|
14
17
|
"prepublishOnly": "npm test",
|
15
18
|
"version:patch": "npm version patch",
|
16
19
|
"version:minor": "npm version minor",
|