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) }