Plex Skill
Query Plex Media Server by writing UV inline Python scripts using plexapi.
Setup
Credentials
Requires PLEX_SERVER_URL in workspace/.env.
If not set, ask the user for their Plex server URL (e.g. http://192.168.1.100:32400), then append to .env:
echo 'PLEX_SERVER_URL=http://their-server:32400' >> .env
Authentication
Plex uses OAuth with a PIN-based flow. This MUST be done in two separate steps because you need to show the user the link and wait for them to click it before polling.
Step 1: Generate auth URL and save state
set -a; source .env 2>/dev/null; set +a; uv run - <<'EOF'
# /// script
# requires-python = ">=3.11"
# dependencies = ["plexapi>=4.18"]
# ///
import json
from pathlib import Path
from plexapi.myplex import MyPlexPinLogin
pinlogin = MyPlexPinLogin(oauth=True)
code = pinlogin._getCode()
pin_id = pinlogin._id
client_id = pinlogin._headers()['X-Plex-Client-Identifier']
# Save state for step 2
Path("plex-oauth-state.json").write_text(json.dumps({
"pin_id": pin_id,
"code": code,
"client_id": client_id,
}))
print(f"URL: {pinlogin.oauthUrl()}")
EOF
Send the URL to the user as a clickable markdown link. Tell them to sign in and let you know when done. The PIN is valid for ~15 minutes so there's no rush.
IMPORTANT: Do NOT call pinlogin.run() or pinlogin.waitForLogin() — these start a blocking polling thread that will timeout before the user can click the link. Instead, manually call _getCode() and save the state.
Step 2: Poll for token (run ONLY after user confirms they've signed in)
export PLEX_SERVER_URL=$(grep PLEX_SERVER_URL .env | cut -d= -f2); uv run - <<'EOF'
# /// script
# requires-python = ">=3.11"
# dependencies = ["plexapi>=4.18", "requests"]
# ///
import json, os, requests, time, xml.etree.ElementTree as ET
from pathlib import Path
from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount
state = json.loads(Path("plex-oauth-state.json").read_text())
headers = {
"Accept": "application/xml",
"X-Plex-Client-Identifier": state["client_id"],
"X-Plex-Product": "PlexAPI",
}
# Poll with backoff to avoid rate limiting (429)
for i in range(15):
resp = requests.get(
f"https://plex.tv/api/v2/pins/{state['pin_id']}",
headers=headers, timeout=10
)
if resp.status_code == 429:
time.sleep(5)
continue
if resp.status_code == 200:
root = ET.fromstring(resp.text)
token = root.attrib.get('authToken', '')
if token:
server_url = os.environ["PLEX_SERVER_URL"]
plex = PlexServer(server_url, token)
Path("plex-token.json").write_text(json.dumps({
"token": token,
"server_url": server_url
}, indent=2))
Path("plex-token.json").chmod(0o600)
account = MyPlexAccount(token=token)
print(f"SUCCESS: Connected as {account.username}")
print(f"Server: {plex.friendlyName}")
for section in plex.library.sections():
print(f" - {section.title} ({section.type})")
exit(0)
time.sleep(3)
print("ERROR: No token received. Ask user to try signing in again.")
exit(1)
EOF
Key details about the OAuth flow:
- •
MyPlexPinLogin.run()generates a NEW pin and starts a blocking thread — don't use it for two-step auth - •
MyPlexPinLogin.pinraisesBadRequestwhenoauth=True— don't access it - •Use
_getCode()to generate the PIN without starting the polling thread - •Save
_id(pin ID),_code, and the client identifier to a file between steps - •In step 2, poll the Plex API directly with
requestsusing the saved pin ID - •Plex rate limits aggressively — poll every 3s, back off on 429s
Alternative: direct token auth. If the user already has a Plex token, skip OAuth and store it directly:
export PLEX_SERVER_URL=$(grep PLEX_SERVER_URL .env | cut -d= -f2); uv run - <<'EOF'
# /// script
# requires-python = ">=3.11"
# dependencies = ["plexapi>=4.18"]
# ///
import json, os
from pathlib import Path
from plexapi.server import PlexServer
server_url = os.environ["PLEX_SERVER_URL"]
token = os.environ["PLEX_TOKEN"]
plex = PlexServer(server_url, token)
Path("plex-token.json").write_text(json.dumps({
"token": token,
"server_url": server_url
}, indent=2))
Path("plex-token.json").chmod(0o600)
print(f"SUCCESS: Connected to {plex.friendlyName}")
for section in plex.library.sections():
print(f" - {section.title} ({section.type})")
EOF
For the direct token approach, the user can find their token by opening any media item in the Plex web UI → Get Info → View XML → copy X-Plex-Token from the URL.
Query Template
export PLEX_SERVER_URL=$(grep PLEX_SERVER_URL .env | cut -d= -f2); uv run - <<'EOF'
# /// script
# requires-python = ">=3.11"
# dependencies = ["plexapi>=4.18"]
# ///
import json
from pathlib import Path
from plexapi.server import PlexServer
TOKEN_FILE = Path("plex-token.json")
if not TOKEN_FILE.exists():
print("ERROR: Not authenticated — run Plex setup first")
exit(1)
config = json.loads(TOKEN_FILE.read_text())
plex = PlexServer(config["server_url"], config["token"])
# === YOUR QUERY CODE HERE ===
for section in plex.library.sections():
print(f"{section.title} ({section.type}): {section.totalSize} items")
EOF
API Reference
Server Methods
| Method | Returns | Description |
|---|---|---|
plex.library.sections() | list[LibrarySection] | All library sections |
plex.library.section(name) | LibrarySection | Get section by title |
plex.library.sectionByID(id) | LibrarySection | Get section by ID |
plex.library.onDeck() | list[Media] | On deck items |
plex.library.recentlyAdded() | list[Media] | Recently added across all sections |
plex.continueWatching() | list[Media] | Continue watching hub |
plex.history(maxresults=N) | list[Media] | Watch history |
Section Methods
| Method | Returns | Description |
|---|---|---|
section.all() | list[Media] | All items in section |
section.search(title, **filters) | list[Media] | Search with filters |
section.recentlyAdded() | list[Media] | Recently added to section |
section.onDeck() | list[Media] | On deck for section |
Media Fields (Movie)
| Field | Type | Description |
|---|---|---|
title | str | Movie title |
year | int | Release year |
duration | int | Duration in milliseconds |
rating | float | Critic rating |
audienceRating | float | Audience rating (Rotten Tomatoes) |
contentRating | str | Content rating (PG-13, R, etc.) |
summary | str | Plot summary |
genres | list[Genre] | Genre tags (access .tag) |
directors | list[Director] | Directors (access .tag) |
roles | list[Role] | Cast members (access .tag) |
studio | str | Production studio |
addedAt | datetime | When added to library |
lastViewedAt | datetime | Last watched (None if unwatched) |
viewCount | int | Times watched |
Media Fields (Show)
| Field | Type | Description |
|---|---|---|
title | str | Show title |
year | int | First aired year |
childCount | int | Number of seasons |
leafCount | int | Total episodes |
viewedLeafCount | int | Watched episodes |
duration | int | Typical episode duration (ms) |
Media Fields (Episode)
| Field | Type | Description |
|---|---|---|
title | str | Episode title |
grandparentTitle | str | Show name |
parentTitle | str | Season name |
parentIndex | int | Season number |
index | int | Episode number |
duration | int | Duration in milliseconds |
Navigation (Shows)
| Method | Returns | Description |
|---|---|---|
show.seasons() | list[Season] | All seasons |
show.episodes() | list[Episode] | All episodes |
show.episode(season=N, episode=M) | Episode | Specific episode |
season.episodes() | list[Episode] | Episodes in season |
Examples
These show non-obvious patterns. For straightforward methods, use the API reference above.
TV Show Navigation (unintuitive field names)
shows = plex.library.section("TV Shows")
show = shows.get("Breaking Bad")
print(f"{show.title}: {show.leafCount} episodes")
for season in show.seasons():
print(f" {season.title}: {len(season.episodes())} episodes")
# Episode fields use grandparent/parent for show/season
ep = show.episode(season=1, episode=1)
print(f"{ep.grandparentTitle} S{ep.parentIndex}E{ep.index}: {ep.title}")
Search with Filters
movies = plex.library.section("Movies")
# Filter by genre + year
action = movies.search(genre="Action", year=2023)
for m in action:
# Tags are objects — access .tag for the string
genres = [g.tag for g in m.genres]
print(f"{m.title} ({m.year}) - {', '.join(genres)}")
Quick Reference
# Unit conversions duration_min = media.duration / 60000 duration_hrs = media.duration / 3600000 # Library section types # "movie", "show", "artist" (music), "photo" # Access tag names from lists genres = [g.tag for g in movie.genres] actors = [r.tag for r in movie.roles] directors = [d.tag for d in movie.directors] # Check if watched is_watched = media.viewCount > 0 is_unwatched = media.lastViewedAt is None
Re-authentication
Plex tokens are long-lived but can be revoked. If queries fail with "Unauthorized", re-run the OAuth flow from the Setup section.
Debugging
If a method call fails or you're unsure what's available, introspect:
uv run -c "from plexapi.server import PlexServer; print([m for m in dir(PlexServer) if not m.startswith('_')])"
For full library docs: webfetch https://github.com/pkkid/python-plexapi
Troubleshooting
- •"Not authenticated": Run the OAuth flow from the Setup section
- •"Connection refused": Check PLEX_SERVER_URL, ensure server is running
- •"Unauthorized": Token revoked, re-run OAuth
- •Section not found: Use exact section title (case-sensitive), check
plex.library.sections() - •Empty results: Some methods return generators; convert with
list()if needed - •Rate limited (429): Plex API rate limits aggressively. Back off and retry. Avoid rapid repeated OAuth attempts.