chore: import upstream snapshot with attribution
This commit is contained in:
Vendored
+298
@@ -0,0 +1,298 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
Vendored
+409
@@ -0,0 +1,409 @@
|
||||
package plex
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestClient returns a Client pointed at the given test server.
|
||||
func newTestClient(srv *httptest.Server) *Client {
|
||||
return NewClient(srv.URL, "test-token")
|
||||
}
|
||||
|
||||
func TestPing_OK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("X-Plex-Token") != "test-token" {
|
||||
t.Errorf("expected token in query, got %q", r.URL.Query().Get("X-Plex-Token"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"MediaContainer":{"friendlyName":"My Plex"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if err := newTestClient(srv).Ping(); err != nil {
|
||||
t.Fatalf("Ping() unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_Unauthorized(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).Ping()
|
||||
if err == nil {
|
||||
t.Fatal("Ping() expected error on 401, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token invalid") {
|
||||
t.Errorf("expected 'token invalid' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_ServerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := newTestClient(srv).Ping()
|
||||
if err == nil {
|
||||
t.Fatal("Ping() expected error on 500, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMusicSections_FiltersByType(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"MediaContainer": {
|
||||
"Directory": [
|
||||
{"key": "1", "type": "artist", "title": "Music"},
|
||||
{"key": "2", "type": "movie", "title": "Movies"},
|
||||
{"key": "3", "type": "artist", "title": "Jazz"}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sections, err := newTestClient(srv).MusicSections()
|
||||
if err != nil {
|
||||
t.Fatalf("MusicSections() error: %v", err)
|
||||
}
|
||||
if len(sections) != 2 {
|
||||
t.Fatalf("expected 2 music sections, got %d", len(sections))
|
||||
}
|
||||
if sections[0].Key != "1" || sections[0].Title != "Music" {
|
||||
t.Errorf("unexpected section[0]: %+v", sections[0])
|
||||
}
|
||||
if sections[1].Key != "3" || sections[1].Title != "Jazz" {
|
||||
t.Errorf("unexpected section[1]: %+v", sections[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMusicSections_Empty(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"MediaContainer":{"Directory":[{"key":"1","type":"movie","title":"Movies"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sections, err := newTestClient(srv).MusicSections()
|
||||
if err != nil {
|
||||
t.Fatalf("MusicSections() unexpected error: %v", err)
|
||||
}
|
||||
if len(sections) != 0 {
|
||||
t.Errorf("expected 0 music sections, got %d", len(sections))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMusicSections_LibraryFilter(t *testing.T) {
|
||||
serverJSON := `{"MediaContainer":{"Directory":[
|
||||
{"key":"1","type":"artist","title":"Music"},
|
||||
{"key":"2","type":"artist","title":"Jazz"},
|
||||
{"key":"3","type":"artist","title":"Classical"}
|
||||
]}}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
libraries []string
|
||||
wantLen int
|
||||
wantTitles []string
|
||||
wantKeys []string
|
||||
}{
|
||||
{
|
||||
name: "filter to subset",
|
||||
libraries: []string{"Jazz", "Classical"},
|
||||
wantLen: 2,
|
||||
wantTitles: []string{"Jazz", "Classical"},
|
||||
},
|
||||
{
|
||||
name: "case-insensitive match",
|
||||
libraries: []string{"MUSIC"},
|
||||
wantLen: 1,
|
||||
wantKeys: []string{"1"},
|
||||
},
|
||||
{
|
||||
name: "no match returns empty",
|
||||
libraries: []string{"DoesNotExist"},
|
||||
wantLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(serverJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
sections, err := NewClient(srv.URL, "test-token", tt.libraries...).MusicSections()
|
||||
if err != nil {
|
||||
t.Fatalf("MusicSections() error: %v", err)
|
||||
}
|
||||
if len(sections) != tt.wantLen {
|
||||
t.Fatalf("got %d sections, want %d: %v", len(sections), tt.wantLen, sections)
|
||||
}
|
||||
for i, title := range tt.wantTitles {
|
||||
if sections[i].Title != title {
|
||||
t.Errorf("sections[%d].Title = %q, want %q", i, sections[i].Title, title)
|
||||
}
|
||||
}
|
||||
for i, key := range tt.wantKeys {
|
||||
if sections[i].Key != key {
|
||||
t.Errorf("sections[%d].Key = %q, want %q", i, sections[i].Key, key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbums_RequestsType9(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("type") != "9" {
|
||||
t.Errorf("expected type=9 in request, got %q", r.URL.Query().Get("type"))
|
||||
}
|
||||
if !strings.HasSuffix(r.URL.Path, "/library/sections/3/all") {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"MediaContainer": {
|
||||
"totalSize": 2,
|
||||
"Metadata": [
|
||||
{
|
||||
"ratingKey": "456",
|
||||
"title": "Kind of Blue",
|
||||
"parentTitle": "Miles Davis",
|
||||
"year": 1959,
|
||||
"leafCount": 5
|
||||
},
|
||||
{
|
||||
"ratingKey": "457",
|
||||
"title": "Bitches Brew",
|
||||
"parentTitle": "Miles Davis",
|
||||
"year": 1970,
|
||||
"leafCount": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
albums, err := newTestClient(srv).Albums("3")
|
||||
if err != nil {
|
||||
t.Fatalf("Albums() error: %v", err)
|
||||
}
|
||||
if len(albums) != 2 {
|
||||
t.Fatalf("expected 2 albums, got %d", len(albums))
|
||||
}
|
||||
a := albums[0]
|
||||
if a.RatingKey != "456" || a.Title != "Kind of Blue" || a.ArtistName != "Miles Davis" || a.Year != 1959 || a.TrackCount != 5 {
|
||||
t.Errorf("unexpected album[0]: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbums_Paginates(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
start := r.URL.Query().Get("X-Plex-Container-Start")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch start {
|
||||
case "0", "":
|
||||
w.Write([]byte(`{"MediaContainer":{"totalSize":2,"Metadata":[{"ratingKey":"1","title":"A","parentTitle":"Art","year":2000,"leafCount":1}]}}`))
|
||||
case "300":
|
||||
// Second page — but totalSize=2 means we should never reach here with pageSize=300.
|
||||
// This tests that we stop after the first page when totalSize <= pageSize.
|
||||
t.Errorf("unexpected second page request (offset=%s)", start)
|
||||
w.Write([]byte(`{"MediaContainer":{"totalSize":2,"Metadata":[]}}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
albums, err := newTestClient(srv).Albums("1")
|
||||
if err != nil {
|
||||
t.Fatalf("Albums() error: %v", err)
|
||||
}
|
||||
if len(albums) != 1 {
|
||||
t.Errorf("expected 1 album, got %d", len(albums))
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("expected 1 API call, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracks_MapsAllFields(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/library/metadata/456/children") {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"MediaContainer": {
|
||||
"Metadata": [
|
||||
{
|
||||
"ratingKey": "789",
|
||||
"title": "So What",
|
||||
"grandparentTitle": "Miles Davis",
|
||||
"parentTitle": "Kind of Blue",
|
||||
"year": 1959,
|
||||
"index": 1,
|
||||
"duration": 565000,
|
||||
"Media": [{"Part": [{"key": "/library/parts/100/111/So_What.flac"}]}]
|
||||
},
|
||||
{
|
||||
"ratingKey": "790",
|
||||
"title": "Freddie Freeloader",
|
||||
"grandparentTitle": "Miles Davis",
|
||||
"parentTitle": "Kind of Blue",
|
||||
"year": 1959,
|
||||
"index": 2,
|
||||
"duration": 586000,
|
||||
"Media": [{"Part": [{"key": "/library/parts/101/222/Freddie.flac"}]}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tracks, err := newTestClient(srv).Tracks("456")
|
||||
if err != nil {
|
||||
t.Fatalf("Tracks() error: %v", err)
|
||||
}
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("expected 2 tracks, got %d", len(tracks))
|
||||
}
|
||||
tr := tracks[0]
|
||||
if tr.RatingKey != "789" {
|
||||
t.Errorf("RatingKey: got %q, want %q", tr.RatingKey, "789")
|
||||
}
|
||||
if tr.Title != "So What" {
|
||||
t.Errorf("Title: got %q, want %q", tr.Title, "So What")
|
||||
}
|
||||
if tr.ArtistName != "Miles Davis" {
|
||||
t.Errorf("ArtistName: got %q, want %q", tr.ArtistName, "Miles Davis")
|
||||
}
|
||||
if tr.AlbumName != "Kind of Blue" {
|
||||
t.Errorf("AlbumName: got %q, want %q", tr.AlbumName, "Kind of Blue")
|
||||
}
|
||||
if tr.Year != 1959 {
|
||||
t.Errorf("Year: got %d, want 1959", tr.Year)
|
||||
}
|
||||
if tr.TrackNumber != 1 {
|
||||
t.Errorf("TrackNumber: got %d, want 1", tr.TrackNumber)
|
||||
}
|
||||
if tr.Duration != 565000 {
|
||||
t.Errorf("Duration: got %d, want 565000", tr.Duration)
|
||||
}
|
||||
if tr.PartKey != "/library/parts/100/111/So_What.flac" {
|
||||
t.Errorf("PartKey: got %q, want /library/parts/100/111/So_What.flac", tr.PartKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracks_MissingMedia(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"MediaContainer": {
|
||||
"Metadata": [
|
||||
{"ratingKey": "1", "title": "Track Without Media", "Media": []}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tracks, err := newTestClient(srv).Tracks("42")
|
||||
if err != nil {
|
||||
t.Fatalf("Tracks() error: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("expected 1 track, got %d", len(tracks))
|
||||
}
|
||||
if tracks[0].PartKey != "" {
|
||||
t.Errorf("expected empty PartKey for track with no media, got %q", tracks[0].PartKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamURL_Format(t *testing.T) {
|
||||
c := NewClient("http://192.168.1.10:32400", "mytoken")
|
||||
got := c.StreamURL("/library/parts/100/111/file.flac")
|
||||
want := "http://192.168.1.10:32400/library/parts/100/111/file.flac?X-Plex-Token=mytoken"
|
||||
if got != want {
|
||||
t.Errorf("StreamURL:\n got %q\n want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamURL_TokenEncoded(t *testing.T) {
|
||||
c := NewClient("http://192.168.1.10:32400", "tok en+special")
|
||||
got := c.StreamURL("/library/parts/1/2/file.mp3")
|
||||
if !strings.Contains(got, "X-Plex-Token=tok+en%2Bspecial") {
|
||||
t.Errorf("StreamURL token not URL-encoded: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_SendsCorrectParams(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("query") != "Miles Davis" {
|
||||
t.Errorf("expected query=Miles Davis, got %q", r.URL.Query().Get("query"))
|
||||
}
|
||||
if r.URL.Query().Get("type") != "10" {
|
||||
t.Errorf("expected type=10, got %q", r.URL.Query().Get("type"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"MediaContainer":{"Metadata":[]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tracks, err := newTestClient(srv).Search("Miles Davis")
|
||||
if err != nil {
|
||||
t.Fatalf("Search() error: %v", err)
|
||||
}
|
||||
if len(tracks) != 0 {
|
||||
t.Errorf("expected 0 tracks, got %d", len(tracks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_EmptyQueryNoRequest(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tracks, err := newTestClient(srv).Search("")
|
||||
if err != nil {
|
||||
t.Fatalf("Search(\"\") unexpected error: %v", err)
|
||||
}
|
||||
if tracks != nil {
|
||||
t.Errorf("expected nil tracks for empty query, got %v", tracks)
|
||||
}
|
||||
if called {
|
||||
t.Error("Search(\"\") should not make an HTTP request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestHeaders(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Accept") != "application/json" {
|
||||
t.Errorf("expected Accept: application/json, got %q", r.Header.Get("Accept"))
|
||||
}
|
||||
if r.Header.Get("X-Plex-Product") != "cliamp" {
|
||||
t.Errorf("expected X-Plex-Product: cliamp, got %q", r.Header.Get("X-Plex-Product"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"MediaContainer":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_ = newTestClient(srv).Ping()
|
||||
}
|
||||
Vendored
+171
@@ -0,0 +1,171 @@
|
||||
package plex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/bjarneo/cliamp/config"
|
||||
"github.com/bjarneo/cliamp/playlist"
|
||||
"github.com/bjarneo/cliamp/provider"
|
||||
)
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ provider.Searcher = (*Provider)(nil)
|
||||
_ provider.AlbumTrackLoader = (*Provider)(nil)
|
||||
)
|
||||
|
||||
// Provider implements playlist.Provider for a Plex Media Server.
|
||||
// Playlists() returns all albums across all music library sections.
|
||||
// Tracks() returns the tracks for a given album ratingKey.
|
||||
type Provider struct {
|
||||
client *Client
|
||||
mu sync.Mutex
|
||||
playlistCache []playlist.PlaylistInfo
|
||||
trackCache map[string][]playlist.Track
|
||||
}
|
||||
|
||||
// newProvider returns a Provider backed by the given Client.
|
||||
func newProvider(client *Client) *Provider {
|
||||
return &Provider{client: client}
|
||||
}
|
||||
|
||||
// NewFromConfig returns a Provider from a PlexConfig, or nil if URL or Token is missing.
|
||||
func NewFromConfig(cfg config.PlexConfig) *Provider {
|
||||
if !cfg.IsSet() {
|
||||
return nil
|
||||
}
|
||||
return newProvider(NewClient(cfg.URL, cfg.Token, cfg.Libraries...))
|
||||
}
|
||||
|
||||
// Name returns the display name used in the provider selector.
|
||||
func (p *Provider) Name() string { return "Plex" }
|
||||
|
||||
// Playlists returns all albums across all Plex music library sections.
|
||||
// Each album is a PlaylistInfo whose ID is the album's Plex ratingKey.
|
||||
// Results are cached after the first successful call.
|
||||
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
|
||||
p.mu.Lock()
|
||||
if p.playlistCache != nil {
|
||||
cached := slices.Clone(p.playlistCache)
|
||||
p.mu.Unlock()
|
||||
return cached, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
sections, err := p.client.MusicSections()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sections) == 0 {
|
||||
return nil, fmt.Errorf("plex: no matching music libraries found on this server")
|
||||
}
|
||||
|
||||
var lists []playlist.PlaylistInfo
|
||||
for _, sec := range sections {
|
||||
albums, err := p.client.Albums(sec.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range albums {
|
||||
name := a.ArtistName + " — " + a.Title
|
||||
if a.Year > 0 {
|
||||
name = fmt.Sprintf("%s — %s (%d)", a.ArtistName, a.Title, a.Year)
|
||||
}
|
||||
lists = append(lists, playlist.PlaylistInfo{
|
||||
ID: a.RatingKey,
|
||||
Name: name,
|
||||
TrackCount: a.TrackCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.playlistCache = lists
|
||||
p.mu.Unlock()
|
||||
|
||||
return slices.Clone(lists), nil
|
||||
}
|
||||
|
||||
// Refresh clears cached playlist and track data so the next call re-fetches
|
||||
// from the server. Implements playlist.Refresher.
|
||||
func (p *Provider) Refresh() {
|
||||
p.mu.Lock()
|
||||
p.playlistCache = nil
|
||||
p.trackCache = nil
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Tracks returns the tracks for the album identified by albumRatingKey.
|
||||
// Each track's Path is a complete authenticated HTTP URL ready for the player.
|
||||
// Tracks with no streamable part (missing Media/Part data) are silently skipped.
|
||||
// Results are cached per albumRatingKey.
|
||||
func (p *Provider) Tracks(albumRatingKey string) ([]playlist.Track, error) {
|
||||
p.mu.Lock()
|
||||
if p.trackCache != nil {
|
||||
if cached, ok := p.trackCache[albumRatingKey]; ok {
|
||||
p.mu.Unlock()
|
||||
return slices.Clone(cached), nil
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
plexTracks, err := p.client.Tracks(albumRatingKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracks := p.convertTracks(plexTracks, 0)
|
||||
|
||||
p.mu.Lock()
|
||||
if p.trackCache == nil {
|
||||
p.trackCache = make(map[string][]playlist.Track)
|
||||
}
|
||||
p.trackCache[albumRatingKey] = tracks
|
||||
p.mu.Unlock()
|
||||
|
||||
return slices.Clone(tracks), nil
|
||||
}
|
||||
|
||||
// SearchTracks searches the Plex music library for tracks matching query.
|
||||
// Implements provider.Searcher.
|
||||
func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
|
||||
plexTracks, err := p.client.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.convertTracks(plexTracks, limit), nil
|
||||
}
|
||||
|
||||
// convertTracks converts Plex tracks to playlist tracks, skipping entries
|
||||
// without a streamable part. If limit > 0, at most limit tracks are returned.
|
||||
func (p *Provider) convertTracks(plexTracks []Track, limit int) []playlist.Track {
|
||||
tracks := make([]playlist.Track, 0, len(plexTracks))
|
||||
for _, t := range plexTracks {
|
||||
if t.PartKey == "" {
|
||||
continue
|
||||
}
|
||||
tracks = append(tracks, playlist.Track{
|
||||
Path: p.client.StreamURL(t.PartKey),
|
||||
Title: t.Title,
|
||||
Artist: t.ArtistName,
|
||||
Album: t.AlbumName,
|
||||
Year: t.Year,
|
||||
TrackNumber: t.TrackNumber,
|
||||
DurationSecs: t.Duration / 1000,
|
||||
Stream: true,
|
||||
})
|
||||
if limit > 0 && len(tracks) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
// AlbumTracks returns the tracks for the given album (ratingKey).
|
||||
// Implements provider.AlbumTrackLoader.
|
||||
func (p *Provider) AlbumTracks(albumID string) ([]playlist.Track, error) {
|
||||
return p.Tracks(albumID)
|
||||
}
|
||||
Vendored
+337
@@ -0,0 +1,337 @@
|
||||
package plex
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bjarneo/cliamp/config"
|
||||
)
|
||||
|
||||
// sectionsHandler returns a handler that serves a single music section.
|
||||
func sectionsHandler(t *testing.T) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/library/sections"):
|
||||
w.Write([]byte(`{"MediaContainer":{"Directory":[{"key":"3","type":"artist","title":"Music"}]}}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/library/sections/3/all"):
|
||||
w.Write([]byte(`{
|
||||
"MediaContainer": {
|
||||
"totalSize": 2,
|
||||
"Metadata": [
|
||||
{"ratingKey":"100","title":"Kind of Blue","parentTitle":"Miles Davis","year":1959,"leafCount":5},
|
||||
{"ratingKey":"101","title":"Bitches Brew","parentTitle":"Miles Davis","year":1970,"leafCount":4}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
case strings.Contains(r.URL.Path, "/library/metadata/100/children"):
|
||||
w.Write([]byte(`{
|
||||
"MediaContainer": {
|
||||
"Metadata": [
|
||||
{
|
||||
"ratingKey":"200","title":"So What","grandparentTitle":"Miles Davis",
|
||||
"parentTitle":"Kind of Blue","year":1959,"index":1,"duration":565000,
|
||||
"Media":[{"Part":[{"key":"/library/parts/1/111/SoWhat.flac"}]}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
case strings.Contains(r.URL.Path, "/library/metadata/101/children"):
|
||||
w.Write([]byte(`{
|
||||
"MediaContainer": {
|
||||
"Metadata": [
|
||||
{
|
||||
"ratingKey":"201","title":"Pharaoh's Dance","grandparentTitle":"Miles Davis",
|
||||
"parentTitle":"Bitches Brew","year":1970,"index":1,"duration":1140000,
|
||||
"Media":[{"Part":[{"key":"/library/parts/2/222/PharaohsDance.flac"}]}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
p := newProvider(NewClient("http://localhost:32400", "tok"))
|
||||
if p.Name() != "Plex" {
|
||||
t.Errorf("Name() = %q, want %q", p.Name(), "Plex")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Playlists(t *testing.T) {
|
||||
srv := httptest.NewServer(sectionsHandler(t))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(NewClient(srv.URL, "tok"))
|
||||
lists, err := p.Playlists()
|
||||
if err != nil {
|
||||
t.Fatalf("Playlists() error: %v", err)
|
||||
}
|
||||
if len(lists) != 2 {
|
||||
t.Fatalf("expected 2 playlists, got %d", len(lists))
|
||||
}
|
||||
|
||||
// Check first album entry
|
||||
if lists[0].ID != "100" {
|
||||
t.Errorf("lists[0].ID = %q, want %q", lists[0].ID, "100")
|
||||
}
|
||||
if !strings.Contains(lists[0].Name, "Miles Davis") {
|
||||
t.Errorf("lists[0].Name %q missing artist", lists[0].Name)
|
||||
}
|
||||
if !strings.Contains(lists[0].Name, "Kind of Blue") {
|
||||
t.Errorf("lists[0].Name %q missing album title", lists[0].Name)
|
||||
}
|
||||
if !strings.Contains(lists[0].Name, "1959") {
|
||||
t.Errorf("lists[0].Name %q missing year", lists[0].Name)
|
||||
}
|
||||
if lists[0].TrackCount != 5 {
|
||||
t.Errorf("lists[0].TrackCount = %d, want 5", lists[0].TrackCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Playlists_Cached(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/library/sections") {
|
||||
callCount++
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/library/sections"):
|
||||
w.Write([]byte(`{"MediaContainer":{"Directory":[{"key":"3","type":"artist","title":"Music"}]}}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/library/sections/3/all"):
|
||||
w.Write([]byte(`{"MediaContainer":{"totalSize":1,"Metadata":[{"ratingKey":"1","title":"Album","parentTitle":"Artist","year":2020,"leafCount":1}]}}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(NewClient(srv.URL, "tok"))
|
||||
if _, err := p.Playlists(); err != nil {
|
||||
t.Fatalf("first Playlists() error: %v", err)
|
||||
}
|
||||
if _, err := p.Playlists(); err != nil {
|
||||
t.Fatalf("second Playlists() error: %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("expected /library/sections called once, called %d times", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Playlists_NoMusicSections(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"MediaContainer":{"Directory":[{"key":"1","type":"movie","title":"Movies"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(NewClient(srv.URL, "tok"))
|
||||
_, err := p.Playlists()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for no music sections, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no matching music libraries") {
|
||||
t.Errorf("unexpected error message: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Tracks(t *testing.T) {
|
||||
srv := httptest.NewServer(sectionsHandler(t))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(NewClient(srv.URL, "tok"))
|
||||
tracks, err := p.Tracks("100")
|
||||
if err != nil {
|
||||
t.Fatalf("Tracks() error: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("expected 1 track, got %d", len(tracks))
|
||||
}
|
||||
|
||||
tr := tracks[0]
|
||||
if tr.Title != "So What" {
|
||||
t.Errorf("Title = %q, want %q", tr.Title, "So What")
|
||||
}
|
||||
if tr.Artist != "Miles Davis" {
|
||||
t.Errorf("Artist = %q, want %q", tr.Artist, "Miles Davis")
|
||||
}
|
||||
if tr.Album != "Kind of Blue" {
|
||||
t.Errorf("Album = %q, want %q", tr.Album, "Kind of Blue")
|
||||
}
|
||||
if tr.Year != 1959 {
|
||||
t.Errorf("Year = %d, want 1959", tr.Year)
|
||||
}
|
||||
if tr.TrackNumber != 1 {
|
||||
t.Errorf("TrackNumber = %d, want 1", tr.TrackNumber)
|
||||
}
|
||||
if tr.DurationSecs != 565 {
|
||||
t.Errorf("DurationSecs = %d, want 565", tr.DurationSecs)
|
||||
}
|
||||
if !tr.Stream {
|
||||
t.Error("Stream = false, want true")
|
||||
}
|
||||
if !strings.HasPrefix(tr.Path, srv.URL) {
|
||||
t.Errorf("Path %q does not start with server URL", tr.Path)
|
||||
}
|
||||
if !strings.Contains(tr.Path, "X-Plex-Token=tok") {
|
||||
t.Errorf("Path %q missing X-Plex-Token", tr.Path)
|
||||
}
|
||||
if !strings.Contains(tr.Path, "/library/parts/1/111/SoWhat.flac") {
|
||||
t.Errorf("Path %q missing part key", tr.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Tracks_SkipsMissingPart(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{
|
||||
"MediaContainer": {
|
||||
"Metadata": [
|
||||
{"ratingKey":"1","title":"Has Part","Media":[{"Part":[{"key":"/library/parts/1/1/file.mp3"}]}]},
|
||||
{"ratingKey":"2","title":"No Part","Media":[]},
|
||||
{"ratingKey":"3","title":"Also No Part"}
|
||||
]
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(NewClient(srv.URL, "tok"))
|
||||
tracks, err := p.Tracks("42")
|
||||
if err != nil {
|
||||
t.Fatalf("Tracks() error: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Errorf("expected 1 track (skipping those with no part), got %d", len(tracks))
|
||||
}
|
||||
if tracks[0].Title != "Has Part" {
|
||||
t.Errorf("expected 'Has Part', got %q", tracks[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Tracks_Cached(t *testing.T) {
|
||||
callCount := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"1","title":"T","Media":[{"Part":[{"key":"/p/1/1/f.mp3"}]}]}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(NewClient(srv.URL, "tok"))
|
||||
if _, err := p.Tracks("99"); err != nil {
|
||||
t.Fatalf("first Tracks() error: %v", err)
|
||||
}
|
||||
if _, err := p.Tracks("99"); err != nil {
|
||||
t.Fatalf("second Tracks() error: %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Errorf("expected children endpoint called once, called %d times", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Tracks_ClientError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := newProvider(NewClient(srv.URL, "bad-token"))
|
||||
_, err := p.Tracks("42")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 401, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFromConfig_NilWhenMissing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.PlexConfig
|
||||
}{
|
||||
{"empty", config.PlexConfig{}},
|
||||
{"no token", config.PlexConfig{URL: "http://localhost:32400"}},
|
||||
{"no url", config.PlexConfig{Token: "tok"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if p := NewFromConfig(tt.cfg); p != nil {
|
||||
t.Errorf("NewFromConfig(%+v) = non-nil, want nil", tt.cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFromConfig_OK(t *testing.T) {
|
||||
cfg := config.PlexConfig{URL: "http://localhost:32400", Token: "mytoken"}
|
||||
if p := NewFromConfig(cfg); p == nil {
|
||||
t.Error("NewFromConfig() returned nil for valid config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFromConfig_LibrariesWired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
libraries []string
|
||||
wantLen int
|
||||
}{
|
||||
{"no filter returns all", nil, 2},
|
||||
{"filter to Jazz", []string{"Jazz"}, 1},
|
||||
{"filter to jazz case-insensitive", []string{"jazz"}, 1},
|
||||
{"filter to both", []string{"Music", "Jazz"}, 2},
|
||||
{"no match returns error", []string{"Classical"}, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/library/sections"):
|
||||
w.Write([]byte(`{"MediaContainer":{"Directory":[
|
||||
{"key":"1","type":"artist","title":"Music"},
|
||||
{"key":"2","type":"artist","title":"Jazz"}
|
||||
]}}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/library/sections/1/all"):
|
||||
w.Write([]byte(`{"MediaContainer":{"totalSize":1,"Metadata":[
|
||||
{"ratingKey":"11","title":"Bitches Brew","parentTitle":"Miles Davis","year":1970,"leafCount":4}
|
||||
]}}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/library/sections/2/all"):
|
||||
w.Write([]byte(`{"MediaContainer":{"totalSize":1,"Metadata":[
|
||||
{"ratingKey":"10","title":"Kind of Blue","parentTitle":"Miles Davis","year":1959,"leafCount":5}
|
||||
]}}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := config.PlexConfig{
|
||||
URL: srv.URL,
|
||||
Token: "tok",
|
||||
Libraries: tt.libraries,
|
||||
}
|
||||
p := NewFromConfig(cfg)
|
||||
if p == nil {
|
||||
t.Fatal("NewFromConfig() returned nil for valid config")
|
||||
}
|
||||
|
||||
lists, err := p.Playlists()
|
||||
if tt.wantLen == 0 {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for no matching libraries, got %d playlists", len(lists))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Playlists() error: %v", err)
|
||||
}
|
||||
if len(lists) != tt.wantLen {
|
||||
t.Fatalf("got %d playlists, want %d", len(lists), tt.wantLen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user