172 lines
4.6 KiB
Go
172 lines
4.6 KiB
Go
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)
|
|
}
|