299 lines
9.1 KiB
Go
299 lines
9.1 KiB
Go
// Package plex implements a playlist.Provider for Plex Media Server.
|
|
package plex
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// maxResponseBody limits API responses to 10 MB to prevent unbounded memory growth.
|
|
const maxResponseBody = 10 << 20
|
|
|
|
// apiClient is used for all Plex API calls with a finite timeout.
|
|
// It is distinct from httpclient.Streaming (which has no timeout) used for audio streams.
|
|
var apiClient = &http.Client{Timeout: 30 * time.Second}
|
|
|
|
// Client speaks to a Plex Media Server over its HTTP API.
|
|
type Client struct {
|
|
baseURL string // e.g. "http://192.168.1.10:32400"
|
|
token string // X-Plex-Token
|
|
libraries []string // if non-empty, MusicSections filters to these titles (case-insensitive)
|
|
}
|
|
|
|
// NewClient returns a Client for the given server URL and authentication token.
|
|
// libraries is an optional list of music library names to restrict MusicSections to.
|
|
// When not provided, all music libraries will be loaded
|
|
func NewClient(baseURL, token string, libraries ...string) *Client {
|
|
return &Client{baseURL: baseURL, token: token, libraries: libraries}
|
|
}
|
|
|
|
// Section represents a Plex library section (e.g. a music library).
|
|
type Section struct {
|
|
Key string // numeric section ID, e.g. "3"
|
|
Title string // display name
|
|
Type string // "artist" for music libraries
|
|
}
|
|
|
|
// Album represents a Plex album with its artist and track count.
|
|
type Album struct {
|
|
RatingKey string // unique album ID (Plex ratingKey)
|
|
Title string
|
|
ArtistName string // parentTitle in the Plex API
|
|
Year int
|
|
TrackCount int // leafCount
|
|
}
|
|
|
|
// Track represents a Plex track with metadata and its first streamable Part.
|
|
type Track struct {
|
|
RatingKey string
|
|
Title string
|
|
ArtistName string // grandparentTitle
|
|
AlbumName string // parentTitle
|
|
Year int
|
|
TrackNumber int // index field in Plex API
|
|
Duration int // milliseconds
|
|
PartKey string // relative path, e.g. "/library/parts/67890/1234567890/file.flac"
|
|
}
|
|
|
|
// get issues an authenticated GET request and decodes the JSON response into result.
|
|
func (c *Client) get(path string, params url.Values, result any) error {
|
|
if params == nil {
|
|
params = url.Values{}
|
|
}
|
|
params.Set("X-Plex-Token", c.token)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+path+"?"+params.Encode(), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("plex: %s: %w", path, err)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("X-Plex-Product", "cliamp")
|
|
req.Header.Set("X-Plex-Client-Identifier", "cliamp")
|
|
|
|
resp, err := apiClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("plex: %s: server unreachable: %w", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
// ok
|
|
case http.StatusUnauthorized:
|
|
return fmt.Errorf("plex: token invalid or expired")
|
|
default:
|
|
return fmt.Errorf("plex: %s: HTTP %s", path, resp.Status)
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
|
if err != nil {
|
|
return fmt.Errorf("plex: %s: %w", path, err)
|
|
}
|
|
return json.Unmarshal(body, result)
|
|
}
|
|
|
|
// Ping checks that the server is reachable and the token is valid.
|
|
// Returns a descriptive error on failure.
|
|
func (c *Client) Ping() error {
|
|
var result struct {
|
|
MediaContainer struct {
|
|
FriendlyName string `json:"friendlyName"`
|
|
} `json:"MediaContainer"`
|
|
}
|
|
return c.get("/", nil, &result)
|
|
}
|
|
|
|
// MusicSections returns library sections of type "artist" (music libraries).
|
|
// When the client was constructed with a library filter, only sections whose
|
|
// title matches one of the allowed names (case-insensitive) are returned.
|
|
func (c *Client) MusicSections() ([]Section, error) {
|
|
var result struct {
|
|
MediaContainer struct {
|
|
Directory []struct {
|
|
Key string `json:"key"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
} `json:"Directory"`
|
|
} `json:"MediaContainer"`
|
|
}
|
|
if err := c.get("/library/sections", nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var sections []Section
|
|
for _, d := range result.MediaContainer.Directory {
|
|
if d.Type == "artist" && c.includeLibrary(d.Title) {
|
|
sections = append(sections, Section{
|
|
Key: d.Key,
|
|
Title: d.Title,
|
|
Type: d.Type,
|
|
})
|
|
}
|
|
}
|
|
return sections, nil
|
|
}
|
|
|
|
// includeLibrary reports whether the library with the given title should be
|
|
// included. When no filter is configured (libraries is empty) all libraries pass.
|
|
func (c *Client) includeLibrary(title string) bool {
|
|
if len(c.libraries) == 0 {
|
|
return true
|
|
}
|
|
for _, lib := range c.libraries {
|
|
if strings.EqualFold(lib, title) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// pageSize is the number of albums requested per API call.
|
|
// Plex paginates /library/sections/{id}/all; without an explicit size the
|
|
// server may return as few as one item.
|
|
const pageSize = 300
|
|
|
|
// Albums returns all albums in the given music section (identified by its key).
|
|
// It requests type=9 (album) directly rather than walking artists, and paginates
|
|
// through the full result set using X-Plex-Container-Start / Size.
|
|
func (c *Client) Albums(sectionKey string) ([]Album, error) {
|
|
type albumPage struct {
|
|
MediaContainer struct {
|
|
TotalSize int `json:"totalSize"`
|
|
Metadata []struct {
|
|
RatingKey string `json:"ratingKey"`
|
|
Title string `json:"title"`
|
|
ParentTitle string `json:"parentTitle"` // artist name
|
|
Year int `json:"year"`
|
|
LeafCount int `json:"leafCount"` // track count
|
|
} `json:"Metadata"`
|
|
} `json:"MediaContainer"`
|
|
}
|
|
|
|
var albums []Album
|
|
for offset := 0; ; offset += pageSize {
|
|
params := url.Values{
|
|
"type": {"9"}, // 9 = album
|
|
"X-Plex-Container-Start": {fmt.Sprintf("%d", offset)},
|
|
"X-Plex-Container-Size": {fmt.Sprintf("%d", pageSize)},
|
|
}
|
|
var page albumPage
|
|
if err := c.get("/library/sections/"+sectionKey+"/all", params, &page); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, m := range page.MediaContainer.Metadata {
|
|
albums = append(albums, Album{
|
|
RatingKey: m.RatingKey,
|
|
Title: m.Title,
|
|
ArtistName: m.ParentTitle,
|
|
Year: m.Year,
|
|
TrackCount: m.LeafCount,
|
|
})
|
|
}
|
|
// Stop when we've fetched everything.
|
|
if offset+pageSize >= page.MediaContainer.TotalSize {
|
|
break
|
|
}
|
|
}
|
|
return albums, nil
|
|
}
|
|
|
|
// Tracks returns all tracks in the given album (identified by its ratingKey).
|
|
func (c *Client) Tracks(albumRatingKey string) ([]Track, error) {
|
|
var result struct {
|
|
MediaContainer struct {
|
|
Metadata []trackJSON `json:"Metadata"`
|
|
} `json:"MediaContainer"`
|
|
}
|
|
if err := c.get("/library/metadata/"+albumRatingKey+"/children", nil, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tracks := make([]Track, 0, len(result.MediaContainer.Metadata))
|
|
for _, m := range result.MediaContainer.Metadata {
|
|
tracks = append(tracks, trackFromJSON(m))
|
|
}
|
|
return tracks, nil
|
|
}
|
|
|
|
// Search searches the music library for tracks matching query.
|
|
// Returns nil without making an HTTP call when query is empty.
|
|
func (c *Client) Search(query string) ([]Track, error) {
|
|
if query == "" {
|
|
return nil, nil
|
|
}
|
|
var result struct {
|
|
MediaContainer struct {
|
|
Metadata []trackJSON `json:"Metadata"`
|
|
} `json:"MediaContainer"`
|
|
}
|
|
params := url.Values{
|
|
"query": {query},
|
|
"type": {"10"}, // 10 = track
|
|
}
|
|
if err := c.get("/library/search", params, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tracks := make([]Track, 0, len(result.MediaContainer.Metadata))
|
|
for _, m := range result.MediaContainer.Metadata {
|
|
tracks = append(tracks, trackFromJSON(m))
|
|
}
|
|
return tracks, nil
|
|
}
|
|
|
|
// StreamURL returns the authenticated HTTP URL for streaming a track part.
|
|
// partKey is the relative path from the Part element, e.g. "/library/parts/…/file.flac".
|
|
// The token is appended as a query parameter; Plex accepts it in either header or query form.
|
|
func (c *Client) StreamURL(partKey string) string {
|
|
return c.baseURL + partKey + "?X-Plex-Token=" + url.QueryEscape(c.token)
|
|
}
|
|
|
|
// IsStreamURL reports whether the given URL looks like a Plex library part
|
|
// endpoint. Used by the player to route these URLs through the buffered
|
|
// navBuffer + ffmpeg pipeline instead of native HTTP streaming.
|
|
func IsStreamURL(urlStr string) bool {
|
|
u, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return strings.Contains(strings.ToLower(u.Path), "/library/parts/")
|
|
}
|
|
|
|
// trackJSON is the shared JSON structure for track responses (children and search).
|
|
type trackJSON struct {
|
|
RatingKey string `json:"ratingKey"`
|
|
Title string `json:"title"`
|
|
GrandparentTitle string `json:"grandparentTitle"` // artist
|
|
ParentTitle string `json:"parentTitle"` // album
|
|
Year int `json:"year"`
|
|
Index int `json:"index"` // track number within album
|
|
Duration int `json:"duration"` // milliseconds
|
|
Media []struct {
|
|
Part []struct {
|
|
Key string `json:"key"`
|
|
} `json:"Part"`
|
|
} `json:"Media"`
|
|
}
|
|
|
|
func trackFromJSON(m trackJSON) Track {
|
|
var partKey string
|
|
if len(m.Media) > 0 && len(m.Media[0].Part) > 0 {
|
|
partKey = m.Media[0].Part[0].Key
|
|
}
|
|
return Track{
|
|
RatingKey: m.RatingKey,
|
|
Title: m.Title,
|
|
ArtistName: m.GrandparentTitle,
|
|
AlbumName: m.ParentTitle,
|
|
Year: m.Year,
|
|
TrackNumber: m.Index,
|
|
Duration: m.Duration,
|
|
PartKey: partKey,
|
|
}
|
|
}
|