plex-mcp 0.0.4 → 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 +20 -0
- package/index.js +263 -45
- package/package.json +2 -1
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
CHANGED
@@ -1,5 +1,25 @@
|
|
1
1
|
# Plex MCP Server - TODO List
|
2
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
|
+
|
3
23
|
## Unimplemented Plex API Features
|
4
24
|
|
5
25
|
### Critical Missing APIs (High Priority)
|
package/index.js
CHANGED
@@ -456,6 +456,10 @@ class PlexMCPServer {
|
|
456
456
|
required: ["playlist_id", "item_keys"],
|
457
457
|
},
|
458
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
|
+
/*
|
459
463
|
{
|
460
464
|
name: "remove_from_playlist",
|
461
465
|
description: "Remove items from an existing playlist",
|
@@ -477,6 +481,7 @@ class PlexMCPServer {
|
|
477
481
|
required: ["playlist_id", "item_keys"],
|
478
482
|
},
|
479
483
|
},
|
484
|
+
*/
|
480
485
|
{
|
481
486
|
name: "delete_playlist",
|
482
487
|
description: "Delete an existing playlist",
|
@@ -644,8 +649,9 @@ class PlexMCPServer {
|
|
644
649
|
return await this.handleCreatePlaylist(request.params.arguments);
|
645
650
|
case "add_to_playlist":
|
646
651
|
return await this.handleAddToPlaylist(request.params.arguments);
|
647
|
-
|
648
|
-
|
652
|
+
// DISABLED: remove_from_playlist - PROBLEMATIC operation
|
653
|
+
// case "remove_from_playlist":
|
654
|
+
// return await this.handleRemoveFromPlaylist(request.params.arguments);
|
649
655
|
case "delete_playlist":
|
650
656
|
return await this.handleDeletePlaylist(request.params.arguments);
|
651
657
|
case "get_watched_status":
|
@@ -1516,7 +1522,9 @@ class PlexMCPServer {
|
|
1516
1522
|
const playlistUrl = `${plexUrl}/playlists/${playlist_id}`;
|
1517
1523
|
const response = await axios.get(playlistUrl, {
|
1518
1524
|
params: {
|
1519
|
-
'X-Plex-Token': plexToken
|
1525
|
+
'X-Plex-Token': plexToken,
|
1526
|
+
'X-Plex-Container-Start': 0,
|
1527
|
+
'X-Plex-Container-Size': limit || 50
|
1520
1528
|
},
|
1521
1529
|
httpsAgent: this.getHttpsAgent()
|
1522
1530
|
});
|
@@ -1527,14 +1535,46 @@ class PlexMCPServer {
|
|
1527
1535
|
content: [
|
1528
1536
|
{
|
1529
1537
|
type: "text",
|
1530
|
-
text: `Playlist with ID ${playlist_id} not found
|
1538
|
+
text: `Playlist with ID ${playlist_id} not found`,
|
1531
1539
|
},
|
1532
1540
|
],
|
1533
1541
|
};
|
1534
1542
|
}
|
1535
1543
|
|
1536
1544
|
const playlist = playlistData.Metadata[0];
|
1537
|
-
|
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
|
+
}
|
1538
1578
|
|
1539
1579
|
// Limit results if specified
|
1540
1580
|
const limitedItems = limit ? items.slice(0, limit) : items;
|
@@ -1555,7 +1595,7 @@ class PlexMCPServer {
|
|
1555
1595
|
resultText += `\n\n`;
|
1556
1596
|
|
1557
1597
|
if (limitedItems.length === 0) {
|
1558
|
-
resultText += `This playlist
|
1598
|
+
resultText += `This playlist appears to be empty or items could not be retrieved.`;
|
1559
1599
|
} else {
|
1560
1600
|
resultText += limitedItems.map((item, index) => {
|
1561
1601
|
let itemText = `${index + 1}. **${item.title}**`;
|
@@ -1786,6 +1826,17 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1786
1826
|
async handleAddToPlaylist(args) {
|
1787
1827
|
const { playlist_id, item_keys } = args;
|
1788
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
|
+
|
1789
1840
|
try {
|
1790
1841
|
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1791
1842
|
const plexToken = process.env.PLEX_TOKEN;
|
@@ -1795,24 +1846,51 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1795
1846
|
}
|
1796
1847
|
|
1797
1848
|
// Get playlist info before adding items
|
1798
|
-
const
|
1799
|
-
const
|
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, {
|
1800
1854
|
params: {
|
1801
1855
|
'X-Plex-Token': plexToken
|
1802
1856
|
},
|
1803
1857
|
httpsAgent: this.getHttpsAgent()
|
1804
1858
|
});
|
1805
1859
|
|
1806
|
-
const
|
1807
|
-
const
|
1808
|
-
?
|
1809
|
-
|
1810
|
-
|
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
|
+
}
|
1811
1889
|
|
1812
1890
|
const addUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
1813
1891
|
const params = {
|
1814
1892
|
'X-Plex-Token': plexToken,
|
1815
|
-
uri: item_keys.map(key => `server
|
1893
|
+
uri: item_keys.map(key => `server://${machineIdentifier}/com.plexapp.plugins.library/library/metadata/${key}`).join(',')
|
1816
1894
|
};
|
1817
1895
|
|
1818
1896
|
const response = await axios.put(addUrl, null, {
|
@@ -1820,17 +1898,26 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1820
1898
|
httpsAgent: this.getHttpsAgent()
|
1821
1899
|
});
|
1822
1900
|
|
1823
|
-
//
|
1824
|
-
const
|
1825
|
-
params: {
|
1826
|
-
'X-Plex-Token': plexToken
|
1827
|
-
},
|
1828
|
-
httpsAgent: this.getHttpsAgent()
|
1829
|
-
});
|
1901
|
+
// Check if the PUT request was successful based on HTTP status
|
1902
|
+
const putSuccessful = response.status >= 200 && response.status < 300;
|
1830
1903
|
|
1831
|
-
|
1832
|
-
|
1833
|
-
|
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
|
+
}
|
1834
1921
|
|
1835
1922
|
const actualAdded = afterCount - beforeCount;
|
1836
1923
|
const attempted = item_keys.length;
|
@@ -1840,10 +1927,15 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1840
1927
|
resultText += `• Actually added: ${actualAdded} item(s)\n`;
|
1841
1928
|
resultText += `• Playlist size: ${beforeCount} → ${afterCount} items\n`;
|
1842
1929
|
|
1930
|
+
// If HTTP request was successful but count didn't change,
|
1931
|
+
// it's likely the items already exist or are duplicates
|
1843
1932
|
if (actualAdded === attempted) {
|
1844
1933
|
resultText += `✅ All items added successfully!`;
|
1845
1934
|
} else if (actualAdded > 0) {
|
1846
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.`;
|
1847
1939
|
} else {
|
1848
1940
|
resultText += `❌ No items were added. This may indicate:\n`;
|
1849
1941
|
resultText += ` - Invalid item IDs (use ratingKey from search results)\n`;
|
@@ -1860,11 +1952,27 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1860
1952
|
],
|
1861
1953
|
};
|
1862
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
|
+
|
1863
1971
|
return {
|
1864
1972
|
content: [
|
1865
1973
|
{
|
1866
1974
|
type: "text",
|
1867
|
-
text:
|
1975
|
+
text: errorMessage,
|
1868
1976
|
},
|
1869
1977
|
],
|
1870
1978
|
isError: true,
|
@@ -1872,9 +1980,24 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1872
1980
|
}
|
1873
1981
|
}
|
1874
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
|
1875
1987
|
async handleRemoveFromPlaylist(args) {
|
1876
1988
|
const { playlist_id, item_keys } = args;
|
1877
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
|
+
|
1878
2001
|
try {
|
1879
2002
|
const plexUrl = process.env.PLEX_URL || 'http://localhost:32400';
|
1880
2003
|
const plexToken = process.env.PLEX_TOKEN;
|
@@ -1884,24 +2007,81 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1884
2007
|
}
|
1885
2008
|
|
1886
2009
|
// Get playlist info before removing items
|
1887
|
-
const
|
1888
|
-
const
|
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, {
|
1889
2015
|
params: {
|
1890
2016
|
'X-Plex-Token': plexToken
|
1891
2017
|
},
|
1892
2018
|
httpsAgent: this.getHttpsAgent()
|
1893
2019
|
});
|
1894
2020
|
|
1895
|
-
const
|
1896
|
-
const
|
1897
|
-
?
|
1898
|
-
|
1899
|
-
|
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
|
+
}
|
1900
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
|
1901
2081
|
const removeUrl = `${plexUrl}/playlists/${playlist_id}/items`;
|
1902
2082
|
const params = {
|
1903
2083
|
'X-Plex-Token': plexToken,
|
1904
|
-
uri:
|
2084
|
+
uri: itemsToRemove.map(item => `server://${machineIdentifier}/com.plexapp.plugins.library/library/metadata/${item.ratingKey}`).join(',')
|
1905
2085
|
};
|
1906
2086
|
|
1907
2087
|
const response = await axios.delete(removeUrl, {
|
@@ -1909,30 +2089,52 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1909
2089
|
httpsAgent: this.getHttpsAgent()
|
1910
2090
|
});
|
1911
2091
|
|
1912
|
-
//
|
1913
|
-
const
|
1914
|
-
params: {
|
1915
|
-
'X-Plex-Token': plexToken
|
1916
|
-
},
|
1917
|
-
httpsAgent: this.getHttpsAgent()
|
1918
|
-
});
|
2092
|
+
// Check if the DELETE request was successful based on HTTP status
|
2093
|
+
const deleteSuccessful = response.status >= 200 && response.status < 300;
|
1919
2094
|
|
1920
|
-
|
1921
|
-
|
1922
|
-
|
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
|
+
}
|
1923
2112
|
|
1924
2113
|
const actualRemoved = beforeCount - afterCount;
|
1925
2114
|
const attempted = item_keys.length;
|
1926
2115
|
|
1927
2116
|
let resultText = `Playlist "${playlistTitle}" update:\n`;
|
1928
2117
|
resultText += `• Attempted to remove: ${attempted} item(s)\n`;
|
2118
|
+
resultText += `• Found in playlist: ${itemsToRemove.length} item(s)\n`;
|
1929
2119
|
resultText += `• Actually removed: ${actualRemoved} item(s)\n`;
|
1930
|
-
resultText += `• Playlist size: ${beforeCount} → ${afterCount} items\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
|
+
}
|
1931
2127
|
|
1932
2128
|
if (actualRemoved === attempted) {
|
1933
2129
|
resultText += `✅ All items removed successfully!`;
|
1934
2130
|
} else if (actualRemoved > 0) {
|
1935
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.`;
|
1936
2138
|
} else {
|
1937
2139
|
resultText += `❌ No items were removed. This may indicate:\n`;
|
1938
2140
|
resultText += ` - Invalid item IDs\n`;
|
@@ -1949,11 +2151,27 @@ You can try creating the playlist manually in Plex and then use other MCP tools
|
|
1949
2151
|
],
|
1950
2152
|
};
|
1951
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
|
+
|
1952
2170
|
return {
|
1953
2171
|
content: [
|
1954
2172
|
{
|
1955
2173
|
type: "text",
|
1956
|
-
text:
|
2174
|
+
text: errorMessage,
|
1957
2175
|
},
|
1958
2176
|
],
|
1959
2177
|
isError: true,
|
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": {
|
@@ -10,6 +10,7 @@
|
|
10
10
|
"start": "node index.js",
|
11
11
|
"test": "jest --testPathIgnorePatterns=/tests/e2e/",
|
12
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)\"",
|
13
14
|
"test:all": "jest",
|
14
15
|
"test:watch": "jest --watch --testPathIgnorePatterns=/tests/e2e/",
|
15
16
|
"test:coverage": "jest --coverage --testPathIgnorePatterns=/tests/e2e/",
|