| name | plex-integration |
| description | Plex API endpoints, authentication, filtering, music radio, and video content. Use when working on Plex integration, library browsing, track queries, radio features, or video playback. |
Plex API for Music Radio & Playlists
This guide describes the Plex API endpoints used for querying tracks and building radio/playlist features in NullPlayer.
Authentication
All requests require authentication headers:
X-Plex-Token: {token}
X-Plex-Client-Identifier: {unique-client-id}
X-Plex-Product: NullPlayer
X-Plex-Version: 1.0
X-Plex-Platform: macOS
X-Plex-Device: Mac
Accept: application/json
Core Endpoints
Library Sections
GET /library/sections
Returns all libraries. Music libraries have type: "artist".
Music Library Rule For Radio
Plex radio must target a music library section. currentLibrary can point at movies or shows because Plex browsing is cross-media, so radio code must not blindly reuse it.
Actual app behavior:
- Use
currentLibrary only when currentLibrary.isMusicLibrary == true
- Otherwise fall back to the first available music library from
availableLibraries
- If no music library exists, fail early and log that no music library is available
If a track-style Plex radio query is sent to a non-music section, Plex can return 200 OK with an empty metadata list, which looks like a radio failure even though the query shape is valid.
Query Items by Type
GET /library/sections/{libraryID}/all?type={typeID}
Type values:
| Type ID | Content |
|---|
| 8 | Artists |
| 9 | Albums |
| 10 | Tracks |
| 1 | Movies |
| 2 | TV Shows |
Library Browser Artist Grouping
Plex can expose multiple artist records with the same display name but different ratingKey values. In the Library Browser, normal Plex artist browsing shows one visible row per normalized artist name while keeping every underlying Plex artist record reachable.
Current behavior in both classic PlexBrowserView and modern ModernLibraryBrowserView:
- Normalize artist names with trimmed whitespace, case-insensitive + diacritic-insensitive folding, and lowercasing (so e.g. "Motörhead" groups with "Motorhead"), then use
plex-artist-{normalizedName} as the grouped display key.
- Pick the visible representative from the same-name group by highest
albumCount.
- When expanding, playing, or queueing a grouped row, fan out across every artist record in the group and then deduplicate albums/tracks by stable metadata keys.
- Search mode intentionally treats the selected Plex artist as a single record instead of expanding all same-name records.
Performance requirement: build group indexes once when artist/album counts are rebuilt. buildArtistAlbumCounts() should populate plexArtistGroupsByName, plexAlbumsByArtistGroupKey, and plexAlbumCountsByArtistGroupKey from cachedArtists and cachedAlbums. Expanding a grouped artist row should first use plexAlbumsByArtistGroupKey[groupKey] and avoid rescanning the full album cache on the main actor, because large same-name groups can otherwise beachball the UI.
Popular Tracks (Last.fm Integration)
Plex identifies "hit" tracks using the ratingCount field, which contains global popularity data from Last.fm - the number of unique listeners who have scrobbled the track worldwide.
Example: "Purple Haze" by Jimi Hendrix would have a high ratingCount (millions of scrobbles), while a deep cut might have significantly lower count or none.
Radio API
Sonically Similar Tracks (Primary Radio API)
GET /library/sections/{libraryID}/all?type=10&track.sonicallySimilar={trackID}&sort=random&limit=100
Returns tracks that are sonically similar to the seed track. Use sort=random for diverse results.
Sonically Similar Artists
GET /library/sections/{libraryID}/all?type=8&artist.sonicallySimilar={artistID}&limit=15
Sonically Similar Albums
GET /library/sections/{libraryID}/all?type=9&album.sonicallySimilar={albumID}&limit=10
Only the Hits Radio
Plays only popular/hit tracks based on Last.fm scrobble data.
Threshold: 1,000,000+ scrobbles
Sonic Version:
GET /library/sections/{libID}/all?type=10&track.sonicallySimilar={trackID}&ratingCount>=1000000&sort=random&limit=100
Non-Sonic Version:
GET /library/sections/{libID}/all?type=10&ratingCount>=1000000&sort=random&limit=100
Deep Cuts Radio
Plays lesser-known tracks.
Threshold: Under 1,000 scrobbles
Sonic Version:
GET /library/sections/{libID}/all?type=10&track.sonicallySimilar={trackID}&ratingCount<=999&sort=random&limit=100
Non-Sonic Version:
GET /library/sections/{libID}/all?type=10&ratingCount<=999&sort=random&limit=100
Rating Radio (My Ratings)
Plays tracks based on user's personal star ratings:
| Station | Min Rating | Description |
|---|
| 5 Stars Radio | 10 (5★) | Only tracks rated exactly 5 stars |
| 4+ Stars Radio | 8 (4★+) | Highly rated tracks (4-5 stars) |
| 3+ Stars Radio | 6 (3★+) | Good tracks (3-5 stars) |
| 2+ Stars Radio | 4 (2★+) | Any track rated 2+ stars |
| All Rated Radio | 0.1 | Any track with a rating |
Note: Plex stores ratings on a 0-10 scale internally (10 = 5 stars).
Sonic Version:
GET /library/sections/{libID}/all?type=10&track.sonicallySimilar={trackID}&userRating>=8&sort=random&limit=100
Non-Sonic Version:
GET /library/sections/{libID}/all?type=10&userRating>=8&sort=random&limit=100
URL Encoding Warning
IMPORTANT: Plex filter operators (>=, <=, =, !=) must NOT be URL-encoded.
- WRONG:
userRating%3E%3D=8 (URLQueryItem encodes >= as %3E%3D)
- CORRECT:
userRating>=8 (literal >= in the URL)
Note: Plex only supports >=, <=, =, !=. The < and > operators (without equals) are NOT supported and return HTTP 400. Use <= with value-1 instead of < (e.g., ratingCount<=999 instead of ratingCount<1000).
When using Swift's URLQueryItem, build URLs manually for filter parameters:
URLQueryItem(name: "userRating>=", value: "8")
let urlString = "\(baseURL)/library/sections/\(id)/all?type=10&userRating>=8&..."
Filter Parameters
By Artist
?artist.id={artistID}
By Genre
?genre={genreID}
By Year/Decade
?year={year}
?year>=1980&year<=1989
Sorting Options
?sort=random # Random order (great for radio)
?sort=titleSort # Alphabetical
?sort=lastViewedAt:desc # Recently played
?sort=addedAt:desc # Recently added
?sort=year:desc # By year
Video Content (Movies & TV Shows)
Fetching Movies
GET /library/sections/{libraryID}/all?type=1
Fetching TV Shows
GET /library/sections/{libraryID}/all?type=2
Multi-Version Movies
Movies may contain multiple media versions:
Important: The top-level duration field may point to bonus content, not the main movie. NullPlayer uses the longest duration from the Media array to identify primary content.
External IDs (IMDB, TMDB, TVDB)
Movies, TV shows, and episodes include external service IDs in the Guid array:
{
"title": "The Matrix",
"Guid": [
{"id": "imdb://tt0133093"},
{"id": "tmdb://603"},
{"id": "tvdb://12345"}
]
}
| Service | URL Pattern |
|---|
| IMDB | https://www.imdb.com/title/{id}/ |
| TMDB | https://www.themoviedb.org/movie/{id} (movies) |
| TMDB | https://www.themoviedb.org/tv/{id} (TV shows) |
| TVDB | https://www.thetvdb.com/series/{id} |
NullPlayer uses these IDs to provide "View Online" context menu links.
Setting User Ratings
PUT /:/rate?key={ratingKey}&identifier=com.plexapp.plugins.library&rating={rating}
| Parameter | Description |
|---|
| key | The item's ratingKey |
| identifier | Always com.plexapp.plugins.library |
| rating | 0-10 scale (2 per star), or -1 to clear |
Rating Scale:
- 2 = 1 star, 4 = 2 stars, 6 = 3 stars, 8 = 4 stars, 10 = 5 stars, -1 = Clear
Building Streaming URLs
{baseURL}{Media.Part.key}?X-Plex-Token={token}
Example:
http://192.168.0.102:32400/library/parts/653835/1723508508/file.flac?X-Plex-Token=TOKEN
Sonos Casting Metadata
Plex stream URLs can be extensionless or otherwise not reliable for format detection. When converting PlexTrack to Track, preserve Plex audio format metadata in Track.contentType using codec/container fields (audioCodec, Media.container, then Part.container). This keeps the existing Sonos compatibility path working for extensionless streams.
Important cases:
flac -> audio/flac; extensionless 24/96 or 24/192 FLAC must be identified as FLAC so CastManager.isSonosCompatible applies the 48 kHz Sonos limit.
alac -> audio/mp4, matching the shared cast content-type mapping used for Apple Lossless in MP4 containers.
- Restored Plex tracks from
StreamingTrackResolver must populate contentType the same way as PlexManager.convertToTrack.
For lossless Plex tracks with nil sampleRate, castCurrentTrack and castNewTrack fetch the sample rate from Plex by rating key before making the final strict Sonos compatibility decision. That fetch must be triggered from URL extension or Track.contentType, because Plex stream URLs may not have .flac or .wav extensions.
Requirements
- Plex Pass subscription (for sonic analysis)
- Plex Media Server v1.24.0+ (64-bit)
- Sonic analysis enabled on the music library
- Tracks must be analyzed (check for
musicAnalysisVersion attribute)
Plex Radio History
Tracks played during Plex Radio sessions are recorded in a local SQLite database so they can be filtered out of future radio queues (preventing repeats) and viewed by the user.
Storage
- Database:
~/Library/Application Support/NullPlayer/plex_radio_history.db
- Table:
plex_radio_history
- Unique constraint:
(plex_rating_key, plex_server_id) — one record per track per server, upserted on each play (updates played_at)
- Indexes:
played_at (for retention cutoff queries), normalized_key (for fallback matching)
Deduplication / Normalized Key
Radio library rescans can change a track's ratingKey. To still suppress known tracks, a normalized_key ("artist|title" lowercased, trimmed) is stored alongside the rating key. filterOutHistoryTracks does a two-pass filter:
- Match by
plexRatingKey (exact)
- Match by
normalized_key (fuzzy fallback)
Retention
Configurable via PlexRadioHistoryInterval enum:
| Value | Duration |
|---|
off | Disabled (no filtering) |
twoWeeks | 2 weeks |
oneMonth | 1 month (default) |
threeMonths | 3 months |
sixMonths | 6 months |
Stored in UserDefaults key plexRadioHistoryInterval. When off, filterOutHistoryTracks returns the input unchanged.
Recording
PlexRadioHistory.shared.recordTrackPlayed(_:) is called from AudioEngine when a Plex track finishes — at natural track completion and during gapless crossfade. Uses track.plexServerId (set by PlexManager.convertToTrack), falling back to PlexManager.shared.currentServer?.id.
Filtering
PlexRadioHistory.shared.filterOutHistoryTracks(_:) is called inside PlexManager when building radio queues — after fetching tracks from the Plex API, before returning them to the engine.
Context Menu (User UI)
Under Playback Options → Radio History:
- Plex History Retention submenu — sets
retentionInterval (Off / 2 Weeks / 1 Month / 3 Months / 6 Months)
- View Plex Radio History — opens
http://127.0.0.1:8765/radio-history in the default browser
- Clear Plex Radio History — confirmation dialog, then
clearHistory()
Web History Page
Served by LocalMediaServer on port 8765. Routes:
GET /radio-history — returns HTML from generateHistoryHTML()
POST /radio-history/delete/:id — removes a single entry by rowid
The page is a dark-themed sortable table (Track / Artist / Album / Track ID / Played). Each row has a Remove button that calls the delete endpoint via fetch(). No page reload needed.
Key Source Files
| File | Purpose |
|---|
Plex/PlexManager.swift | Singleton managing connections, caching, track conversion |
Plex/PlexServerClient.swift | HTTP client for Plex REST API |
Plex/PlexModels.swift | Domain models (Server, Artist, Album, Track, etc.) |
Plex/PlexPlaybackReporter.swift | Audio scrobbling and "now playing" reporting |
Plex/PlexVideoPlaybackReporter.swift | Video scrobbling with periodic timeline updates |
Plex/PlexRadioHistory.swift | SQLite-backed play history for Plex Radio — recording, filtering, web UI |
Implementation Gotchas
API Filter Operators — Build URLs Manually
URLQueryItem will URL-encode operators like >=, <= which breaks Plex filtering. Build URLs manually for filter params. Note: Plex only supports >=, <=, =, != operators — NOT < or > (use <= with value-1 instead):
URLQueryItem(name: "userRating>=", value: "8")
let url = "...&ratingCount<1000&..."
let url = "\(baseURL)/library/sections/\(id)/all?type=10&userRating>=8&..."
let url = "...&ratingCount<=999&..."
References