From d2648da2be4459ef6c6831443834659d5e1b766f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Mon, 30 Mar 2026 13:00:06 +0200 Subject: [PATCH 1/6] Decouple providers behind capability interfaces --- external/local/provider.go | 14 +++++ external/navidrome/client.go | 69 +++++++++++++++------ external/plex/provider.go | 43 +++++++++++++ external/radio/provider.go | 4 ++ external/spotify/provider.go | 21 ++++++- external/spotify/stub_windows.go | 7 ++- main.go | 10 ++- player/decode.go | 28 ++++----- player/pipeline.go | 19 +++--- player/player.go | 28 ++++++--- playlist/playlist.go | 13 +++- provider/interfaces.go | 81 ++++++++++++++++++++++++ provider/types.go | 30 +++++++++ ui/commands.go | 43 +++++++------ ui/keys.go | 17 ++++-- ui/keys_nav.go | 102 ++++++++++++++++++------------- ui/keys_spotify_search.go | 31 +++++++--- ui/model.go | 71 +++++++++++++++------ ui/state.go | 18 +++--- ui/view_nav.go | 17 +++++- 20 files changed, 508 insertions(+), 158 deletions(-) create mode 100644 provider/interfaces.go create mode 100644 provider/types.go diff --git a/external/local/provider.go b/external/local/provider.go index 64059122..f8a2c12b 100644 --- a/external/local/provider.go +++ b/external/local/provider.go @@ -3,6 +3,7 @@ package local import ( + "context" "errors" "fmt" "io" @@ -16,6 +17,13 @@ import ( "cliamp/internal/appdir" "cliamp/internal/tomlutil" "cliamp/playlist" + "cliamp/provider" +) + +// Compile-time interface checks. +var ( + _ provider.PlaylistWriter = (*Provider)(nil) + _ provider.PlaylistDeleter = (*Provider)(nil) ) // Provider reads and writes TOML-based playlists stored on disk. @@ -138,6 +146,12 @@ func (p *Provider) savePlaylist(name string, tracks []playlist.Track) error { return nil } +// AddTrackToPlaylist appends a track to the named playlist. +// Implements provider.PlaylistWriter. +func (p *Provider) AddTrackToPlaylist(_ context.Context, playlistID string, track playlist.Track) error { + return p.AddTrack(playlistID, track) +} + // DeletePlaylist removes the TOML file for the named playlist. func (p *Provider) DeletePlaylist(name string) error { path, err := p.safePath(name) diff --git a/external/navidrome/client.go b/external/navidrome/client.go index cdeea783..b78c08fa 100644 --- a/external/navidrome/client.go +++ b/external/navidrome/client.go @@ -10,11 +10,21 @@ import ( "net/http" "net/url" "os" + "strings" "sync" "time" "cliamp/config" "cliamp/playlist" + "cliamp/provider" +) + +// Compile-time interface checks. +var ( + _ provider.ArtistBrowser = (*NavidromeClient)(nil) + _ provider.AlbumBrowser = (*NavidromeClient)(nil) + _ provider.AlbumTrackLoader = (*NavidromeClient)(nil) + _ provider.Scrobbler = (*NavidromeClient)(nil) ) // httpClient is used for all Navidrome API calls with a finite timeout. @@ -47,6 +57,20 @@ var SortTypes = []string{ SortByGenre, } +// IsSubsonicStreamURL reports whether path is a Subsonic stream or download +// endpoint. Used by the player to select the buffered download pipeline. +func IsSubsonicStreamURL(path string) bool { + u, err := url.Parse(path) + if err != nil { + return false + } + p := strings.ToLower(u.Path) + return strings.HasSuffix(p, "/rest/stream") || + strings.HasSuffix(p, "/rest/stream.view") || + strings.HasSuffix(p, "/rest/download") || + strings.HasSuffix(p, "/rest/download.view") +} + // SortTypeLabel returns a human-readable label for a sort type constant. func SortTypeLabel(s string) string { switch s { @@ -71,22 +95,25 @@ func SortTypeLabel(s string) string { } } -// Artist represents a Navidrome/Subsonic artist entry. -type Artist struct { - ID string - Name string - AlbumCount int -} - -// Album represents a Navidrome/Subsonic album entry. -type Album struct { - ID string - Name string - Artist string - ArtistID string - Year int - SongCount int - Genre string +// Artist is a Navidrome/Subsonic artist — aliased to the provider type. +type Artist = provider.ArtistInfo + +// Album is a Navidrome/Subsonic album — aliased to the provider type. +type Album = provider.AlbumInfo + +// AlbumSortTypes returns the available sort options for album browsing. +// Implements provider.AlbumBrowser. +func (c *NavidromeClient) AlbumSortTypes() []provider.SortType { + return []provider.SortType{ + {ID: SortAlphabeticalByName, Label: "Alphabetical by Name"}, + {ID: SortAlphabeticalByArtist, Label: "Alphabetical by Artist"}, + {ID: SortNewest, Label: "Newest"}, + {ID: SortRecent, Label: "Recently Played"}, + {ID: SortFrequent, Label: "Most Played"}, + {ID: SortStarred, Label: "Starred"}, + {ID: SortByYear, Label: "By Year"}, + {ID: SortByGenre, Label: "By Genre"}, + } } // NavidromeClient implements playlist.Provider for a Navidrome/Subsonic server. @@ -397,7 +424,6 @@ type subsonicSong struct { func (c *NavidromeClient) songToTrack(s subsonicSong) playlist.Track { return playlist.Track{ Path: c.streamURL(s.ID), - NavidromeID: s.ID, Title: s.Title, Artist: s.Artist, Album: s.Album, @@ -406,6 +432,7 @@ func (c *NavidromeClient) songToTrack(s subsonicSong) playlist.Track { Genre: s.Genre, Stream: true, DurationSecs: s.Duration, + ProviderMeta: map[string]string{"navidrome.id": s.ID}, } } @@ -445,7 +472,13 @@ func (c *NavidromeClient) streamURL(id string) string { // If submission is false, it registers a "now playing" notification only. // If submission is true, it records a full play (updates play count, last.fm, etc.). // The call is best-effort: errors are silently discarded. -func (c *NavidromeClient) Scrobble(id string, submission bool) { +// Scrobble reports playback to the Navidrome/Subsonic server. +// Implements provider.Scrobbler. +func (c *NavidromeClient) Scrobble(track playlist.Track, submission bool) { + id := track.Meta("navidrome.id") + if id == "" { + return + } params := url.Values{ "id": {id}, "submission": {fmt.Sprintf("%t", submission)}, diff --git a/external/plex/provider.go b/external/plex/provider.go index 6983153a..b34aedc0 100644 --- a/external/plex/provider.go +++ b/external/plex/provider.go @@ -1,11 +1,19 @@ package plex import ( + "context" "fmt" "sync" "cliamp/config" "cliamp/playlist" + "cliamp/provider" +) + +// Compile-time interface checks. +var ( + _ provider.Searcher = (*Provider)(nil) + _ provider.AlbumTrackLoader = (*Provider)(nil) ) // Provider implements playlist.Provider for a Plex Media Server. @@ -125,3 +133,38 @@ func (p *Provider) Tracks(albumRatingKey string) ([]playlist.Track, error) { return 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 + } + 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, nil +} + +// 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) +} diff --git a/external/radio/provider.go b/external/radio/provider.go index 4c0ced12..4229a591 100644 --- a/external/radio/provider.go +++ b/external/radio/provider.go @@ -17,8 +17,12 @@ import ( "cliamp/internal/appdir" "cliamp/internal/tomlutil" "cliamp/playlist" + "cliamp/provider" ) +// Compile-time interface check. +var _ provider.FavoriteToggler = (*Provider)(nil) + const builtinName = "cliamp radio" const builtinURL = "https://radio.cliamp.stream/streams.m3u" diff --git a/external/spotify/provider.go b/external/spotify/provider.go index 704ec3b4..5208f6d3 100644 --- a/external/spotify/provider.go +++ b/external/spotify/provider.go @@ -22,6 +22,16 @@ import ( "github.com/gopxl/beep/v2" "cliamp/playlist" + "cliamp/provider" +) + +// Compile-time interface checks. +var ( + _ provider.Searcher = (*SpotifyProvider)(nil) + _ provider.PlaylistWriter = (*SpotifyProvider)(nil) + _ provider.PlaylistCreator = (*SpotifyProvider)(nil) + _ provider.CustomStreamer = (*SpotifyProvider)(nil) + _ provider.Closer = (*SpotifyProvider)(nil) ) // maxResponseBody limits JSON API responses to 10 MB. @@ -442,10 +452,16 @@ func isAuthError(err error) bool { // NewStreamer creates a SpotifyStreamer for the given spotify:track:xxx URI. // Called by the player's StreamerFactory when it encounters a Spotify URI. // +// URISchemes returns the URI prefixes handled by this provider. +// Implements provider.CustomStreamer. +func (p *SpotifyProvider) URISchemes() []string { return []string{"spotify:"} } + +// NewStreamer creates a SpotifyStreamer for the given spotify:track:xxx URI. // If the stream fails due to an auth error (e.g. expired session, AES key // rejection), the player first tries a silent reconnect from cached credentials. // If that fails or the retry still hits an auth error, it falls back to an // interactive OAuth2 flow and retries once more. +// Implements provider.CustomStreamer. func (p *SpotifyProvider) NewStreamer(uri string) (beep.StreamSeekCloser, beep.Format, time.Duration, error) { if err := p.ensureSession(); err != nil { return nil, beep.Format{}, 0, err @@ -640,7 +656,10 @@ func (p *SpotifyProvider) SearchTracks(ctx context.Context, query string, limit } // AddTrackToPlaylist adds a track to an existing Spotify playlist. -func (p *SpotifyProvider) AddTrackToPlaylist(ctx context.Context, playlistID, trackURI string) error { +// The track's Path is used as the Spotify URI (e.g. "spotify:track:xxx"). +// Implements provider.PlaylistWriter. +func (p *SpotifyProvider) AddTrackToPlaylist(ctx context.Context, playlistID string, track playlist.Track) error { + trackURI := track.Path if err := p.ensureSession(); err != nil { return err } diff --git a/external/spotify/stub_windows.go b/external/spotify/stub_windows.go index 14501850..cfe9fedc 100644 --- a/external/spotify/stub_windows.go +++ b/external/spotify/stub_windows.go @@ -43,6 +43,9 @@ func (p *SpotifyProvider) Tracks(_ string) ([]playlist.Track, error) { return ni // Authenticate is a no-op. func (p *SpotifyProvider) Authenticate() error { return nil } +// URISchemes returns the URI prefixes handled by this provider. +func (p *SpotifyProvider) URISchemes() []string { return []string{"spotify:"} } + // NewStreamer returns an error — Spotify streaming is unavailable on Windows. func (p *SpotifyProvider) NewStreamer(_ string) (beep.StreamSeekCloser, beep.Format, time.Duration, error) { return nil, beep.Format{}, 0, errSpotifyUnavailable @@ -54,7 +57,9 @@ func (p *SpotifyProvider) SearchTracks(_ context.Context, _ string, _ int) ([]pl } // AddTrackToPlaylist is a no-op on Windows. -func (p *SpotifyProvider) AddTrackToPlaylist(_ context.Context, _, _ string) error { return nil } +func (p *SpotifyProvider) AddTrackToPlaylist(_ context.Context, _ string, _ playlist.Track) error { + return nil +} // CreatePlaylist is a no-op on Windows. func (p *SpotifyProvider) CreatePlaylist(_ context.Context, _ string) (string, error) { diff --git a/main.go b/main.go index 2b20000c..34ce552d 100644 --- a/main.go +++ b/main.go @@ -175,7 +175,13 @@ func run(overrides config.Overrides, positional []string) error { // Register Spotify streamer factory so spotify: URIs are decoded // through go-librespot instead of the normal file/HTTP pipeline. if spotifyProv != nil { - p.SetStreamerFactory(spotifyProv.NewStreamer) + p.RegisterStreamerFactory("spotify:", spotifyProv.NewStreamer) + } + + // Register Navidrome/Subsonic URL matcher so those streams use the + // buffered download + ffmpeg pipeline for gapless, seekable playback. + if navClient != nil { + p.RegisterBufferedURLMatcher(navidrome.IsSubsonicStreamURL) } cfg.ApplyPlayer(p) @@ -192,7 +198,7 @@ func run(overrides config.Overrides, positional []string) error { defer luaMgr.Close() } - m := ui.NewModel(p, pl, providers, defaultProvider, localProv, spotifyProv, themes, cfg.Navidrome, navClient, luaMgr) + m := ui.NewModel(p, pl, providers, defaultProvider, localProv, themes, cfg.Navidrome, navClient, luaMgr) // Wire Lua plugin state provider with read-only access to player/playlist. if luaMgr != nil { diff --git a/player/decode.go b/player/decode.go index 4a095eb2..05257dd3 100644 --- a/player/decode.go +++ b/player/decode.go @@ -43,10 +43,15 @@ func isURL(path string) bool { return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") } -// isCustomURI reports whether path is a custom URI scheme (e.g., spotify:track:xxx) -// that should be handled by the StreamerFactory rather than normal file/HTTP decoding. -func isCustomURI(path string) bool { - return strings.HasPrefix(path, "spotify:") +// matchCustomURI returns the StreamerFactory for the given path if it matches +// a registered custom URI scheme prefix, or nil if no scheme matches. +func (p *Player) matchCustomURI(path string) StreamerFactory { + for scheme, factory := range p.customFactories { + if strings.HasPrefix(path, scheme) { + return factory + } + } + return nil } // sourceResult holds the opened stream and optional HTTP metadata. @@ -159,18 +164,13 @@ func needsFFmpeg(ext string) bool { return false } -// isNavidromeURL reports whether path is a Subsonic stream or download endpoint. -// Used to select the navBuffer pipeline path in buildPipelineAt. -func isNavidromeURL(path string) bool { - u, err := url.Parse(path) - if err != nil { +// isBufferedURL reports whether the given URL requires the buffered download +// + ffmpeg pipeline. Returns true if a registered matcher matches the URL. +func (p *Player) isBufferedURL(path string) bool { + if p.bufferedURLMatch == nil { return false } - p := strings.ToLower(u.Path) - return strings.HasSuffix(p, "/rest/stream") || - strings.HasSuffix(p, "/rest/stream.view") || - strings.HasSuffix(p, "/rest/download") || - strings.HasSuffix(p, "/rest/download.view") + return p.bufferedURLMatch(path) } // decodeWithExt selects the decoder using an explicit extension. diff --git a/player/pipeline.go b/player/pipeline.go index 29dae3b6..a45adb44 100644 --- a/player/pipeline.go +++ b/player/pipeline.go @@ -94,10 +94,10 @@ func (p *Player) buildPipelineAt(path string, byteOffset int64, timeOffset time. // Clear stream title on each new pipeline build. p.streamTitle.Store("") - // Custom URI schemes (e.g., spotify:track:xxx) are handled by the + // Custom URI schemes (e.g., spotify:track:xxx) are handled by a // registered StreamerFactory, bypassing normal file/HTTP decoding. - if p.customFactory != nil && isCustomURI(path) { - decoder, format, dur, err := p.customFactory(path) + if factory := p.matchCustomURI(path); factory != nil { + decoder, format, dur, err := factory(path) if err != nil { return nil, fmt.Errorf("custom streamer: %w", err) } @@ -120,12 +120,13 @@ func (p *Player) buildPipelineAt(path string, byteOffset int64, timeOffset time. onMeta = p.setStreamTitle } - // Navidrome/Subsonic tracks: buffer-while-playing via navBuffer + ffmpeg pipe. - // The navBuffer downloads in the background; ffmpeg reads from it via stdin - // and starts producing PCM as soon as the first frames arrive — no waiting - // for the full download. seekable=true routes Seek() through navFFmpegStreamer - // which repositions the navBuffer and restarts ffmpeg without HTTP reconnect. - if isURL(path) && isNavidromeURL(path) && byteOffset == 0 { + // Buffered HTTP tracks (e.g. Subsonic streams): buffer-while-playing via + // navBuffer + ffmpeg pipe. The navBuffer downloads in the background; ffmpeg + // reads from it via stdin and starts producing PCM as soon as the first + // frames arrive — no waiting for the full download. seekable=true routes + // Seek() through navFFmpegStreamer which repositions the navBuffer and + // restarts ffmpeg without HTTP reconnect. + if isURL(path) && p.isBufferedURL(path) && byteOffset == 0 { nb, contentLen, err := newNavBuffer(path) if err != nil { return nil, fmt.Errorf("navidrome buffer: %w", err) diff --git a/player/player.go b/player/player.go index 737aadec..7d7e1233 100644 --- a/player/player.go +++ b/player/player.go @@ -51,8 +51,9 @@ type Player struct { gaplessAdvance atomic.Bool // set when gapless transition fires seekGen atomic.Int64 // generation counter for yt-dlp seeks; incremented to cancel stale seeks - streamTitle atomic.Value // stores string, set by ICY reader callback - customFactory StreamerFactory // optional factory for custom URI schemes (e.g., spotify:) + streamTitle atomic.Value // stores string, set by ICY reader callback + customFactories map[string]StreamerFactory // URI scheme prefix -> factory (e.g. "spotify:" -> fn) + bufferedURLMatch func(string) bool // optional: returns true for URLs needing navBuffer pipeline } // New creates a Player and initializes the speaker with the given quality settings. @@ -650,13 +651,26 @@ func (p *Player) StreamBytes() (downloaded, total int64) { return downloaded, total } -// SetStreamerFactory registers a factory function for custom URI schemes. -// When buildPipeline encounters a URI that isn't a local file or HTTP URL, -// it calls this factory to create the decoder. -func (p *Player) SetStreamerFactory(f StreamerFactory) { +// RegisterStreamerFactory registers a factory for a custom URI scheme prefix +// (e.g., "spotify:"). When buildPipeline encounters a path starting with this +// prefix, it calls the factory to create the decoder instead of the normal +// file/HTTP pipeline. +func (p *Player) RegisterStreamerFactory(scheme string, f StreamerFactory) { p.mu.Lock() defer p.mu.Unlock() - p.customFactory = f + if p.customFactories == nil { + p.customFactories = make(map[string]StreamerFactory) + } + p.customFactories[scheme] = f +} + +// RegisterBufferedURLMatcher registers a function that identifies HTTP URLs +// requiring the buffered download + ffmpeg pipeline (e.g. Subsonic stream +// endpoints). This replaces hardcoded URL pattern checks. +func (p *Player) RegisterBufferedURLMatcher(match func(string) bool) { + p.mu.Lock() + defer p.mu.Unlock() + p.bufferedURLMatch = match } // Close fully stops the speaker and cleans up all resources. diff --git a/playlist/playlist.go b/playlist/playlist.go index 502e40ef..df86ca3d 100644 --- a/playlist/playlist.go +++ b/playlist/playlist.go @@ -41,7 +41,18 @@ type Track struct { Stream bool // true for HTTP/HTTPS URLs Realtime bool // true for real-time/live streams (e.g. radio) DurationSecs int // known duration in seconds (0 = unknown) - NavidromeID string // Subsonic song ID; empty for non-Navidrome tracks + + // ProviderMeta holds provider-specific key-value pairs. + // Keys are namespaced by provider, e.g. "navidrome.id", "spotify.uri". + ProviderMeta map[string]string +} + +// Meta returns the value for a provider-specific metadata key, or "" if unset. +func (t Track) Meta(key string) string { + if t.ProviderMeta == nil { + return "" + } + return t.ProviderMeta[key] } // IsURL reports whether path is an HTTP or HTTPS URL, or a yt-dlp search protocol string. diff --git a/provider/interfaces.go b/provider/interfaces.go new file mode 100644 index 00000000..bcba49b1 --- /dev/null +++ b/provider/interfaces.go @@ -0,0 +1,81 @@ +package provider + +import ( + "context" + "time" + + "github.com/gopxl/beep/v2" + + "cliamp/playlist" +) + +// Searcher is implemented by providers that support searching for tracks. +type Searcher interface { + SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) +} + +// ArtistBrowser is implemented by providers that support listing artists +// and their albums. +type ArtistBrowser interface { + Artists() ([]ArtistInfo, error) + ArtistAlbums(artistID string) ([]AlbumInfo, error) +} + +// AlbumBrowser is implemented by providers that support paginated album +// listing with configurable sort order. +type AlbumBrowser interface { + AlbumList(sortType string, offset, size int) ([]AlbumInfo, error) + AlbumSortTypes() []SortType +} + +// AlbumTrackLoader is implemented by providers that can return the tracks +// of a specific album (as opposed to a playlist). +type AlbumTrackLoader interface { + AlbumTracks(albumID string) ([]playlist.Track, error) +} + +// Scrobbler is implemented by providers that report playback to an +// external service (e.g. Navidrome/Subsonic, Last.fm). +type Scrobbler interface { + Scrobble(track playlist.Track, submission bool) +} + +// PlaylistWriter is implemented by providers that support adding tracks +// to existing playlists. +type PlaylistWriter interface { + AddTrackToPlaylist(ctx context.Context, playlistID string, track playlist.Track) error +} + +// PlaylistCreator is implemented by providers that support creating new +// playlists. +type PlaylistCreator interface { + CreatePlaylist(ctx context.Context, name string) (string, error) +} + +// PlaylistDeleter is implemented by providers that support removing +// playlists and individual tracks. +type PlaylistDeleter interface { + DeletePlaylist(name string) error + RemoveTrack(name string, index int) error +} + +// CustomStreamer is implemented by providers that need a custom audio +// decode path for non-standard URI schemes (e.g. spotify:track:xxx). +type CustomStreamer interface { + // URISchemes returns the URI prefixes this provider handles. + URISchemes() []string + // NewStreamer creates a decoder for the given URI. + NewStreamer(uri string) (beep.StreamSeekCloser, beep.Format, time.Duration, error) +} + +// FavoriteToggler is implemented by providers that support marking items +// as favorites (e.g. radio station favorites). +type FavoriteToggler interface { + ToggleFavorite(id string) (added bool, name string, err error) +} + +// Closer is implemented by providers that hold resources (sessions, +// connections) that should be released on shutdown. +type Closer interface { + Close() +} diff --git a/provider/types.go b/provider/types.go new file mode 100644 index 00000000..5c0818df --- /dev/null +++ b/provider/types.go @@ -0,0 +1,30 @@ +// Package provider defines optional capability interfaces for music providers. +// Providers implement the base playlist.Provider interface and may additionally +// implement any of the interfaces here to expose extended features (browsing, +// searching, scrobbling, etc.). The UI discovers capabilities at runtime via +// type assertions. +package provider + +// AlbumInfo describes an album in a provider's catalog. +type AlbumInfo struct { + ID string + Name string + Artist string + ArtistID string + Year int + SongCount int + Genre string +} + +// ArtistInfo describes an artist in a provider's catalog. +type ArtistInfo struct { + ID string + Name string + AlbumCount int +} + +// SortType describes one sort option for album listing. +type SortType struct { + ID string // e.g. "alphabeticalByName" + Label string // e.g. "By Name" +} diff --git a/ui/commands.go b/ui/commands.go index 82bb28da..14d31b36 100644 --- a/ui/commands.go +++ b/ui/commands.go @@ -6,12 +6,11 @@ import ( tea "github.com/charmbracelet/bubbletea" - "cliamp/external/navidrome" "cliamp/external/radio" - "cliamp/external/spotify" "cliamp/lyrics" "cliamp/player" "cliamp/playlist" + "cliamp/provider" "cliamp/resolve" ) @@ -74,17 +73,17 @@ type ytdlSavedMsg struct { // — Navidrome browser message types — -// navArtistsLoadedMsg carries the full artist list from getArtists. -type navArtistsLoadedMsg []navidrome.Artist +// navArtistsLoadedMsg carries the full artist list from a provider.ArtistBrowser. +type navArtistsLoadedMsg []provider.ArtistInfo // navAlbumsLoadedMsg carries one page of albums and the fetch offset. type navAlbumsLoadedMsg struct { - albums []navidrome.Album + albums []provider.AlbumInfo offset int // the offset this page was requested at isLast bool // true when the server returned fewer than the requested page size } -// navTracksLoadedMsg carries the track list for the selected album/artist. +// navTracksLoadedMsg carries the track list from a provider.AlbumTrackLoader. type navTracksLoadedMsg []playlist.Track // provAuthDoneMsg signals that interactive provider authentication completed. @@ -220,9 +219,9 @@ func resolveWrapperURLs(tracks []playlist.Track) []playlist.Track { const navAlbumPageSize = 100 -func fetchNavArtistsCmd(c *navidrome.NavidromeClient) tea.Cmd { +func fetchNavArtistsCmd(b provider.ArtistBrowser) tea.Cmd { return func() tea.Msg { - artists, err := c.Artists() + artists, err := b.Artists() if err != nil { return err } @@ -230,9 +229,9 @@ func fetchNavArtistsCmd(c *navidrome.NavidromeClient) tea.Cmd { } } -func fetchNavArtistAlbumsCmd(c *navidrome.NavidromeClient, artistID string) tea.Cmd { +func fetchNavArtistAlbumsCmd(b provider.ArtistBrowser, artistID string) tea.Cmd { return func() tea.Msg { - albums, err := c.ArtistAlbums(artistID) + albums, err := b.ArtistAlbums(artistID) if err != nil { return err } @@ -241,9 +240,9 @@ func fetchNavArtistAlbumsCmd(c *navidrome.NavidromeClient, artistID string) tea. } } -func fetchNavAlbumListCmd(c *navidrome.NavidromeClient, sortType string, offset int) tea.Cmd { +func fetchNavAlbumListCmd(b provider.AlbumBrowser, sortType string, offset int) tea.Cmd { return func() tea.Msg { - albums, err := c.AlbumList(sortType, offset, navAlbumPageSize) + albums, err := b.AlbumList(sortType, offset, navAlbumPageSize) if err != nil { return err } @@ -255,9 +254,9 @@ func fetchNavAlbumListCmd(c *navidrome.NavidromeClient, sortType string, offset } } -func fetchNavAlbumTracksCmd(c *navidrome.NavidromeClient, albumID string) tea.Cmd { +func fetchNavAlbumTracksCmd(l provider.AlbumTrackLoader, albumID string) tea.Cmd { return func() tea.Msg { - tracks, err := c.AlbumTracks(albumID) + tracks, err := l.AlbumTracks(albumID) if err != nil { return err } @@ -318,40 +317,40 @@ type spotCreatedMsg struct { err error } -func fetchSpotSearchCmd(prov *spotify.SpotifyProvider, query string) tea.Cmd { +func fetchSpotSearchCmd(s provider.Searcher, query string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - tracks, err := prov.SearchTracks(ctx, query, 20) + tracks, err := s.SearchTracks(ctx, query, 20) return spotSearchResultsMsg{tracks: tracks, err: err} } } -func fetchSpotPlaylistsCmd(prov *spotify.SpotifyProvider) tea.Cmd { +func fetchSpotPlaylistsCmd(prov playlist.Provider) tea.Cmd { return func() tea.Msg { playlists, err := prov.Playlists() return spotPlaylistsMsg{playlists: playlists, err: err} } } -func addToSpotPlaylistCmd(prov *spotify.SpotifyProvider, playlistID, trackURI, name string) tea.Cmd { +func addToSpotPlaylistCmd(w provider.PlaylistWriter, playlistID string, track playlist.Track, name string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - err := prov.AddTrackToPlaylist(ctx, playlistID, trackURI) + err := w.AddTrackToPlaylist(ctx, playlistID, track) return spotAddedMsg{name: name, err: err} } } -func createSpotPlaylistCmd(prov *spotify.SpotifyProvider, name, trackURI string) tea.Cmd { +func createSpotPlaylistCmd(c provider.PlaylistCreator, w provider.PlaylistWriter, name string, track playlist.Track) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - id, err := prov.CreatePlaylist(ctx, name) + id, err := c.CreatePlaylist(ctx, name) if err != nil { return spotCreatedMsg{name: name, err: err} } - err = prov.AddTrackToPlaylist(ctx, id, trackURI) + err = w.AddTrackToPlaylist(ctx, id, track) return spotCreatedMsg{name: name, err: err} } } diff --git a/ui/keys.go b/ui/keys.go index 15658704..d83de22c 100644 --- a/ui/keys.go +++ b/ui/keys.go @@ -14,6 +14,7 @@ import ( "cliamp/external/radio" "cliamp/internal/fileutil" "cliamp/playlist" + "cliamp/provider" ) // quit shuts down the player and signals the TUI to exit. @@ -39,7 +40,7 @@ func (m *Model) quit() tea.Cmd { // scrobbleCurrent fires a scrobble for the currently playing track if applicable. func (m *Model) scrobbleCurrent() { - if track, _ := m.playlist.Current(); track.NavidromeID != "" { + if track, _ := m.playlist.Current(); track.Meta("navidrome.id") != "" { m.maybeScrobble(track, m.player.Position(), m.player.Duration()) } } @@ -210,8 +211,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { case "o": m.openFileBrowser() case "N": - if m.navClient != nil { - m.openNavBrowser() + if prov := m.findBrowseProvider(); prov != nil { + m.openNavBrowserWith(prov) } case "pgup", "ctrl+u": if m.provCursor > 0 { @@ -506,8 +507,12 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { m.focus = focusNetSearch case "F": - if m.spotifyProvider != nil { + if prov := m.findProviderWith(func(p playlist.Provider) bool { + _, ok := p.(provider.Searcher) + return ok + }); prov != nil { m.spotSearch = spotSearchState{ + prov: prov, visible: true, screen: spotSearchInput, } @@ -550,8 +555,8 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { m.urlInput = "" case "N": - if m.navClient != nil { - m.openNavBrowser() + if prov := m.findBrowseProvider(); prov != nil { + m.openNavBrowserWith(prov) } case "R": diff --git a/ui/keys_nav.go b/ui/keys_nav.go index 3bac79c7..dc83886c 100644 --- a/ui/keys_nav.go +++ b/ui/keys_nav.go @@ -4,18 +4,13 @@ import ( tea "github.com/charmbracelet/bubbletea" "cliamp/config" - "cliamp/external/navidrome" "cliamp/playlist" + "cliamp/provider" ) -// handleNavBrowserKey processes key presses while the Navidrome browser is open. +// handleNavBrowserKey processes key presses while the provider browser is open. +// Works with any provider implementing ArtistBrowser, AlbumBrowser, and/or AlbumTrackLoader. func (m *Model) handleNavBrowserKey(msg tea.KeyMsg) tea.Cmd { - navClient := m.navClient - if navClient == nil { - m.navBrowser.visible = false - return nil - } - // Search bar: active on any list/track screen (not the mode menu). if m.navBrowser.mode != navBrowseModeMenu { if m.navBrowser.searching { @@ -34,18 +29,18 @@ func (m *Model) handleNavBrowserKey(msg tea.KeyMsg) tea.Cmd { switch m.navBrowser.mode { case navBrowseModeMenu: - return m.handleNavMenuKey(msg, navClient) + return m.handleNavMenuKey(msg) case navBrowseModeByAlbum: - return m.handleNavByAlbumKey(msg, navClient) + return m.handleNavByAlbumKey(msg) case navBrowseModeByArtist: - return m.handleNavByArtistKey(msg, navClient) + return m.handleNavByArtistKey(msg) case navBrowseModeByArtistAlbum: - return m.handleNavByArtistAlbumKey(msg, navClient) + return m.handleNavByArtistAlbumKey(msg) } return nil } -func (m *Model) handleNavMenuKey(msg tea.KeyMsg, navClient *navidrome.NavidromeClient) tea.Cmd { +func (m *Model) handleNavMenuKey(msg tea.KeyMsg) tea.Cmd { const menuItems = 3 switch msg.String() { case "ctrl+c": @@ -62,6 +57,10 @@ func (m *Model) handleNavMenuKey(msg tea.KeyMsg, navClient *navidrome.NavidromeC case "enter", "l", "right": switch m.navBrowser.cursor { case 0: // By Album + ab, ok := m.navBrowser.prov.(provider.AlbumBrowser) + if !ok { + return nil + } m.navBrowser.mode = navBrowseModeByAlbum m.navBrowser.screen = navBrowseScreenList m.navBrowser.cursor = 0 @@ -70,23 +69,31 @@ func (m *Model) handleNavMenuKey(msg tea.KeyMsg, navClient *navidrome.NavidromeC m.navBrowser.albumLoading = true m.navBrowser.albumDone = false m.navBrowser.loading = false - return fetchNavAlbumListCmd(navClient, m.navBrowser.sortType, 0) + return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0) case 1: // By Artist + ab, ok := m.navBrowser.prov.(provider.ArtistBrowser) + if !ok { + return nil + } m.navBrowser.mode = navBrowseModeByArtist m.navBrowser.screen = navBrowseScreenList m.navBrowser.cursor = 0 m.navBrowser.scroll = 0 m.navBrowser.artists = nil m.navBrowser.loading = true - return fetchNavArtistsCmd(navClient) + return fetchNavArtistsCmd(ab) case 2: // By Artist / Album + ab, ok := m.navBrowser.prov.(provider.ArtistBrowser) + if !ok { + return nil + } m.navBrowser.mode = navBrowseModeByArtistAlbum m.navBrowser.screen = navBrowseScreenList m.navBrowser.cursor = 0 m.navBrowser.scroll = 0 m.navBrowser.artists = nil m.navBrowser.loading = true - return fetchNavArtistsCmd(navClient) + return fetchNavArtistsCmd(ab) } case "esc", "N", "backspace", "b": m.navBrowser.visible = false @@ -94,32 +101,32 @@ func (m *Model) handleNavMenuKey(msg tea.KeyMsg, navClient *navidrome.NavidromeC return nil } -func (m *Model) handleNavByAlbumKey(msg tea.KeyMsg, navClient *navidrome.NavidromeClient) tea.Cmd { +func (m *Model) handleNavByAlbumKey(msg tea.KeyMsg) tea.Cmd { switch m.navBrowser.screen { case navBrowseScreenList: - return m.handleNavAlbumListKey(msg, navClient, false) + return m.handleNavAlbumListKey(msg, false) case navBrowseScreenTracks: return m.handleNavTrackListKey(msg) } return nil } -func (m *Model) handleNavByArtistKey(msg tea.KeyMsg, navClient *navidrome.NavidromeClient) tea.Cmd { +func (m *Model) handleNavByArtistKey(msg tea.KeyMsg) tea.Cmd { switch m.navBrowser.screen { case navBrowseScreenList: - return m.handleNavArtistListKey(msg, navClient) + return m.handleNavArtistListKey(msg) case navBrowseScreenTracks: return m.handleNavTrackListKey(msg) } return nil } -func (m *Model) handleNavByArtistAlbumKey(msg tea.KeyMsg, navClient *navidrome.NavidromeClient) tea.Cmd { +func (m *Model) handleNavByArtistAlbumKey(msg tea.KeyMsg) tea.Cmd { switch m.navBrowser.screen { case navBrowseScreenList: - return m.handleNavArtistListKey(msg, navClient) + return m.handleNavArtistListKey(msg) case navBrowseScreenAlbums: - return m.handleNavAlbumListKey(msg, navClient, true) + return m.handleNavAlbumListKey(msg, true) case navBrowseScreenTracks: return m.handleNavTrackListKey(msg) } @@ -127,7 +134,7 @@ func (m *Model) handleNavByArtistAlbumKey(msg tea.KeyMsg, navClient *navidrome.N } // handleNavArtistListKey handles the artist list screen (used by both By Artist and By Artist/Album modes). -func (m *Model) handleNavArtistListKey(msg tea.KeyMsg, navClient *navidrome.NavidromeClient) tea.Cmd { +func (m *Model) handleNavArtistListKey(msg tea.KeyMsg) tea.Cmd { // Determine effective list length (filtered or full). listLen := len(m.navBrowser.artists) if len(m.navBrowser.searchIdx) > 0 { @@ -152,6 +159,10 @@ func (m *Model) handleNavArtistListKey(msg tea.KeyMsg, navClient *navidrome.Navi if m.navBrowser.loading || len(m.navBrowser.artists) == 0 { return nil } + ab, ok := m.navBrowser.prov.(provider.ArtistBrowser) + if !ok { + return nil + } // Resolve raw index (filtered or direct). rawIdx := m.navBrowser.cursor if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) { @@ -168,14 +179,11 @@ func (m *Model) handleNavArtistListKey(msg tea.KeyMsg, navClient *navidrome.Navi m.navBrowser.cursor = 0 m.navBrowser.scroll = 0 m.navClearSearch() - return fetchNavArtistAlbumsCmd(navClient, artist.ID) + return fetchNavArtistAlbumsCmd(ab, artist.ID) } // By Artist: fetch all albums first, then all tracks via a two-step command. - // We use a dedicated command that fetches albums then tracks in one shot. - // Clear any active artist-list search filter before transitioning so that - // stale navSearchIdx entries are not misapplied to the incoming track list. m.navClearSearch() - return m.fetchNavArtistAllTracksCmd(navClient, artist.ID) + return m.fetchNavArtistAllTracksCmd(ab, artist.ID) case "esc", "h", "left", "backspace": // Back to menu. m.navClearSearch() @@ -187,7 +195,7 @@ func (m *Model) handleNavArtistListKey(msg tea.KeyMsg, navClient *navidrome.Navi // handleNavAlbumListKey handles the album list screen. // artistAlbums=true means this is the artist's album sub-screen (ArtistAlbum mode), not the global list. -func (m *Model) handleNavAlbumListKey(msg tea.KeyMsg, navClient *navidrome.NavidromeClient, artistAlbums bool) tea.Cmd { +func (m *Model) handleNavAlbumListKey(msg tea.KeyMsg, artistAlbums bool) tea.Cmd { // Determine effective list length (filtered or full). listLen := len(m.navBrowser.albums) if len(m.navBrowser.searchIdx) > 0 { @@ -209,8 +217,10 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyMsg, navClient *navidrome.Navid m.navMaybeAdjustScroll() // Lazy-load next page: only trigger on the raw (unfiltered) list. if !artistAlbums && len(m.navBrowser.searchIdx) == 0 && !m.navBrowser.albumLoading && !m.navBrowser.albumDone && m.navBrowser.cursor >= len(m.navBrowser.albums)-10 { - m.navBrowser.albumLoading = true - return fetchNavAlbumListCmd(navClient, m.navBrowser.sortType, len(m.navBrowser.albums)) + if ab, ok := m.navBrowser.prov.(provider.AlbumBrowser); ok { + m.navBrowser.albumLoading = true + return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, len(m.navBrowser.albums)) + } } } case "enter", "l", "right": @@ -226,13 +236,20 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyMsg, navClient *navidrome.Navid m.navBrowser.selAlbum = album m.navBrowser.loading = true m.navClearSearch() - return fetchNavAlbumTracksCmd(navClient, album.ID) + if l, ok := m.navBrowser.prov.(provider.AlbumTrackLoader); ok { + return fetchNavAlbumTracksCmd(l, album.ID) + } + return nil case "s": if artistAlbums { return nil // Sort only applies to global album list. } + ab, ok := m.navBrowser.prov.(provider.AlbumBrowser) + if !ok { + return nil + } // Cycle to the next sort type. - m.navBrowser.sortType = navNextSort(m.navBrowser.sortType) + m.navBrowser.sortType = navNextSort(m.navBrowser.sortType, ab.AlbumSortTypes()) m.navBrowser.albums = nil m.navBrowser.cursor = 0 m.navBrowser.scroll = 0 @@ -243,7 +260,7 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyMsg, navClient *navidrome.Navid if err := config.SaveNavidromeSort(m.navBrowser.sortType); err != nil { m.status.Showf(statusTTLDefault, "Sort save failed: %s", err) } - return fetchNavAlbumListCmd(navClient, m.navBrowser.sortType, 0) + return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0) case "esc", "h", "left", "backspace": m.navClearSearch() if artistAlbums { @@ -436,14 +453,17 @@ func (m *Model) handleNavSearchKey(msg tea.KeyMsg) tea.Cmd { return nil } -// navNextSort returns the sort type that follows s in SortTypes, wrapping around. -func navNextSort(s string) string { - for i, t := range navidrome.SortTypes { - if t == s { - return navidrome.SortTypes[(i+1)%len(navidrome.SortTypes)] +// navNextSort returns the sort type that follows s in the given sort types, wrapping around. +func navNextSort(s string, types []provider.SortType) string { + for i, t := range types { + if t.ID == s { + return types[(i+1)%len(types)].ID } } - return navidrome.SortTypes[0] + if len(types) > 0 { + return types[0].ID + } + return s } // navMaybeAdjustScroll keeps navCursor visible within the rendered list window. diff --git a/ui/keys_spotify_search.go b/ui/keys_spotify_search.go index 307f0b25..369e07b3 100644 --- a/ui/keys_spotify_search.go +++ b/ui/keys_spotify_search.go @@ -1,8 +1,12 @@ package ui -import tea "github.com/charmbracelet/bubbletea" +import ( + tea "github.com/charmbracelet/bubbletea" -// handleSpotSearchKey dispatches key presses to the active Spotify search screen. + "cliamp/provider" +) + +// handleSpotSearchKey dispatches key presses to the active provider search screen. func (m *Model) handleSpotSearchKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "ctrl+c": @@ -30,9 +34,13 @@ func (m *Model) handleSpotSearchInputKey(msg tea.KeyMsg) tea.Cmd { m.spotSearch.visible = false case tea.KeyEnter: if m.spotSearch.query != "" && !m.spotSearch.loading { + s, ok := m.spotSearch.prov.(provider.Searcher) + if !ok { + return nil + } m.spotSearch.loading = true m.spotSearch.err = "" - return fetchSpotSearchCmd(m.spotifyProvider, m.spotSearch.query) + return fetchSpotSearchCmd(s, m.spotSearch.query) } case tea.KeyBackspace: if m.spotSearch.query != "" { @@ -70,7 +78,7 @@ func (m *Model) handleSpotSearchResultsKey(msg tea.KeyMsg) tea.Cmd { m.spotSearch.selTrack = m.spotSearch.results[m.spotSearch.cursor] m.spotSearch.loading = true m.spotSearch.err = "" - return fetchSpotPlaylistsCmd(m.spotifyProvider) + return fetchSpotPlaylistsCmd(m.spotSearch.prov) } case "esc", "backspace": m.spotSearch.screen = spotSearchInput @@ -79,7 +87,7 @@ func (m *Model) handleSpotSearchResultsKey(msg tea.KeyMsg) tea.Cmd { return nil } -// handleSpotSearchPlaylistKey handles picking a Spotify playlist to add to. +// handleSpotSearchPlaylistKey handles picking a playlist to add to. func (m *Model) handleSpotSearchPlaylistKey(msg tea.KeyMsg) tea.Cmd { count := len(m.spotSearch.playlists) + 1 // +1 for "+ New Playlist..." @@ -100,6 +108,10 @@ func (m *Model) handleSpotSearchPlaylistKey(msg tea.KeyMsg) tea.Cmd { if m.spotSearch.loading { return nil } + w, ok := m.spotSearch.prov.(provider.PlaylistWriter) + if !ok { + return nil + } if m.spotSearch.cursor < len(m.spotSearch.playlists) { // Add to existing playlist. pl := m.spotSearch.playlists[m.spotSearch.cursor] @@ -109,7 +121,7 @@ func (m *Model) handleSpotSearchPlaylistKey(msg tea.KeyMsg) tea.Cmd { } m.spotSearch.loading = true m.spotSearch.err = "" - return addToSpotPlaylistCmd(m.spotifyProvider, pl.ID, m.spotSearch.selTrack.Path, pl.Name) + return addToSpotPlaylistCmd(w, pl.ID, m.spotSearch.selTrack, pl.Name) } // "+ New Playlist..." selected. m.spotSearch.screen = spotSearchNewName @@ -131,9 +143,14 @@ func (m *Model) handleSpotSearchNewNameKey(msg tea.KeyMsg) tea.Cmd { m.spotSearch.cursor = len(m.spotSearch.playlists) // back on "+ New Playlist..." case tea.KeyEnter: if m.spotSearch.newName != "" && !m.spotSearch.loading { + c, cOk := m.spotSearch.prov.(provider.PlaylistCreator) + w, wOk := m.spotSearch.prov.(provider.PlaylistWriter) + if !cOk || !wOk { + return nil + } m.spotSearch.loading = true m.spotSearch.err = "" - return createSpotPlaylistCmd(m.spotifyProvider, m.spotSearch.newName, m.spotSearch.selTrack.Path) + return createSpotPlaylistCmd(c, w, m.spotSearch.newName, m.spotSearch.selTrack) } case tea.KeyBackspace: if m.spotSearch.newName != "" { diff --git a/ui/model.go b/ui/model.go index 7d9063e4..636adf77 100644 --- a/ui/model.go +++ b/ui/model.go @@ -15,11 +15,11 @@ import ( "cliamp/external/local" "cliamp/external/navidrome" "cliamp/external/radio" - "cliamp/external/spotify" "cliamp/luaplugin" "cliamp/mpris" "cliamp/player" "cliamp/playlist" + "cliamp/provider" "cliamp/theme" ) @@ -160,9 +160,8 @@ type Model struct { height int // Provider state - provider playlist.Provider - localProvider *local.Provider // direct ref for write operations (add-to-playlist) - spotifyProvider *spotify.SpotifyProvider // direct ref for search/playlist write operations + provider playlist.Provider + localProvider *local.Provider // direct ref for local playlist file operations (always available) providerLists []playlist.PlaylistInfo provCursor int provLoading bool @@ -266,7 +265,7 @@ type Model struct { // navCfg is the Navidrome config used to seed the initial browse sort preference. // nav is the raw NavidromeClient (may be nil); stored directly so the browser // key handler doesn't have to unwrap a provider. -func NewModel(p *player.Player, pl *playlist.Playlist, providers []ProviderEntry, defaultProvider string, localProv *local.Provider, spotifyProv *spotify.SpotifyProvider, themes []theme.Theme, navCfg config.NavidromeConfig, nav *navidrome.NavidromeClient, luaMgr *luaplugin.Manager) Model { +func NewModel(p *player.Player, pl *playlist.Playlist, providers []ProviderEntry, defaultProvider string, localProv *local.Provider, themes []theme.Theme, navCfg config.NavidromeConfig, nav *navidrome.NavidromeClient, luaMgr *luaplugin.Manager) Model { sortType := navCfg.BrowseSort if sortType == "" { sortType = navidrome.SortAlphabeticalByName @@ -281,7 +280,6 @@ func NewModel(p *player.Player, pl *playlist.Playlist, providers []ProviderEntry themes: themes, themeIdx: -1, // Default (ANSI) localProvider: localProv, - spotifyProvider: spotifyProv, providers: providers, navBrowser: navBrowserState{sortType: sortType}, navClient: nav, @@ -305,6 +303,22 @@ func NewModel(p *player.Player, pl *playlist.Playlist, providers []ProviderEntry return m } +// findProviderWith returns the first registered provider that satisfies the +// given capability check. This is used for cross-provider shortcuts like "N" +// (browse) and "F" (search) which should work regardless of the active provider. +func (m *Model) findProviderWith(check func(playlist.Provider) bool) playlist.Provider { + // Prefer the active provider if it matches. + if check(m.provider) { + return m.provider + } + for _, pe := range m.providers { + if pe.Provider != nil && check(pe.Provider) { + return pe.Provider + } + } + return nil +} + // SetAutoPlay makes the player start playback immediately on Init. func (m *Model) SetAutoPlay(v bool) { m.autoPlay = v } @@ -475,7 +489,7 @@ func (m *Model) openPlaylistManager() { // plMgrEnterTrackList loads the tracks for a playlist and switches to screen 1. func (m *Model) plMgrEnterTrackList(name string) { - tracks, err := m.localProvider.Tracks(name) + tracks, err := m.localProvider.Tracks(name) // localProvider is always available for playlist management if err != nil { m.status.Showf(statusTTLDefault, "Load failed: %s", err) return @@ -655,15 +669,20 @@ func (m *Model) flushPendingSpeedSave() { // fetchNavArtistAllTracksCmd first fetches the artist's album list, then fetches // all tracks across every album. This is used by the "By Artist" browse mode. -func (m *Model) fetchNavArtistAllTracksCmd(navClient *navidrome.NavidromeClient, artistID string) tea.Cmd { +// The provider must implement both ArtistBrowser and AlbumTrackLoader. +func (m *Model) fetchNavArtistAllTracksCmd(ab provider.ArtistBrowser, artistID string) tea.Cmd { + loader, _ := m.navBrowser.prov.(provider.AlbumTrackLoader) return func() tea.Msg { - albums, err := navClient.ArtistAlbums(artistID) + albums, err := ab.ArtistAlbums(artistID) if err != nil { return err } + if loader == nil { + return navTracksLoadedMsg(nil) + } var all []playlist.Track for _, album := range albums { - tracks, err := navClient.AlbumTracks(album.ID) + tracks, err := loader.AlbumTracks(album.ID) if err != nil { return err } @@ -718,7 +737,20 @@ func (m *Model) navClearSearch() { m.navBrowser.scroll = 0 } -func (m *Model) openNavBrowser() { +// findBrowseProvider returns the first provider that supports browsing +// (ArtistBrowser or AlbumBrowser), preferring the active provider. +func (m *Model) findBrowseProvider() playlist.Provider { + return m.findProviderWith(func(p playlist.Provider) bool { + if _, ok := p.(provider.ArtistBrowser); ok { + return true + } + _, ok := p.(provider.AlbumBrowser) + return ok + }) +} + +func (m *Model) openNavBrowserWith(prov playlist.Provider) { + m.navBrowser.prov = prov m.navBrowser.visible = true m.navBrowser.mode = navBrowseModeMenu m.navBrowser.screen = navBrowseScreenList @@ -1183,7 +1215,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case navArtistsLoadedMsg: - m.navBrowser.artists = []navidrome.Artist(msg) + m.navBrowser.artists = []provider.ArtistInfo(msg) m.navBrowser.loading = false m.navBrowser.cursor = 0 m.navBrowser.scroll = 0 @@ -1943,8 +1975,8 @@ func (m *Model) lyricsSyncable() bool { return false } // ICY radio streams: position counts from stream connect, not song start. - // Navidrome streams have NavidromeID set — those track position correctly. - if track.Stream && track.NavidromeID == "" { + // Navidrome streams have provider metadata set — those track position correctly. + if track.Stream && track.Meta("navidrome.id") == "" { return false } return true @@ -1980,7 +2012,7 @@ func (m *Model) updateSearch() { // conditions are met: // - navClient is configured // - scrobbling is enabled in config -// - the track has a NavidromeID (i.e. it came from Navidrome) +// - the track has a navidrome.id in ProviderMeta (i.e. it came from Navidrome) // - elapsed is at least 50% of the track's known duration // // The call is dispatched in a goroutine so it never blocks the UI. @@ -2001,7 +2033,7 @@ func (m *Model) maybeScrobble(track playlist.Track, elapsed, duration time.Durat if m.navClient == nil || !m.navScrobbleEnabled { return } - if track.NavidromeID == "" { + if track.Meta("navidrome.id") == "" { return } if duration <= 0 { @@ -2014,8 +2046,7 @@ func (m *Model) maybeScrobble(track playlist.Track, elapsed, duration time.Durat if elapsed < duration/2 { return // less than 50% played } - id := track.NavidromeID - go m.navClient.Scrobble(id, true) + go m.navClient.Scrobble(track, true) } // nowPlaying fires a now-playing notification for the given track if configured. @@ -2024,8 +2055,8 @@ func (m *Model) nowPlaying(track playlist.Track) { m.luaMgr.Emit(luaplugin.EventTrackChange, trackToMap(track)) } - if m.navClient == nil || !m.navScrobbleEnabled || track.NavidromeID == "" { + if m.navClient == nil || !m.navScrobbleEnabled || track.Meta("navidrome.id") == "" { return } - go m.navClient.Scrobble(track.NavidromeID, false) + go m.navClient.Scrobble(track, false) } diff --git a/ui/state.go b/ui/state.go index 4575fbff..658671d8 100644 --- a/ui/state.go +++ b/ui/state.go @@ -7,9 +7,9 @@ import ( "fmt" "time" - "cliamp/external/navidrome" "cliamp/lyrics" "cliamp/playlist" + "cliamp/provider" ) // searchState holds state for the playlist search overlay. @@ -98,18 +98,21 @@ type fileBrowserState struct { err string } -// navBrowserState holds state for the Navidrome explore browser overlay. +// navBrowserState holds state for the provider explore browser overlay. +// Works with any provider implementing the browse capability interfaces +// (provider.ArtistBrowser, provider.AlbumBrowser, provider.AlbumTrackLoader). type navBrowserState struct { + prov playlist.Provider // the provider being browsed (may differ from active provider) visible bool mode navBrowseModeType screen navBrowseScreenType cursor int scroll int - artists []navidrome.Artist - albums []navidrome.Album + artists []provider.ArtistInfo + albums []provider.AlbumInfo tracks []playlist.Track - selArtist navidrome.Artist - selAlbum navidrome.Album + selArtist provider.ArtistInfo + selAlbum provider.AlbumInfo sortType string albumLoading bool albumDone bool @@ -129,8 +132,9 @@ const ( spotSearchNewName // typing new playlist name ) -// spotSearchState holds state for the Spotify song search + add-to-playlist overlay. +// spotSearchState holds state for the provider search + add-to-playlist overlay. type spotSearchState struct { + prov playlist.Provider // the provider being searched (may differ from active provider) visible bool screen spotSearchScreenType query string diff --git a/ui/view_nav.go b/ui/view_nav.go index c37b4919..7d345c0e 100644 --- a/ui/view_nav.go +++ b/ui/view_nav.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "cliamp/external/navidrome" + "cliamp/provider" ) // — Navidrome browser renderers — @@ -97,7 +97,7 @@ func (m Model) renderNavAlbumList(artistAlbums bool) []string { lines := []string{titleStr, ""} if !artistAlbums { - sortLabel := navidrome.SortTypeLabel(m.navBrowser.sortType) + sortLabel := m.navSortLabel(m.navBrowser.sortType) lines = append(lines, dimStyle.Render(" Sort: ")+activeToggle.Render(sortLabel), "") } @@ -221,3 +221,16 @@ func (m Model) renderNavTrackList() []string { helpKey("/", "Search"))...) return lines } + +// navSortLabel returns the human-readable label for the current sort type +// by querying the provider's AlbumSortTypes. Falls back to the raw ID. +func (m Model) navSortLabel(sortID string) string { + if ab, ok := m.navBrowser.prov.(provider.AlbumBrowser); ok { + for _, st := range ab.AlbumSortTypes() { + if st.ID == sortID { + return st.Label + } + } + } + return sortID +} From 22c17b51d80a7f0e61b9ca1a1c687e0d0d8ac7c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Mon, 30 Mar 2026 13:10:11 +0200 Subject: [PATCH 2/6] Add docs to provider vel --- docs/provider-development.md | 193 +++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/provider-development.md diff --git a/docs/provider-development.md b/docs/provider-development.md new file mode 100644 index 00000000..7449eb5c --- /dev/null +++ b/docs/provider-development.md @@ -0,0 +1,193 @@ +# Creating a Provider + +Providers live in `external//` (e.g. `external/jellyfin/`). A provider is +a Go package that implements the base `playlist.Provider` interface and +optionally implements capability interfaces from the `provider/` package. The UI +discovers capabilities at runtime via type assertions and enables features +accordingly. + +See the existing providers for reference: +- `external/navidrome/` — Subsonic API, browsing, scrobbling +- `external/plex/` — Plex Media Server, search, album tracks +- `external/spotify/` — Spotify, search, playlist management, custom streaming +- `external/radio/` — internet radio, favorites +- `external/local/` — local TOML playlist files + +## Base Interface (required) + +Every provider must implement `playlist.Provider`: + +```go +type Provider interface { + Name() string + Playlists() ([]playlist.PlaylistInfo, error) + Tracks(playlistID string) ([]Track, error) +} +``` + +This gives the provider a name, a list of playlists, and the ability to return +tracks for a playlist. That's enough for basic playback. + +## Capability Interfaces (optional) + +Implement any combination of these to unlock additional UI features. All +interfaces are defined in `provider/interfaces.go`. + +| Interface | What it enables | Methods | +|---|---|---| +| `Searcher` | Track search overlay | `SearchTracks(ctx, query, limit)` | +| `ArtistBrowser` | Hierarchical artist browsing | `Artists()`, `ArtistAlbums(id)` | +| `AlbumBrowser` | Paginated album browsing with sort | `AlbumList(sort, offset, size)`, `AlbumSortTypes()` | +| `AlbumTrackLoader` | Album track listing | `AlbumTracks(albumID)` | +| `Scrobbler` | Playback reporting | `Scrobble(track, submission)` | +| `PlaylistWriter` | Add track to playlist | `AddTrackToPlaylist(ctx, playlistID, track)` | +| `PlaylistCreator` | Create new playlist | `CreatePlaylist(ctx, name)` | +| `PlaylistDeleter` | Remove playlists/tracks | `DeletePlaylist(name)`, `RemoveTrack(name, index)` | +| `CustomStreamer` | Custom URI decode pipeline | `URISchemes()`, `NewStreamer(uri)` | +| `FavoriteToggler` | Favorite toggling | `ToggleFavorite(id)` | +| `Closer` | Cleanup on shutdown | `Close()` | +| `Authenticator` | Interactive sign-in flow | `Authenticate() error` (in `playlist` package) | + +## Steps + +### 1. Create the package + +Create `external//provider.go`: + +```go +package jellyfin + +import ( + "context" + "cliamp/playlist" + "cliamp/provider" +) + +// Compile-time interface checks. +var ( + _ provider.Searcher = (*Provider)(nil) + _ provider.AlbumTrackLoader = (*Provider)(nil) +) + +type Provider struct { + baseURL string + token string +} + +func New(baseURL, token string) *Provider { + return &Provider{baseURL: baseURL, token: token} +} + +func (p *Provider) Name() string { return "Jellyfin" } + +func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) { + // Fetch playlists from your server's API. + return nil, nil +} + +func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) { + // Fetch tracks for a playlist. + return nil, nil +} + +func (p *Provider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) { + // Search the server's catalog. + return nil, nil +} + +func (p *Provider) AlbumTracks(albumID string) ([]playlist.Track, error) { + // Fetch tracks for an album. + return nil, nil +} +``` + +### 2. Return tracks + +When building `playlist.Track` values: + +- **`Path`** — the playable URL or file path. For HTTP streams, use a full URL. + For custom URI schemes (e.g. `spotify:track:xxx`), implement `CustomStreamer`. +- **`Stream: true`** — set this for HTTP URLs so the player uses the streaming + pipeline. +- **`ProviderMeta`** — attach provider-specific metadata as a string map with + namespaced keys. This is used for features like scrobbling: + +```go +playlist.Track{ + Path: "https://my-server/stream/123", + Title: "Song Title", + Artist: "Artist Name", + Stream: true, + ProviderMeta: map[string]string{"jellyfin.id": "123"}, +} +``` + +### 3. Add configuration + +Add a config struct to `config/config.go`: + +```go +type JellyfinConfig struct { + URL string `toml:"url"` + Token string `toml:"token"` +} +``` + +Add the field to the top-level `Config` struct and a TOML section: + +```toml +[jellyfin] +url = "https://jellyfin.example.com" +token = "your-api-key" +``` + +### 4. Register in main.go + +Wire up the provider in the `run()` function in `main.go`: + +```go +if cfg.Jellyfin.URL != "" && cfg.Jellyfin.Token != "" { + jfProv := jellyfin.New(cfg.Jellyfin.URL, cfg.Jellyfin.Token) + providers = append(providers, ui.ProviderEntry{ + Key: "jellyfin", Name: "Jellyfin", Provider: jfProv, + }) +} +``` + +If your provider needs a custom audio pipeline (like Spotify's `spotify:` URIs), +register a streamer factory: + +```go +if cs, ok := myProv.(provider.CustomStreamer); ok { + for _, scheme := range cs.URISchemes() { + p.RegisterStreamerFactory(scheme, cs.NewStreamer) + } +} +``` + +If your provider needs the buffered download pipeline for its stream URLs +(like Navidrome's Subsonic endpoints), register a URL matcher: + +```go +p.RegisterBufferedURLMatcher(jellyfin.IsStreamURL) +``` + +### 5. Add a `--provider` flag value + +In `main.go`'s help text, add your provider key to the `--provider` line so +users can set it as their default. + +## What the UI Does Automatically + +You don't need to touch the UI code. Based on which interfaces your provider +implements, the UI will automatically: + +- Show the browse overlay ("N") if any registered provider implements `ArtistBrowser` or `AlbumBrowser` +- Show the search overlay ("F") if any registered provider implements `Searcher` +- Enable add-to-playlist in search results if the searched provider implements `PlaylistWriter` +- Scrobble playback if `Scrobbler` is implemented +- Run interactive auth on first use if `Authenticator` is implemented +- Call `Close()` on shutdown if `Closer` is implemented + +The "N" and "F" shortcuts work regardless of which provider is currently active +— they find the first registered provider with the needed capability. From 1cfe9478911f4cbea2ba30c124102fe6c70726b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Mon, 30 Mar 2026 13:17:54 +0200 Subject: [PATCH 3/6] Simplify, refactor and remove dead code --- external/navidrome/client.go | 51 +++++++++++------------------------- external/plex/provider.go | 25 ++++++------------ external/spotify/provider.go | 3 --- provider/types.go | 5 ++++ ui/keys.go | 2 +- ui/model.go | 8 +++--- 6 files changed, 33 insertions(+), 61 deletions(-) diff --git a/external/navidrome/client.go b/external/navidrome/client.go index b78c08fa..2611589d 100644 --- a/external/navidrome/client.go +++ b/external/navidrome/client.go @@ -71,49 +71,28 @@ func IsSubsonicStreamURL(path string) bool { strings.HasSuffix(p, "/rest/download.view") } -// SortTypeLabel returns a human-readable label for a sort type constant. -func SortTypeLabel(s string) string { - switch s { - case SortAlphabeticalByName: - return "Alphabetical by Name" - case SortAlphabeticalByArtist: - return "Alphabetical by Artist" - case SortNewest: - return "Newest" - case SortRecent: - return "Recently Played" - case SortFrequent: - return "Most Played" - case SortStarred: - return "Starred" - case SortByYear: - return "By Year" - case SortByGenre: - return "By Genre" - default: - return s - } -} - // Artist is a Navidrome/Subsonic artist — aliased to the provider type. type Artist = provider.ArtistInfo // Album is a Navidrome/Subsonic album — aliased to the provider type. type Album = provider.AlbumInfo +// albumSortTypes is the static list of sort options for album browsing. +var albumSortTypes = []provider.SortType{ + {ID: SortAlphabeticalByName, Label: "Alphabetical by Name"}, + {ID: SortAlphabeticalByArtist, Label: "Alphabetical by Artist"}, + {ID: SortNewest, Label: "Newest"}, + {ID: SortRecent, Label: "Recently Played"}, + {ID: SortFrequent, Label: "Most Played"}, + {ID: SortStarred, Label: "Starred"}, + {ID: SortByYear, Label: "By Year"}, + {ID: SortByGenre, Label: "By Genre"}, +} + // AlbumSortTypes returns the available sort options for album browsing. // Implements provider.AlbumBrowser. func (c *NavidromeClient) AlbumSortTypes() []provider.SortType { - return []provider.SortType{ - {ID: SortAlphabeticalByName, Label: "Alphabetical by Name"}, - {ID: SortAlphabeticalByArtist, Label: "Alphabetical by Artist"}, - {ID: SortNewest, Label: "Newest"}, - {ID: SortRecent, Label: "Recently Played"}, - {ID: SortFrequent, Label: "Most Played"}, - {ID: SortStarred, Label: "Starred"}, - {ID: SortByYear, Label: "By Year"}, - {ID: SortByGenre, Label: "By Genre"}, - } + return albumSortTypes } // NavidromeClient implements playlist.Provider for a Navidrome/Subsonic server. @@ -432,7 +411,7 @@ func (c *NavidromeClient) songToTrack(s subsonicSong) playlist.Track { Genre: s.Genre, Stream: true, DurationSecs: s.Duration, - ProviderMeta: map[string]string{"navidrome.id": s.ID}, + ProviderMeta: map[string]string{provider.MetaNavidromeID: s.ID}, } } @@ -475,7 +454,7 @@ func (c *NavidromeClient) streamURL(id string) string { // Scrobble reports playback to the Navidrome/Subsonic server. // Implements provider.Scrobbler. func (c *NavidromeClient) Scrobble(track playlist.Track, submission bool) { - id := track.Meta("navidrome.id") + id := track.Meta(provider.MetaNavidromeID) if id == "" { return } diff --git a/external/plex/provider.go b/external/plex/provider.go index b34aedc0..58003e9c 100644 --- a/external/plex/provider.go +++ b/external/plex/provider.go @@ -107,22 +107,7 @@ func (p *Provider) Tracks(albumRatingKey string) ([]playlist.Track, error) { return nil, err } - tracks := make([]playlist.Track, 0, len(plexTracks)) - for _, t := range plexTracks { - if t.PartKey == "" { - continue // no streamable file attached; skip silently - } - 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, - }) - } + tracks := p.convertTracks(plexTracks, 0) p.mu.Lock() if p.trackCache == nil { @@ -141,6 +126,12 @@ func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]p 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 == "" { @@ -160,7 +151,7 @@ func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]p break } } - return tracks, nil + return tracks } // AlbumTracks returns the tracks for the given album (ratingKey). diff --git a/external/spotify/provider.go b/external/spotify/provider.go index 5208f6d3..e1aa8383 100644 --- a/external/spotify/provider.go +++ b/external/spotify/provider.go @@ -449,9 +449,6 @@ func isAuthError(err error) bool { return false } -// NewStreamer creates a SpotifyStreamer for the given spotify:track:xxx URI. -// Called by the player's StreamerFactory when it encounters a Spotify URI. -// // URISchemes returns the URI prefixes handled by this provider. // Implements provider.CustomStreamer. func (p *SpotifyProvider) URISchemes() []string { return []string{"spotify:"} } diff --git a/provider/types.go b/provider/types.go index 5c0818df..e1b23039 100644 --- a/provider/types.go +++ b/provider/types.go @@ -28,3 +28,8 @@ type SortType struct { ID string // e.g. "alphabeticalByName" Label string // e.g. "By Name" } + +// ProviderMeta key constants used across providers and the UI. +const ( + MetaNavidromeID = "navidrome.id" +) diff --git a/ui/keys.go b/ui/keys.go index d83de22c..ea4264e2 100644 --- a/ui/keys.go +++ b/ui/keys.go @@ -40,7 +40,7 @@ func (m *Model) quit() tea.Cmd { // scrobbleCurrent fires a scrobble for the currently playing track if applicable. func (m *Model) scrobbleCurrent() { - if track, _ := m.playlist.Current(); track.Meta("navidrome.id") != "" { + if track, _ := m.playlist.Current(); track.Meta(provider.MetaNavidromeID) != "" { m.maybeScrobble(track, m.player.Position(), m.player.Duration()) } } diff --git a/ui/model.go b/ui/model.go index 636adf77..a8100050 100644 --- a/ui/model.go +++ b/ui/model.go @@ -489,7 +489,7 @@ func (m *Model) openPlaylistManager() { // plMgrEnterTrackList loads the tracks for a playlist and switches to screen 1. func (m *Model) plMgrEnterTrackList(name string) { - tracks, err := m.localProvider.Tracks(name) // localProvider is always available for playlist management + tracks, err := m.localProvider.Tracks(name) if err != nil { m.status.Showf(statusTTLDefault, "Load failed: %s", err) return @@ -1976,7 +1976,7 @@ func (m *Model) lyricsSyncable() bool { } // ICY radio streams: position counts from stream connect, not song start. // Navidrome streams have provider metadata set — those track position correctly. - if track.Stream && track.Meta("navidrome.id") == "" { + if track.Stream && track.Meta(provider.MetaNavidromeID) == "" { return false } return true @@ -2033,7 +2033,7 @@ func (m *Model) maybeScrobble(track playlist.Track, elapsed, duration time.Durat if m.navClient == nil || !m.navScrobbleEnabled { return } - if track.Meta("navidrome.id") == "" { + if track.Meta(provider.MetaNavidromeID) == "" { return } if duration <= 0 { @@ -2055,7 +2055,7 @@ func (m *Model) nowPlaying(track playlist.Track) { m.luaMgr.Emit(luaplugin.EventTrackChange, trackToMap(track)) } - if m.navClient == nil || !m.navScrobbleEnabled || track.Meta("navidrome.id") == "" { + if m.navClient == nil || !m.navScrobbleEnabled || track.Meta(provider.MetaNavidromeID) == "" { return } go m.navClient.Scrobble(track, false) From c800b29d803db154d25cb1728458e8bc3452be31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Mon, 30 Mar 2026 15:55:41 +0200 Subject: [PATCH 4/6] Completely decouple from the ui --- external/radio/provider.go | 46 +++++++++++++- main.go | 2 +- provider/interfaces.go | 27 ++++++++ ui/commands.go | 35 ++++++----- ui/keys.go | 70 ++++++++++++--------- ui/keys_radio.go | 28 +++++---- ui/model.go | 122 +++++++++++++++++-------------------- ui/view.go | 8 +-- 8 files changed, 204 insertions(+), 134 deletions(-) diff --git a/external/radio/provider.go b/external/radio/provider.go index 4229a591..112b783b 100644 --- a/external/radio/provider.go +++ b/external/radio/provider.go @@ -20,8 +20,13 @@ import ( "cliamp/provider" ) -// Compile-time interface check. -var _ provider.FavoriteToggler = (*Provider)(nil) +// Compile-time interface checks. +var ( + _ provider.FavoriteToggler = (*Provider)(nil) + _ provider.CatalogLoader = (*Provider)(nil) + _ provider.CatalogSearcher = (*Provider)(nil) + _ provider.SectionedList = (*Provider)(nil) +) const builtinName = "cliamp radio" const builtinURL = "https://radio.cliamp.stream/streams.m3u" @@ -229,13 +234,48 @@ func (p *Provider) IsSearching() bool { return p.searchResults != nil } +// LoadCatalogPage fetches the next page of catalog entries from the Radio +// Browser API and appends them to the provider's catalog. +// Implements provider.CatalogLoader. +func (p *Provider) LoadCatalogPage(offset, limit int) (int, error) { + stations, err := TopStationsOffset(offset, limit) + if err != nil { + return 0, err + } + p.AppendCatalog(stations) + return len(stations), nil +} + +// SearchCatalog performs a server-side station search via the Radio Browser API. +// Results are reflected in subsequent Playlists() calls. +// Implements provider.CatalogSearcher. +func (p *Provider) SearchCatalog(query string) (int, error) { + stations, err := SearchStations(query, 200) + if err != nil { + return 0, err + } + p.SetSearchResults(stations) + return len(stations), nil +} + +// IsFavoritableID reports whether the given ID can be favorited. +// Implements provider.SectionedList. +func (p *Provider) IsFavoritableID(id string) bool { + return IsCatalogOrFavID(id) +} + // IsCatalogOrFavID returns true if the ID belongs to a catalog, search, or favorite entry. func IsCatalogOrFavID(id string) bool { return strings.HasPrefix(id, "c:") || strings.HasPrefix(id, "f:") || strings.HasPrefix(id, "s:") } // IDPrefix returns the type prefix of a provider list ID ("l", "f", "c", or ""). -func IDPrefix(id string) string { +// Also implements provider.SectionedList when called as a method. +func (p *Provider) IDPrefix(id string) string { + return idPrefix(id) +} + +func idPrefix(id string) string { prefix, _, ok := strings.Cut(id, ":") if !ok { return "" diff --git a/main.go b/main.go index 34ce552d..04f6c485 100644 --- a/main.go +++ b/main.go @@ -198,7 +198,7 @@ func run(overrides config.Overrides, positional []string) error { defer luaMgr.Close() } - m := ui.NewModel(p, pl, providers, defaultProvider, localProv, themes, cfg.Navidrome, navClient, luaMgr) + m := ui.NewModel(p, pl, providers, defaultProvider, localProv, themes, cfg.Navidrome.BrowseSort, luaMgr) // Wire Lua plugin state provider with read-only access to player/playlist. if luaMgr != nil { diff --git a/provider/interfaces.go b/provider/interfaces.go index bcba49b1..f986ed55 100644 --- a/provider/interfaces.go +++ b/provider/interfaces.go @@ -74,6 +74,33 @@ type FavoriteToggler interface { ToggleFavorite(id string) (added bool, name string, err error) } +// CatalogLoader is implemented by providers that support lazy-loading +// catalog pages from an external source (e.g. Radio Browser API). +type CatalogLoader interface { + // LoadCatalogPage fetches the next page of catalog entries starting at + // offset. Returns the number of items added and any error. + LoadCatalogPage(offset, limit int) (added int, err error) +} + +// CatalogSearcher is implemented by providers that support server-side +// catalog search (e.g. radio station search via an API). +type CatalogSearcher interface { + // SearchCatalog performs a server-side search. Results are reflected + // in the next Playlists() call. + SearchCatalog(query string) (int, error) + ClearSearch() + IsSearching() bool +} + +// SectionedList is implemented by providers whose playlist list has +// logical sections (e.g. local stations, favorites, catalog). +type SectionedList interface { + // IDPrefix returns the section prefix for a playlist ID (e.g. "f", "c", "s"). + IDPrefix(id string) string + // IsFavoritableID reports whether the given ID can be favorited. + IsFavoritableID(id string) bool +} + // Closer is implemented by providers that hold resources (sessions, // connections) that should be released on shutdown. type Closer interface { diff --git a/ui/commands.go b/ui/commands.go index 14d31b36..5191b4f1 100644 --- a/ui/commands.go +++ b/ui/commands.go @@ -6,7 +6,6 @@ import ( tea "github.com/charmbracelet/bubbletea" - "cliamp/external/radio" "cliamp/lyrics" "cliamp/player" "cliamp/playlist" @@ -264,34 +263,34 @@ func fetchNavAlbumTracksCmd(l provider.AlbumTrackLoader, albumID string) tea.Cmd } } -// radioProvSearchMsg carries API search results for the provider view. -type radioProvSearchMsg struct { - stations []radio.CatalogStation - err error +// catalogSearchMsg carries the result of a provider.CatalogSearcher.SearchCatalog call. +type catalogSearchMsg struct { + count int + err error } -func fetchRadioProvSearchCmd(query string) tea.Cmd { +func fetchCatalogSearchCmd(s provider.CatalogSearcher, query string) tea.Cmd { return func() tea.Msg { - stations, err := radio.SearchStations(query, 200) - return radioProvSearchMsg{stations: stations, err: err} + count, err := s.SearchCatalog(query) + return catalogSearchMsg{count: count, err: err} } } -// — Radio batch loading for provider integration — +// — Catalog batch loading for providers with lazy catalogs — -// radioBatchSize is the number of catalog stations to fetch per page. -const radioBatchSize = 100 +// catalogBatchSize is the number of catalog entries to fetch per page. +const catalogBatchSize = 100 -// radioBatchMsg carries a page of catalog stations from the Radio Browser API. -type radioBatchMsg struct { - stations []radio.CatalogStation - err error +// catalogBatchMsg carries the result of a provider.CatalogLoader.LoadCatalogPage call. +type catalogBatchMsg struct { + added int + err error } -func fetchRadioBatchCmd(offset, limit int) tea.Cmd { +func fetchCatalogBatchCmd(loader provider.CatalogLoader, offset, limit int) tea.Cmd { return func() tea.Msg { - stations, err := radio.TopStationsOffset(offset, limit) - return radioBatchMsg{stations: stations, err: err} + added, err := loader.LoadCatalogPage(offset, limit) + return catalogBatchMsg{added: added, err: err} } } diff --git a/ui/keys.go b/ui/keys.go index ea4264e2..a3aa643a 100644 --- a/ui/keys.go +++ b/ui/keys.go @@ -1,6 +1,7 @@ package ui import ( + "context" "fmt" "os" "path/filepath" @@ -11,7 +12,6 @@ import ( "github.com/charmbracelet/lipgloss" "cliamp/config" - "cliamp/external/radio" "cliamp/internal/fileutil" "cliamp/playlist" "cliamp/provider" @@ -40,7 +40,7 @@ func (m *Model) quit() tea.Cmd { // scrobbleCurrent fires a scrobble for the currently playing track if applicable. func (m *Model) scrobbleCurrent() { - if track, _ := m.playlist.Current(); track.Meta(provider.MetaNavidromeID) != "" { + if track, idx := m.playlist.Current(); idx >= 0 { m.maybeScrobble(track, m.player.Position(), m.player.Duration()) } } @@ -177,7 +177,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { m.provCursor = 0 } // Auto-load next catalog page when scrolling near the bottom. - return m.maybeLoadRadioBatch() + return m.maybeLoadCatalogBatch() case "enter": if m.provSignIn { if auth, ok := m.provider.(playlist.Authenticator); ok { @@ -193,9 +193,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { case "tab": m.focus = focusEQ case "esc", "backspace", "b": - // If viewing radio search results, clear them first. - if rp, ok := m.provider.(*radio.Provider); ok && rp.IsSearching() { - m.restoreRadioCatalog(rp) + // If viewing catalog search results, clear them first. + if cs, ok := m.provider.(provider.CatalogSearcher); ok && cs.IsSearching() { + m.restoreCatalog(cs) return nil } if m.playlist.Len() > 0 { @@ -222,14 +222,14 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { if m.provCursor < len(m.providerLists)-1 { m.provCursor = min(len(m.providerLists)-1, m.provCursor+m.plVisible) } - return m.maybeLoadRadioBatch() + return m.maybeLoadCatalogBatch() case "g", "home": m.provCursor = 0 case "G", "end": if len(m.providerLists) > 0 { m.provCursor = len(m.providerLists) - 1 } - return m.maybeLoadRadioBatch() + return m.maybeLoadCatalogBatch() case "J": m.openJumpMode() case "x": @@ -715,9 +715,9 @@ func (m *Model) handleJumpKey(msg tea.KeyMsg) tea.Cmd { // For the radio provider, Enter fires an API search; for others, Enter loads the // selected result. Esc cancels and restores the normal catalog view. func (m *Model) handleProvSearchKey(msg tea.KeyMsg) tea.Cmd { - // Radio provider: API-based search (no live client-side filtering). - if rp, ok := m.provider.(*radio.Provider); ok { - return m.handleRadioProvSearchKey(msg, rp) + // Catalog search: API-based search (no live client-side filtering). + if cs, ok := m.provider.(provider.CatalogSearcher); ok { + return m.handleCatalogSearchKey(msg, cs) } switch msg.Type { case tea.KeyEscape: @@ -755,21 +755,21 @@ func (m *Model) handleProvSearchKey(msg tea.KeyMsg) tea.Cmd { return nil } -// handleRadioProvSearchKey handles search input for the radio provider. +// handleCatalogSearchKey handles search input for providers with catalog search. // Types a query, Enter fires API search, Esc cancels/clears. -func (m *Model) handleRadioProvSearchKey(msg tea.KeyMsg, rp *radio.Provider) tea.Cmd { +func (m *Model) handleCatalogSearchKey(msg tea.KeyMsg, cs provider.CatalogSearcher) tea.Cmd { switch msg.Type { case tea.KeyEscape: m.provSearch.active = false - m.restoreRadioCatalog(rp) + m.restoreCatalog(cs) case tea.KeyEnter: m.provSearch.active = false if m.provSearch.query == "" { - m.restoreRadioCatalog(rp) + m.restoreCatalog(cs) return nil } m.provLoading = true - return fetchRadioProvSearchCmd(m.provSearch.query) + return fetchCatalogSearchCmd(cs, m.provSearch.query) case tea.KeyBackspace, tea.KeyDelete: if m.provSearch.query != "" { m.provSearch.query = removeLastRune(m.provSearch.query) @@ -784,13 +784,13 @@ func (m *Model) handleRadioProvSearchKey(msg tea.KeyMsg, rp *radio.Provider) tea return nil } -// restoreRadioCatalog clears API search results and restores the normal catalog view. -func (m *Model) restoreRadioCatalog(rp *radio.Provider) { - if !rp.IsSearching() { +// restoreCatalog clears search results and restores the normal catalog view. +func (m *Model) restoreCatalog(cs provider.CatalogSearcher) { + if !cs.IsSearching() { return } - rp.ClearSearch() - if lists, err := rp.Playlists(); err == nil { + cs.ClearSearch() + if lists, err := m.provider.Playlists(); err == nil { m.providerLists = lists } m.provCursor = 0 @@ -980,10 +980,12 @@ func (m *Model) handlePlMgrListKey(msg tea.KeyMsg) tea.Cmd { case "y", "Y": if m.plManager.cursor < len(m.plManager.playlists) { name := m.plManager.playlists[m.plManager.cursor].Name - if err := m.localProvider.DeletePlaylist(name); err != nil { - m.status.Showf(statusTTLDefault, "Delete failed: %s", err) - } else { - m.status.Showf(statusTTLDefault, "Deleted %q", name) + if d, ok := m.localProvider.(provider.PlaylistDeleter); ok { + if err := d.DeletePlaylist(name); err != nil { + m.status.Showf(statusTTLDefault, "Delete failed: %s", err) + } else { + m.status.Showf(statusTTLDefault, "Deleted %q", name) + } } m.plMgrRefreshList() } @@ -1077,7 +1079,7 @@ func (m *Model) handlePlMgrTracksKey(msg tea.KeyMsg) tea.Cmd { case "d": // Remove highlighted track. if len(m.plManager.tracks) > 0 && m.plManager.cursor < len(m.plManager.tracks) { - err := m.localProvider.RemoveTrack(m.plManager.selPlaylist, m.plManager.cursor) + err := m.localDeleter().RemoveTrack(m.plManager.selPlaylist, m.plManager.cursor) if err != nil { m.status.Showf(statusTTLDefault, "Remove failed: %s", err) } else { @@ -1137,6 +1139,12 @@ func (m *Model) handlePlMgrNewNameKey(msg tea.KeyMsg) tea.Cmd { return nil } +// localDeleter returns the PlaylistDeleter from the local provider. +func (m *Model) localDeleter() provider.PlaylistDeleter { + d, _ := m.localProvider.(provider.PlaylistDeleter) + return d +} + // addToPlaylist appends the current track to a local playlist and shows a status message. func (m *Model) addToPlaylist(name string) { track, idx := m.playlist.Current() @@ -1144,10 +1152,12 @@ func (m *Model) addToPlaylist(name string) { m.status.Show("No track to add", statusTTLShort) return } - if err := m.localProvider.AddTrack(name, track); err != nil { - m.status.Showf(statusTTLDefault, "Failed: %s", err) - } else { - m.status.Showf(statusTTLDefault, "Added to %q", name) + if w, ok := m.localProvider.(provider.PlaylistWriter); ok { + if err := w.AddTrackToPlaylist(context.Background(), name, track); err != nil { + m.status.Showf(statusTTLDefault, "Failed: %s", err) + } else { + m.status.Showf(statusTTLDefault, "Added to %q", name) + } } } diff --git a/ui/keys_radio.go b/ui/keys_radio.go index 107dc950..57cf8af2 100644 --- a/ui/keys_radio.go +++ b/ui/keys_radio.go @@ -3,41 +3,43 @@ package ui import ( tea "github.com/charmbracelet/bubbletea" - "cliamp/external/radio" + "cliamp/provider" ) -// maybeLoadRadioBatch triggers a catalog batch fetch when the cursor is near the -// bottom of the provider list and more stations are available. -func (m *Model) maybeLoadRadioBatch() tea.Cmd { - rp, ok := m.provider.(*radio.Provider) +// maybeLoadCatalogBatch triggers a catalog batch fetch when the cursor is near the +// bottom of the provider list and more entries are available. +func (m *Model) maybeLoadCatalogBatch() tea.Cmd { + loader, ok := m.provider.(provider.CatalogLoader) if !ok { return nil } if m.radioBatch.loading || m.radioBatch.done { return nil } - if rp.IsSearching() { + if cs, ok := m.provider.(provider.CatalogSearcher); ok && cs.IsSearching() { return nil } if m.provCursor >= len(m.providerLists)-10 { m.radioBatch.loading = true - return fetchRadioBatchCmd(m.radioBatch.offset, radioBatchSize) + return fetchCatalogBatchCmd(loader, m.radioBatch.offset, catalogBatchSize) } return nil } // toggleProviderFavorite toggles favorite status for the current entry in the -// provider list (only works for catalog, search, and favorite entries). +// provider list (only works for providers implementing FavoriteToggler + SectionedList). func (m *Model) toggleProviderFavorite() tea.Cmd { - rp, ok := m.provider.(*radio.Provider) + ft, ok := m.provider.(provider.FavoriteToggler) if !ok || len(m.providerLists) == 0 { return nil } id := m.providerLists[m.provCursor].ID - if !radio.IsCatalogOrFavID(id) { - return nil + if sl, ok := m.provider.(provider.SectionedList); ok { + if !sl.IsFavoritableID(id) { + return nil + } } - added, name, err := rp.ToggleFavorite(id) + added, name, err := ft.ToggleFavorite(id) if err != nil { return nil } @@ -48,7 +50,7 @@ func (m *Model) toggleProviderFavorite() tea.Cmd { } prevID := id - if lists, err := rp.Playlists(); err == nil { + if lists, err := m.provider.Playlists(); err == nil { m.providerLists = lists for i, p := range m.providerLists { if p.ID == prevID { diff --git a/ui/model.go b/ui/model.go index a8100050..6407af86 100644 --- a/ui/model.go +++ b/ui/model.go @@ -12,9 +12,6 @@ import ( "github.com/charmbracelet/lipgloss" "cliamp/config" - "cliamp/external/local" - "cliamp/external/navidrome" - "cliamp/external/radio" "cliamp/luaplugin" "cliamp/mpris" "cliamp/player" @@ -161,7 +158,7 @@ type Model struct { // Provider state provider playlist.Provider - localProvider *local.Provider // direct ref for local playlist file operations (always available) + localProvider playlist.Provider // local playlist provider for file-based playlist management (always available) providerLists []playlist.PlaylistInfo provCursor int provLoading bool @@ -253,38 +250,30 @@ type Model struct { cachedDur time.Duration lastTickAt time.Time // wall time of previous tickMsg; used for tick delta - // Navidrome client (kept separate from navBrowser for non-browser operations) - navClient *navidrome.NavidromeClient - navScrobbleEnabled bool } // NewModel creates a Model wired to the given player and playlist. // providers is the ordered list of available providers (Radio, Navidrome, Spotify). -// defaultProvider is the config key of the provider to select initially ("radio", "navidrome", "spotify"). +// defaultProvider is the config key of the provider to select initially. // localProv is an optional direct reference to the local provider for write ops. -// navCfg is the Navidrome config used to seed the initial browse sort preference. -// nav is the raw NavidromeClient (may be nil); stored directly so the browser -// key handler doesn't have to unwrap a provider. -func NewModel(p *player.Player, pl *playlist.Playlist, providers []ProviderEntry, defaultProvider string, localProv *local.Provider, themes []theme.Theme, navCfg config.NavidromeConfig, nav *navidrome.NavidromeClient, luaMgr *luaplugin.Manager) Model { - sortType := navCfg.BrowseSort - if sortType == "" { - sortType = navidrome.SortAlphabeticalByName +// browseSortType seeds the initial album browse sort preference (empty = default). +func NewModel(p *player.Player, pl *playlist.Playlist, providers []ProviderEntry, defaultProvider string, localProv playlist.Provider, themes []theme.Theme, browseSortType string, luaMgr *luaplugin.Manager) Model { + if browseSortType == "" { + browseSortType = "alphabeticalByName" } m := Model{ - player: p, - playlist: pl, - vis: NewVisualizer(float64(p.SampleRate())), - seekStepLarge: 30 * time.Second, - plVisible: 5, - eqPresetIdx: -1, // custom until a preset is selected - themes: themes, - themeIdx: -1, // Default (ANSI) - localProvider: localProv, - providers: providers, - navBrowser: navBrowserState{sortType: sortType}, - navClient: nav, - navScrobbleEnabled: navCfg.ScrobbleEnabled(), - luaMgr: luaMgr, + player: p, + playlist: pl, + vis: NewVisualizer(float64(p.SampleRate())), + seekStepLarge: 30 * time.Second, + plVisible: 5, + eqPresetIdx: -1, // custom until a preset is selected + themes: themes, + themeIdx: -1, // Default (ANSI) + localProvider: localProv, + providers: providers, + navBrowser: navBrowserState{sortType: browseSortType}, + luaMgr: luaMgr, } m.termTitle = initialTerminalTitleState() // Select the default provider pill. @@ -1188,10 +1177,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case []playlist.PlaylistInfo: m.providerLists = msg m.provLoading = false - // Start loading catalog stations when the radio provider is active. - if _, ok := m.provider.(*radio.Provider); ok && !m.radioBatch.loading && !m.radioBatch.done { + // Start loading catalog when the provider supports lazy catalog loading. + if loader, ok := m.provider.(provider.CatalogLoader); ok && !m.radioBatch.loading && !m.radioBatch.done { m.radioBatch.loading = true - return m, fetchRadioBatchCmd(m.radioBatch.offset, radioBatchSize) + return m, fetchCatalogBatchCmd(loader, m.radioBatch.offset, catalogBatchSize) } return m, nil @@ -1251,43 +1240,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.navBrowser.screen = navBrowseScreenTracks return m, nil - case radioBatchMsg: + case catalogBatchMsg: m.radioBatch.loading = false if msg.err != nil { m.radioBatch.done = true m.status.Show("Catalog load failed", statusTTLDefault) return m, nil } - if len(msg.stations) == 0 { + if msg.added == 0 { m.radioBatch.done = true return m, nil } - if rp, ok := m.provider.(*radio.Provider); ok { - rp.AppendCatalog(msg.stations) - if lists, err := rp.Playlists(); err == nil { - m.providerLists = lists - } + if lists, err := m.provider.Playlists(); err == nil { + m.providerLists = lists } - m.radioBatch.offset += len(msg.stations) - if len(msg.stations) < radioBatchSize { + m.radioBatch.offset += msg.added + if msg.added < catalogBatchSize { m.radioBatch.done = true } return m, nil - case radioProvSearchMsg: + case catalogSearchMsg: m.provLoading = false - if rp, ok := m.provider.(*radio.Provider); ok { - if msg.err != nil { - m.status.Show("Search failed", statusTTLDefault) - } else { - rp.SetSearchResults(msg.stations) - if lists, err := rp.Playlists(); err == nil { - m.providerLists = lists - } - m.provCursor = 0 - if len(msg.stations) == 0 { - m.status.Show("No stations found", statusTTLDefault) - } + if msg.err != nil { + m.status.Show("Search failed", statusTTLDefault) + } else { + if lists, err := m.provider.Playlists(); err == nil { + m.providerLists = lists + } + m.provCursor = 0 + if msg.count == 0 { + m.status.Show("No stations found", statusTTLDefault) } } return m, nil @@ -1975,8 +1958,8 @@ func (m *Model) lyricsSyncable() bool { return false } // ICY radio streams: position counts from stream connect, not song start. - // Navidrome streams have provider metadata set — those track position correctly. - if track.Stream && track.Meta(provider.MetaNavidromeID) == "" { + // Provider streams with metadata (e.g. Navidrome) track position correctly. + if track.Stream && len(track.ProviderMeta) == 0 { return false } return true @@ -2012,7 +1995,7 @@ func (m *Model) updateSearch() { // conditions are met: // - navClient is configured // - scrobbling is enabled in config -// - the track has a navidrome.id in ProviderMeta (i.e. it came from Navidrome) +// - a registered provider implements Scrobbler // - elapsed is at least 50% of the track's known duration // // The call is dispatched in a goroutine so it never blocks the UI. @@ -2030,10 +2013,8 @@ func (m *Model) maybeScrobble(track playlist.Track, elapsed, duration time.Durat } } - if m.navClient == nil || !m.navScrobbleEnabled { - return - } - if track.Meta(provider.MetaNavidromeID) == "" { + scrobbler := m.findScrobbler() + if scrobbler == nil { return } if duration <= 0 { @@ -2046,7 +2027,7 @@ func (m *Model) maybeScrobble(track playlist.Track, elapsed, duration time.Durat if elapsed < duration/2 { return // less than 50% played } - go m.navClient.Scrobble(track, true) + go scrobbler.Scrobble(track, true) } // nowPlaying fires a now-playing notification for the given track if configured. @@ -2055,8 +2036,19 @@ func (m *Model) nowPlaying(track playlist.Track) { m.luaMgr.Emit(luaplugin.EventTrackChange, trackToMap(track)) } - if m.navClient == nil || !m.navScrobbleEnabled || track.Meta(provider.MetaNavidromeID) == "" { - return + if scrobbler := m.findScrobbler(); scrobbler != nil { + go scrobbler.Scrobble(track, true) + } +} + +// findScrobbler returns the first registered provider that implements Scrobbler. +func (m *Model) findScrobbler() provider.Scrobbler { + prov := m.findProviderWith(func(p playlist.Provider) bool { + _, ok := p.(provider.Scrobbler) + return ok + }) + if prov == nil { + return nil } - go m.navClient.Scrobble(track, false) + return prov.(provider.Scrobbler) } diff --git a/ui/view.go b/ui/view.go index 8c383f44..dae47c22 100644 --- a/ui/view.go +++ b/ui/view.go @@ -9,8 +9,8 @@ import ( "github.com/charmbracelet/lipgloss" - "cliamp/external/radio" "cliamp/playlist" + "cliamp/provider" "cliamp/theme" ) @@ -455,7 +455,7 @@ func (m Model) renderProviderList() string { return dimStyle.Render(" No playlists found.\n Add playlists to ~/.config/cliamp/playlists/") } - _, isRadio := m.provider.(*radio.Provider) + sl, isRadio := m.provider.(provider.SectionedList) var lines []string @@ -501,7 +501,7 @@ func (m Model) renderProviderList() string { // Insert section headers on prefix transitions for the radio provider. if isRadio { - pfx := radio.IDPrefix(p.ID) + pfx := sl.IDPrefix(p.ID) if pfx != prevPrefix { switch pfx { case "f": @@ -622,7 +622,7 @@ func (m Model) renderJumpOverlay() string { func (m Model) renderHelp() string { if m.focus == focusProvider { help := helpKey("↑↓", "Navigate ") + helpKey("Enter", "Load ") + helpKey("/", "Search ") - if _, ok := m.provider.(*radio.Provider); ok { + if _, ok := m.provider.(provider.FavoriteToggler); ok { help += helpKey("f", "Fav ") } return help + helpKey("Tab", "Focus ") + helpKey("Ctrl+K", "Keys") From 13ddba0e9e972893bd82066f6e090dbcd8d60cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Mon, 30 Mar 2026 16:03:05 +0200 Subject: [PATCH 5/6] Simplify --- ui/keys_radio.go | 6 +++--- ui/model.go | 22 +++++++++++----------- ui/state.go | 4 ++-- ui/view.go | 2 +- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/ui/keys_radio.go b/ui/keys_radio.go index 57cf8af2..a1118d2c 100644 --- a/ui/keys_radio.go +++ b/ui/keys_radio.go @@ -13,15 +13,15 @@ func (m *Model) maybeLoadCatalogBatch() tea.Cmd { if !ok { return nil } - if m.radioBatch.loading || m.radioBatch.done { + if m.catalogBatch.loading || m.catalogBatch.done { return nil } if cs, ok := m.provider.(provider.CatalogSearcher); ok && cs.IsSearching() { return nil } if m.provCursor >= len(m.providerLists)-10 { - m.radioBatch.loading = true - return fetchCatalogBatchCmd(loader, m.radioBatch.offset, catalogBatchSize) + m.catalogBatch.loading = true + return fetchCatalogBatchCmd(loader, m.catalogBatch.offset, catalogBatchSize) } return nil } diff --git a/ui/model.go b/ui/model.go index 6407af86..85a534ec 100644 --- a/ui/model.go +++ b/ui/model.go @@ -181,7 +181,7 @@ type Model struct { spotSearch spotSearchState fileBrowser fileBrowserState navBrowser navBrowserState - radioBatch radioBatchState + catalogBatch catalogBatchState ytdlBatch ytdlBatchState reconnect reconnectState save saveState @@ -531,7 +531,7 @@ func (m *Model) switchProvider(idx int) tea.Cmd { m.provLoading = true m.provSignIn = false m.provSearch.active = false - m.radioBatch = radioBatchState{} // reset catalog batch for new provider + m.catalogBatch = catalogBatchState{} // reset catalog batch for new provider m.focus = focusProvider return fetchPlaylistsCmd(m.provider) } @@ -1178,9 +1178,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.providerLists = msg m.provLoading = false // Start loading catalog when the provider supports lazy catalog loading. - if loader, ok := m.provider.(provider.CatalogLoader); ok && !m.radioBatch.loading && !m.radioBatch.done { - m.radioBatch.loading = true - return m, fetchCatalogBatchCmd(loader, m.radioBatch.offset, catalogBatchSize) + if loader, ok := m.provider.(provider.CatalogLoader); ok && !m.catalogBatch.loading && !m.catalogBatch.done { + m.catalogBatch.loading = true + return m, fetchCatalogBatchCmd(loader, m.catalogBatch.offset, catalogBatchSize) } return m, nil @@ -1241,22 +1241,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case catalogBatchMsg: - m.radioBatch.loading = false + m.catalogBatch.loading = false if msg.err != nil { - m.radioBatch.done = true + m.catalogBatch.done = true m.status.Show("Catalog load failed", statusTTLDefault) return m, nil } if msg.added == 0 { - m.radioBatch.done = true + m.catalogBatch.done = true return m, nil } if lists, err := m.provider.Playlists(); err == nil { m.providerLists = lists } - m.radioBatch.offset += msg.added + m.catalogBatch.offset += msg.added if msg.added < catalogBatchSize { - m.radioBatch.done = true + m.catalogBatch.done = true } return m, nil @@ -2037,7 +2037,7 @@ func (m *Model) nowPlaying(track playlist.Track) { } if scrobbler := m.findScrobbler(); scrobbler != nil { - go scrobbler.Scrobble(track, true) + go scrobbler.Scrobble(track, false) } } diff --git a/ui/state.go b/ui/state.go index 658671d8..79a883af 100644 --- a/ui/state.go +++ b/ui/state.go @@ -147,8 +147,8 @@ type spotSearchState struct { err string } -// radioBatchState holds state for lazy-loading catalog stations from the Radio Browser API. -type radioBatchState struct { +// catalogBatchState holds state for lazy-loading catalog entries from a provider.CatalogLoader. +type catalogBatchState struct { offset int // next offset to fetch loading bool // true while a fetch is in flight done bool // true when all stations have been loaded diff --git a/ui/view.go b/ui/view.go index dae47c22..2f140a84 100644 --- a/ui/view.go +++ b/ui/view.go @@ -527,7 +527,7 @@ func (m Model) renderProviderList() string { } // Loading indicator for catalog batch. - if isRadio && m.radioBatch.loading { + if isRadio && m.catalogBatch.loading { lines = append(lines, dimStyle.Render(" Loading more stations...")) } From 0835d59d9eb8e474264dcb0e88207c99dcb1e24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Mon, 30 Mar 2026 16:06:35 +0200 Subject: [PATCH 6/6] Dead code cleanup --- config/config.go | 6 ------ ui/model.go | 8 -------- 2 files changed, 14 deletions(-) diff --git a/config/config.go b/config/config.go index 22010aaa..b48dc8d8 100644 --- a/config/config.go +++ b/config/config.go @@ -39,12 +39,6 @@ func (n NavidromeConfig) IsSet() bool { return n.URL != "" && n.User != "" && n.Password != "" } -// ScrobbleEnabled reports whether scrobbling is active. -// Scrobbling is opt-out: it is enabled unless "scrobble = false" is explicitly set. -func (n NavidromeConfig) ScrobbleEnabled() bool { - return !n.ScrobbleDisabled -} - // SpotifyConfig holds settings for the Spotify provider. // Requires a Spotify Premium account and a client_id from // developer.spotify.com/dashboard. diff --git a/ui/model.go b/ui/model.go index 85a534ec..980a7b0a 100644 --- a/ui/model.go +++ b/ui/model.go @@ -923,14 +923,6 @@ func (m Model) mainFrameFixedLines(includeTransient bool) int { return lipgloss.Height(frameStyle.Render(content)) } -func (m Model) playlistVisibleLimit(limit int) int { - available := m.height - m.mainFrameFixedLines(false) - if available < minPlVisible { - return minPlVisible - } - return min(limit, available) -} - func (m Model) effectivePlaylistVisible() int { available := m.height - m.mainFrameFixedLines(true) if available <= 0 {