Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
193 changes: 193 additions & 0 deletions docs/provider-development.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Creating a Provider

Providers live in `external/<name>/` (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/<name>/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.
14 changes: 14 additions & 0 deletions external/local/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package local

import (
"context"
"errors"
"fmt"
"io"
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
86 changes: 49 additions & 37 deletions external/navidrome/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -47,46 +57,42 @@ var SortTypes = []string{
SortByGenre,
}

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

// Artist represents a Navidrome/Subsonic artist entry.
type Artist struct {
ID string
Name string
AlbumCount int
// 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"},
}

// Album represents a Navidrome/Subsonic album entry.
type Album struct {
ID string
Name string
Artist string
ArtistID string
Year int
SongCount int
Genre string
// AlbumSortTypes returns the available sort options for album browsing.
// Implements provider.AlbumBrowser.
func (c *NavidromeClient) AlbumSortTypes() []provider.SortType {
return albumSortTypes
}

// NavidromeClient implements playlist.Provider for a Navidrome/Subsonic server.
Expand Down Expand Up @@ -397,7 +403,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,
Expand All @@ -406,6 +411,7 @@ func (c *NavidromeClient) songToTrack(s subsonicSong) playlist.Track {
Genre: s.Genre,
Stream: true,
DurationSecs: s.Duration,
ProviderMeta: map[string]string{provider.MetaNavidromeID: s.ID},
}
}

Expand Down Expand Up @@ -445,7 +451,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(provider.MetaNavidromeID)
if id == "" {
return
}
params := url.Values{
"id": {id},
"submission": {fmt.Sprintf("%t", submission)},
Expand Down
Loading