chore: import upstream snapshot with attribution
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:13 +08:00
commit ead81af521
414 changed files with 73946 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
// Package emby adapts the shared Emby/Jellyfin client (internal/embyapi) to an
// Emby server and exposes it as a playlist provider.
package emby
import "github.com/bjarneo/cliamp/internal/embyapi"
// Client and Track alias the shared embyapi types so the provider layer reads
// naturally and external callers keep using emby.Client.
type (
Client = embyapi.Client
Track = embyapi.Track
)
// NewClient returns a Client for the given Emby server URL and credentials.
func NewClient(baseURL, token, userID, user, password string) *Client {
return embyapi.NewEmbyClient(baseURL, token, userID, user, password)
}
// IsStreamURL reports whether the URL is an Emby item download endpoint.
// Used by the player to route these URLs through the buffered ffmpeg pipeline.
func IsStreamURL(path string) bool { return embyapi.IsStreamURL(path) }
+201
View File
@@ -0,0 +1,201 @@
package emby
import (
"context"
"fmt"
"sync"
"time"
"github.com/bjarneo/cliamp/config"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
var (
_ provider.ArtistBrowser = (*Provider)(nil)
_ provider.AlbumBrowser = (*Provider)(nil)
_ provider.AlbumTrackLoader = (*Provider)(nil)
_ provider.PlaybackReporter = (*Provider)(nil)
_ provider.Searcher = (*Provider)(nil)
)
// Provider implements playlist.Provider for an Emby server.
// Playlists() returns albums across all music views.
// Tracks() returns the tracks for a given album item.
type Provider struct {
client *Client
mu sync.Mutex
playlistCache []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
}
func newProvider(client *Client) *Provider {
return &Provider{client: client}
}
// NewFromConfig returns a Provider from an EmbyConfig, or nil if URL or token is missing.
func NewFromConfig(cfg config.EmbyConfig) *Provider {
if !cfg.IsSet() {
return nil
}
return newProvider(NewClient(cfg.URL, cfg.Token, cfg.UserID, cfg.User, cfg.Password))
}
// Name returns the display name used in the provider selector.
func (p *Provider) Name() string { return "Emby" }
// Refresh clears cached playlist, track, and album data so the next call
// re-fetches from the server. Implements playlist.Refresher.
func (p *Provider) Refresh() {
p.mu.Lock()
p.playlistCache = nil
p.trackCache = nil
p.mu.Unlock()
p.client.ClearCache()
}
func (p *Provider) Artists() ([]provider.ArtistInfo, error) {
artists, err := p.client.Artists()
if err != nil {
return nil, fmt.Errorf("artists: %w", err)
}
return artists, nil
}
func (p *Provider) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
albums, err := p.client.ArtistAlbums(artistID)
if err != nil {
return nil, fmt.Errorf("artist albums: %w", err)
}
return albums, nil
}
func (p *Provider) AlbumList(sortType string, offset, size int) ([]provider.AlbumInfo, error) {
albums, err := p.client.AlbumList(sortType, offset, size)
if err != nil {
return nil, fmt.Errorf("album list: %w", err)
}
return albums, nil
}
func (p *Provider) AlbumSortTypes() []provider.SortType {
return p.client.AlbumSortTypes()
}
func (p *Provider) DefaultAlbumSort() string {
return p.client.DefaultAlbumSort()
}
func (p *Provider) AlbumTracks(albumID string) ([]playlist.Track, error) {
return p.Tracks(albumID)
}
func (p *Provider) CanReportPlayback(track playlist.Track) bool {
return track.Meta(provider.MetaEmbyID) != ""
}
func (p *Provider) ReportNowPlaying(track playlist.Track, position time.Duration, canSeek bool) {
_ = p.client.ReportNowPlaying(track, position, canSeek)
}
func (p *Provider) ReportScrobble(track playlist.Track, elapsed, _ time.Duration, canSeek bool) {
_ = p.client.ReportScrobble(track, elapsed, canSeek)
}
// Playlists returns all albums across all Emby music views.
// Results are cached after the first successful call.
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
p.mu.Lock()
if p.playlistCache != nil {
cached := p.playlistCache
p.mu.Unlock()
return cached, nil
}
p.mu.Unlock()
albums, err := p.client.Albums()
if err != nil {
return nil, fmt.Errorf("playlists: %w", err)
}
out := make([]playlist.PlaylistInfo, 0, len(albums))
for _, a := range albums {
name := a.Name
if a.Artist != "" {
name = a.Artist + " — " + a.Name
}
if a.Year > 0 {
name = fmt.Sprintf("%s (%d)", name, a.Year)
}
out = append(out, playlist.PlaylistInfo{
ID: a.ID,
Name: name,
TrackCount: a.TrackCount,
})
}
p.mu.Lock()
p.playlistCache = out
p.mu.Unlock()
return out, nil
}
// SearchTracks searches the Emby music library for tracks matching query.
// Implements provider.Searcher.
func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
embyTracks, err := p.client.Search(query, limit)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
return p.toPlaylistTracks(embyTracks), nil
}
// Tracks returns the tracks for one album item.
// Results are cached per album id.
func (p *Provider) Tracks(albumID string) ([]playlist.Track, error) {
p.mu.Lock()
if p.trackCache != nil {
if cached, ok := p.trackCache[albumID]; ok {
p.mu.Unlock()
return cached, nil
}
}
p.mu.Unlock()
embyTracks, err := p.client.Tracks(albumID)
if err != nil {
return nil, fmt.Errorf("tracks: %w", err)
}
out := p.toPlaylistTracks(embyTracks)
p.mu.Lock()
if p.trackCache == nil {
p.trackCache = make(map[string][]playlist.Track)
}
p.trackCache[albumID] = out
p.mu.Unlock()
return out, nil
}
// toPlaylistTracks converts Emby Tracks to playlist.Tracks, attaching the
// authenticated stream URL and Emby item ID metadata.
func (p *Provider) toPlaylistTracks(embyTracks []Track) []playlist.Track {
out := make([]playlist.Track, 0, len(embyTracks))
for _, t := range embyTracks {
out = append(out, playlist.Track{
Path: p.client.StreamURL(t.ID),
Title: t.Name,
Artist: t.Artist,
Album: t.Album,
Year: t.Year,
TrackNumber: t.TrackNumber,
DurationSecs: t.DurationSecs,
Stream: true,
ProviderMeta: map[string]string{provider.MetaEmbyID: t.ID},
})
}
return out
}
+126
View File
@@ -0,0 +1,126 @@
package emby
import (
"bytes"
"io"
"net/http"
"testing"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func jsonResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(body)),
}
}
func mockProvider(userID string, fn roundTripFunc) *Provider {
c := NewClient("https://emby.example.com", "tok", userID, "", "")
c.SetHTTPClient(&http.Client{Transport: fn})
return newProvider(c)
}
func TestProviderName(t *testing.T) {
p := newProvider(NewClient("https://emby.example.com", "tok", "user-1", "", ""))
if p.Name() != "Emby" {
t.Fatalf("Name() = %q, want Emby", p.Name())
}
}
func TestProviderPlaylists(t *testing.T) {
p := mockProvider("", func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/Users/Me":
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
case "/Users/user-1/Views":
return jsonResponse(`{"Items":[{"Id":"lib-1","Name":"Music","CollectionType":"music"}]}`), nil
case "/Items":
return jsonResponse(`{"Items":[{"Id":"album-1","Name":"Kind of Blue","AlbumArtist":"Miles Davis","ProductionYear":1959,"ChildCount":5}]}`), nil
default:
t.Fatalf("unexpected path %s", req.URL.Path)
return nil, nil
}
})
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists() error: %v", err)
}
if len(lists) != 1 {
t.Fatalf("expected 1 playlist, got %d", len(lists))
}
if lists[0].ID != "album-1" || lists[0].TrackCount != 5 {
t.Fatalf("playlist = %+v", lists[0])
}
if lists[0].Name != "Miles Davis — Kind of Blue (1959)" {
t.Fatalf("playlist name = %q", lists[0].Name)
}
}
func TestProviderTracks(t *testing.T) {
p := mockProvider("user-1", func(req *http.Request) (*http.Response, error) {
if req.URL.Path != "/Items" {
t.Fatalf("unexpected path %s", req.URL.Path)
}
return jsonResponse(`{
"Items": [
{
"Id":"track-1",
"Name":"So What",
"Album":"Kind of Blue",
"Artists":["Miles Davis"],
"ProductionYear":1959,
"IndexNumber":1,
"RunTimeTicks":5650000000
}
]
}`), nil
})
tracks, err := p.Tracks("album-1")
if err != nil {
t.Fatalf("Tracks() error: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("expected 1 track, got %d", len(tracks))
}
tr := tracks[0]
if tr.Title != "So What" || tr.Artist != "Miles Davis" || tr.Album != "Kind of Blue" || tr.TrackNumber != 1 || !tr.Stream {
t.Fatalf("track = %+v", tr)
}
if got := tr.Meta(provider.MetaEmbyID); got != "track-1" {
t.Fatalf("track meta emby id = %q, want track-1", got)
}
}
func TestProviderCanReportPlayback(t *testing.T) {
p := newProvider(NewClient("https://emby.example.com", "tok", "user-1", "", ""))
tests := []struct {
name string
track playlist.Track
want bool
}{
{"emby track", trackWithMeta(provider.MetaEmbyID, "track-1"), true},
{"non-emby track", trackWithMeta(provider.MetaNavidromeID, "nav-1"), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := p.CanReportPlayback(tc.track); got != tc.want {
t.Fatalf("CanReportPlayback() = %v, want %v", got, tc.want)
}
})
}
}
func trackWithMeta(key, value string) playlist.Track {
return playlist.Track{ProviderMeta: map[string]string{key: value}}
}
+21
View File
@@ -0,0 +1,21 @@
// Package jellyfin adapts the shared Emby/Jellyfin client (internal/embyapi)
// to a Jellyfin server and exposes it as a playlist provider.
package jellyfin
import "github.com/bjarneo/cliamp/internal/embyapi"
// Client and Track alias the shared embyapi types so the provider layer reads
// naturally and external callers keep using jellyfin.Client.
type (
Client = embyapi.Client
Track = embyapi.Track
)
// NewClient returns a Client for the given Jellyfin server URL and credentials.
func NewClient(baseURL, token, userID, user, password string) *Client {
return embyapi.NewJellyfinClient(baseURL, token, userID, user, password)
}
// IsStreamURL reports whether the URL is a Jellyfin item download endpoint.
// Used by the player to route these URLs through the buffered ffmpeg pipeline.
func IsStreamURL(path string) bool { return embyapi.IsStreamURL(path) }
+189
View File
@@ -0,0 +1,189 @@
package jellyfin
import (
"context"
"fmt"
"sync"
"time"
"github.com/bjarneo/cliamp/config"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
var (
_ provider.ArtistBrowser = (*Provider)(nil)
_ provider.AlbumBrowser = (*Provider)(nil)
_ provider.AlbumTrackLoader = (*Provider)(nil)
_ provider.PlaybackReporter = (*Provider)(nil)
_ provider.Searcher = (*Provider)(nil)
)
// Provider implements playlist.Provider for a Jellyfin server.
// Playlists() returns albums across all music views.
// Tracks() returns the tracks for a given album item.
type Provider struct {
client *Client
mu sync.Mutex
playlistCache []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
}
func newProvider(client *Client) *Provider {
return &Provider{client: client}
}
// NewFromConfig returns a Provider from a JellyfinConfig, or nil if URL or token is missing.
func NewFromConfig(cfg config.JellyfinConfig) *Provider {
if !cfg.IsSet() {
return nil
}
return newProvider(NewClient(cfg.URL, cfg.Token, cfg.UserID, cfg.User, cfg.Password))
}
// Name returns the display name used in the provider selector.
func (p *Provider) Name() string { return "Jellyfin" }
// Refresh clears cached playlist, track, and album data so the next call
// re-fetches from the server. Implements playlist.Refresher.
func (p *Provider) Refresh() {
p.mu.Lock()
p.playlistCache = nil
p.trackCache = nil
p.mu.Unlock()
p.client.ClearCache()
}
func (p *Provider) Artists() ([]provider.ArtistInfo, error) {
return p.client.Artists()
}
func (p *Provider) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
return p.client.ArtistAlbums(artistID)
}
func (p *Provider) AlbumList(sortType string, offset, size int) ([]provider.AlbumInfo, error) {
return p.client.AlbumList(sortType, offset, size)
}
func (p *Provider) AlbumSortTypes() []provider.SortType {
return p.client.AlbumSortTypes()
}
func (p *Provider) DefaultAlbumSort() string {
return p.client.DefaultAlbumSort()
}
func (p *Provider) AlbumTracks(albumID string) ([]playlist.Track, error) {
return p.Tracks(albumID)
}
func (p *Provider) CanReportPlayback(track playlist.Track) bool {
return track.Meta(provider.MetaJellyfinID) != ""
}
func (p *Provider) ReportNowPlaying(track playlist.Track, position time.Duration, canSeek bool) {
_ = p.client.ReportNowPlaying(track, position, canSeek)
}
func (p *Provider) ReportScrobble(track playlist.Track, elapsed, _ time.Duration, canSeek bool) {
_ = p.client.ReportScrobble(track, elapsed, canSeek)
}
// Playlists returns all albums across all Jellyfin music views.
// Results are cached after the first successful call.
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
p.mu.Lock()
if p.playlistCache != nil {
cached := p.playlistCache
p.mu.Unlock()
return cached, nil
}
p.mu.Unlock()
albums, err := p.client.Albums()
if err != nil {
return nil, err
}
out := make([]playlist.PlaylistInfo, 0, len(albums))
for _, a := range albums {
name := a.Name
if a.Artist != "" {
name = a.Artist + " — " + a.Name
}
if a.Year > 0 {
name = fmt.Sprintf("%s (%d)", name, a.Year)
}
out = append(out, playlist.PlaylistInfo{
ID: a.ID,
Name: name,
TrackCount: a.TrackCount,
})
}
p.mu.Lock()
p.playlistCache = out
p.mu.Unlock()
return out, nil
}
// SearchTracks searches the Jellyfin music library for tracks matching query.
// Implements provider.Searcher.
func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
jfTracks, err := p.client.Search(query, limit)
if err != nil {
return nil, err
}
return p.toPlaylistTracks(jfTracks), nil
}
// Tracks returns the tracks for one album item.
// Results are cached per album id.
func (p *Provider) Tracks(albumID string) ([]playlist.Track, error) {
p.mu.Lock()
if p.trackCache != nil {
if cached, ok := p.trackCache[albumID]; ok {
p.mu.Unlock()
return cached, nil
}
}
p.mu.Unlock()
jfTracks, err := p.client.Tracks(albumID)
if err != nil {
return nil, err
}
out := p.toPlaylistTracks(jfTracks)
p.mu.Lock()
if p.trackCache == nil {
p.trackCache = make(map[string][]playlist.Track)
}
p.trackCache[albumID] = out
p.mu.Unlock()
return out, nil
}
// toPlaylistTracks converts Jellyfin Tracks to playlist.Tracks, attaching the
// authenticated stream URL and Jellyfin item ID metadata.
func (p *Provider) toPlaylistTracks(jfTracks []Track) []playlist.Track {
out := make([]playlist.Track, 0, len(jfTracks))
for _, t := range jfTracks {
out = append(out, playlist.Track{
Path: p.client.StreamURL(t.ID),
Title: t.Name,
Artist: t.Artist,
Album: t.Album,
Year: t.Year,
TrackNumber: t.TrackNumber,
DurationSecs: t.DurationSecs,
Stream: true,
ProviderMeta: map[string]string{provider.MetaJellyfinID: t.ID},
})
}
return out
}
+117
View File
@@ -0,0 +1,117 @@
package jellyfin
import (
"bytes"
"io"
"net/http"
"testing"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func jsonResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(body)),
}
}
func mockProvider(userID string, fn roundTripFunc) *Provider {
c := NewClient("https://jf.example.com", "tok", userID, "", "")
c.SetHTTPClient(&http.Client{Transport: fn})
return newProvider(c)
}
func TestProviderName(t *testing.T) {
p := newProvider(NewClient("https://jf.example.com", "tok", "user-1", "", ""))
if p.Name() != "Jellyfin" {
t.Fatalf("Name() = %q, want Jellyfin", p.Name())
}
}
func TestProviderPlaylists(t *testing.T) {
p := mockProvider("", func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/Users/Me":
return jsonResponse(`{"Id":"user-1","Name":"Nomad"}`), nil
case "/Users/user-1/Views":
return jsonResponse(`{"Items":[{"Id":"lib-1","Name":"Music","CollectionType":"music"}]}`), nil
case "/Items":
return jsonResponse(`{"Items":[{"Id":"album-1","Name":"Kind of Blue","AlbumArtist":"Miles Davis","ProductionYear":1959,"ChildCount":5}]}`), nil
default:
t.Fatalf("unexpected path %s", req.URL.Path)
return nil, nil
}
})
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists() error: %v", err)
}
if len(lists) != 1 {
t.Fatalf("expected 1 playlist, got %d", len(lists))
}
if lists[0].ID != "album-1" || lists[0].TrackCount != 5 {
t.Fatalf("playlist = %+v", lists[0])
}
if lists[0].Name != "Miles Davis — Kind of Blue (1959)" {
t.Fatalf("playlist name = %q", lists[0].Name)
}
}
func TestProviderTracks(t *testing.T) {
p := mockProvider("user-1", func(req *http.Request) (*http.Response, error) {
if req.URL.Path != "/Items" {
t.Fatalf("unexpected path %s", req.URL.Path)
}
return jsonResponse(`{
"Items": [
{
"Id":"track-1",
"Name":"So What",
"Album":"Kind of Blue",
"Artists":["Miles Davis"],
"ProductionYear":1959,
"IndexNumber":1,
"RunTimeTicks":5650000000
}
]
}`), nil
})
tracks, err := p.Tracks("album-1")
if err != nil {
t.Fatalf("Tracks() error: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("expected 1 track, got %d", len(tracks))
}
tr := tracks[0]
if tr.Title != "So What" || tr.Artist != "Miles Davis" || tr.Album != "Kind of Blue" || tr.TrackNumber != 1 || !tr.Stream {
t.Fatalf("track = %+v", tr)
}
if got := tr.Meta(provider.MetaJellyfinID); got != "track-1" {
t.Fatalf("track meta jellyfin id = %q, want track-1", got)
}
}
func TestProviderCanReportPlayback(t *testing.T) {
p := newProvider(NewClient("https://jf.example.com", "tok", "user-1", "", ""))
if !p.CanReportPlayback(trackWithMeta(provider.MetaJellyfinID, "track-1")) {
t.Fatal("CanReportPlayback() = false, want true")
}
if p.CanReportPlayback(trackWithMeta(provider.MetaNavidromeID, "nav-1")) {
t.Fatal("CanReportPlayback() = true for non-Jellyfin track")
}
}
func trackWithMeta(key, value string) playlist.Track {
return playlist.Track{ProviderMeta: map[string]string{key: value}}
}
+578
View File
@@ -0,0 +1,578 @@
// Package local implements a playlist.Provider backed by TOML files in
// ~/.config/cliamp/playlists/.
package local
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/fuzzy"
"github.com/bjarneo/cliamp/internal/tomlutil"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// Compile-time interface checks.
var (
_ provider.PlaylistWriter = (*Provider)(nil)
_ provider.PlaylistBatchWriter = (*Provider)(nil)
_ provider.PlaylistCreator = (*Provider)(nil)
_ provider.PlaylistSaver = (*Provider)(nil)
_ provider.PlaylistDeleter = (*Provider)(nil)
_ provider.PlaylistRenamer = (*Provider)(nil)
_ provider.BookmarkSetter = (*Provider)(nil)
_ provider.Searcher = (*Provider)(nil)
)
// Provider reads and writes TOML-based playlists stored on disk.
type Provider struct {
dir string // e.g. ~/.config/cliamp/playlists/
history *history.Store
}
// New creates a Provider using ~/.config/cliamp/playlists/ as the base directory.
func New() *Provider {
dir, err := appdir.Dir()
if err != nil {
return nil
}
return &Provider{
dir: filepath.Join(dir, "playlists"),
history: history.New(),
}
}
func (p *Provider) Name() string { return "Local" }
// safePath validates a playlist name and returns the absolute path to its TOML
// file, ensuring the result stays within p.dir. This prevents path traversal
// via names containing ".." or path separators.
func (p *Provider) safePath(name string) (string, error) {
if strings.ContainsAny(name, "/\\") || strings.TrimSpace(name) == "" {
return "", fmt.Errorf("invalid playlist name %q", name)
}
resolved := filepath.Join(p.dir, name+".toml")
if !strings.HasPrefix(resolved, filepath.Clean(p.dir)+string(filepath.Separator)) {
return "", fmt.Errorf("playlist path escapes base directory")
}
return resolved, nil
}
func validateNewName(name string) error {
if strings.ContainsAny(name, "/\\:<>\"|?*") || name == ".." || name == "." || strings.TrimSpace(name) == "" {
return fmt.Errorf("invalid playlist name %q", name)
}
return nil
}
func isHistoryName(name string) bool {
return name == history.PlaylistName
}
// Playlists scans the directory for .toml files and returns their metadata,
// prepending the virtual "Recently Played" entry when the user has any
// recorded plays. Returns an empty list (not error) when neither exists.
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
var lists []playlist.PlaylistInfo
if info, ok := p.historyInfo(); ok {
lists = append(lists, info)
}
entries, err := os.ReadDir(p.dir)
if errors.Is(err, fs.ErrNotExist) {
return lists, nil
}
if err != nil {
return nil, err
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".toml") {
continue
}
name := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
tracks, err := p.loadTOML(filepath.Join(p.dir, e.Name()))
if err != nil {
continue
}
lists = append(lists, playlist.PlaylistInfo{
ID: name,
Name: name,
TrackCount: len(tracks),
DurationSecs: playlist.TotalDurationSecs(tracks),
})
}
return lists, nil
}
// historyInfo returns the synthetic PlaylistInfo entry for "Recently Played",
// or ok=false when the history store is unavailable or empty.
func (p *Provider) historyInfo() (playlist.PlaylistInfo, bool) {
if p.history == nil {
return playlist.PlaylistInfo{}, false
}
tracks, err := p.history.Tracks(0)
if err != nil || len(tracks) == 0 {
return playlist.PlaylistInfo{}, false
}
return playlist.PlaylistInfo{
ID: history.PlaylistName,
Name: history.PlaylistName,
TrackCount: len(tracks),
DurationSecs: playlist.TotalDurationSecs(tracks),
}, true
}
// Tracks parses the TOML file for the given playlist name and returns its tracks.
// The reserved "Recently Played" name is served from the history store.
func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) {
if isHistoryName(playlistID) {
if p.history == nil {
return nil, nil
}
return p.history.Tracks(0)
}
path, err := p.safePath(playlistID)
if err != nil {
return nil, err
}
return p.loadTOML(path)
}
// AddTrack appends a track to the named playlist, creating the directory and
// file if needed.
func (p *Provider) AddTrack(playlistName string, track playlist.Track) error {
_, _, err := p.AddTracks(playlistName, []playlist.Track{track})
return err
}
// AddTracks appends multiple tracks, skipping exact path duplicates already in
// the playlist or repeated in the input. It creates the playlist file if needed.
func (p *Provider) AddTracks(playlistName string, tracks []playlist.Track) (added, skipped int, err error) {
if isHistoryName(playlistName) {
return 0, 0, errReservedHistoryName
}
if err := os.MkdirAll(p.dir, 0o755); err != nil {
return 0, 0, err
}
path, err := p.safePath(playlistName)
if err != nil {
return 0, 0, err
}
existing, err := p.loadTOML(path)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return 0, 0, err
}
if err := validateNewName(playlistName); err != nil {
return 0, 0, err
}
existing = nil
}
seen := make(map[string]struct{}, len(existing)+len(tracks))
for _, t := range existing {
seen[t.Path] = struct{}{}
}
for _, t := range tracks {
if _, ok := seen[t.Path]; ok {
skipped++
continue
}
seen[t.Path] = struct{}{}
existing = append(existing, t)
added++
}
if added == 0 {
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
return 0, skipped, p.savePlaylist(playlistName, existing)
} else if err != nil {
return 0, skipped, err
}
return 0, skipped, nil
}
return added, skipped, p.savePlaylist(playlistName, existing)
}
// CreatePlaylist creates an empty playlist file.
func (p *Provider) CreatePlaylist(_ context.Context, name string) (string, error) {
if isHistoryName(name) {
return "", errReservedHistoryName
}
if err := os.MkdirAll(p.dir, 0o755); err != nil {
return "", err
}
if err := validateNewName(name); err != nil {
return "", err
}
path, err := p.safePath(name)
if err != nil {
return "", err
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
if errors.Is(err, fs.ErrExist) {
return "", fmt.Errorf("playlist %q already exists", name)
}
return "", err
}
if err := f.Close(); err != nil {
return "", err
}
return name, nil
}
// Exists reports whether a playlist with the given name exists on disk, or
// whether it refers to the virtual "Recently Played" history with at least
// one entry recorded.
func (p *Provider) Exists(name string) bool {
if isHistoryName(name) {
_, ok := p.historyInfo()
return ok
}
path, err := p.safePath(name)
if err != nil {
return false
}
_, err = os.Stat(path)
return err == nil
}
// savePlaylist overwrites the named playlist with the given tracks.
func (p *Provider) savePlaylist(name string, tracks []playlist.Track) error {
if err := os.MkdirAll(p.dir, 0o755); err != nil {
return err
}
path, err := p.safePath(name)
if err != nil {
return err
}
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
if err := validateNewName(name); err != nil {
return err
}
} else if err != nil {
return err
}
// Atomic write: write to temp file, then rename.
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
for i, t := range tracks {
if i > 0 {
fmt.Fprintln(f)
}
writeTrack(f, t)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// errReservedHistoryName is returned when a caller tries to write to or
// otherwise mutate the synthetic history playlist.
var errReservedHistoryName = errors.New(`"Recently Played" is a virtual history playlist and cannot be modified`)
// SetBookmark toggles the bookmark flag on a track and rewrites the playlist.
func (p *Provider) SetBookmark(playlistName string, idx int) error {
if isHistoryName(playlistName) {
return errReservedHistoryName
}
tracks, err := p.loadTOMLByName(playlistName)
if err != nil {
return err
}
if idx < 0 || idx >= len(tracks) {
return fmt.Errorf("index %d out of range (playlist has %d tracks)", idx, len(tracks))
}
tracks[idx].Bookmark = !tracks[idx].Bookmark
return p.savePlaylist(playlistName, tracks)
}
// SetBookmarkByPath toggles the bookmark flag on the first track with path and
// rewrites the playlist. This avoids corrupting saved playlists when the live
// queue has been filtered, reordered, or otherwise diverged from file order.
func (p *Provider) SetBookmarkByPath(playlistName string, path string) error {
if isHistoryName(playlistName) {
return errReservedHistoryName
}
tracks, err := p.loadTOMLByName(playlistName)
if err != nil {
return err
}
for i := range tracks {
if tracks[i].Path == path {
tracks[i].Bookmark = !tracks[i].Bookmark
return p.savePlaylist(playlistName, tracks)
}
}
return fmt.Errorf("track path %q not found in playlist %q", path, playlistName)
}
// loadTOMLByName loads tracks for a named playlist.
func (p *Provider) loadTOMLByName(name string) ([]playlist.Track, error) {
path, err := p.safePath(name)
if err != nil {
return nil, err
}
return p.loadTOML(path)
}
// SavePlaylist overwrites a playlist with the given tracks.
func (p *Provider) SavePlaylist(name string, tracks []playlist.Track) error {
if isHistoryName(name) {
return errReservedHistoryName
}
return p.savePlaylist(name, tracks)
}
// 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)
}
// AddTracksToPlaylist appends multiple tracks to the named playlist.
// Implements provider.PlaylistBatchWriter.
func (p *Provider) AddTracksToPlaylist(_ context.Context, playlistID string, tracks []playlist.Track) (int, int, error) {
return p.AddTracks(playlistID, tracks)
}
// SearchTracks does a case-insensitive fuzzy search across every saved playlist
// for tracks whose title, artist, or album match query, ranked by relevance
// (best match first). Returns up to limit results (limit <= 0 means no cap).
// Implements provider.Searcher.
func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
q := strings.TrimSpace(query)
if q == "" {
return nil, nil
}
entries, err := os.ReadDir(p.dir)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
type scored struct {
track playlist.Track
score int
}
var matches []scored
seen := make(map[string]struct{})
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".toml") {
continue
}
tracks, err := p.loadTOML(filepath.Join(p.dir, e.Name()))
if err != nil {
continue
}
for _, t := range tracks {
if _, dup := seen[t.Path]; dup {
continue
}
score, ok := trackMatchScore(t, q)
if !ok {
continue
}
seen[t.Path] = struct{}{}
matches = append(matches, scored{t, score})
}
}
sort.SliceStable(matches, func(a, b int) bool {
return matches[a].score > matches[b].score
})
if limit > 0 && len(matches) > limit {
matches = matches[:limit]
}
out := make([]playlist.Track, len(matches))
for i, m := range matches {
out[i] = m.track
}
return out, nil
}
// trackMatchScore returns the best fuzzy score for query across the track's
// title, artist, and album, and whether any of them matched.
func trackMatchScore(t playlist.Track, query string) (int, bool) {
best, ok := 0, false
for _, field := range [...]string{t.Title, t.Artist, t.Album} {
if field == "" {
continue
}
if s, matched := fuzzy.Match(query, field); matched && (!ok || s > best) {
best, ok = s, true
}
}
return best, ok
}
// RenamePlaylist renames a playlist by renaming its TOML file.
// The reserved "Recently Played" history playlist cannot be renamed.
func (p *Provider) RenamePlaylist(oldName, newName string) error {
if isHistoryName(oldName) || isHistoryName(newName) {
return errReservedHistoryName
}
oldPath, err := p.safePath(oldName)
if err != nil {
return fmt.Errorf("invalid playlist name %q: %w", oldName, err)
}
if err := validateNewName(newName); err != nil {
return err
}
newPath, err := p.safePath(newName)
if err != nil {
return fmt.Errorf("invalid playlist name %q: %w", newName, err)
}
if _, err := os.Stat(newPath); err == nil {
return fmt.Errorf("playlist %q already exists", newName)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat destination playlist %q: %w", newName, err)
}
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("rename playlist %q to %q: %w", oldName, newName, err)
}
return nil
}
// DeletePlaylist removes the TOML file for the named playlist.
// "Recently Played" cannot be deleted via this method — use ClearHistory.
func (p *Provider) DeletePlaylist(name string) error {
if isHistoryName(name) {
return errReservedHistoryName
}
path, err := p.safePath(name)
if err != nil {
return err
}
return os.Remove(path)
}
// ClearHistory wipes the recorded play history. Returns nil if no history
// exists yet.
func (p *Provider) ClearHistory() error {
if p.history == nil {
return nil
}
return p.history.Clear()
}
// RemoveTrack removes a track by index from the named playlist.
// Empty playlists are kept on disk; deleting a playlist remains explicit.
func (p *Provider) RemoveTrack(name string, index int) error {
if isHistoryName(name) {
return errReservedHistoryName
}
tracks, err := p.Tracks(name)
if err != nil {
return err
}
if index < 0 || index >= len(tracks) {
return fmt.Errorf("track index %d out of range", index)
}
tracks = slices.Delete(tracks, index, index+1)
return p.savePlaylist(name, tracks)
}
// writeTrack writes a single [[track]] TOML section to w.
func writeTrack(w io.Writer, t playlist.Track) {
fmt.Fprintln(w, "[[track]]")
fmt.Fprintf(w, "path = %q\n", t.Path)
fmt.Fprintf(w, "title = %q\n", t.Title)
if t.Feed {
fmt.Fprintln(w, "feed = true")
}
if t.Artist != "" {
fmt.Fprintf(w, "artist = %q\n", t.Artist)
}
if t.Album != "" {
fmt.Fprintf(w, "album = %q\n", t.Album)
}
if t.Genre != "" {
fmt.Fprintf(w, "genre = %q\n", t.Genre)
}
if t.Year != 0 {
fmt.Fprintf(w, "year = %d\n", t.Year)
}
if t.TrackNumber != 0 {
fmt.Fprintf(w, "track_number = %d\n", t.TrackNumber)
}
if t.DurationSecs != 0 {
fmt.Fprintf(w, "duration_secs = %d\n", t.DurationSecs)
}
if t.EmbeddedLyrics != "" {
fmt.Fprintf(w, "embedded_lyrics = %q\n", t.EmbeddedLyrics)
}
if t.AlbumArtURL != "" {
fmt.Fprintf(w, "album_art_url = %q\n", t.AlbumArtURL)
}
if t.Bookmark {
fmt.Fprintln(w, "bookmark = true")
}
}
// loadTOML parses a minimal TOML file with [[track]] sections.
// Each section supports path, title, and artist keys.
func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var tracks []playlist.Track
tomlutil.ParseSections(data, "track", func(f map[string]string) {
t := playlist.Track{
Path: f["path"],
Title: f["title"],
Artist: f["artist"],
Album: f["album"],
Genre: f["genre"],
Feed: f["feed"] == "true",
}
t.EmbeddedLyrics = f["embedded_lyrics"]
t.AlbumArtURL = f["album_art_url"]
t.Stream = playlist.IsURL(t.Path)
// "favorite" is the pre-rename alias for "bookmark"; prefer bookmark.
bookmark, ok := f["bookmark"]
if !ok {
bookmark = f["favorite"]
}
t.Bookmark = bookmark == "true"
if n, err := strconv.Atoi(f["year"]); err == nil {
t.Year = n
}
if n, err := strconv.Atoi(f["track_number"]); err == nil {
t.TrackNumber = n
}
if n, err := strconv.Atoi(f["duration_secs"]); err == nil {
t.DurationSecs = n
}
tracks = append(tracks, t)
})
return tracks, nil
}
+724
View File
@@ -0,0 +1,724 @@
package local
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/playlist"
)
func newTestProvider(t *testing.T) *Provider {
t.Helper()
dir := t.TempDir()
return &Provider{dir: dir}
}
// --- safePath ---
func TestSafePathValid(t *testing.T) {
p := newTestProvider(t)
got, err := p.safePath("rock")
if err != nil {
t.Fatalf("safePath(%q): %v", "rock", err)
}
want := filepath.Join(p.dir, "rock.toml")
if got != want {
t.Fatalf("safePath(%q) = %q, want %q", "rock", got, want)
}
}
func TestSafePathRejectsTraversal(t *testing.T) {
p := newTestProvider(t)
bad := []string{"", " ", "foo/bar", "foo\\bar"}
for _, name := range bad {
if _, err := p.safePath(name); err == nil {
t.Errorf("safePath(%q) should have returned error", name)
}
}
}
func TestValidateNewNameRejectsNonPortableNames(t *testing.T) {
bad := []string{"..", ".", "", " ", "foo/bar", "foo\\bar", "bad:name", "bad?name"}
for _, name := range bad {
if err := validateNewName(name); err == nil {
t.Errorf("validateNewName(%q) should have returned error", name)
}
}
}
func TestSafePathRejectsSlash(t *testing.T) {
p := newTestProvider(t)
if _, err := p.safePath("../escape"); err == nil {
t.Fatal("safePath should reject paths with /")
}
}
// --- Name ---
func TestProviderName(t *testing.T) {
p := newTestProvider(t)
if got := p.Name(); got != "Local" {
t.Fatalf("Name() = %q, want %q", got, "Local")
}
}
// --- writeTrack ---
func TestWriteTrackMinimal(t *testing.T) {
var buf bytes.Buffer
writeTrack(&buf, playlist.Track{
Path: "/music/song.mp3",
Title: "Song",
})
got := buf.String()
if !strings.Contains(got, "[[track]]") {
t.Fatal("missing [[track]] header")
}
if !strings.Contains(got, `path = "/music/song.mp3"`) {
t.Fatal("missing path")
}
if !strings.Contains(got, `title = "Song"`) {
t.Fatal("missing title")
}
// Optional fields should be absent.
if strings.Contains(got, "artist") {
t.Fatal("empty artist should not be written")
}
if strings.Contains(got, "bookmark") {
t.Fatal("false bookmark should not be written")
}
}
func TestWriteTrackAllFields(t *testing.T) {
var buf bytes.Buffer
writeTrack(&buf, playlist.Track{
Path: "/music/song.flac",
Title: "Title",
Artist: "Artist",
Album: "Album",
Genre: "Rock",
Year: 2024,
TrackNumber: 3,
DurationSecs: 240,
Bookmark: true,
Feed: true,
EmbeddedLyrics: "[00:01.00]Line",
AlbumArtURL: "file:///tmp/cover.jpg",
})
got := buf.String()
for _, want := range []string{
`path = "/music/song.flac"`,
`title = "Title"`,
`artist = "Artist"`,
`album = "Album"`,
`genre = "Rock"`,
"year = 2024",
"track_number = 3",
"duration_secs = 240",
`embedded_lyrics = "[00:01.00]Line"`,
`album_art_url = "file:///tmp/cover.jpg"`,
"bookmark = true",
"feed = true",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in output:\n%s", want, got)
}
}
}
// --- loadTOML round-trip ---
func TestLoadTOMLRoundTrip(t *testing.T) {
p := newTestProvider(t)
os.MkdirAll(p.dir, 0o755)
tracks := []playlist.Track{
{Path: "/a.mp3", Title: "A", Artist: "Art1", Album: "Alb", Year: 2020, TrackNumber: 1, DurationSecs: 180, Bookmark: true, EmbeddedLyrics: "Line 1\nLine 2", AlbumArtURL: "file:///tmp/a.jpg"},
{Path: "/b.flac", Title: "B", Genre: "Jazz", Feed: true},
}
if err := p.savePlaylist("test", tracks); err != nil {
t.Fatalf("savePlaylist: %v", err)
}
loaded, err := p.Tracks("test")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(loaded) != 2 {
t.Fatalf("got %d tracks, want 2", len(loaded))
}
if loaded[0].Path != "/a.mp3" || loaded[0].Title != "A" || loaded[0].Artist != "Art1" {
t.Fatalf("track 0 mismatch: %+v", loaded[0])
}
if !loaded[0].Bookmark {
t.Fatal("track 0 should be bookmarked")
}
if loaded[0].Year != 2020 || loaded[0].TrackNumber != 1 || loaded[0].DurationSecs != 180 {
t.Fatalf("track 0 numeric fields mismatch: %+v", loaded[0])
}
if loaded[0].EmbeddedLyrics != "Line 1\nLine 2" || loaded[0].AlbumArtURL != "file:///tmp/a.jpg" {
t.Fatalf("track 0 embedded fields mismatch: %+v", loaded[0])
}
if loaded[1].Path != "/b.flac" || loaded[1].Title != "B" || loaded[1].Genre != "Jazz" {
t.Fatalf("track 1 mismatch: %+v", loaded[1])
}
if !loaded[1].Feed {
t.Fatal("track 1 should have feed=true")
}
}
func TestLoadTOMLComments(t *testing.T) {
p := newTestProvider(t)
os.MkdirAll(p.dir, 0o755)
content := `# This is a comment
[[track]]
path = "/a.mp3"
title = "A"
# inline comment
`
path := filepath.Join(p.dir, "commented.toml")
os.WriteFile(path, []byte(content), 0o644)
tracks, err := p.loadTOML(path)
if err != nil {
t.Fatalf("loadTOML: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(tracks))
}
if tracks[0].Title != "A" {
t.Fatalf("Title = %q, want %q", tracks[0].Title, "A")
}
}
// --- Playlists ---
func TestPlaylistsEmpty(t *testing.T) {
p := newTestProvider(t)
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists: %v", err)
}
if len(lists) != 0 {
t.Fatalf("got %d playlists, want 0", len(lists))
}
}
func TestPlaylistsLists(t *testing.T) {
p := newTestProvider(t)
os.MkdirAll(p.dir, 0o755)
p.savePlaylist("rock", []playlist.Track{{Path: "/a.mp3", Title: "A"}})
p.savePlaylist("jazz", []playlist.Track{{Path: "/b.mp3", Title: "B"}, {Path: "/c.mp3", Title: "C"}})
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists: %v", err)
}
if len(lists) != 2 {
t.Fatalf("got %d playlists, want 2", len(lists))
}
counts := map[string]int{}
for _, l := range lists {
counts[l.Name] = l.TrackCount
}
if counts["rock"] != 1 {
t.Fatalf("rock has %d tracks, want 1", counts["rock"])
}
if counts["jazz"] != 2 {
t.Fatalf("jazz has %d tracks, want 2", counts["jazz"])
}
}
// --- AddTrack ---
func TestAddTrack(t *testing.T) {
p := newTestProvider(t)
if err := p.AddTrack("new", playlist.Track{Path: "/x.mp3", Title: "X"}); err != nil {
t.Fatalf("AddTrack: %v", err)
}
tracks, err := p.Tracks("new")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(tracks) != 1 || tracks[0].Title != "X" {
t.Fatalf("unexpected tracks: %+v", tracks)
}
// Append another.
if err := p.AddTrack("new", playlist.Track{Path: "/y.mp3", Title: "Y"}); err != nil {
t.Fatalf("AddTrack: %v", err)
}
tracks, _ = p.Tracks("new")
if len(tracks) != 2 {
t.Fatalf("got %d tracks, want 2", len(tracks))
}
}
func TestCreatePlaylistCreatesEmptyFile(t *testing.T) {
p := newTestProvider(t)
id, err := p.CreatePlaylist(context.Background(), "empty")
if err != nil {
t.Fatalf("CreatePlaylist: %v", err)
}
if id != "empty" {
t.Fatalf("CreatePlaylist id = %q, want empty", id)
}
if !p.Exists("empty") {
t.Fatal("created playlist should exist")
}
tracks, err := p.Tracks("empty")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(tracks) != 0 {
t.Fatalf("got %d tracks, want 0", len(tracks))
}
if _, err := p.CreatePlaylist(context.Background(), "empty"); err == nil {
t.Fatal("creating an existing playlist should fail")
}
}
func TestExistingPlaylistWithLegacyNameRemainsWritable(t *testing.T) {
p := newTestProvider(t)
if err := os.MkdirAll(p.dir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
path := filepath.Join(p.dir, "bad:name.toml")
data := []byte("[[track]]\npath = \"/a.mp3\"\ntitle = \"A\"\n")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
tracks, err := p.Tracks("bad:name")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(tracks) != 1 || tracks[0].Path != "/a.mp3" {
t.Fatalf("Tracks = %+v, want legacy playlist contents", tracks)
}
if err := p.AddTrack("bad:name", playlist.Track{Path: "/b.mp3", Title: "B"}); err != nil {
t.Fatalf("AddTrack legacy name: %v", err)
}
tracks, err = p.Tracks("bad:name")
if err != nil {
t.Fatalf("Tracks after AddTrack: %v", err)
}
if len(tracks) != 2 || tracks[1].Path != "/b.mp3" {
t.Fatalf("Tracks after AddTrack = %+v, want appended track", tracks)
}
if _, err := p.CreatePlaylist(context.Background(), "new:name"); err == nil {
t.Fatal("CreatePlaylist should reject new non-portable names")
}
}
func TestAddTracksSkipsDuplicatePaths(t *testing.T) {
p := newTestProvider(t)
if err := p.AddTrack("dupes", playlist.Track{Path: "/a.mp3", Title: "A"}); err != nil {
t.Fatalf("AddTrack: %v", err)
}
added, skipped, err := p.AddTracks("dupes", []playlist.Track{
{Path: "/a.mp3", Title: "A again"},
{Path: "/b.mp3", Title: "B"},
{Path: "/b.mp3", Title: "B again"},
})
if err != nil {
t.Fatalf("AddTracks: %v", err)
}
if added != 1 || skipped != 2 {
t.Fatalf("AddTracks added=%d skipped=%d, want 1/2", added, skipped)
}
tracks, err := p.Tracks("dupes")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(tracks) != 2 || tracks[0].Path != "/a.mp3" || tracks[1].Path != "/b.mp3" {
t.Fatalf("unexpected tracks: %+v", tracks)
}
}
// --- Exists ---
func TestExists(t *testing.T) {
p := newTestProvider(t)
if p.Exists("nope") {
t.Fatal("should not exist")
}
p.AddTrack("yes", playlist.Track{Path: "/a.mp3", Title: "A"})
if !p.Exists("yes") {
t.Fatal("should exist after AddTrack")
}
}
// --- SetBookmark ---
func TestSetBookmark(t *testing.T) {
p := newTestProvider(t)
p.AddTrack("marks", playlist.Track{Path: "/a.mp3", Title: "A"})
if err := p.SetBookmark("marks", 0); err != nil {
t.Fatalf("SetBookmark: %v", err)
}
tracks, _ := p.Tracks("marks")
if !tracks[0].Bookmark {
t.Fatal("track should be bookmarked after toggle")
}
// Toggle off.
p.SetBookmark("marks", 0)
tracks, _ = p.Tracks("marks")
if tracks[0].Bookmark {
t.Fatal("track should not be bookmarked after second toggle")
}
}
func TestSetBookmarkOutOfRange(t *testing.T) {
p := newTestProvider(t)
p.AddTrack("one", playlist.Track{Path: "/a.mp3", Title: "A"})
if err := p.SetBookmark("one", 5); err == nil {
t.Fatal("expected error for out-of-range index")
}
if err := p.SetBookmark("one", -1); err == nil {
t.Fatal("expected error for negative index")
}
}
func TestSetBookmarkByPath(t *testing.T) {
p := newTestProvider(t)
if _, _, err := p.AddTracks("marks", []playlist.Track{
{Path: "/a.mp3", Title: "A"},
{Path: "/b.mp3", Title: "B"},
}); err != nil {
t.Fatalf("AddTracks: %v", err)
}
if err := p.SetBookmarkByPath("marks", "/b.mp3"); err != nil {
t.Fatalf("SetBookmarkByPath: %v", err)
}
tracks, err := p.Tracks("marks")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if tracks[0].Bookmark || !tracks[1].Bookmark {
t.Fatalf("wrong bookmark row toggled: %+v", tracks)
}
if err := p.SetBookmarkByPath("marks", "/missing.mp3"); err == nil {
t.Fatal("missing path should return an error")
}
}
// --- RemoveTrack ---
func TestRemoveTrack(t *testing.T) {
p := newTestProvider(t)
if _, _, err := p.AddTracks("rem", []playlist.Track{
{Path: "/a.mp3", Title: "A"},
{Path: "/b.mp3", Title: "B"},
{Path: "/c.mp3", Title: "C"},
}); err != nil {
t.Fatalf("AddTracks: %v", err)
}
if err := p.RemoveTrack("rem", 1); err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
tracks, _ := p.Tracks("rem")
if len(tracks) != 2 {
t.Fatalf("got %d tracks, want 2", len(tracks))
}
if tracks[0].Title != "A" || tracks[1].Title != "C" {
t.Fatalf("wrong tracks after remove: %+v", tracks)
}
}
func TestRemoveTrackKeepsEmptyPlaylist(t *testing.T) {
p := newTestProvider(t)
p.AddTrack("solo", playlist.Track{Path: "/a.mp3", Title: "A"})
if err := p.RemoveTrack("solo", 0); err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
if !p.Exists("solo") {
t.Fatal("playlist should remain after last track is removed")
}
tracks, err := p.Tracks("solo")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(tracks) != 0 {
t.Fatalf("got %d tracks, want 0", len(tracks))
}
}
// --- DeletePlaylist ---
func TestDeletePlaylist(t *testing.T) {
p := newTestProvider(t)
p.AddTrack("del", playlist.Track{Path: "/a.mp3", Title: "A"})
if err := p.DeletePlaylist("del"); err != nil {
t.Fatalf("DeletePlaylist: %v", err)
}
if p.Exists("del") {
t.Fatal("playlist should be deleted")
}
}
// --- SavePlaylist ---
func TestSavePlaylistOverwrites(t *testing.T) {
p := newTestProvider(t)
if _, _, err := p.AddTracks("over", []playlist.Track{
{Path: "/a.mp3", Title: "A"},
{Path: "/b.mp3", Title: "B"},
}); err != nil {
t.Fatalf("AddTracks: %v", err)
}
// Overwrite with single track.
if err := p.SavePlaylist("over", []playlist.Track{{Path: "/c.mp3", Title: "C"}}); err != nil {
t.Fatalf("SavePlaylist: %v", err)
}
tracks, _ := p.Tracks("over")
if len(tracks) != 1 || tracks[0].Title != "C" {
t.Fatalf("expected single track C, got: %+v", tracks)
}
}
// --- loadTOML edge cases ---
func TestLoadTOMLMissingFile(t *testing.T) {
p := newTestProvider(t)
_, err := p.Tracks("nonexistent")
if err == nil {
t.Fatal("expected error for missing playlist")
}
}
func TestLoadTOMLStreamField(t *testing.T) {
p := newTestProvider(t)
os.MkdirAll(p.dir, 0o755)
content := `[[track]]
path = "https://stream.example.com/live"
title = "Live Radio"
`
path := filepath.Join(p.dir, "radio.toml")
os.WriteFile(path, []byte(content), 0o644)
tracks, err := p.loadTOML(path)
if err != nil {
t.Fatalf("loadTOML: %v", err)
}
if !tracks[0].Stream {
t.Fatal("URL path should set Stream=true")
}
}
// --- Virtual "Recently Played" history playlist ---
func newTestProviderWithHistory(t *testing.T) *Provider {
t.Helper()
dir := t.TempDir()
historyPath := filepath.Join(dir, "history.toml")
return &Provider{dir: filepath.Join(dir, "playlists"), history: history.NewAt(historyPath)}
}
func TestPlaylistsIncludesHistoryWhenNonEmpty(t *testing.T) {
p := newTestProviderWithHistory(t)
if err := p.history.Record(playlist.Track{Path: "/a.mp3", Title: "A"}, time.Now()); err != nil {
t.Fatalf("Record: %v", err)
}
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists: %v", err)
}
if len(lists) == 0 || lists[0].Name != history.PlaylistName {
t.Fatalf("expected first playlist to be %q, got %+v", history.PlaylistName, lists)
}
if lists[0].TrackCount != 1 {
t.Errorf("history TrackCount = %d, want 1", lists[0].TrackCount)
}
}
func TestPlaylistsOmitsHistoryWhenEmpty(t *testing.T) {
p := newTestProviderWithHistory(t)
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists: %v", err)
}
for _, pl := range lists {
if pl.Name == history.PlaylistName {
t.Fatalf("history entry should not appear when empty")
}
}
}
func TestTracksReadsFromHistory(t *testing.T) {
p := newTestProviderWithHistory(t)
base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
p.history.Record(playlist.Track{Path: "/a.mp3", Title: "A"}, base)
p.history.Record(playlist.Track{Path: "/b.mp3", Title: "B"}, base.Add(time.Hour))
tracks, err := p.Tracks(history.PlaylistName)
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(tracks) != 2 || tracks[0].Title != "B" || tracks[1].Title != "A" {
t.Fatalf("history tracks order wrong: %+v", tracks)
}
}
func TestWritesRejectedForHistoryName(t *testing.T) {
p := newTestProviderWithHistory(t)
track := playlist.Track{Path: "/a.mp3", Title: "A"}
tests := []struct {
name string
call func() error
}{
{"AddTrack", func() error { return p.AddTrack(history.PlaylistName, track) }},
{"AddTracks", func() error { _, _, err := p.AddTracks(history.PlaylistName, []playlist.Track{track}); return err }},
{"SavePlaylist", func() error { return p.SavePlaylist(history.PlaylistName, []playlist.Track{track}) }},
{"DeletePlaylist", func() error { return p.DeletePlaylist(history.PlaylistName) }},
{"RemoveTrack", func() error { return p.RemoveTrack(history.PlaylistName, 0) }},
{"SetBookmark", func() error { return p.SetBookmark(history.PlaylistName, 0) }},
{"SetBookmarkByPath", func() error { return p.SetBookmarkByPath(history.PlaylistName, track.Path) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := tt.call(); err == nil {
t.Errorf("%s should reject history name", tt.name)
}
})
}
}
func TestExistsForHistoryName(t *testing.T) {
p := newTestProviderWithHistory(t)
if p.Exists(history.PlaylistName) {
t.Error("Exists should be false when history is empty")
}
p.history.Record(playlist.Track{Path: "/a.mp3"}, time.Now())
if !p.Exists(history.PlaylistName) {
t.Error("Exists should be true once a play is recorded")
}
}
func TestClearHistoryRemovesEntries(t *testing.T) {
p := newTestProviderWithHistory(t)
p.history.Record(playlist.Track{Path: "/a.mp3"}, time.Now())
if err := p.ClearHistory(); err != nil {
t.Fatalf("ClearHistory: %v", err)
}
if got, _ := p.history.Recent(0); len(got) != 0 {
t.Errorf("history not cleared: %d entries remain", len(got))
}
}
// --- SearchTracks (fuzzy) ---
func TestTrackMatchScore(t *testing.T) {
tests := []struct {
name string
track playlist.Track
query string
want bool
}{
{"title subsequence", playlist.Track{Title: "Sakura"}, "skr", true},
{"artist subsequence", playlist.Track{Artist: "Radiohead"}, "rdhd", true},
{"album substring", playlist.Track{Album: "In Rainbows"}, "rainbow", true},
{"no match", playlist.Track{Title: "Sakura"}, "zzz", false},
{"all fields empty", playlist.Track{}, "x", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, ok := trackMatchScore(tt.track, tt.query); ok != tt.want {
t.Fatalf("trackMatchScore(%+v, %q) ok = %v, want %v", tt.track, tt.query, ok, tt.want)
}
})
}
}
func TestSearchTracksFuzzyRanksAndMatchesSubsequence(t *testing.T) {
p := newTestProvider(t)
if err := p.savePlaylist("lib", []playlist.Track{
{Path: "/1.mp3", Title: "Cherry Blossom (Sakura Mix)"},
{Path: "/2.mp3", Title: "Sakura"},
{Path: "/3.mp3", Title: "Thunderstruck"},
}); err != nil {
t.Fatalf("savePlaylist: %v", err)
}
// "skr" is a non-contiguous subsequence a substring search would miss.
got, err := p.SearchTracks(context.Background(), "skr", 0)
if err != nil {
t.Fatalf("SearchTracks: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d results, want 2: %+v", len(got), got)
}
// "Sakura" (prefix match) outranks "Cherry Blossom (Sakura Mix)".
if got[0].Title != "Sakura" {
t.Fatalf("top result = %q, want %q", got[0].Title, "Sakura")
}
}
func TestSearchTracksEmptyQuery(t *testing.T) {
p := newTestProvider(t)
if err := p.savePlaylist("lib", []playlist.Track{{Path: "/1.mp3", Title: "Sakura"}}); err != nil {
t.Fatalf("savePlaylist: %v", err)
}
got, err := p.SearchTracks(context.Background(), " ", 0)
if err != nil {
t.Fatalf("SearchTracks: %v", err)
}
if got != nil {
t.Fatalf("blank query should return nil, got %+v", got)
}
}
func TestSearchTracksLimit(t *testing.T) {
p := newTestProvider(t)
if err := p.savePlaylist("lib", []playlist.Track{
{Path: "/1.mp3", Title: "Sakura One"},
{Path: "/2.mp3", Title: "Sakura Two"},
{Path: "/3.mp3", Title: "Sakura Three"},
}); err != nil {
t.Fatalf("savePlaylist: %v", err)
}
got, err := p.SearchTracks(context.Background(), "sakura", 2)
if err != nil {
t.Fatalf("SearchTracks: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d results, want 2 (limit)", len(got))
}
}
+547
View File
@@ -0,0 +1,547 @@
package navidrome
import (
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/bjarneo/cliamp/config"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// Compile-time interface checks.
var (
_ provider.ArtistBrowser = (*NavidromeClient)(nil)
_ provider.AlbumBrowser = (*NavidromeClient)(nil)
_ provider.AlbumTrackLoader = (*NavidromeClient)(nil)
_ provider.AlbumSortSaver = (*NavidromeClient)(nil)
_ provider.PlaybackReporter = (*NavidromeClient)(nil)
_ provider.Searcher = (*NavidromeClient)(nil)
)
// httpClient is used for all Navidrome API calls with a finite timeout.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// maxResponseBody limits JSON API responses to 10 MB to prevent unbounded memory growth.
const maxResponseBody = 10 << 20
// Sort type constants for album browsing (Subsonic getAlbumList2 "type" parameter).
const (
SortAlphabeticalByName = "alphabeticalByName"
SortAlphabeticalByArtist = "alphabeticalByArtist"
SortNewest = "newest"
SortRecent = "recent"
SortFrequent = "frequent"
SortStarred = "starred"
SortByYear = "byYear"
SortByGenre = "byGenre"
)
// 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 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"},
}
// NavidromeClient implements playlist.Provider for a Navidrome/Subsonic server.
type NavidromeClient struct {
url string
user string
password string
browseSort string
scrobbleDisabled bool
mu sync.Mutex
playlistCache []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
}
// New creates a NavidromeClient with the given server credentials.
func New(serverURL, user, password string) *NavidromeClient {
return &NavidromeClient{
url: serverURL,
user: user,
password: password,
browseSort: SortAlphabeticalByName,
}
}
// NewFromEnv creates a NavidromeClient from NAVIDROME_URL, NAVIDROME_USER,
// and NAVIDROME_PASS environment variables. Returns nil if any are unset.
func NewFromEnv() *NavidromeClient {
u := os.Getenv("NAVIDROME_URL")
user := os.Getenv("NAVIDROME_USER")
pass := os.Getenv("NAVIDROME_PASS")
if u == "" || user == "" || pass == "" {
return nil
}
return New(u, user, pass)
}
// NewFromConfig creates a NavidromeClient from a config.NavidromeConfig value.
// Returns nil if any of the required fields (URL, User, Password) are empty.
func NewFromConfig(cfg config.NavidromeConfig) *NavidromeClient {
if !cfg.IsSet() {
return nil
}
client := New(cfg.URL, cfg.User, cfg.Password)
if cfg.BrowseSort != "" {
client.browseSort = cfg.BrowseSort
}
client.scrobbleDisabled = cfg.ScrobbleDisabled
return client
}
func (c *NavidromeClient) Name() string {
return "Navidrome"
}
// Ping verifies connectivity and credentials via the Subsonic ping.view
// endpoint. Returns a descriptive error on auth or network failure.
func (c *NavidromeClient) Ping() error {
var dummy struct{}
return c.subsonicGet("ping.view", nil, &dummy)
}
func (c *NavidromeClient) AlbumSortTypes() []provider.SortType {
return albumSortTypes
}
func (c *NavidromeClient) DefaultAlbumSort() string {
if c.browseSort != "" {
return c.browseSort
}
return SortAlphabeticalByName
}
func (c *NavidromeClient) SaveAlbumSort(sortType string) error {
if sortType == "" {
sortType = SortAlphabeticalByName
}
c.browseSort = sortType
return config.SaveNavidromeSort(sortType)
}
// subsonicError represents an application-level error from the Subsonic API.
// The API returns HTTP 200 even for errors; the real status is in the JSON body.
type subsonicError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// checkSubsonicError inspects the decoded JSON response for an application-level
// error (e.g., wrong credentials, missing resource). Returns nil if status is "ok".
func checkSubsonicError(status string, apiErr *subsonicError) error {
if status == "ok" || status == "" {
return nil
}
if apiErr != nil && apiErr.Message != "" {
return fmt.Errorf("navidrome: %s (code %d)", apiErr.Message, apiErr.Code)
}
return fmt.Errorf("navidrome: request failed (status %q)", status)
}
func (c *NavidromeClient) buildURL(endpoint string, params url.Values) string {
// Use crypto/rand for the salt as recommended by the Subsonic API spec.
// MD5 is required by the protocol — not a choice.
saltBytes := make([]byte, 8)
if _, err := io.ReadFull(rand.Reader, saltBytes); err != nil {
// Fallback to timestamp if crypto/rand fails (should never happen).
saltBytes = fmt.Appendf(nil, "%d", time.Now().UnixNano())
}
salt := hex.EncodeToString(saltBytes)
hash := md5.Sum([]byte(c.password + salt))
token := hex.EncodeToString(hash[:])
if params == nil {
params = url.Values{}
}
params.Set("u", c.user)
params.Set("t", token)
params.Set("s", salt)
params.Set("v", "1.0.0")
params.Set("c", "cliamp")
params.Set("f", "json")
return fmt.Sprintf("%s/rest/%s?%s", c.url, endpoint, params.Encode())
}
// subsonicGet performs a GET to the Subsonic API endpoint, decodes the JSON
// response into result, and checks for both HTTP and API-level errors.
func (c *NavidromeClient) subsonicGet(endpoint string, params url.Values, result any) error {
resp, err := httpClient.Get(c.buildURL(endpoint, params))
if err != nil {
return fmt.Errorf("navidrome: %s: %w", endpoint, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("navidrome: %s: http status %s", endpoint, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return fmt.Errorf("navidrome: %s: %w", endpoint, err)
}
// Check for API-level errors.
var env struct {
SubsonicResponse struct {
Status string `json:"status"`
Error *subsonicError `json:"error"`
} `json:"subsonic-response"`
}
if err := json.Unmarshal(body, &env); err != nil {
return fmt.Errorf("navidrome: %s: %w", endpoint, err)
}
if err := checkSubsonicError(env.SubsonicResponse.Status, env.SubsonicResponse.Error); err != nil {
return err
}
return json.Unmarshal(body, result)
}
func (c *NavidromeClient) Playlists() ([]playlist.PlaylistInfo, error) {
c.mu.Lock()
if c.playlistCache != nil {
cached := slices.Clone(c.playlistCache)
c.mu.Unlock()
return cached, nil
}
c.mu.Unlock()
var result struct {
SubsonicResponse struct {
Playlists struct {
Playlist []struct {
ID string `json:"id"`
Name string `json:"name"`
Count int `json:"songCount"`
} `json:"playlist"`
} `json:"playlists"`
} `json:"subsonic-response"`
}
if err := c.subsonicGet("getPlaylists", nil, &result); err != nil {
return nil, err
}
var lists []playlist.PlaylistInfo
for _, p := range result.SubsonicResponse.Playlists.Playlist {
lists = append(lists, playlist.PlaylistInfo{
ID: p.ID,
Name: p.Name,
TrackCount: p.Count,
})
}
c.mu.Lock()
c.playlistCache = lists
c.mu.Unlock()
return slices.Clone(lists), nil
}
func (c *NavidromeClient) Tracks(id string) ([]playlist.Track, error) {
c.mu.Lock()
if c.trackCache != nil {
if cached, ok := c.trackCache[id]; ok {
c.mu.Unlock()
return slices.Clone(cached), nil
}
}
c.mu.Unlock()
var result struct {
SubsonicResponse struct {
Playlist struct {
Entry []subsonicSong `json:"entry"`
} `json:"playlist"`
} `json:"subsonic-response"`
}
if err := c.subsonicGet("getPlaylist", url.Values{"id": {id}}, &result); err != nil {
return nil, err
}
var tracks []playlist.Track
for _, t := range result.SubsonicResponse.Playlist.Entry {
tracks = append(tracks, c.songToTrack(t))
}
c.mu.Lock()
if c.trackCache == nil {
c.trackCache = make(map[string][]playlist.Track)
}
c.trackCache[id] = tracks
c.mu.Unlock()
return slices.Clone(tracks), nil
}
// Refresh clears cached playlist and track data so the next Playlists/Tracks
// call re-fetches from the server. Implements playlist.Refresher.
func (c *NavidromeClient) Refresh() {
c.mu.Lock()
c.playlistCache = nil
c.trackCache = nil
c.mu.Unlock()
}
// Artists returns all artists from the server, flattening the index structure.
func (c *NavidromeClient) Artists() ([]Artist, error) {
var result struct {
SubsonicResponse struct {
Artists struct {
Index []struct {
Artist []struct {
ID string `json:"id"`
Name string `json:"name"`
AlbumCount int `json:"albumCount"`
} `json:"artist"`
} `json:"index"`
} `json:"artists"`
} `json:"subsonic-response"`
}
if err := c.subsonicGet("getArtists", nil, &result); err != nil {
return nil, err
}
var artists []Artist
for _, idx := range result.SubsonicResponse.Artists.Index {
for _, a := range idx.Artist {
artists = append(artists, Artist{
ID: a.ID,
Name: a.Name,
AlbumCount: a.AlbumCount,
})
}
}
return artists, nil
}
// ArtistAlbums returns all albums for the given artist ID.
func (c *NavidromeClient) ArtistAlbums(artistID string) ([]Album, error) {
var result struct {
SubsonicResponse struct {
Artist struct {
Album []subsonicAlbum `json:"album"`
} `json:"artist"`
} `json:"subsonic-response"`
}
if err := c.subsonicGet("getArtist", url.Values{"id": {artistID}}, &result); err != nil {
return nil, err
}
var albums []Album
for _, a := range result.SubsonicResponse.Artist.Album {
albums = append(albums, albumFromSubsonic(a))
}
return albums, nil
}
// AlbumList returns a page of albums sorted by sortType.
// offset and size control pagination; size should be ≤ 500.
func (c *NavidromeClient) AlbumList(sortType string, offset, size int) ([]Album, error) {
if sortType == "" {
sortType = SortAlphabeticalByName
}
params := url.Values{
"type": {sortType},
"offset": {fmt.Sprintf("%d", offset)},
"size": {fmt.Sprintf("%d", size)},
}
var result struct {
SubsonicResponse struct {
AlbumList2 struct {
Album []subsonicAlbum `json:"album"`
} `json:"albumList2"`
} `json:"subsonic-response"`
}
if err := c.subsonicGet("getAlbumList2", params, &result); err != nil {
return nil, err
}
var albums []Album
for _, a := range result.SubsonicResponse.AlbumList2.Album {
albums = append(albums, albumFromSubsonic(a))
}
return albums, nil
}
// AlbumTracks returns all tracks for the given album ID with full metadata.
func (c *NavidromeClient) AlbumTracks(albumID string) ([]playlist.Track, error) {
var result struct {
SubsonicResponse struct {
Album struct {
Song []subsonicSong `json:"song"`
} `json:"album"`
} `json:"subsonic-response"`
}
if err := c.subsonicGet("getAlbum", url.Values{"id": {albumID}}, &result); err != nil {
return nil, err
}
var tracks []playlist.Track
for _, s := range result.SubsonicResponse.Album.Song {
tracks = append(tracks, c.songToTrack(s))
}
return tracks, nil
}
// SearchTracks searches the Subsonic library for songs matching query
// using the search3.view endpoint. Implements provider.Searcher.
func (c *NavidromeClient) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
if limit <= 0 {
limit = 50
}
params := url.Values{
"query": {query},
"songCount": {strconv.Itoa(limit)},
"albumCount": {"0"},
"artistCount": {"0"},
}
var result struct {
SubsonicResponse struct {
SearchResult3 struct {
Song []subsonicSong `json:"song"`
} `json:"searchResult3"`
} `json:"subsonic-response"`
}
if err := c.subsonicGet("search3", params, &result); err != nil {
return nil, err
}
tracks := make([]playlist.Track, 0, len(result.SubsonicResponse.SearchResult3.Song))
for _, s := range result.SubsonicResponse.SearchResult3.Song {
tracks = append(tracks, c.songToTrack(s))
}
return tracks, nil
}
// subsonicSong holds the common JSON fields returned by the Subsonic API
// for tracks in both getPlaylist and getAlbum responses.
type subsonicSong struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Year int `json:"year"`
TrackNumber int `json:"track"`
Genre string `json:"genre"`
Duration int `json:"duration"`
}
func (c *NavidromeClient) songToTrack(s subsonicSong) playlist.Track {
return playlist.Track{
Path: c.streamURL(s.ID),
Title: s.Title,
Artist: s.Artist,
Album: s.Album,
Year: s.Year,
TrackNumber: s.TrackNumber,
Genre: s.Genre,
Stream: true,
DurationSecs: s.Duration,
ProviderMeta: map[string]string{provider.MetaNavidromeID: s.ID},
}
}
// subsonicAlbum holds the common JSON fields returned by the Subsonic API
// for albums in both getArtist and getAlbumList2 responses.
type subsonicAlbum struct {
ID string `json:"id"`
Name string `json:"name"`
Artist string `json:"artist"`
ArtistID string `json:"artistId"`
Year int `json:"year"`
SongCount int `json:"songCount"`
Genre string `json:"genre"`
}
func albumFromSubsonic(a subsonicAlbum) Album {
return Album{
ID: a.ID,
Name: a.Name,
Artist: a.Artist,
ArtistID: a.ArtistID,
Year: a.Year,
TrackCount: a.SongCount,
Genre: a.Genre,
}
}
// streamURL generates the authenticated streaming URL for a track ID.
// format=raw (Subsonic API 1.9.0+) instructs the server to return the original
// file without transcoding, giving a genuine Content-Length and preserving
// audio quality (FLAC, OPUS, AAC, MP3 — whatever is stored).
func (c *NavidromeClient) streamURL(id string) string {
return c.buildURL("stream", url.Values{"id": {id}, "format": {"raw"}})
}
func (c *NavidromeClient) CanReportPlayback(track playlist.Track) bool {
return !c.scrobbleDisabled && track.Meta(provider.MetaNavidromeID) != ""
}
func (c *NavidromeClient) ReportNowPlaying(track playlist.Track, _ time.Duration, _ bool) {
c.scrobble(track.Meta(provider.MetaNavidromeID), false)
}
func (c *NavidromeClient) ReportScrobble(track playlist.Track, _, _ time.Duration, _ bool) {
c.scrobble(track.Meta(provider.MetaNavidromeID), true)
}
// scrobble reports playback of a track to the Subsonic server.
// 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) {
if id == "" {
return
}
params := url.Values{
"id": {id},
"submission": {fmt.Sprintf("%t", submission)},
}
if submission {
// Pass the current wall-clock time in milliseconds as required by
// the spec for submission=true (Subsonic API 1.8.0+).
params.Set("time", fmt.Sprintf("%d", time.Now().UnixMilli()))
}
resp, err := httpClient.Get(c.buildURL("scrobble", params))
if err != nil {
return // fire-and-forget; ignore network errors
}
resp.Body.Close()
}
+604
View File
@@ -0,0 +1,604 @@
package navidrome
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/bjarneo/cliamp/config"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// subsonicHandler serves fake Subsonic API responses for testing.
func subsonicHandler(t *testing.T) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
path := r.URL.Path
switch {
case strings.HasSuffix(path, "/rest/getPlaylists"):
w.Write([]byte(`{
"subsonic-response": {
"status": "ok",
"playlists": {
"playlist": [
{"id":"pl-1","name":"Jazz Classics","songCount":12},
{"id":"pl-2","name":"Late Night","songCount":8}
]
}
}
}`))
case strings.HasSuffix(path, "/rest/getPlaylist"):
w.Write([]byte(`{
"subsonic-response": {
"status": "ok",
"playlist": {
"entry": [
{
"id":"song-1","title":"So What","artist":"Miles Davis",
"album":"Kind of Blue","year":1959,"track":1,"genre":"Jazz","duration":565
},
{
"id":"song-2","title":"Blue in Green","artist":"Miles Davis",
"album":"Kind of Blue","year":1959,"track":3,"genre":"Jazz","duration":327
}
]
}
}
}`))
case strings.HasSuffix(path, "/rest/getArtists"):
w.Write([]byte(`{
"subsonic-response": {
"status": "ok",
"artists": {
"index": [
{
"artist": [
{"id":"ar-1","name":"Miles Davis","albumCount":5},
{"id":"ar-2","name":"Mingus","albumCount":3}
]
},
{
"artist": [
{"id":"ar-3","name":"Thelonious Monk","albumCount":4}
]
}
]
}
}
}`))
case strings.HasSuffix(path, "/rest/getArtist"):
w.Write([]byte(`{
"subsonic-response": {
"status": "ok",
"artist": {
"album": [
{"id":"al-1","name":"Kind of Blue","artist":"Miles Davis","artistId":"ar-1","year":1959,"songCount":5,"genre":"Jazz"},
{"id":"al-2","name":"Bitches Brew","artist":"Miles Davis","artistId":"ar-1","year":1970,"songCount":4,"genre":"Jazz"}
]
}
}
}`))
case strings.HasSuffix(path, "/rest/getAlbumList2"):
w.Write([]byte(`{
"subsonic-response": {
"status": "ok",
"albumList2": {
"album": [
{"id":"al-1","name":"Kind of Blue","artist":"Miles Davis","artistId":"ar-1","year":1959,"songCount":5,"genre":"Jazz"}
]
}
}
}`))
case strings.HasSuffix(path, "/rest/getAlbum"):
w.Write([]byte(`{
"subsonic-response": {
"status": "ok",
"album": {
"song": [
{
"id":"song-1","title":"So What","artist":"Miles Davis",
"album":"Kind of Blue","year":1959,"track":1,"genre":"Jazz","duration":565
}
]
}
}
}`))
case strings.HasSuffix(path, "/rest/search3"):
w.Write([]byte(`{
"subsonic-response": {
"status": "ok",
"searchResult3": {
"song": [
{
"id":"song-1","title":"So What","artist":"Miles Davis",
"album":"Kind of Blue","year":1959,"track":1,"genre":"Jazz","duration":565
},
{
"id":"song-7","title":"What'd I Say","artist":"Ray Charles",
"album":"What'd I Say","year":1959,"track":1,"genre":"Soul","duration":380
}
]
}
}
}`))
case strings.HasSuffix(path, "/rest/scrobble"):
w.Write([]byte(`{"subsonic-response":{"status":"ok"}}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}
}
func newTestClient(t *testing.T) (*NavidromeClient, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(subsonicHandler(t))
c := New(srv.URL, "testuser", "testpass")
return c, srv
}
func TestName(t *testing.T) {
c := New("http://localhost", "u", "p")
if c.Name() != "Navidrome" {
t.Errorf("Name() = %q, want %q", c.Name(), "Navidrome")
}
}
func TestPlaylists(t *testing.T) {
c, srv := newTestClient(t)
defer srv.Close()
lists, err := c.Playlists()
if err != nil {
t.Fatalf("Playlists() error: %v", err)
}
if len(lists) != 2 {
t.Fatalf("expected 2 playlists, got %d", len(lists))
}
if lists[0].ID != "pl-1" || lists[0].Name != "Jazz Classics" || lists[0].TrackCount != 12 {
t.Errorf("lists[0] = %+v", lists[0])
}
if lists[1].ID != "pl-2" || lists[1].Name != "Late Night" || lists[1].TrackCount != 8 {
t.Errorf("lists[1] = %+v", lists[1])
}
}
func TestPlaylists_Cached(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/rest/getPlaylists") {
callCount++
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"subsonic-response": {
"status": "ok",
"playlists": {"playlist": [{"id":"1","name":"P","songCount":1}]}
}
}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "p")
if _, err := c.Playlists(); err != nil {
t.Fatalf("first Playlists() error: %v", err)
}
if _, err := c.Playlists(); err != nil {
t.Fatalf("second Playlists() error: %v", err)
}
if callCount != 1 {
t.Errorf("expected getPlaylists called once, called %d times", callCount)
}
}
func TestTracks(t *testing.T) {
c, srv := newTestClient(t)
defer srv.Close()
tracks, err := c.Tracks("pl-1")
if err != nil {
t.Fatalf("Tracks() error: %v", err)
}
if len(tracks) != 2 {
t.Fatalf("expected 2 tracks, got %d", len(tracks))
}
tr := tracks[0]
if tr.Title != "So What" {
t.Errorf("Title = %q, want %q", tr.Title, "So What")
}
if tr.Artist != "Miles Davis" {
t.Errorf("Artist = %q, want %q", tr.Artist, "Miles Davis")
}
if tr.Album != "Kind of Blue" {
t.Errorf("Album = %q, want %q", tr.Album, "Kind of Blue")
}
if tr.Year != 1959 {
t.Errorf("Year = %d, want 1959", tr.Year)
}
if tr.TrackNumber != 1 {
t.Errorf("TrackNumber = %d, want 1", tr.TrackNumber)
}
if tr.Genre != "Jazz" {
t.Errorf("Genre = %q, want %q", tr.Genre, "Jazz")
}
if tr.DurationSecs != 565 {
t.Errorf("DurationSecs = %d, want 565", tr.DurationSecs)
}
if !tr.Stream {
t.Error("Stream = false, want true")
}
if got := tr.Meta(provider.MetaNavidromeID); got != "song-1" {
t.Errorf("Meta(NavidromeID) = %q, want %q", got, "song-1")
}
if !strings.Contains(tr.Path, "/rest/stream") {
t.Errorf("Path %q missing /rest/stream", tr.Path)
}
}
func TestTracks_Cached(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/rest/getPlaylist") {
callCount++
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"subsonic-response": {
"status": "ok",
"playlist": {"entry": [{"id":"1","title":"T","artist":"A","album":"Al","duration":60}]}
}
}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "p")
if _, err := c.Tracks("pl-1"); err != nil {
t.Fatalf("first Tracks() error: %v", err)
}
if _, err := c.Tracks("pl-1"); err != nil {
t.Fatalf("second Tracks() error: %v", err)
}
if callCount != 1 {
t.Errorf("expected getPlaylist called once, called %d times", callCount)
}
}
func TestArtists(t *testing.T) {
c, srv := newTestClient(t)
defer srv.Close()
artists, err := c.Artists()
if err != nil {
t.Fatalf("Artists() error: %v", err)
}
if len(artists) != 3 {
t.Fatalf("expected 3 artists (across 2 indexes), got %d", len(artists))
}
if artists[0].ID != "ar-1" || artists[0].Name != "Miles Davis" || artists[0].AlbumCount != 5 {
t.Errorf("artists[0] = %+v", artists[0])
}
if artists[2].ID != "ar-3" || artists[2].Name != "Thelonious Monk" {
t.Errorf("artists[2] = %+v", artists[2])
}
}
func TestArtistAlbums(t *testing.T) {
c, srv := newTestClient(t)
defer srv.Close()
albums, err := c.ArtistAlbums("ar-1")
if err != nil {
t.Fatalf("ArtistAlbums() error: %v", err)
}
if len(albums) != 2 {
t.Fatalf("expected 2 albums, got %d", len(albums))
}
if albums[0].ID != "al-1" || albums[0].Name != "Kind of Blue" || albums[0].Year != 1959 {
t.Errorf("albums[0] = %+v", albums[0])
}
if albums[1].ID != "al-2" || albums[1].Name != "Bitches Brew" || albums[1].Year != 1970 {
t.Errorf("albums[1] = %+v", albums[1])
}
}
func TestAlbumList(t *testing.T) {
c, srv := newTestClient(t)
defer srv.Close()
albums, err := c.AlbumList(SortAlphabeticalByName, 0, 50)
if err != nil {
t.Fatalf("AlbumList() error: %v", err)
}
if len(albums) != 1 {
t.Fatalf("expected 1 album, got %d", len(albums))
}
if albums[0].ID != "al-1" || albums[0].Name != "Kind of Blue" {
t.Errorf("albums[0] = %+v", albums[0])
}
}
func TestAlbumTracks(t *testing.T) {
c, srv := newTestClient(t)
defer srv.Close()
tracks, err := c.AlbumTracks("al-1")
if err != nil {
t.Fatalf("AlbumTracks() error: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("expected 1 track, got %d", len(tracks))
}
if tracks[0].Title != "So What" || tracks[0].DurationSecs != 565 {
t.Errorf("tracks[0] = %+v", tracks[0])
}
}
func TestSearchTracks(t *testing.T) {
c, srv := newTestClient(t)
defer srv.Close()
tracks, err := c.SearchTracks(t.Context(), "what", 20)
if err != nil {
t.Fatalf("SearchTracks() error: %v", err)
}
if len(tracks) != 2 {
t.Fatalf("expected 2 tracks, got %d", len(tracks))
}
if tracks[0].Title != "So What" {
t.Errorf("tracks[0].Title = %q, want %q", tracks[0].Title, "So What")
}
if !tracks[0].Stream {
t.Error("tracks[0].Stream should be true")
}
if tracks[0].Meta(provider.MetaNavidromeID) != "song-1" {
t.Errorf("tracks[0] meta navidrome id = %q, want song-1", tracks[0].Meta(provider.MetaNavidromeID))
}
}
func TestSubsonicError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"subsonic-response": {
"status": "failed",
"error": {"code": 40, "message": "Wrong username or password"}
}
}`))
}))
defer srv.Close()
c := New(srv.URL, "bad", "creds")
_, err := c.Playlists()
if err == nil {
t.Fatal("expected error for bad credentials, got nil")
}
if !strings.Contains(err.Error(), "Wrong username or password") {
t.Errorf("error = %q, expected credential error", err.Error())
}
}
func TestHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := New(srv.URL, "u", "p")
_, err := c.Playlists()
if err == nil {
t.Fatal("expected error for HTTP 500, got nil")
}
}
func TestBuildURL_AuthParams(t *testing.T) {
c := New("https://music.example.com", "alice", "secret123")
u := c.buildURL("ping", nil)
parsed, err := url.Parse(u)
if err != nil {
t.Fatalf("buildURL() returned invalid URL: %v", err)
}
q := parsed.Query()
if q.Get("u") != "alice" {
t.Errorf("user = %q, want alice", q.Get("u"))
}
if q.Get("t") == "" {
t.Error("token (t) is empty")
}
if q.Get("s") == "" {
t.Error("salt (s) is empty")
}
if q.Get("v") != "1.0.0" {
t.Errorf("version = %q, want 1.0.0", q.Get("v"))
}
if q.Get("c") != "cliamp" {
t.Errorf("client = %q, want cliamp", q.Get("c"))
}
if q.Get("f") != "json" {
t.Errorf("format = %q, want json", q.Get("f"))
}
if !strings.HasPrefix(u, "https://music.example.com/rest/ping?") {
t.Errorf("URL = %q, missing expected prefix", u)
}
}
func TestIsSubsonicStreamURL(t *testing.T) {
tests := []struct {
url string
want bool
}{
{"https://music.example.com/rest/stream?id=1", true},
{"https://music.example.com/rest/stream.view?id=1", true},
{"https://music.example.com/rest/download?id=1", true},
{"https://music.example.com/rest/download.view?id=1", true},
{"https://music.example.com/rest/getPlaylists", false},
{"https://example.com/song.mp3", false},
{"", false},
}
for _, tt := range tests {
if got := IsSubsonicStreamURL(tt.url); got != tt.want {
t.Errorf("IsSubsonicStreamURL(%q) = %v, want %v", tt.url, got, tt.want)
}
}
}
func TestNewFromEnv(t *testing.T) {
t.Setenv("NAVIDROME_URL", "")
t.Setenv("NAVIDROME_USER", "")
t.Setenv("NAVIDROME_PASS", "")
if c := NewFromEnv(); c != nil {
t.Error("NewFromEnv() should return nil when env vars are empty")
}
t.Setenv("NAVIDROME_URL", "https://music.test")
t.Setenv("NAVIDROME_USER", "alice")
t.Setenv("NAVIDROME_PASS", "secret")
c := NewFromEnv()
if c == nil {
t.Fatal("NewFromEnv() returned nil with all env vars set")
}
if c.url != "https://music.test" || c.user != "alice" || c.password != "secret" {
t.Errorf("NewFromEnv() = %+v", c)
}
}
func TestNewFromConfig(t *testing.T) {
tests := []struct {
name string
cfg config.NavidromeConfig
wantNil bool
}{
{"empty", config.NavidromeConfig{}, true},
{"no password", config.NavidromeConfig{URL: "http://localhost", User: "u"}, true},
{"no url", config.NavidromeConfig{User: "u", Password: "p"}, true},
{"valid", config.NavidromeConfig{URL: "http://localhost", User: "u", Password: "p"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := NewFromConfig(tt.cfg)
if (c == nil) != tt.wantNil {
t.Errorf("NewFromConfig(%+v) nil=%v, want nil=%v", tt.cfg, c == nil, tt.wantNil)
}
})
}
}
func TestNewFromConfig_BrowseSort(t *testing.T) {
cfg := config.NavidromeConfig{
URL: "http://localhost", User: "u", Password: "p",
BrowseSort: SortNewest,
}
c := NewFromConfig(cfg)
if c.browseSort != SortNewest {
t.Errorf("browseSort = %q, want %q", c.browseSort, SortNewest)
}
}
func TestNewFromConfig_ScrobbleDisabled(t *testing.T) {
cfg := config.NavidromeConfig{
URL: "http://localhost", User: "u", Password: "p",
ScrobbleDisabled: true,
}
c := NewFromConfig(cfg)
if !c.scrobbleDisabled {
t.Error("scrobbleDisabled = false, want true")
}
}
func TestAlbumSortTypes(t *testing.T) {
c := New("http://localhost", "u", "p")
sorts := c.AlbumSortTypes()
if len(sorts) != 8 {
t.Fatalf("expected 8 sort types, got %d", len(sorts))
}
}
func TestDefaultAlbumSort(t *testing.T) {
c := New("http://localhost", "u", "p")
if c.DefaultAlbumSort() != SortAlphabeticalByName {
t.Errorf("DefaultAlbumSort() = %q, want %q", c.DefaultAlbumSort(), SortAlphabeticalByName)
}
c.browseSort = SortNewest
if c.DefaultAlbumSort() != SortNewest {
t.Errorf("DefaultAlbumSort() = %q, want %q", c.DefaultAlbumSort(), SortNewest)
}
}
func TestCanReportPlayback(t *testing.T) {
c := New("http://localhost", "u", "p")
track := trackWithNavidromeMeta("song-1")
if !c.CanReportPlayback(track) {
t.Error("CanReportPlayback() = false for track with navidrome ID")
}
c.scrobbleDisabled = true
if c.CanReportPlayback(track) {
t.Error("CanReportPlayback() = true when scrobbling is disabled")
}
}
func TestCanReportPlayback_NoMeta(t *testing.T) {
c := New("http://localhost", "u", "p")
track := trackWithNavidromeMeta("")
if c.CanReportPlayback(track) {
t.Error("CanReportPlayback() = true for track without navidrome ID")
}
}
func TestScrobble(t *testing.T) {
var gotSubmission string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/rest/scrobble") {
gotSubmission = r.URL.Query().Get("submission")
}
w.Write([]byte(`{"subsonic-response":{"status":"ok"}}`))
}))
defer srv.Close()
c := New(srv.URL, "u", "p")
c.ReportNowPlaying(trackWithNavidromeMeta("song-1"), 0, false)
if gotSubmission != "false" {
t.Errorf("ReportNowPlaying submission = %q, want false", gotSubmission)
}
c.ReportScrobble(trackWithNavidromeMeta("song-1"), 0, 0, false)
if gotSubmission != "true" {
t.Errorf("ReportScrobble submission = %q, want true", gotSubmission)
}
}
func TestCheckSubsonicError(t *testing.T) {
if err := checkSubsonicError("ok", nil); err != nil {
t.Errorf("expected nil for status ok, got %v", err)
}
if err := checkSubsonicError("", nil); err != nil {
t.Errorf("expected nil for empty status, got %v", err)
}
err := checkSubsonicError("failed", &subsonicError{Code: 40, Message: "Wrong password"})
if err == nil {
t.Fatal("expected error for failed status")
}
if !strings.Contains(err.Error(), "Wrong password") {
t.Errorf("error = %q, missing message", err.Error())
}
err = checkSubsonicError("failed", nil)
if err == nil {
t.Fatal("expected error for failed status with nil error body")
}
}
func trackWithNavidromeMeta(id string) playlist.Track {
meta := map[string]string{}
if id != "" {
meta[provider.MetaNavidromeID] = id
}
return playlist.Track{ProviderMeta: meta}
}
+597
View File
@@ -0,0 +1,597 @@
// Package netease implements a playlist.Provider for NetEase Cloud Music.
package netease
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/resolve"
)
var (
_ playlist.Provider = (*Provider)(nil)
_ provider.Searcher = (*Provider)(nil)
)
const (
defaultAPIBase = "https://music.163.com"
probeURL = "https://music.163.com/#/playlist?id=3778678"
apiTimeout = 15 * time.Second
// neteaseCodeOK is the application-level success code in NetEase API
// responses. It happens to share the value of HTTP 200 but is a distinct
// field, so it gets its own constant rather than comparing to http.StatusOK.
neteaseCodeOK = 200
)
// ErrNotAuthenticated is returned when browser cookies do not contain a
// signed-in NetEase Cloud Music account.
var ErrNotAuthenticated = errors.New("netease: browser session is not signed in")
// Config holds settings for the NetEase provider.
type Config struct {
Enabled bool
CookiesFrom string
UserID string
}
// IsSet reports whether the provider should be exposed.
func (c Config) IsSet() bool { return c.Enabled }
// Account describes the signed-in NetEase account visible through cookies.
type Account struct {
UserID string
Nickname string
VIPType int
}
type chartPlaylist struct {
id string
name string
}
var charts = []chartPlaylist{
{id: "3778678", name: "Hot Songs"},
{id: "3779629", name: "New Songs"},
{id: "19723756", name: "Rising Songs"},
{id: "2884035", name: "Original Songs"},
}
// Provider implements playlist.Provider and provider.Searcher.
type Provider struct {
apiBase string
httpClient *http.Client
cookiesFrom string
userID string
mu sync.Mutex
cookieHeader string
playlists []playlist.PlaylistInfo
account *Account
}
// NewFromConfig returns a provider, or nil when NetEase is not enabled.
// Sets resolve's yt-dlp cookies as a side effect when CookiesFrom is non-empty
// so URL resolution uses the same signed-in browser session.
func NewFromConfig(cfg Config) *Provider {
if !cfg.Enabled {
return nil
}
cfg.CookiesFrom = strings.TrimSpace(cfg.CookiesFrom)
if cfg.CookiesFrom != "" {
resolve.SetYTDLCookiesFrom(cfg.CookiesFrom)
}
return New(cfg)
}
// New creates a NetEase provider.
func New(cfg Config) *Provider {
return &Provider{
apiBase: defaultAPIBase,
httpClient: &http.Client{Timeout: apiTimeout},
cookiesFrom: strings.TrimSpace(cfg.CookiesFrom),
userID: strings.TrimSpace(cfg.UserID),
}
}
func newWithBase(cfg Config, base string) *Provider {
p := New(cfg)
p.apiBase = strings.TrimRight(base, "/")
return p
}
func (p *Provider) Name() string { return "NetEase Cloud Music" }
// Refresh clears cached account and playlist state.
func (p *Provider) Refresh() {
p.mu.Lock()
defer p.mu.Unlock()
p.cookieHeader = ""
p.playlists = nil
p.account = nil
}
// CheckLogin verifies that the given browser has a signed-in NetEase account.
func CheckLogin(ctx context.Context, browser string) (Account, error) {
p := New(Config{Enabled: true, CookiesFrom: browser})
return p.Account(ctx)
}
// Account returns the signed-in account from browser cookies.
func (p *Provider) Account(ctx context.Context) (Account, error) {
p.mu.Lock()
if p.account != nil {
acc := *p.account
p.mu.Unlock()
return acc, nil
}
p.mu.Unlock()
var resp accountResponse
if err := p.apiGet(ctx, "/api/nuser/account/get", nil, &resp); err != nil {
return Account{}, err
}
if resp.Code != neteaseCodeOK {
return Account{}, fmt.Errorf("netease: account request failed with code %d", resp.Code)
}
uid := resp.Account.ID
if uid == 0 {
uid = resp.Profile.UserID
}
if uid == 0 {
return Account{}, ErrNotAuthenticated
}
acc := Account{
UserID: strconv.FormatInt(uid, 10),
Nickname: resp.Profile.Nickname,
VIPType: firstNonZero(resp.Profile.VIPType, resp.Account.VIPType),
}
p.mu.Lock()
p.account = &acc
if p.userID == "" {
p.userID = acc.UserID
}
p.mu.Unlock()
return acc, nil
}
// Playlists returns account playlists followed by public chart playlists.
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
p.mu.Lock()
if p.playlists != nil {
out := append([]playlist.PlaylistInfo(nil), p.playlists...)
p.mu.Unlock()
return out, nil
}
userID := p.userID
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()
var infos []playlist.PlaylistInfo
if userID == "" && p.cookiesFrom != "" {
acc, err := p.Account(ctx)
if err != nil {
return nil, err
}
userID = acc.UserID
}
if userID != "" {
userLists, err := p.userPlaylists(ctx, userID)
if err != nil {
return nil, err
}
infos = append(infos, userLists...)
}
infos = append(infos, chartPlaylists()...)
p.mu.Lock()
p.playlists = append([]playlist.PlaylistInfo(nil), infos...)
p.mu.Unlock()
return infos, nil
}
// Tracks returns tracks for a user playlist or built-in chart playlist.
func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) {
id, err := cleanPlaylistID(playlistID)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()
params := url.Values{"id": {id}}
var resp playlistDetailResponse
if err := p.apiGet(ctx, "/api/playlist/detail", params, &resp); err != nil {
return nil, err
}
if resp.Code != neteaseCodeOK {
return nil, fmt.Errorf("netease: playlist detail failed with code %d", resp.Code)
}
return songsToTracks(resp.Result.Tracks), nil
}
// SearchTracks searches NetEase songs.
func (p *Provider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
q := strings.TrimSpace(query)
if q == "" {
return nil, nil
}
if limit <= 0 {
limit = 20
}
params := url.Values{
"s": {q},
"type": {"1"},
"offset": {"0"},
"limit": {strconv.Itoa(limit)},
}
var resp searchResponse
if err := p.apiGet(ctx, "/api/search/get/web", params, &resp); err != nil {
return nil, err
}
if resp.Code != neteaseCodeOK {
return nil, fmt.Errorf("netease: search failed with code %d", resp.Code)
}
return songsToTracks(resp.Result.Songs), nil
}
func (p *Provider) userPlaylists(ctx context.Context, userID string) ([]playlist.PlaylistInfo, error) {
uid, err := strconv.ParseInt(strings.TrimSpace(userID), 10, 64)
if err != nil || uid <= 0 {
return nil, fmt.Errorf("netease: invalid user_id %q", userID)
}
const pageSize = 100
var out []playlist.PlaylistInfo
for offset := 0; ; offset += pageSize {
params := url.Values{
"uid": {strconv.FormatInt(uid, 10)},
"limit": {strconv.Itoa(pageSize)},
"offset": {strconv.Itoa(offset)},
}
var resp userPlaylistsResponse
if err := p.apiGet(ctx, "/api/user/playlist", params, &resp); err != nil {
return nil, err
}
if resp.Code != neteaseCodeOK {
return nil, fmt.Errorf("netease: playlist request failed with code %d", resp.Code)
}
for _, item := range resp.Playlist {
section := "Saved Playlists"
if item.UserID == uid {
section = "My Playlists"
}
name := strings.TrimSpace(item.Name)
if item.SpecialType == 5 {
name = "Liked Songs"
section = "My Playlists"
}
if name == "" {
name = "Untitled Playlist"
}
out = append(out, playlist.PlaylistInfo{
ID: "user:" + strconv.FormatInt(item.ID, 10),
Name: name,
TrackCount: item.TrackCount,
Section: section,
})
}
if len(resp.Playlist) < pageSize {
break
}
}
return out, nil
}
func chartPlaylists() []playlist.PlaylistInfo {
out := make([]playlist.PlaylistInfo, 0, len(charts))
for _, chart := range charts {
out = append(out, playlist.PlaylistInfo{
ID: "chart:" + chart.id,
Name: chart.name,
Section: "Charts",
})
}
return out
}
func cleanPlaylistID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf("netease: empty playlist id")
}
if v, ok := strings.CutPrefix(id, "user:"); ok {
id = v
} else if v, ok := strings.CutPrefix(id, "chart:"); ok {
id = v
}
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return "", fmt.Errorf("netease: invalid playlist id %q", id)
}
return id, nil
}
func songsToTracks(songs []song) []playlist.Track {
tracks := make([]playlist.Track, 0, len(songs))
for _, s := range songs {
if s.ID == 0 {
continue
}
tracks = append(tracks, playlist.Track{
Path: songURL(s.ID),
Title: s.Name,
Artist: joinArtists(s.Artists),
Album: s.Album.Name,
TrackNumber: s.TrackNumber,
Stream: true,
DurationSecs: millisToSeconds(s.DurationMS),
ProviderMeta: map[string]string{provider.MetaNetEaseID: strconv.FormatInt(s.ID, 10)},
})
}
return tracks
}
func songURL(id int64) string {
return "https://music.163.com/#/song?id=" + strconv.FormatInt(id, 10)
}
func joinArtists(artists []artist) string {
if len(artists) == 0 {
return ""
}
names := make([]string, 0, len(artists))
for _, a := range artists {
if name := strings.TrimSpace(a.Name); name != "" {
names = append(names, name)
}
}
return strings.Join(names, ", ")
}
func millisToSeconds(ms int) int {
if ms <= 0 {
return 0
}
return (ms + 999) / 1000
}
func firstNonZero(values ...int) int {
for _, v := range values {
if v != 0 {
return v
}
}
return 0
}
func (p *Provider) apiGet(ctx context.Context, path string, params url.Values, out any) error {
endpoint, err := url.Parse(p.apiBase + path)
if err != nil {
return err
}
if params != nil {
endpoint.RawQuery = params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Referer", p.apiBase+"/")
if header, err := p.ensureCookieHeader(ctx); err != nil {
return err
} else if header != "" {
req.Header.Set("Cookie", header)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("netease: http status %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("netease: decode response: %w", err)
}
return nil
}
func (p *Provider) ensureCookieHeader(ctx context.Context) (string, error) {
if p.cookiesFrom == "" {
return "", nil
}
p.mu.Lock()
if p.cookieHeader != "" {
header := p.cookieHeader
p.mu.Unlock()
return header, nil
}
p.mu.Unlock()
header, err := extractBrowserCookieHeader(ctx, p.cookiesFrom)
if err != nil {
return "", err
}
p.mu.Lock()
p.cookieHeader = header
p.mu.Unlock()
return header, nil
}
func extractBrowserCookieHeader(ctx context.Context, browser string) (string, error) {
if _, err := exec.LookPath("yt-dlp"); err != nil {
return "", fmt.Errorf("yt-dlp not found. Install with: %s", ytDLPInstallHint())
}
tmp, err := os.CreateTemp("", "cliamp-netease-cookies-*.txt")
if err != nil {
return "", err
}
path := tmp.Name()
tmp.Close()
os.Remove(path)
defer os.Remove(path)
cmdCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "yt-dlp",
"--cookies-from-browser", browser,
"--cookies", path,
"--flat-playlist",
"--playlist-end", "1",
"--socket-timeout", "15",
"--print", "title",
probeURL,
)
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return "", fmt.Errorf("netease: load browser cookies: %s: %w", msg, err)
}
return "", fmt.Errorf("netease: load browser cookies: %w", err)
}
header, err := cookieHeaderFromNetscapeFile(path)
if err != nil {
return "", err
}
if header == "" {
return "", fmt.Errorf("netease: no NetEase cookies found in browser session")
}
return header, nil
}
func ytDLPInstallHint() string {
switch runtime.GOOS {
case "darwin":
return "brew install yt-dlp"
case "linux":
if _, err := exec.LookPath("apt-get"); err == nil {
return "sudo apt install yt-dlp"
}
if _, err := exec.LookPath("pacman"); err == nil {
return "sudo pacman -S yt-dlp"
}
return "pip install yt-dlp"
case "windows":
return "winget install yt-dlp"
default:
return "pip install yt-dlp"
}
}
func cookieHeaderFromNetscapeFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
seen := map[string]bool{}
var pairs []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "# Netscape") || strings.HasPrefix(line, "# This file") {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 7 {
continue
}
domain := strings.TrimPrefix(fields[0], "#HttpOnly_")
if !isNetEaseCookieDomain(domain) {
continue
}
name := fields[5]
value := fields[6]
if name == "" || seen[name] {
continue
}
seen[name] = true
pairs = append(pairs, name+"="+value)
}
if err := scanner.Err(); err != nil {
return "", err
}
return strings.Join(pairs, "; "), nil
}
func isNetEaseCookieDomain(domain string) bool {
domain = strings.TrimPrefix(strings.ToLower(domain), ".")
return domain == "163.com" || domain == "music.163.com" || strings.HasSuffix(domain, ".music.163.com")
}
type accountResponse struct {
Code int `json:"code"`
Account struct {
ID int64 `json:"id"`
VIPType int `json:"vipType"`
} `json:"account"`
Profile struct {
UserID int64 `json:"userId"`
Nickname string `json:"nickname"`
VIPType int `json:"vipType"`
} `json:"profile"`
}
type userPlaylistsResponse struct {
Code int `json:"code"`
Playlist []playlistItem `json:"playlist"`
}
type playlistItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
UserID int64 `json:"userId"`
TrackCount int `json:"trackCount"`
SpecialType int `json:"specialType"`
}
type playlistDetailResponse struct {
Code int `json:"code"`
Result struct {
Tracks []song `json:"tracks"`
} `json:"result"`
}
type searchResponse struct {
Code int `json:"code"`
Result struct {
Songs []song `json:"songs"`
} `json:"result"`
}
type song struct {
ID int64 `json:"id"`
Name string `json:"name"`
DurationMS int `json:"duration"`
TrackNumber int `json:"no"`
Artists []artist `json:"artists"`
Album album `json:"album"`
}
type artist struct {
Name string `json:"name"`
}
type album struct {
Name string `json:"name"`
}
+168
View File
@@ -0,0 +1,168 @@
package netease
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/bjarneo/cliamp/provider"
)
func TestPlaylistsIncludesAccountListsAndCharts(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/user/playlist" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("uid"); got != "42" {
t.Fatalf("uid = %q, want 42", got)
}
w.Write([]byte(`{"code":200,"playlist":[
{"id":10,"name":"Daily Picks","userId":42,"trackCount":12,"specialType":5},
{"id":11,"name":"Road Trip","userId":42,"trackCount":8,"specialType":0},
{"id":12,"name":"Saved Mix","userId":99,"trackCount":20,"specialType":0}
]}`))
}))
defer srv.Close()
p := newWithBase(Config{Enabled: true, UserID: "42"}, srv.URL)
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists() error = %v", err)
}
if len(lists) != 7 {
t.Fatalf("got %d playlists, want 7", len(lists))
}
if lists[0].ID != "user:10" || lists[0].Name != "Liked Songs" || lists[0].Section != "My Playlists" {
t.Fatalf("liked playlist = %+v", lists[0])
}
if lists[2].Section != "Saved Playlists" {
t.Fatalf("saved playlist section = %q", lists[2].Section)
}
if lists[3].ID != "chart:3778678" || lists[3].Section != "Charts" {
t.Fatalf("first chart = %+v", lists[3])
}
}
func TestTracksMapsSongs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/playlist/detail" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("id"); got != "10" {
t.Fatalf("id = %q, want 10", got)
}
w.Write([]byte(`{"code":200,"result":{"tracks":[
{"id":100,"name":"First Track","duration":123456,"no":3,
"artists":[{"name":"Artist One"},{"name":"Artist Two"}],
"album":{"name":"Album One"}}
]}}`))
}))
defer srv.Close()
p := newWithBase(Config{Enabled: true}, srv.URL)
tracks, err := p.Tracks("user:10")
if err != nil {
t.Fatalf("Tracks() error = %v", err)
}
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(tracks))
}
tr := tracks[0]
if tr.Path != "https://music.163.com/#/song?id=100" {
t.Fatalf("Path = %q", tr.Path)
}
if tr.Artist != "Artist One, Artist Two" || tr.Album != "Album One" {
t.Fatalf("metadata = artist %q album %q", tr.Artist, tr.Album)
}
if tr.DurationSecs != 124 {
t.Fatalf("DurationSecs = %d, want 124", tr.DurationSecs)
}
if tr.Meta(provider.MetaNetEaseID) != "100" {
t.Fatalf("MetaNetEaseID = %q", tr.Meta(provider.MetaNetEaseID))
}
}
func TestSearchTracks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/search/get/web" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if got := r.URL.Query().Get("s"); got != "query" {
t.Fatalf("search query = %q, want query", got)
}
if got := r.URL.Query().Get("limit"); got != "5" {
t.Fatalf("limit = %q, want 5", got)
}
w.Write([]byte(`{"code":200,"result":{"songs":[
{"id":200,"name":"Search Hit","duration":1000,
"artists":[{"name":"Artist"}],"album":{"name":"Album"}}
]}}`))
}))
defer srv.Close()
p := newWithBase(Config{Enabled: true}, srv.URL)
tracks, err := p.SearchTracks(context.Background(), " query ", 5)
if err != nil {
t.Fatalf("SearchTracks() error = %v", err)
}
if len(tracks) != 1 || tracks[0].Title != "Search Hit" {
t.Fatalf("tracks = %+v", tracks)
}
}
func TestCookieHeaderFromNetscapeFileFiltersNetEaseCookies(t *testing.T) {
path := t.TempDir() + "/cookies.txt"
data := strings.Join([]string{
"# Netscape HTTP Cookie File",
".music.163.com\tTRUE\t/\tTRUE\t0\tMUSIC_U\tabc",
"#HttpOnly_.163.com\tTRUE\t/\tTRUE\t0\t__csrf\tdef",
".example.com\tTRUE\t/\tTRUE\t0\tOTHER\tignored",
"",
}, "\n")
if err := osWriteFile(path, data); err != nil {
t.Fatal(err)
}
header, err := cookieHeaderFromNetscapeFile(path)
if err != nil {
t.Fatalf("cookieHeaderFromNetscapeFile() error = %v", err)
}
if header != "MUSIC_U=abc; __csrf=def" {
t.Fatalf("header = %q", header)
}
}
func TestExtractBrowserCookieHeaderMissingYTDLPShowsInstallHint(t *testing.T) {
t.Setenv("PATH", t.TempDir())
_, err := extractBrowserCookieHeader(context.Background(), "chrome")
if err == nil {
t.Fatal("extractBrowserCookieHeader() error = nil, want missing yt-dlp error")
}
msg := err.Error()
if !strings.HasPrefix(msg, "yt-dlp not found. Install with: ") {
t.Fatalf("error = %q", msg)
}
if strings.TrimPrefix(msg, "yt-dlp not found. Install with: ") == "" {
t.Fatalf("missing install hint in error = %q", msg)
}
}
func TestLiveCheckLoginWithBrowser(t *testing.T) {
browser := os.Getenv("CLIAMP_NETEASE_LIVE_BROWSER")
if browser == "" {
t.Skip("set CLIAMP_NETEASE_LIVE_BROWSER to run live browser-cookie check")
}
acc, err := CheckLogin(context.Background(), browser)
if err != nil {
t.Fatalf("CheckLogin() error = %v", err)
}
if acc.UserID == "" {
t.Fatal("CheckLogin() returned empty user id")
}
}
func osWriteFile(path, data string) error {
return os.WriteFile(path, []byte(data), 0o644)
}
+298
View File
@@ -0,0 +1,298 @@
// Package plex implements a playlist.Provider for Plex Media Server.
package plex
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// maxResponseBody limits API responses to 10 MB to prevent unbounded memory growth.
const maxResponseBody = 10 << 20
// apiClient is used for all Plex API calls with a finite timeout.
// It is distinct from httpclient.Streaming (which has no timeout) used for audio streams.
var apiClient = &http.Client{Timeout: 30 * time.Second}
// Client speaks to a Plex Media Server over its HTTP API.
type Client struct {
baseURL string // e.g. "http://192.168.1.10:32400"
token string // X-Plex-Token
libraries []string // if non-empty, MusicSections filters to these titles (case-insensitive)
}
// NewClient returns a Client for the given server URL and authentication token.
// libraries is an optional list of music library names to restrict MusicSections to.
// When not provided, all music libraries will be loaded
func NewClient(baseURL, token string, libraries ...string) *Client {
return &Client{baseURL: baseURL, token: token, libraries: libraries}
}
// Section represents a Plex library section (e.g. a music library).
type Section struct {
Key string // numeric section ID, e.g. "3"
Title string // display name
Type string // "artist" for music libraries
}
// Album represents a Plex album with its artist and track count.
type Album struct {
RatingKey string // unique album ID (Plex ratingKey)
Title string
ArtistName string // parentTitle in the Plex API
Year int
TrackCount int // leafCount
}
// Track represents a Plex track with metadata and its first streamable Part.
type Track struct {
RatingKey string
Title string
ArtistName string // grandparentTitle
AlbumName string // parentTitle
Year int
TrackNumber int // index field in Plex API
Duration int // milliseconds
PartKey string // relative path, e.g. "/library/parts/67890/1234567890/file.flac"
}
// get issues an authenticated GET request and decodes the JSON response into result.
func (c *Client) get(path string, params url.Values, result any) error {
if params == nil {
params = url.Values{}
}
params.Set("X-Plex-Token", c.token)
req, err := http.NewRequest(http.MethodGet, c.baseURL+path+"?"+params.Encode(), nil)
if err != nil {
return fmt.Errorf("plex: %s: %w", path, err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Plex-Product", "cliamp")
req.Header.Set("X-Plex-Client-Identifier", "cliamp")
resp, err := apiClient.Do(req)
if err != nil {
return fmt.Errorf("plex: %s: server unreachable: %w", path, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// ok
case http.StatusUnauthorized:
return fmt.Errorf("plex: token invalid or expired")
default:
return fmt.Errorf("plex: %s: HTTP %s", path, resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return fmt.Errorf("plex: %s: %w", path, err)
}
return json.Unmarshal(body, result)
}
// Ping checks that the server is reachable and the token is valid.
// Returns a descriptive error on failure.
func (c *Client) Ping() error {
var result struct {
MediaContainer struct {
FriendlyName string `json:"friendlyName"`
} `json:"MediaContainer"`
}
return c.get("/", nil, &result)
}
// MusicSections returns library sections of type "artist" (music libraries).
// When the client was constructed with a library filter, only sections whose
// title matches one of the allowed names (case-insensitive) are returned.
func (c *Client) MusicSections() ([]Section, error) {
var result struct {
MediaContainer struct {
Directory []struct {
Key string `json:"key"`
Type string `json:"type"`
Title string `json:"title"`
} `json:"Directory"`
} `json:"MediaContainer"`
}
if err := c.get("/library/sections", nil, &result); err != nil {
return nil, err
}
var sections []Section
for _, d := range result.MediaContainer.Directory {
if d.Type == "artist" && c.includeLibrary(d.Title) {
sections = append(sections, Section{
Key: d.Key,
Title: d.Title,
Type: d.Type,
})
}
}
return sections, nil
}
// includeLibrary reports whether the library with the given title should be
// included. When no filter is configured (libraries is empty) all libraries pass.
func (c *Client) includeLibrary(title string) bool {
if len(c.libraries) == 0 {
return true
}
for _, lib := range c.libraries {
if strings.EqualFold(lib, title) {
return true
}
}
return false
}
// pageSize is the number of albums requested per API call.
// Plex paginates /library/sections/{id}/all; without an explicit size the
// server may return as few as one item.
const pageSize = 300
// Albums returns all albums in the given music section (identified by its key).
// It requests type=9 (album) directly rather than walking artists, and paginates
// through the full result set using X-Plex-Container-Start / Size.
func (c *Client) Albums(sectionKey string) ([]Album, error) {
type albumPage struct {
MediaContainer struct {
TotalSize int `json:"totalSize"`
Metadata []struct {
RatingKey string `json:"ratingKey"`
Title string `json:"title"`
ParentTitle string `json:"parentTitle"` // artist name
Year int `json:"year"`
LeafCount int `json:"leafCount"` // track count
} `json:"Metadata"`
} `json:"MediaContainer"`
}
var albums []Album
for offset := 0; ; offset += pageSize {
params := url.Values{
"type": {"9"}, // 9 = album
"X-Plex-Container-Start": {fmt.Sprintf("%d", offset)},
"X-Plex-Container-Size": {fmt.Sprintf("%d", pageSize)},
}
var page albumPage
if err := c.get("/library/sections/"+sectionKey+"/all", params, &page); err != nil {
return nil, err
}
for _, m := range page.MediaContainer.Metadata {
albums = append(albums, Album{
RatingKey: m.RatingKey,
Title: m.Title,
ArtistName: m.ParentTitle,
Year: m.Year,
TrackCount: m.LeafCount,
})
}
// Stop when we've fetched everything.
if offset+pageSize >= page.MediaContainer.TotalSize {
break
}
}
return albums, nil
}
// Tracks returns all tracks in the given album (identified by its ratingKey).
func (c *Client) Tracks(albumRatingKey string) ([]Track, error) {
var result struct {
MediaContainer struct {
Metadata []trackJSON `json:"Metadata"`
} `json:"MediaContainer"`
}
if err := c.get("/library/metadata/"+albumRatingKey+"/children", nil, &result); err != nil {
return nil, err
}
tracks := make([]Track, 0, len(result.MediaContainer.Metadata))
for _, m := range result.MediaContainer.Metadata {
tracks = append(tracks, trackFromJSON(m))
}
return tracks, nil
}
// Search searches the music library for tracks matching query.
// Returns nil without making an HTTP call when query is empty.
func (c *Client) Search(query string) ([]Track, error) {
if query == "" {
return nil, nil
}
var result struct {
MediaContainer struct {
Metadata []trackJSON `json:"Metadata"`
} `json:"MediaContainer"`
}
params := url.Values{
"query": {query},
"type": {"10"}, // 10 = track
}
if err := c.get("/library/search", params, &result); err != nil {
return nil, err
}
tracks := make([]Track, 0, len(result.MediaContainer.Metadata))
for _, m := range result.MediaContainer.Metadata {
tracks = append(tracks, trackFromJSON(m))
}
return tracks, nil
}
// StreamURL returns the authenticated HTTP URL for streaming a track part.
// partKey is the relative path from the Part element, e.g. "/library/parts/…/file.flac".
// The token is appended as a query parameter; Plex accepts it in either header or query form.
func (c *Client) StreamURL(partKey string) string {
return c.baseURL + partKey + "?X-Plex-Token=" + url.QueryEscape(c.token)
}
// IsStreamURL reports whether the given URL looks like a Plex library part
// endpoint. Used by the player to route these URLs through the buffered
// navBuffer + ffmpeg pipeline instead of native HTTP streaming.
func IsStreamURL(urlStr string) bool {
u, err := url.Parse(urlStr)
if err != nil {
return false
}
return strings.Contains(strings.ToLower(u.Path), "/library/parts/")
}
// trackJSON is the shared JSON structure for track responses (children and search).
type trackJSON struct {
RatingKey string `json:"ratingKey"`
Title string `json:"title"`
GrandparentTitle string `json:"grandparentTitle"` // artist
ParentTitle string `json:"parentTitle"` // album
Year int `json:"year"`
Index int `json:"index"` // track number within album
Duration int `json:"duration"` // milliseconds
Media []struct {
Part []struct {
Key string `json:"key"`
} `json:"Part"`
} `json:"Media"`
}
func trackFromJSON(m trackJSON) Track {
var partKey string
if len(m.Media) > 0 && len(m.Media[0].Part) > 0 {
partKey = m.Media[0].Part[0].Key
}
return Track{
RatingKey: m.RatingKey,
Title: m.Title,
ArtistName: m.GrandparentTitle,
AlbumName: m.ParentTitle,
Year: m.Year,
TrackNumber: m.Index,
Duration: m.Duration,
PartKey: partKey,
}
}
+409
View File
@@ -0,0 +1,409 @@
package plex
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newTestClient returns a Client pointed at the given test server.
func newTestClient(srv *httptest.Server) *Client {
return NewClient(srv.URL, "test-token")
}
func TestPing_OK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("X-Plex-Token") != "test-token" {
t.Errorf("expected token in query, got %q", r.URL.Query().Get("X-Plex-Token"))
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"MediaContainer":{"friendlyName":"My Plex"}}`))
}))
defer srv.Close()
if err := newTestClient(srv).Ping(); err != nil {
t.Fatalf("Ping() unexpected error: %v", err)
}
}
func TestPing_Unauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
err := newTestClient(srv).Ping()
if err == nil {
t.Fatal("Ping() expected error on 401, got nil")
}
if !strings.Contains(err.Error(), "token invalid") {
t.Errorf("expected 'token invalid' in error, got %q", err.Error())
}
}
func TestPing_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
err := newTestClient(srv).Ping()
if err == nil {
t.Fatal("Ping() expected error on 500, got nil")
}
}
func TestMusicSections_FiltersByType(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"MediaContainer": {
"Directory": [
{"key": "1", "type": "artist", "title": "Music"},
{"key": "2", "type": "movie", "title": "Movies"},
{"key": "3", "type": "artist", "title": "Jazz"}
]
}
}`))
}))
defer srv.Close()
sections, err := newTestClient(srv).MusicSections()
if err != nil {
t.Fatalf("MusicSections() error: %v", err)
}
if len(sections) != 2 {
t.Fatalf("expected 2 music sections, got %d", len(sections))
}
if sections[0].Key != "1" || sections[0].Title != "Music" {
t.Errorf("unexpected section[0]: %+v", sections[0])
}
if sections[1].Key != "3" || sections[1].Title != "Jazz" {
t.Errorf("unexpected section[1]: %+v", sections[1])
}
}
func TestMusicSections_Empty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"MediaContainer":{"Directory":[{"key":"1","type":"movie","title":"Movies"}]}}`))
}))
defer srv.Close()
sections, err := newTestClient(srv).MusicSections()
if err != nil {
t.Fatalf("MusicSections() unexpected error: %v", err)
}
if len(sections) != 0 {
t.Errorf("expected 0 music sections, got %d", len(sections))
}
}
func TestMusicSections_LibraryFilter(t *testing.T) {
serverJSON := `{"MediaContainer":{"Directory":[
{"key":"1","type":"artist","title":"Music"},
{"key":"2","type":"artist","title":"Jazz"},
{"key":"3","type":"artist","title":"Classical"}
]}}`
tests := []struct {
name string
libraries []string
wantLen int
wantTitles []string
wantKeys []string
}{
{
name: "filter to subset",
libraries: []string{"Jazz", "Classical"},
wantLen: 2,
wantTitles: []string{"Jazz", "Classical"},
},
{
name: "case-insensitive match",
libraries: []string{"MUSIC"},
wantLen: 1,
wantKeys: []string{"1"},
},
{
name: "no match returns empty",
libraries: []string{"DoesNotExist"},
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(serverJSON))
}))
defer srv.Close()
sections, err := NewClient(srv.URL, "test-token", tt.libraries...).MusicSections()
if err != nil {
t.Fatalf("MusicSections() error: %v", err)
}
if len(sections) != tt.wantLen {
t.Fatalf("got %d sections, want %d: %v", len(sections), tt.wantLen, sections)
}
for i, title := range tt.wantTitles {
if sections[i].Title != title {
t.Errorf("sections[%d].Title = %q, want %q", i, sections[i].Title, title)
}
}
for i, key := range tt.wantKeys {
if sections[i].Key != key {
t.Errorf("sections[%d].Key = %q, want %q", i, sections[i].Key, key)
}
}
})
}
}
func TestAlbums_RequestsType9(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("type") != "9" {
t.Errorf("expected type=9 in request, got %q", r.URL.Query().Get("type"))
}
if !strings.HasSuffix(r.URL.Path, "/library/sections/3/all") {
t.Errorf("unexpected path %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"MediaContainer": {
"totalSize": 2,
"Metadata": [
{
"ratingKey": "456",
"title": "Kind of Blue",
"parentTitle": "Miles Davis",
"year": 1959,
"leafCount": 5
},
{
"ratingKey": "457",
"title": "Bitches Brew",
"parentTitle": "Miles Davis",
"year": 1970,
"leafCount": 4
}
]
}
}`))
}))
defer srv.Close()
albums, err := newTestClient(srv).Albums("3")
if err != nil {
t.Fatalf("Albums() error: %v", err)
}
if len(albums) != 2 {
t.Fatalf("expected 2 albums, got %d", len(albums))
}
a := albums[0]
if a.RatingKey != "456" || a.Title != "Kind of Blue" || a.ArtistName != "Miles Davis" || a.Year != 1959 || a.TrackCount != 5 {
t.Errorf("unexpected album[0]: %+v", a)
}
}
func TestAlbums_Paginates(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
start := r.URL.Query().Get("X-Plex-Container-Start")
w.Header().Set("Content-Type", "application/json")
switch start {
case "0", "":
w.Write([]byte(`{"MediaContainer":{"totalSize":2,"Metadata":[{"ratingKey":"1","title":"A","parentTitle":"Art","year":2000,"leafCount":1}]}}`))
case "300":
// Second page — but totalSize=2 means we should never reach here with pageSize=300.
// This tests that we stop after the first page when totalSize <= pageSize.
t.Errorf("unexpected second page request (offset=%s)", start)
w.Write([]byte(`{"MediaContainer":{"totalSize":2,"Metadata":[]}}`))
}
}))
defer srv.Close()
albums, err := newTestClient(srv).Albums("1")
if err != nil {
t.Fatalf("Albums() error: %v", err)
}
if len(albums) != 1 {
t.Errorf("expected 1 album, got %d", len(albums))
}
if callCount != 1 {
t.Errorf("expected 1 API call, got %d", callCount)
}
}
func TestTracks_MapsAllFields(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/library/metadata/456/children") {
t.Errorf("unexpected path %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"MediaContainer": {
"Metadata": [
{
"ratingKey": "789",
"title": "So What",
"grandparentTitle": "Miles Davis",
"parentTitle": "Kind of Blue",
"year": 1959,
"index": 1,
"duration": 565000,
"Media": [{"Part": [{"key": "/library/parts/100/111/So_What.flac"}]}]
},
{
"ratingKey": "790",
"title": "Freddie Freeloader",
"grandparentTitle": "Miles Davis",
"parentTitle": "Kind of Blue",
"year": 1959,
"index": 2,
"duration": 586000,
"Media": [{"Part": [{"key": "/library/parts/101/222/Freddie.flac"}]}]
}
]
}
}`))
}))
defer srv.Close()
tracks, err := newTestClient(srv).Tracks("456")
if err != nil {
t.Fatalf("Tracks() error: %v", err)
}
if len(tracks) != 2 {
t.Fatalf("expected 2 tracks, got %d", len(tracks))
}
tr := tracks[0]
if tr.RatingKey != "789" {
t.Errorf("RatingKey: got %q, want %q", tr.RatingKey, "789")
}
if tr.Title != "So What" {
t.Errorf("Title: got %q, want %q", tr.Title, "So What")
}
if tr.ArtistName != "Miles Davis" {
t.Errorf("ArtistName: got %q, want %q", tr.ArtistName, "Miles Davis")
}
if tr.AlbumName != "Kind of Blue" {
t.Errorf("AlbumName: got %q, want %q", tr.AlbumName, "Kind of Blue")
}
if tr.Year != 1959 {
t.Errorf("Year: got %d, want 1959", tr.Year)
}
if tr.TrackNumber != 1 {
t.Errorf("TrackNumber: got %d, want 1", tr.TrackNumber)
}
if tr.Duration != 565000 {
t.Errorf("Duration: got %d, want 565000", tr.Duration)
}
if tr.PartKey != "/library/parts/100/111/So_What.flac" {
t.Errorf("PartKey: got %q, want /library/parts/100/111/So_What.flac", tr.PartKey)
}
}
func TestTracks_MissingMedia(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"MediaContainer": {
"Metadata": [
{"ratingKey": "1", "title": "Track Without Media", "Media": []}
]
}
}`))
}))
defer srv.Close()
tracks, err := newTestClient(srv).Tracks("42")
if err != nil {
t.Fatalf("Tracks() error: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("expected 1 track, got %d", len(tracks))
}
if tracks[0].PartKey != "" {
t.Errorf("expected empty PartKey for track with no media, got %q", tracks[0].PartKey)
}
}
func TestStreamURL_Format(t *testing.T) {
c := NewClient("http://192.168.1.10:32400", "mytoken")
got := c.StreamURL("/library/parts/100/111/file.flac")
want := "http://192.168.1.10:32400/library/parts/100/111/file.flac?X-Plex-Token=mytoken"
if got != want {
t.Errorf("StreamURL:\n got %q\n want %q", got, want)
}
}
func TestStreamURL_TokenEncoded(t *testing.T) {
c := NewClient("http://192.168.1.10:32400", "tok en+special")
got := c.StreamURL("/library/parts/1/2/file.mp3")
if !strings.Contains(got, "X-Plex-Token=tok+en%2Bspecial") {
t.Errorf("StreamURL token not URL-encoded: %q", got)
}
}
func TestSearch_SendsCorrectParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("query") != "Miles Davis" {
t.Errorf("expected query=Miles Davis, got %q", r.URL.Query().Get("query"))
}
if r.URL.Query().Get("type") != "10" {
t.Errorf("expected type=10, got %q", r.URL.Query().Get("type"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"MediaContainer":{"Metadata":[]}}`))
}))
defer srv.Close()
tracks, err := newTestClient(srv).Search("Miles Davis")
if err != nil {
t.Fatalf("Search() error: %v", err)
}
if len(tracks) != 0 {
t.Errorf("expected 0 tracks, got %d", len(tracks))
}
}
func TestSearch_EmptyQueryNoRequest(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
tracks, err := newTestClient(srv).Search("")
if err != nil {
t.Fatalf("Search(\"\") unexpected error: %v", err)
}
if tracks != nil {
t.Errorf("expected nil tracks for empty query, got %v", tracks)
}
if called {
t.Error("Search(\"\") should not make an HTTP request")
}
}
func TestRequestHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/json" {
t.Errorf("expected Accept: application/json, got %q", r.Header.Get("Accept"))
}
if r.Header.Get("X-Plex-Product") != "cliamp" {
t.Errorf("expected X-Plex-Product: cliamp, got %q", r.Header.Get("X-Plex-Product"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"MediaContainer":{}}`))
}))
defer srv.Close()
_ = newTestClient(srv).Ping()
}
+171
View File
@@ -0,0 +1,171 @@
package plex
import (
"context"
"fmt"
"slices"
"sync"
"github.com/bjarneo/cliamp/config"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// Compile-time interface checks.
var (
_ provider.Searcher = (*Provider)(nil)
_ provider.AlbumTrackLoader = (*Provider)(nil)
)
// Provider implements playlist.Provider for a Plex Media Server.
// Playlists() returns all albums across all music library sections.
// Tracks() returns the tracks for a given album ratingKey.
type Provider struct {
client *Client
mu sync.Mutex
playlistCache []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
}
// newProvider returns a Provider backed by the given Client.
func newProvider(client *Client) *Provider {
return &Provider{client: client}
}
// NewFromConfig returns a Provider from a PlexConfig, or nil if URL or Token is missing.
func NewFromConfig(cfg config.PlexConfig) *Provider {
if !cfg.IsSet() {
return nil
}
return newProvider(NewClient(cfg.URL, cfg.Token, cfg.Libraries...))
}
// Name returns the display name used in the provider selector.
func (p *Provider) Name() string { return "Plex" }
// Playlists returns all albums across all Plex music library sections.
// Each album is a PlaylistInfo whose ID is the album's Plex ratingKey.
// Results are cached after the first successful call.
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
p.mu.Lock()
if p.playlistCache != nil {
cached := slices.Clone(p.playlistCache)
p.mu.Unlock()
return cached, nil
}
p.mu.Unlock()
sections, err := p.client.MusicSections()
if err != nil {
return nil, err
}
if len(sections) == 0 {
return nil, fmt.Errorf("plex: no matching music libraries found on this server")
}
var lists []playlist.PlaylistInfo
for _, sec := range sections {
albums, err := p.client.Albums(sec.Key)
if err != nil {
return nil, err
}
for _, a := range albums {
name := a.ArtistName + " — " + a.Title
if a.Year > 0 {
name = fmt.Sprintf("%s — %s (%d)", a.ArtistName, a.Title, a.Year)
}
lists = append(lists, playlist.PlaylistInfo{
ID: a.RatingKey,
Name: name,
TrackCount: a.TrackCount,
})
}
}
p.mu.Lock()
p.playlistCache = lists
p.mu.Unlock()
return slices.Clone(lists), nil
}
// Refresh clears cached playlist and track data so the next call re-fetches
// from the server. Implements playlist.Refresher.
func (p *Provider) Refresh() {
p.mu.Lock()
p.playlistCache = nil
p.trackCache = nil
p.mu.Unlock()
}
// Tracks returns the tracks for the album identified by albumRatingKey.
// Each track's Path is a complete authenticated HTTP URL ready for the player.
// Tracks with no streamable part (missing Media/Part data) are silently skipped.
// Results are cached per albumRatingKey.
func (p *Provider) Tracks(albumRatingKey string) ([]playlist.Track, error) {
p.mu.Lock()
if p.trackCache != nil {
if cached, ok := p.trackCache[albumRatingKey]; ok {
p.mu.Unlock()
return slices.Clone(cached), nil
}
}
p.mu.Unlock()
plexTracks, err := p.client.Tracks(albumRatingKey)
if err != nil {
return nil, err
}
tracks := p.convertTracks(plexTracks, 0)
p.mu.Lock()
if p.trackCache == nil {
p.trackCache = make(map[string][]playlist.Track)
}
p.trackCache[albumRatingKey] = tracks
p.mu.Unlock()
return slices.Clone(tracks), nil
}
// SearchTracks searches the Plex music library for tracks matching query.
// Implements provider.Searcher.
func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
plexTracks, err := p.client.Search(query)
if err != nil {
return nil, err
}
return p.convertTracks(plexTracks, limit), nil
}
// convertTracks converts Plex tracks to playlist tracks, skipping entries
// without a streamable part. If limit > 0, at most limit tracks are returned.
func (p *Provider) convertTracks(plexTracks []Track, limit int) []playlist.Track {
tracks := make([]playlist.Track, 0, len(plexTracks))
for _, t := range plexTracks {
if t.PartKey == "" {
continue
}
tracks = append(tracks, playlist.Track{
Path: p.client.StreamURL(t.PartKey),
Title: t.Title,
Artist: t.ArtistName,
Album: t.AlbumName,
Year: t.Year,
TrackNumber: t.TrackNumber,
DurationSecs: t.Duration / 1000,
Stream: true,
})
if limit > 0 && len(tracks) >= limit {
break
}
}
return tracks
}
// AlbumTracks returns the tracks for the given album (ratingKey).
// Implements provider.AlbumTrackLoader.
func (p *Provider) AlbumTracks(albumID string) ([]playlist.Track, error) {
return p.Tracks(albumID)
}
+337
View File
@@ -0,0 +1,337 @@
package plex
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/bjarneo/cliamp/config"
)
// sectionsHandler returns a handler that serves a single music section.
func sectionsHandler(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/library/sections"):
w.Write([]byte(`{"MediaContainer":{"Directory":[{"key":"3","type":"artist","title":"Music"}]}}`))
case strings.HasSuffix(r.URL.Path, "/library/sections/3/all"):
w.Write([]byte(`{
"MediaContainer": {
"totalSize": 2,
"Metadata": [
{"ratingKey":"100","title":"Kind of Blue","parentTitle":"Miles Davis","year":1959,"leafCount":5},
{"ratingKey":"101","title":"Bitches Brew","parentTitle":"Miles Davis","year":1970,"leafCount":4}
]
}
}`))
case strings.Contains(r.URL.Path, "/library/metadata/100/children"):
w.Write([]byte(`{
"MediaContainer": {
"Metadata": [
{
"ratingKey":"200","title":"So What","grandparentTitle":"Miles Davis",
"parentTitle":"Kind of Blue","year":1959,"index":1,"duration":565000,
"Media":[{"Part":[{"key":"/library/parts/1/111/SoWhat.flac"}]}]
}
]
}
}`))
case strings.Contains(r.URL.Path, "/library/metadata/101/children"):
w.Write([]byte(`{
"MediaContainer": {
"Metadata": [
{
"ratingKey":"201","title":"Pharaoh's Dance","grandparentTitle":"Miles Davis",
"parentTitle":"Bitches Brew","year":1970,"index":1,"duration":1140000,
"Media":[{"Part":[{"key":"/library/parts/2/222/PharaohsDance.flac"}]}]
}
]
}
}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}
}
func TestProvider_Name(t *testing.T) {
p := newProvider(NewClient("http://localhost:32400", "tok"))
if p.Name() != "Plex" {
t.Errorf("Name() = %q, want %q", p.Name(), "Plex")
}
}
func TestProvider_Playlists(t *testing.T) {
srv := httptest.NewServer(sectionsHandler(t))
defer srv.Close()
p := newProvider(NewClient(srv.URL, "tok"))
lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists() error: %v", err)
}
if len(lists) != 2 {
t.Fatalf("expected 2 playlists, got %d", len(lists))
}
// Check first album entry
if lists[0].ID != "100" {
t.Errorf("lists[0].ID = %q, want %q", lists[0].ID, "100")
}
if !strings.Contains(lists[0].Name, "Miles Davis") {
t.Errorf("lists[0].Name %q missing artist", lists[0].Name)
}
if !strings.Contains(lists[0].Name, "Kind of Blue") {
t.Errorf("lists[0].Name %q missing album title", lists[0].Name)
}
if !strings.Contains(lists[0].Name, "1959") {
t.Errorf("lists[0].Name %q missing year", lists[0].Name)
}
if lists[0].TrackCount != 5 {
t.Errorf("lists[0].TrackCount = %d, want 5", lists[0].TrackCount)
}
}
func TestProvider_Playlists_Cached(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/library/sections") {
callCount++
}
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/library/sections"):
w.Write([]byte(`{"MediaContainer":{"Directory":[{"key":"3","type":"artist","title":"Music"}]}}`))
case strings.HasSuffix(r.URL.Path, "/library/sections/3/all"):
w.Write([]byte(`{"MediaContainer":{"totalSize":1,"Metadata":[{"ratingKey":"1","title":"Album","parentTitle":"Artist","year":2020,"leafCount":1}]}}`))
}
}))
defer srv.Close()
p := newProvider(NewClient(srv.URL, "tok"))
if _, err := p.Playlists(); err != nil {
t.Fatalf("first Playlists() error: %v", err)
}
if _, err := p.Playlists(); err != nil {
t.Fatalf("second Playlists() error: %v", err)
}
if callCount != 1 {
t.Errorf("expected /library/sections called once, called %d times", callCount)
}
}
func TestProvider_Playlists_NoMusicSections(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"MediaContainer":{"Directory":[{"key":"1","type":"movie","title":"Movies"}]}}`))
}))
defer srv.Close()
p := newProvider(NewClient(srv.URL, "tok"))
_, err := p.Playlists()
if err == nil {
t.Fatal("expected error for no music sections, got nil")
}
if !strings.Contains(err.Error(), "no matching music libraries") {
t.Errorf("unexpected error message: %q", err.Error())
}
}
func TestProvider_Tracks(t *testing.T) {
srv := httptest.NewServer(sectionsHandler(t))
defer srv.Close()
p := newProvider(NewClient(srv.URL, "tok"))
tracks, err := p.Tracks("100")
if err != nil {
t.Fatalf("Tracks() error: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("expected 1 track, got %d", len(tracks))
}
tr := tracks[0]
if tr.Title != "So What" {
t.Errorf("Title = %q, want %q", tr.Title, "So What")
}
if tr.Artist != "Miles Davis" {
t.Errorf("Artist = %q, want %q", tr.Artist, "Miles Davis")
}
if tr.Album != "Kind of Blue" {
t.Errorf("Album = %q, want %q", tr.Album, "Kind of Blue")
}
if tr.Year != 1959 {
t.Errorf("Year = %d, want 1959", tr.Year)
}
if tr.TrackNumber != 1 {
t.Errorf("TrackNumber = %d, want 1", tr.TrackNumber)
}
if tr.DurationSecs != 565 {
t.Errorf("DurationSecs = %d, want 565", tr.DurationSecs)
}
if !tr.Stream {
t.Error("Stream = false, want true")
}
if !strings.HasPrefix(tr.Path, srv.URL) {
t.Errorf("Path %q does not start with server URL", tr.Path)
}
if !strings.Contains(tr.Path, "X-Plex-Token=tok") {
t.Errorf("Path %q missing X-Plex-Token", tr.Path)
}
if !strings.Contains(tr.Path, "/library/parts/1/111/SoWhat.flac") {
t.Errorf("Path %q missing part key", tr.Path)
}
}
func TestProvider_Tracks_SkipsMissingPart(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"MediaContainer": {
"Metadata": [
{"ratingKey":"1","title":"Has Part","Media":[{"Part":[{"key":"/library/parts/1/1/file.mp3"}]}]},
{"ratingKey":"2","title":"No Part","Media":[]},
{"ratingKey":"3","title":"Also No Part"}
]
}
}`))
}))
defer srv.Close()
p := newProvider(NewClient(srv.URL, "tok"))
tracks, err := p.Tracks("42")
if err != nil {
t.Fatalf("Tracks() error: %v", err)
}
if len(tracks) != 1 {
t.Errorf("expected 1 track (skipping those with no part), got %d", len(tracks))
}
if tracks[0].Title != "Has Part" {
t.Errorf("expected 'Has Part', got %q", tracks[0].Title)
}
}
func TestProvider_Tracks_Cached(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"MediaContainer":{"Metadata":[{"ratingKey":"1","title":"T","Media":[{"Part":[{"key":"/p/1/1/f.mp3"}]}]}]}}`))
}))
defer srv.Close()
p := newProvider(NewClient(srv.URL, "tok"))
if _, err := p.Tracks("99"); err != nil {
t.Fatalf("first Tracks() error: %v", err)
}
if _, err := p.Tracks("99"); err != nil {
t.Fatalf("second Tracks() error: %v", err)
}
if callCount != 1 {
t.Errorf("expected children endpoint called once, called %d times", callCount)
}
}
func TestProvider_Tracks_ClientError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
p := newProvider(NewClient(srv.URL, "bad-token"))
_, err := p.Tracks("42")
if err == nil {
t.Fatal("expected error on 401, got nil")
}
}
func TestNewFromConfig_NilWhenMissing(t *testing.T) {
tests := []struct {
name string
cfg config.PlexConfig
}{
{"empty", config.PlexConfig{}},
{"no token", config.PlexConfig{URL: "http://localhost:32400"}},
{"no url", config.PlexConfig{Token: "tok"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if p := NewFromConfig(tt.cfg); p != nil {
t.Errorf("NewFromConfig(%+v) = non-nil, want nil", tt.cfg)
}
})
}
}
func TestNewFromConfig_OK(t *testing.T) {
cfg := config.PlexConfig{URL: "http://localhost:32400", Token: "mytoken"}
if p := NewFromConfig(cfg); p == nil {
t.Error("NewFromConfig() returned nil for valid config")
}
}
func TestNewFromConfig_LibrariesWired(t *testing.T) {
tests := []struct {
name string
libraries []string
wantLen int
}{
{"no filter returns all", nil, 2},
{"filter to Jazz", []string{"Jazz"}, 1},
{"filter to jazz case-insensitive", []string{"jazz"}, 1},
{"filter to both", []string{"Music", "Jazz"}, 2},
{"no match returns error", []string{"Classical"}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/library/sections"):
w.Write([]byte(`{"MediaContainer":{"Directory":[
{"key":"1","type":"artist","title":"Music"},
{"key":"2","type":"artist","title":"Jazz"}
]}}`))
case strings.HasSuffix(r.URL.Path, "/library/sections/1/all"):
w.Write([]byte(`{"MediaContainer":{"totalSize":1,"Metadata":[
{"ratingKey":"11","title":"Bitches Brew","parentTitle":"Miles Davis","year":1970,"leafCount":4}
]}}`))
case strings.HasSuffix(r.URL.Path, "/library/sections/2/all"):
w.Write([]byte(`{"MediaContainer":{"totalSize":1,"Metadata":[
{"ratingKey":"10","title":"Kind of Blue","parentTitle":"Miles Davis","year":1959,"leafCount":5}
]}}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
cfg := config.PlexConfig{
URL: srv.URL,
Token: "tok",
Libraries: tt.libraries,
}
p := NewFromConfig(cfg)
if p == nil {
t.Fatal("NewFromConfig() returned nil for valid config")
}
lists, err := p.Playlists()
if tt.wantLen == 0 {
if err == nil {
t.Errorf("expected error for no matching libraries, got %d playlists", len(lists))
}
return
}
if err != nil {
t.Fatalf("Playlists() error: %v", err)
}
if len(lists) != tt.wantLen {
t.Fatalf("got %d playlists, want %d", len(lists), tt.wantLen)
}
})
}
}
+210
View File
@@ -0,0 +1,210 @@
package qobuz
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// bundleBaseURL is the Qobuz web player origin that ships the JS bundle.
const bundleBaseURL = "https://play.qobuz.com"
// fallbackPrivateKey is the static OAuth code-exchange private_key documented
// for the production environment. It is used only when the value cannot be
// scraped from the bundle (the in-bundle name has changed across releases).
// Source: SofusA/qobine qobuz-api.md reverse-engineering notes.
const fallbackPrivateKey = "6lz8C03UDIC7"
// Regexes that scrape the app_id, signing secrets and OAuth private key from
// the Qobuz web player's bundle.js. Adapted from DashLt's spoofbuz (via the
// qobuz-dl-go project) and cross-checked against the SofusA/qobine
// reverse-engineered Qobuz API reference (qobuz-api.md). Qobuz has shipped
// several bundle formats over time, so the private key has multiple candidate
// patterns.
var (
reSeedTimezone = regexp.MustCompile(
`[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)`,
)
reAppID = regexp.MustCompile(
`production:{api:{appId:"(?P<app_id>\d{9})",appSecret:"\w{32}"`,
)
rePrivateKeyPatterns = []*regexp.Regexp{
regexp.MustCompile(`privateKey:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`),
regexp.MustCompile(`private_key:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`),
regexp.MustCompile(`oauthKey:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`),
regexp.MustCompile(`clientSecret:\s*"(?P<key>[A-Za-z0-9+/=_\-]{6,128})"`),
}
reBundleURL = regexp.MustCompile(
`<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>`,
)
)
// bundle holds the scraped Qobuz web player JavaScript bundle.
type bundle struct {
content string
}
// fetchBundle downloads the Qobuz login page and its bundle.js. ctx cancels the
// requests.
func fetchBundle(ctx context.Context) (*bundle, error) {
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleBaseURL+"/login", nil)
if err != nil {
return nil, fmt.Errorf("qobuz: build login request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("qobuz: get login page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("qobuz: get login page: HTTP %s", resp.Status)
}
page, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("qobuz: read login page: %w", err)
}
match := reBundleURL.FindSubmatch(page)
if match == nil {
return nil, fmt.Errorf("qobuz: bundle URL not found in login page")
}
bundlePath := string(match[1])
req2, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleBaseURL+bundlePath, nil)
if err != nil {
return nil, fmt.Errorf("qobuz: build bundle request: %w", err)
}
resp2, err := client.Do(req2)
if err != nil {
return nil, fmt.Errorf("qobuz: get bundle.js: %w", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
return nil, fmt.Errorf("qobuz: get bundle.js: HTTP %s", resp2.Status)
}
body, err := io.ReadAll(resp2.Body)
if err != nil {
return nil, fmt.Errorf("qobuz: read bundle.js: %w", err)
}
return &bundle{content: string(body)}, nil
}
// appID extracts the Qobuz application ID from the bundle.
func (b *bundle) appID() (string, error) {
m := reAppID.FindStringSubmatch(b.content)
if m == nil {
return "", fmt.Errorf("qobuz: app_id not found in bundle")
}
return m[reAppID.SubexpIndex("app_id")], nil
}
// privateKey extracts the OAuth private key, falling back to the documented
// production value when the bundle pattern cannot be matched.
func (b *bundle) privateKey() string {
for _, re := range rePrivateKeyPatterns {
if m := re.FindStringSubmatch(b.content); m != nil {
return m[re.SubexpIndex("key")]
}
}
return fallbackPrivateKey
}
// capitalizeFirst upper-cases the first byte of s (timezone names are ASCII).
func capitalizeFirst(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// secrets extracts the API signing secrets from the bundle. The result maps a
// timezone name to its decoded secret; callers try each until one validates.
func (b *bundle) secrets() (map[string]string, error) {
seeds := make(map[string][]string)
for _, m := range reSeedTimezone.FindAllStringSubmatch(b.content, -1) {
seed := m[reSeedTimezone.SubexpIndex("seed")]
tz := m[reSeedTimezone.SubexpIndex("timezone")]
seeds[tz] = append(seeds[tz], seed)
}
if len(seeds) == 0 {
return nil, fmt.Errorf("qobuz: no seeds found in bundle")
}
// Replicate the Python OrderedDict + move_to_end ordering used by spoofbuz.
tzList := make([]string, 0, len(seeds))
for tz := range seeds {
tzList = append(tzList, tz)
}
if len(tzList) >= 2 {
tzList[0], tzList[1] = tzList[1], tzList[0]
}
capitalised := make([]string, len(tzList))
for i, tz := range tzList {
capitalised[i] = capitalizeFirst(tz)
}
reInfoExtras := regexp.MustCompile(
`name:"\w+/(?P<timezone>` + strings.Join(capitalised, "|") + `)",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"`,
)
for _, m := range reInfoExtras.FindAllStringSubmatch(b.content, -1) {
tz := strings.ToLower(m[reInfoExtras.SubexpIndex("timezone")])
info := m[reInfoExtras.SubexpIndex("info")]
extras := m[reInfoExtras.SubexpIndex("extras")]
seeds[tz] = append(seeds[tz], info, extras)
}
secrets := make(map[string]string, len(seeds))
for tz, parts := range seeds {
joined := strings.Join(parts, "")
if len(joined) <= 44 {
continue
}
trimmed := joined[:len(joined)-44]
// Pad to a multiple of 4 so StdEncoding accepts it (Python's b64decode
// pads automatically).
padded := trimmed + strings.Repeat("=", (4-len(trimmed)%4)%4)
decoded, err := base64.StdEncoding.DecodeString(padded)
if err != nil {
continue
}
secrets[tz] = string(decoded)
}
if len(secrets) == 0 {
return nil, fmt.Errorf("qobuz: no secrets decoded from bundle")
}
return secrets, nil
}
// scrapeCredentials fetches the bundle and returns the app_id, the list of
// candidate signing secrets, and the OAuth private key.
func scrapeCredentials(ctx context.Context) (string, []string, string, error) {
b, err := fetchBundle(ctx)
if err != nil {
return "", nil, "", err
}
appID, err := b.appID()
if err != nil {
return "", nil, "", err
}
secretMap, err := b.secrets()
if err != nil {
return "", nil, "", err
}
secrets := make([]string, 0, len(secretMap))
for _, s := range secretMap {
if s != "" {
secrets = append(secrets, s)
}
}
return appID, secrets, b.privateKey(), nil
}
+28
View File
@@ -0,0 +1,28 @@
package qobuz
import "testing"
func TestBundlePrivateKeyScraped(t *testing.T) {
b := &bundle{content: `foo privateKey: "scrapedKey123" bar`}
if got := b.privateKey(); got != "scrapedKey123" {
t.Fatalf("privateKey() = %q, want scraped value", got)
}
}
func TestBundlePrivateKeyFallback(t *testing.T) {
b := &bundle{content: `no key here at all`}
if got := b.privateKey(); got != fallbackPrivateKey {
t.Fatalf("privateKey() = %q, want fallback %q", got, fallbackPrivateKey)
}
}
func TestBundleAppID(t *testing.T) {
b := &bundle{content: `x=production:{api:{appId:"798273057",appSecret:"05a4851e74ee47fda346f50cfdfc4f09"}}`}
got, err := b.appID()
if err != nil {
t.Fatalf("appID() error = %v", err)
}
if got != "798273057" {
t.Fatalf("appID() = %q, want 798273057", got)
}
}
+466
View File
@@ -0,0 +1,466 @@
package qobuz
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
apiBaseURL = "https://www.qobuz.com/api.json/0.2/"
apiUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"
defaultQuality = 6
)
// validQuality reports whether quality is one of the Qobuz format_id values
// cliamp accepts.
//
// 5 = MP3 320kbps
// 6 = FLAC 16-bit/44.1kHz (CD)
// 7 = FLAC 24-bit up to 96kHz
// 27 = FLAC 24-bit up to 192kHz (Hi-Res)
func validQuality(quality int) bool {
switch quality {
case 5, 6, 7, 27:
return true
default:
return false
}
}
// maxResponseBody limits JSON API responses to 20 MB.
const maxResponseBody = 20 << 20
// client is a Qobuz API client. It is safe for concurrent use once
// authenticated (its fields are not mutated after login).
type client struct {
appID string
secrets []string // candidate signing secrets to validate
secret string // validated signing secret (set by validateSecret)
uat string // user_auth_token
userID string
label string // subscription tier short label
http *http.Client
}
func newClient(appID string, secrets []string) *client {
return &client{
appID: appID,
secrets: secrets,
http: &http.Client{Timeout: 30 * time.Second},
}
}
func md5hex(s string) string {
return fmt.Sprintf("%x", md5.Sum([]byte(s)))
}
// doRequest performs a Qobuz API request and returns the raw response body.
func (c *client) doRequest(ctx context.Context, method, endpoint string, params url.Values, body string) ([]byte, error) {
var reqBody io.Reader
if body != "" {
reqBody = strings.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, apiBaseURL+endpoint, reqBody)
if err != nil {
return nil, fmt.Errorf("qobuz: %s: build request: %w", endpoint, err)
}
req.Header.Set("User-Agent", apiUA)
req.Header.Set("X-App-Id", c.appID)
if body != "" {
// Request bodies are always form-encoded (user/login, oauth/callback).
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
} else {
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
}
if c.uat != "" {
req.Header.Set("X-User-Auth-Token", c.uat)
}
if params != nil {
req.URL.RawQuery = params.Encode()
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("qobuz: %s: request: %w", endpoint, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return nil, fmt.Errorf("qobuz: %s: read response: %w", endpoint, err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("qobuz: %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return respBody, nil
}
// doGet performs a GET and decodes the JSON response into out.
func (c *client) doGet(ctx context.Context, endpoint string, params url.Values, out any) error {
body, err := c.doRequest(ctx, http.MethodGet, endpoint, params, "")
if err != nil {
return err
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("qobuz: %s: decode: %w", endpoint, err)
}
return nil
}
// authWithToken authenticates using a user_id + user_auth_token obtained via
// OAuth and populates the client's user info.
func (c *client) authWithToken(ctx context.Context, userID, userAuthToken string) error {
params := url.Values{
"user_id": {userID},
"user_auth_token": {userAuthToken},
"app_id": {c.appID},
}
var info loginResponse
if err := c.doGet(ctx, "user/login", params, &info); err != nil {
return err
}
c.uat = userAuthToken
c.userID = userID
return c.applyUserInfo(info)
}
// loginResponse is the shape of user/login and oauth/callback responses.
type loginResponse struct {
UserAuthToken string `json:"user_auth_token"`
User struct {
ID json.Number `json:"id"`
Credential struct {
Parameters *struct {
ShortLabel string `json:"short_label"`
} `json:"parameters"`
} `json:"credential"`
} `json:"user"`
}
func (c *client) applyUserInfo(info loginResponse) error {
if info.User.Credential.Parameters == nil {
return fmt.Errorf("qobuz: account is not eligible for streaming (free accounts cannot stream)")
}
if c.uat == "" {
c.uat = info.UserAuthToken
}
if c.userID == "" && info.User.ID != "" {
c.userID = info.User.ID.String()
}
c.label = info.User.Credential.Parameters.ShortLabel
return nil
}
// loginWithOAuth completes authentication from an OAuth redirect result. Qobuz
// may return either a user_auth_token directly or a code that must be exchanged.
func (c *client) loginWithOAuth(ctx context.Context, result oauthResult, privateKey string) error {
if result.Token != "" {
c.uat = result.Token
if result.UserID != "" {
c.userID = result.UserID
}
return c.loadOAuthUserInfo(ctx, "with OAuth token")
}
if result.Code != "" {
return c.exchangeOAuthCode(ctx, result.Code, privateKey)
}
return fmt.Errorf("qobuz: OAuth redirect contained neither token nor code")
}
// exchangeOAuthCode exchanges an OAuth code for a token. Qobuz has used
// different parameter names and HTTP methods over time, so all combinations of
// (GET|POST) x ("code"|"code_autorisation") are tried.
func (c *client) exchangeOAuthCode(ctx context.Context, code, privateKey string) error {
type attempt struct {
method string
paramName string
}
attempts := []attempt{
{http.MethodGet, "code"},
{http.MethodPost, "code"},
{http.MethodGet, "code_autorisation"},
{http.MethodPost, "code_autorisation"},
}
var lastErr error
for _, a := range attempts {
params := url.Values{
a.paramName: {code},
"app_id": {c.appID},
}
if privateKey != "" {
params.Set("private_key", privateKey)
}
var body []byte
var err error
if a.method == http.MethodGet {
body, err = c.doRequest(ctx, http.MethodGet, "oauth/callback", params, "")
} else {
body, err = c.doRequest(ctx, http.MethodPost, "oauth/callback", nil, params.Encode())
}
if err != nil {
lastErr = err
continue
}
var resp struct {
Token string `json:"token"`
loginResponse
}
if err := json.Unmarshal(body, &resp); err != nil {
lastErr = err
continue
}
if resp.Token == "" {
if resp.User.Credential.Parameters != nil {
return c.applyUserInfo(resp.loginResponse)
}
lastErr = fmt.Errorf("qobuz: no token in oauth/callback response")
continue
}
c.uat = resp.Token
return c.loadOAuthUserInfo(ctx, "after OAuth")
}
return fmt.Errorf("qobuz: oauth code exchange failed: %w", lastErr)
}
func (c *client) loadOAuthUserInfo(ctx context.Context, phase string) error {
body, err := c.doRequest(ctx, http.MethodPost, "user/login", nil, "extra=partner")
if err != nil {
return fmt.Errorf("qobuz: user/login %s: %w", phase, err)
}
var info loginResponse
if err := json.Unmarshal(body, &info); err != nil {
return fmt.Errorf("qobuz: decode user/login: %w", err)
}
return c.applyUserInfo(info)
}
// validateSecret picks the first signing secret that the API accepts and stores
// it on the client. It must be called before any signed request (getFileUrl,
// favorites).
func (c *client) validateSecret(ctx context.Context) error {
if c.secret != "" {
return nil
}
for _, secret := range c.secrets {
if secret == "" {
continue
}
// 5966783 is a known public track id used purely to probe the secret.
if _, err := c.trackFileURL(ctx, "5966783", 5, secret); err == nil {
c.secret = secret
return nil
}
}
return fmt.Errorf("qobuz: no valid signing secret found")
}
// apiFileURL is the track/getFileUrl response.
//
// We use track/getFileUrl (the legacy endpoint) on purpose: it returns a plain
// "url" pointing at a complete FLAC/MP3 file that the buffered ffmpeg pipeline
// can stream directly. The current web player instead uses /file/url +
// /session/start, which returns segmented, AES-128-CTR-encrypted CMAF (qbz-1)
// requiring a full key-derivation + per-frame decryption pipeline. Per the
// SofusA/qobine reverse-engineering notes, getFileUrl "may still work but the
// web player now uses /file/url", so this is a known, monitored assumption.
type apiFileURL struct {
URL string `json:"url"`
FormatID int `json:"format_id"`
MimeType string `json:"mime_type"`
Duration int `json:"duration"`
SamplingRate float64 `json:"sampling_rate"`
BitDepth int `json:"bit_depth"`
}
// trackFileURLSig computes the request_sig for track/getFileUrl. Qobuz signs
// the concatenation of the endpoint path, the params in alphabetical order, the
// request timestamp and the app secret. The exact layout matters: a change here
// silently breaks streaming, so it's pinned by a test.
func trackFileURLSig(trackID string, formatID int, ts, secret string) string {
raw := fmt.Sprintf("trackgetFileUrlformat_id%dintentstreamtrack_id%s%s%s",
formatID, trackID, ts, secret)
return md5hex(raw)
}
// trackFileURL returns a signed streaming URL for the given track. If
// secretOverride is empty, the validated client secret is used.
func (c *client) trackFileURL(ctx context.Context, trackID string, formatID int, secretOverride string) (apiFileURL, error) {
if !validQuality(formatID) {
return apiFileURL{}, fmt.Errorf("qobuz: invalid quality %d (choose 5, 6, 7 or 27)", formatID)
}
secret := secretOverride
if secret == "" {
secret = c.secret
}
unix := strconv.FormatInt(time.Now().Unix(), 10)
params := url.Values{
"request_ts": {unix},
"request_sig": {trackFileURLSig(trackID, formatID, unix, secret)},
"track_id": {trackID},
"format_id": {strconv.Itoa(formatID)},
"intent": {"stream"},
}
var out apiFileURL
if err := c.doGet(ctx, "track/getFileUrl", params, &out); err != nil {
return apiFileURL{}, err
}
return out, nil
}
// userPlaylists returns the authenticated user's playlists.
func (c *client) userPlaylists(ctx context.Context) ([]apiPlaylist, error) {
var out struct {
Playlists apiPlaylistList `json:"playlists"`
}
params := url.Values{"limit": {"500"}, "offset": {"0"}}
if err := c.doGet(ctx, "playlist/getUserPlaylists", params, &out); err != nil {
return nil, err
}
return out.Playlists.Items, nil
}
// playlistTracks returns the tracks of a playlist, following pagination.
func (c *client) playlistTracks(ctx context.Context, playlistID string) ([]apiTrack, error) {
const pageSize = 500
var all []apiTrack
for offset := 0; ; offset += pageSize {
var out apiPlaylist
params := url.Values{
"playlist_id": {playlistID},
"extra": {"tracks"},
"limit": {strconv.Itoa(pageSize)},
"offset": {strconv.Itoa(offset)},
}
if err := c.doGet(ctx, "playlist/get", params, &out); err != nil {
return nil, err
}
if out.Tracks == nil || len(out.Tracks.Items) == 0 {
break
}
all = append(all, out.Tracks.Items...)
if offset+pageSize >= out.Tracks.Total {
break
}
}
return all, nil
}
// albumTracks returns the tracks of an album along with the album metadata.
func (c *client) albumGet(ctx context.Context, albumID string) (apiAlbum, error) {
var out apiAlbum
if err := c.doGet(ctx, "album/get", url.Values{"album_id": {albumID}}, &out); err != nil {
return apiAlbum{}, err
}
return out, nil
}
// signedFavorites builds the request_ts/request_sig params for favorites calls.
//
// Note: favorite/getUserFavorites signs only object+method+ts+secret; the
// query params (type/limit/offset) are deliberately NOT folded into the
// signature, matching the working qobuz-dl-go behavior. The qobine reference
// documents a generic "sorted params" rule, but getUserFavorites does not
// require it in practice; do not add the params to rawSig.
func (c *client) favoriteParams(favType string, offset, limit int) url.Values {
unix := strconv.FormatInt(time.Now().Unix(), 10)
rawSig := "favoritegetUserFavorites" + unix + c.secret
return url.Values{
"app_id": {c.appID},
"user_auth_token": {c.uat},
"type": {favType},
"request_ts": {unix},
"request_sig": {md5hex(rawSig)},
"limit": {strconv.Itoa(limit)},
"offset": {strconv.Itoa(offset)},
}
}
// favoriteTracks returns the user's favorite tracks.
func (c *client) favoriteTracks(ctx context.Context, offset, limit int) ([]apiTrack, error) {
var out struct {
Tracks apiTrackList `json:"tracks"`
}
if err := c.doGet(ctx, "favorite/getUserFavorites", c.favoriteParams("tracks", offset, limit), &out); err != nil {
return nil, err
}
return out.Tracks.Items, nil
}
// favoriteAlbums returns the user's favorite albums.
func (c *client) favoriteAlbums(ctx context.Context, offset, limit int) ([]apiAlbum, error) {
var out struct {
Albums apiAlbumList `json:"albums"`
}
if err := c.doGet(ctx, "favorite/getUserFavorites", c.favoriteParams("albums", offset, limit), &out); err != nil {
return nil, err
}
return out.Albums.Items, nil
}
// favoriteArtists returns the user's favorite artists.
func (c *client) favoriteArtists(ctx context.Context, offset, limit int) ([]apiArtist, error) {
var out struct {
Artists apiArtistList `json:"artists"`
}
if err := c.doGet(ctx, "favorite/getUserFavorites", c.favoriteParams("artists", offset, limit), &out); err != nil {
return nil, err
}
return out.Artists.Items, nil
}
// artistAlbums returns the albums for an artist, following pagination.
func (c *client) artistAlbums(ctx context.Context, artistID string) ([]apiAlbum, error) {
const pageSize = 500
var all []apiAlbum
for offset := 0; ; offset += pageSize {
var out struct {
apiArtist
Albums apiAlbumList `json:"albums"`
}
params := url.Values{
"app_id": {c.appID},
"artist_id": {artistID},
"extra": {"albums"},
"limit": {strconv.Itoa(pageSize)},
"offset": {strconv.Itoa(offset)},
}
if err := c.doGet(ctx, "artist/get", params, &out); err != nil {
return nil, err
}
if len(out.Albums.Items) == 0 {
break
}
all = append(all, out.Albums.Items...)
if offset+pageSize >= out.Albums.Total {
break
}
}
return all, nil
}
// searchTracks searches the Qobuz catalog for tracks.
func (c *client) searchTracks(ctx context.Context, query string, limit int) ([]apiTrack, error) {
var out struct {
Tracks apiTrackList `json:"tracks"`
}
params := url.Values{"query": {query}, "limit": {strconv.Itoa(limit)}}
if err := c.doGet(ctx, "track/search", params, &out); err != nil {
return nil, err
}
return out.Tracks.Items, nil
}
+45
View File
@@ -0,0 +1,45 @@
package qobuz
import (
"testing"
)
func TestMD5Hex(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", "d41d8cd98f00b204e9800998ecf8427e"},
{"abc", "900150983cd24fb0d6963f7d28e17f72"},
}
for _, tt := range tests {
if got := md5hex(tt.in); got != tt.want {
t.Errorf("md5hex(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// TestTrackFileURLSig pins the request_sig layout for track/getFileUrl against
// a precomputed md5. If the raw string format in trackFileURLSig changes,
// streaming breaks and this test fails.
func TestTrackFileURLSig(t *testing.T) {
// md5("trackgetFileUrlformat_id6intentstreamtrack_id59667831700000000deadbeefsecret")
const want = "bc7a09d686b3e5c1cd32f5268eff1030"
got := trackFileURLSig("5966783", 6, "1700000000", "deadbeefsecret")
if got != want {
t.Fatalf("trackFileURLSig = %q, want %q", got, want)
}
}
func TestValidQuality(t *testing.T) {
for _, q := range []int{5, 6, 7, 27} {
if !validQuality(q) {
t.Errorf("expected quality %d to be valid", q)
}
}
for _, q := range []int{0, 1, 4, 8, 100} {
if validQuality(q) {
t.Errorf("expected quality %d to be invalid", q)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package qobuz
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/bjarneo/cliamp/internal/appdir"
)
// storedCreds holds persisted Qobuz credentials so the user only signs in once.
// The app_id, secrets and private key are scraped from the Qobuz web player and
// cached here alongside the OAuth user token.
type storedCreds struct {
AppID string `json:"app_id"`
Secrets []string `json:"secrets"`
Secret string `json:"secret"` // validated signing secret
PrivateKey string `json:"private_key"`
UserAuthToken string `json:"user_auth_token"`
UserID string `json:"user_id"`
Label string `json:"label"`
}
// CredsPath returns the absolute path to the stored Qobuz credentials file.
func CredsPath() (string, error) {
dir, err := appdir.Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "qobuz_credentials.json"), nil
}
// DeleteCreds removes the stored Qobuz credentials file. Returns true if a file
// was removed, false if it did not exist.
func DeleteCreds() (bool, error) {
path, err := CredsPath()
if err != nil {
return false, err
}
if err := os.Remove(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return true, nil
}
func loadCreds() (*storedCreds, error) {
path, err := CredsPath()
if err != nil {
return nil, fmt.Errorf("qobuz: credentials path: %w", err)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("qobuz: read credentials: %w", err)
}
var creds storedCreds
if err := json.Unmarshal(data, &creds); err != nil {
return nil, fmt.Errorf("qobuz: parse credentials: %w", err)
}
return &creds, nil
}
func saveCreds(creds *storedCreds) error {
path, err := CredsPath()
if err != nil {
return fmt.Errorf("qobuz: credentials path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("qobuz: create credentials dir: %w", err)
}
data, err := json.Marshal(creds)
if err != nil {
return fmt.Errorf("qobuz: encode credentials: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("qobuz: write credentials: %w", err)
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
// Package qobuz implements a cliamp music provider for Qobuz.
//
// It authenticates via the interactive OAuth browser flow, scrapes the
// app_id / signing secrets / OAuth private key from the Qobuz web player
// bundle.js, and resolves signed CDN stream URLs through the legacy
// track/getFileUrl endpoint. Those URLs are routed through cliamp's
// buffer-while-playing + ffmpeg pipeline (see IsStreamURL and
// RegisterBufferedURLMatcher in main.go), the same path used by the
// Navidrome, Jellyfin, Emby and Plex providers.
//
// Source material consulted for the reverse-engineered API surface:
//
// - Aeneaj/qobuz-dl-go: Go client (primary template for signing,
// bundle scraping and OAuth).
// - DashLt/spoofbuz: secret/seed extraction from bundle.js.
// - SofusA/qobine, qobuz-player-controls/examples/qobuz-api.md: a
// comprehensive reverse-engineered Qobuz API reference used to
// cross-check signing, the OAuth flow, format IDs and the
// legacy-vs-segmented (/file/url) streaming distinction.
package qobuz
+539
View File
@@ -0,0 +1,539 @@
package qobuz
import (
"context"
"fmt"
"math/rand/v2"
"slices"
"strconv"
"sync"
"time"
"github.com/bjarneo/cliamp/applog"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// Compile-time interface checks.
var (
_ playlist.Provider = (*QobuzProvider)(nil)
_ playlist.Authenticator = (*QobuzProvider)(nil)
_ playlist.Refresher = (*QobuzProvider)(nil)
_ provider.Searcher = (*QobuzProvider)(nil)
_ provider.ArtistBrowser = (*QobuzProvider)(nil)
_ provider.AlbumBrowser = (*QobuzProvider)(nil)
_ provider.AlbumTrackLoader = (*QobuzProvider)(nil)
_ provider.Closer = (*QobuzProvider)(nil)
)
// favoriteTracksID is the synthetic playlist ID for the user's favorite tracks.
const favoriteTracksID = "favorites/tracks"
// randomTracksID is the synthetic playlist ID for a random sample of tracks
// drawn from across all of the user's playlists (deduplicated).
const randomTracksID = "playlists/random"
// resolveConcurrency bounds how many track/getFileUrl calls run in parallel
// when resolving a playlist's streaming URLs.
const resolveConcurrency = 8
// playlistFetchConcurrency bounds how many playlist/get calls run in parallel
// when gathering tracks for the Random Tracks entry.
const playlistFetchConcurrency = 8
// favoritesPageSize is the page size for favorite album/artist browsing.
const favoritesPageSize = 100
// randomTracksLimit caps the synthetic Random Tracks list. Each track costs one
// track/getFileUrl call to resolve a (short-lived) stream URL, so resolving an
// unbounded library would be slow and wasteful. When the deduplicated library
// exceeds this, a random sample is taken so it stays a fair cross-section.
// Matches the favorite tracks cap.
const randomTracksLimit = 500
// albumSortTypes is the static sort list for Qobuz album browsing. Qobuz has no
// global catalog listing, so browsing surfaces the user's favorite albums.
var albumSortTypes = []provider.SortType{
{ID: "favorites", Label: "Favorite Albums"},
}
// QobuzProvider implements playlist.Provider backed by the Qobuz API. Streaming
// URLs are resolved per track via track/getFileUrl and routed through the
// player's buffered pipeline (see stream.go).
type QobuzProvider struct {
quality int
mu sync.Mutex
client *client
authCancel context.CancelFunc
listCache []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
}
// New creates a QobuzProvider. Authentication is deferred until the user first
// selects the provider. quality is the preferred Qobuz format_id.
func New(quality int) *QobuzProvider {
if !validQuality(quality) {
quality = defaultQuality
}
return &QobuzProvider{
quality: quality,
trackCache: make(map[string][]playlist.Track),
}
}
func (p *QobuzProvider) Name() string { return "Qobuz" }
// ensureClient builds an authenticated client from stored credentials only
// (no browser). Returns playlist.ErrNeedsAuth if interactive sign-in is needed.
func (p *QobuzProvider) ensureClient() (*client, error) {
p.mu.Lock()
if p.client != nil {
c := p.client
p.mu.Unlock()
return c, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
c, err := newClientSilent(ctx)
if err != nil {
applog.Debug("qobuz: silent auth failed, prompting sign-in: %v", err)
return nil, playlist.ErrNeedsAuth
}
p.mu.Lock()
p.client = c
p.mu.Unlock()
return c, nil
}
// Authenticate runs the interactive OAuth sign-in flow (opens a browser, waits
// for the redirect). Implements playlist.Authenticator.
func (p *QobuzProvider) Authenticate() error {
p.mu.Lock()
if p.client != nil {
p.mu.Unlock()
return nil
}
if p.authCancel != nil {
p.authCancel()
p.authCancel = nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
p.mu.Lock()
p.authCancel = cancel
p.mu.Unlock()
c, err := newClientInteractive(ctx)
p.mu.Lock()
p.authCancel = nil
p.mu.Unlock()
cancel()
if err != nil {
return err
}
p.mu.Lock()
p.client = c
p.mu.Unlock()
return nil
}
// Close cancels any in-progress sign-in. Implements provider.Closer.
func (p *QobuzProvider) Close() {
p.mu.Lock()
defer p.mu.Unlock()
if p.authCancel != nil {
p.authCancel()
p.authCancel = nil
}
}
// Refresh clears cached playlists and tracks so the next call re-fetches and
// re-resolves streaming URLs (which expire). Implements playlist.Refresher.
func (p *QobuzProvider) Refresh() {
p.mu.Lock()
p.listCache = nil
p.trackCache = make(map[string][]playlist.Track)
p.mu.Unlock()
}
// Playlists returns the user's Qobuz playlists plus synthetic Favorite Tracks
// and Random Tracks entries.
func (p *QobuzProvider) Playlists() ([]playlist.PlaylistInfo, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
p.mu.Lock()
if p.listCache != nil {
cached := slices.Clone(p.listCache)
p.mu.Unlock()
return cached, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pls, err := c.userPlaylists(ctx)
if err != nil {
return nil, err
}
lists := []playlist.PlaylistInfo{
{
ID: favoriteTracksID,
Name: "Favorite Tracks",
Section: "Library",
},
{
ID: randomTracksID,
Name: "Random Tracks",
Section: "Library",
},
}
for _, pl := range pls {
lists = append(lists, playlist.PlaylistInfo{
ID: pl.ID.String(),
Name: pl.Name,
TrackCount: pl.TracksCount,
DurationSecs: pl.Duration,
Section: "Your playlists",
})
}
p.mu.Lock()
p.listCache = lists
p.mu.Unlock()
return slices.Clone(lists), nil
}
// Tracks returns the tracks of a playlist (or the synthetic Favorite Tracks /
// Random Tracks entries), each with a resolved streaming URL.
func (p *QobuzProvider) Tracks(playlistID string) ([]playlist.Track, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
p.mu.Lock()
if cached, ok := p.trackCache[playlistID]; ok {
tracks := slices.Clone(cached)
p.mu.Unlock()
return tracks, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
var apiTracks []apiTrack
switch playlistID {
case favoriteTracksID:
apiTracks, err = c.favoriteTracks(ctx, 0, 500)
case randomTracksID:
apiTracks, err = p.randomTracks(ctx, c)
default:
apiTracks, err = c.playlistTracks(ctx, playlistID)
}
if err != nil {
return nil, err
}
tracks := p.resolveTracks(ctx, c, apiTracks, nil)
p.mu.Lock()
p.trackCache[playlistID] = tracks
p.mu.Unlock()
return slices.Clone(tracks), nil
}
// randomTracks aggregates the tracks of every user playlist (fetched
// concurrently), drops tracks that appear in more than one playlist, and
// returns a random sample of at most randomTracksLimit. Sampling (rather than
// truncating) keeps the entry a fair cross-section of the whole library;
// refreshing the provider picks a new sample.
func (p *QobuzProvider) randomTracks(ctx context.Context, c *client) ([]apiTrack, error) {
pls, err := c.userPlaylists(ctx)
if err != nil {
return nil, fmt.Errorf("qobuz: list playlists: %w", err)
}
// Fetch each playlist's tracks in parallel, then merge in playlist order so
// dedupe (first occurrence wins) stays deterministic.
lists := make([][]apiTrack, len(pls))
errs := make([]error, len(pls))
sem := make(chan struct{}, playlistFetchConcurrency)
var wg sync.WaitGroup
for i := range pls {
wg.Add(1)
sem <- struct{}{}
go func(idx int) {
defer wg.Done()
defer func() { <-sem }()
lists[idx], errs[idx] = c.playlistTracks(ctx, pls[idx].ID.String())
}(i)
}
wg.Wait()
var all []apiTrack
for i := range pls {
if errs[i] != nil {
return nil, fmt.Errorf("qobuz: playlist %s: %w", pls[i].ID, errs[i])
}
all = append(all, lists[i]...)
}
return sampleTracks(dedupeTracksByID(all), randomTracksLimit, rand.Shuffle), nil
}
// sampleTracks shuffles a copy of in and returns up to n of the result. The
// list is always randomized because that's the whole point of the Random Tracks
// entry. When the library is larger than n, the shuffle makes it a fair
// sample of the whole library rather than its first n. The shuffle func is
// injected so tests stay deterministic; production passes rand.Shuffle, which
// is safe for concurrent use.
func sampleTracks(in []apiTrack, n int, shuffle func(n int, swap func(i, j int))) []apiTrack {
out := slices.Clone(in)
shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
if len(out) > n {
out = out[:n]
}
return out
}
// dedupeTracksByID returns tracks with duplicate Qobuz IDs removed, keeping the
// first occurrence. Tracks with an empty ID are always kept.
func dedupeTracksByID(in []apiTrack) []apiTrack {
seen := make(map[string]bool, len(in))
out := make([]apiTrack, 0, len(in))
for _, t := range in {
id := t.ID.String()
if id != "" {
if seen[id] {
continue
}
seen[id] = true
}
out = append(out, t)
}
return out
}
// SearchTracks searches the Qobuz catalog. Implements provider.Searcher.
func (p *QobuzProvider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
if limit <= 0 {
limit = 50
}
apiTracks, err := c.searchTracks(ctx, query, limit)
if err != nil {
return nil, err
}
return p.resolveTracks(ctx, c, apiTracks, nil), nil
}
// Artists returns the user's favorite artists. Implements provider.ArtistBrowser.
func (p *QobuzProvider) Artists() ([]provider.ArtistInfo, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var artists []provider.ArtistInfo
for offset := 0; ; offset += favoritesPageSize {
page, err := c.favoriteArtists(ctx, offset, favoritesPageSize)
if err != nil {
return nil, err
}
for _, a := range page {
artists = append(artists, provider.ArtistInfo{
ID: a.ID.String(),
Name: a.Name,
AlbumCount: a.AlbumsCount,
})
}
if len(page) < favoritesPageSize {
break
}
}
return artists, nil
}
// ArtistAlbums returns the albums of an artist. Implements provider.ArtistBrowser.
func (p *QobuzProvider) ArtistAlbums(artistID string) ([]provider.AlbumInfo, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
albums, err := c.artistAlbums(ctx, artistID)
if err != nil {
return nil, err
}
out := make([]provider.AlbumInfo, 0, len(albums))
for _, a := range albums {
out = append(out, albumInfo(a))
}
return out, nil
}
// AlbumList returns the user's favorite albums (Qobuz has no global album
// catalog to browse). Implements provider.AlbumBrowser.
func (p *QobuzProvider) AlbumList(_ string, offset, size int) ([]provider.AlbumInfo, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
if size <= 0 {
size = favoritesPageSize
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
albums, err := c.favoriteAlbums(ctx, offset, size)
if err != nil {
return nil, err
}
out := make([]provider.AlbumInfo, 0, len(albums))
for _, a := range albums {
out = append(out, albumInfo(a))
}
return out, nil
}
func (p *QobuzProvider) AlbumSortTypes() []provider.SortType { return albumSortTypes }
func (p *QobuzProvider) DefaultAlbumSort() string { return "favorites" }
// AlbumTracks returns the tracks of an album. Implements provider.AlbumTrackLoader.
func (p *QobuzProvider) AlbumTracks(albumID string) ([]playlist.Track, error) {
c, err := p.ensureClient()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
album, err := c.albumGet(ctx, albumID)
if err != nil {
return nil, err
}
var tracks []apiTrack
if album.Tracks != nil {
tracks = album.Tracks.Items
}
return p.resolveTracks(ctx, c, tracks, &album), nil
}
// resolveTracks converts API tracks into playable tracks, resolving a signed
// streaming URL for each in parallel. albumFallback supplies album metadata for
// tracks that lack it (album/get nests tracks without an album field). Tracks
// that are not streamable or fail URL resolution are returned as unplayable.
func (p *QobuzProvider) resolveTracks(ctx context.Context, c *client, in []apiTrack, albumFallback *apiAlbum) []playlist.Track {
out := make([]playlist.Track, len(in))
sem := make(chan struct{}, resolveConcurrency)
var wg sync.WaitGroup
for i := range in {
wg.Add(1)
sem <- struct{}{}
go func(idx int) {
defer wg.Done()
defer func() { <-sem }()
out[idx] = p.buildTrack(ctx, c, in[idx], albumFallback)
}(i)
}
wg.Wait()
return out
}
// buildTrack maps a single API track to a playlist.Track, resolving its stream
// URL unless the track is not streamable.
func (p *QobuzProvider) buildTrack(ctx context.Context, c *client, t apiTrack, albumFallback *apiAlbum) playlist.Track {
album := t.Album
if album == nil {
album = albumFallback
}
track := playlist.Track{
Title: t.Title,
Artist: trackArtist(t, album),
TrackNumber: t.TrackNumber,
DurationSecs: t.Duration,
Stream: true,
ProviderMeta: map[string]string{provider.MetaQobuzID: t.ID.String()},
}
if album != nil {
track.Album = album.Title
track.Genre = album.Genre.Name
track.Year = parseYear(album.ReleaseDateOriginal)
}
if !t.Streamable {
track.Unplayable = true
return track
}
file, err := c.trackFileURL(ctx, t.ID.String(), p.quality, "")
if err != nil || file.URL == "" {
if err != nil {
applog.Debug("qobuz: resolve stream url for track %s: %v", t.ID.String(), err)
}
track.Unplayable = true
return track
}
registerStreamURL(file.URL)
track.Path = file.URL
return track
}
// trackArtist picks the best available artist name for a track.
func trackArtist(t apiTrack, album *apiAlbum) string {
if t.Performer.Name != "" {
return t.Performer.Name
}
if album != nil {
return album.Artist.Name
}
return ""
}
// albumInfo maps a Qobuz album to provider.AlbumInfo.
func albumInfo(a apiAlbum) provider.AlbumInfo {
return provider.AlbumInfo{
ID: a.ID,
Name: a.Title,
Artist: a.Artist.Name,
ArtistID: a.Artist.ID.String(),
Year: parseYear(a.ReleaseDateOriginal),
TrackCount: a.TracksCount,
Genre: a.Genre.Name,
}
}
// parseYear extracts the year from a Qobuz "YYYY-MM-DD" date string.
func parseYear(date string) int {
if len(date) < 4 {
return 0
}
y, err := strconv.Atoi(date[:4])
if err != nil {
return 0
}
return y
}
+160
View File
@@ -0,0 +1,160 @@
package qobuz
import (
"encoding/json"
"math/rand/v2"
"strconv"
"testing"
)
func TestParseYear(t *testing.T) {
tests := []struct {
in string
want int
}{
{"2021-05-14", 2021},
{"1999", 1999},
{"", 0},
{"abc", 0},
{"20", 0},
{"19xy-01-01", 0},
}
for _, tt := range tests {
if got := parseYear(tt.in); got != tt.want {
t.Errorf("parseYear(%q) = %d, want %d", tt.in, got, tt.want)
}
}
}
func TestTrackArtist(t *testing.T) {
withPerformer := apiTrack{Performer: apiArtist{Name: "Performer"}}
if got := trackArtist(withPerformer, nil); got != "Performer" {
t.Errorf("performer name: got %q want %q", got, "Performer")
}
album := &apiAlbum{Artist: apiArtist{Name: "AlbumArtist"}}
if got := trackArtist(apiTrack{}, album); got != "AlbumArtist" {
t.Errorf("album fallback: got %q want %q", got, "AlbumArtist")
}
if got := trackArtist(apiTrack{}, nil); got != "" {
t.Errorf("no artist: got %q want empty", got)
}
}
func TestDedupeTracksByID(t *testing.T) {
tracks := []apiTrack{
{ID: "1", Title: "first"},
{ID: "2", Title: "second"},
{ID: "1", Title: "dup of first"},
{ID: "3", Title: "third"},
{ID: "2", Title: "dup of second"},
{ID: "", Title: "no id a"},
{ID: "", Title: "no id b"},
}
got := dedupeTracksByID(tracks)
want := []struct {
id string
title string
}{
{"1", "first"}, // first occurrence wins
{"2", "second"},
{"3", "third"},
{"", "no id a"}, // empty-ID tracks are always kept
{"", "no id b"},
}
if len(got) != len(want) {
t.Fatalf("got %d tracks, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].ID.String() != w.id || got[i].Title != w.title {
t.Errorf("track %d = {%q, %q}, want {%q, %q}",
i, got[i].ID.String(), got[i].Title, w.id, w.title)
}
}
}
func TestDedupeTracksByIDEmpty(t *testing.T) {
if got := dedupeTracksByID(nil); len(got) != 0 {
t.Errorf("dedupeTracksByID(nil) = %v, want empty", got)
}
}
func TestSampleTracks(t *testing.T) {
mk := func(n int) []apiTrack {
ts := make([]apiTrack, n)
for i := range ts {
ts[i] = apiTrack{ID: json.Number(strconv.Itoa(i))}
}
return ts
}
idSet := func(ts []apiTrack) map[string]bool {
m := make(map[string]bool, len(ts))
for _, tr := range ts {
m[tr.ID.String()] = true
}
return m
}
r := rand.New(rand.NewPCG(42, 1024))
// Under the cap: every track is kept, but the list must still be shuffled.
// This is the case that used to be returned in playlist order unchanged.
in := mk(100)
got := sampleTracks(in, 500, r.Shuffle)
if len(got) != 100 {
t.Fatalf("under cap: len = %d, want 100", len(got))
}
want := idSet(in)
for _, tr := range got {
if !want[tr.ID.String()] {
t.Errorf("under cap: track %s not from input", tr.ID)
}
}
sameOrder := true
for i := range got {
if got[i].ID != in[i].ID {
sameOrder = false
break
}
}
if sameOrder {
t.Error("under cap: list was not shuffled")
}
// Over the cap: exactly n tracks, all from the input, no duplicates.
big := mk(1000)
all := idSet(big)
for range 20 {
s := sampleTracks(big, 10, r.Shuffle)
if len(s) != 10 {
t.Fatalf("over cap: len = %d, want 10", len(s))
}
seen := make(map[string]bool, len(s))
for _, tr := range s {
id := tr.ID.String()
if !all[id] {
t.Fatalf("over cap: track %q not from input", id)
}
if seen[id] {
t.Fatalf("over cap: duplicate track %q", id)
}
seen[id] = true
}
}
}
func TestNewQualityNormalization(t *testing.T) {
for _, q := range []int{5, 6, 7, 27} {
if got := New(q).quality; got != q {
t.Errorf("New(%d).quality = %d, want %d", q, got, q)
}
}
for _, q := range []int{0, 1, 99} {
if got := New(q).quality; got != defaultQuality {
t.Errorf("New(%d).quality = %d, want default %d", q, got, defaultQuality)
}
}
}
+212
View File
@@ -0,0 +1,212 @@
package qobuz
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"sync/atomic"
"time"
"github.com/bjarneo/cliamp/applog"
"github.com/bjarneo/cliamp/internal/browser"
)
// authURLObserver is invoked with the OAuth URL when interactive auth begins.
// Used by the TUI to display the URL when the launched browser does not reach
// the user (containers, headless environments).
var authURLObserver atomic.Pointer[func(string)]
// SetAuthURLObserver registers a callback invoked once with the OAuth URL at
// the start of an interactive sign-in. Pass nil to remove.
func SetAuthURLObserver(fn func(string)) {
if fn == nil {
authURLObserver.Store(nil)
return
}
authURLObserver.Store(&fn)
}
func notifyAuthURL(u string) {
applog.Info("qobuz: sign-in URL: %s", u)
if p := authURLObserver.Load(); p != nil {
(*p)(u)
}
}
// oauthResult holds the data captured from a Qobuz OAuth redirect.
type oauthResult struct {
Token string
UserID string
Code string
}
// newClientSilent builds an authenticated client from stored credentials only.
// It never opens a browser; if no usable credentials exist it returns an error.
func newClientSilent(ctx context.Context) (*client, error) {
creds, err := loadCreds()
if err != nil {
return nil, fmt.Errorf("qobuz: no stored credentials: %w", err)
}
if creds.AppID == "" || creds.UserAuthToken == "" {
return nil, fmt.Errorf("qobuz: incomplete stored credentials")
}
c := newClient(creds.AppID, creds.Secrets)
c.secret = creds.Secret
c.uat = creds.UserAuthToken
c.userID = creds.UserID
c.label = creds.Label
if err := c.authWithToken(ctx, creds.UserID, creds.UserAuthToken); err != nil {
return nil, fmt.Errorf("qobuz: stored token rejected: %w", err)
}
if c.secret == "" {
if err := c.validateSecret(ctx); err != nil {
return nil, err
}
}
// Re-persist in case the validated secret or label changed.
_ = saveCreds(credsFromClient(c, creds.PrivateKey))
return c, nil
}
// newClientInteractive scrapes fresh credentials from the Qobuz web player and
// runs the interactive OAuth browser flow, persisting the result on success.
func newClientInteractive(ctx context.Context) (*client, error) {
appID, secrets, privateKey, err := scrapeCredentials(ctx)
if err != nil {
return nil, fmt.Errorf("qobuz: scrape credentials: %w", err)
}
c := newClient(appID, secrets)
// OAuth first: the secret-validation probe (track/getFileUrl) is an
// authenticated endpoint and fails without a user_auth_token, so the
// browser sign-in must complete before validateSecret runs.
result, err := captureOAuthRedirect(ctx, appID)
if err != nil {
return nil, err
}
if err := c.loginWithOAuth(ctx, result, privateKey); err != nil {
return nil, fmt.Errorf("qobuz: OAuth login: %w", err)
}
if err := c.validateSecret(ctx); err != nil {
return nil, err
}
if err := saveCreds(credsFromClient(c, privateKey)); err != nil {
applog.UserError("qobuz: failed to save credentials: %v", err)
}
return c, nil
}
func credsFromClient(c *client, privateKey string) *storedCreds {
return &storedCreds{
AppID: c.appID,
Secrets: c.secrets,
Secret: c.secret,
PrivateKey: privateKey,
UserAuthToken: c.uat,
UserID: c.userID,
Label: c.label,
}
}
// oauthCallbackHTML is shown in the browser once the redirect is captured.
const oauthCallbackHTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>cliamp</title></head>
<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#e0e0e0">
<div style="text-align:center">
<h2>Signed in to Qobuz</h2>
<p>You can close this tab now.</p>
<script>setTimeout(function(){window.close()},1500)</script>
</div></body></html>`
// captureOAuthRedirect starts a local HTTP server on a random port, opens the
// Qobuz OAuth URL in the browser, and waits for the redirect carrying the
// token or code. Qobuz accepts any localhost redirect_url, so a random port is
// fine.
func captureOAuthRedirect(ctx context.Context, appID string) (oauthResult, error) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return oauthResult{}, fmt.Errorf("qobuz: open local port: %w", err)
}
defer lis.Close()
port := lis.Addr().(*net.TCPAddr).Port
// Qobuz validates the redirect_url host server-side and only accepts
// "localhost" (not 127.0.0.1). We still bind 127.0.0.1 below; browsers
// fall back from localhost ([::1]) to 127.0.0.1, so the capture works.
// This matches the proven SofusA/qobine reference flow.
//
// Note: after authorizing, Qobuz shows a "you are signed in, you can leave
// this page" screen with a Back button rather than redirecting back. The
// user must click Back to fire the redirect (see docs/qobuz.md). This is
// Qobuz's behavior; URL-encoding the redirect_url does not change it.
authURL := fmt.Sprintf(
"https://www.qobuz.com/signin/oauth?ext_app_id=%s&redirect_url=http://localhost:%d",
appID, port,
)
notifyAuthURL(authURL)
resultCh := make(chan oauthResult, 1)
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
applog.Debug("qobuz: oauth redirect received: %s", r.URL.RequestURI())
res := parseQueryParams(r.URL.Query())
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(oauthCallbackHTML))
if res.Token != "" || res.Code != "" {
select {
case resultCh <- res:
default:
}
} else {
applog.Debug("qobuz: oauth redirect had no token/code param")
}
}),
}
go func() { _ = srv.Serve(lis) }()
defer func() {
shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
}()
_ = browser.Open(authURL) // best-effort; user can open the URL manually
select {
case res := <-resultCh:
return res, nil
case <-ctx.Done():
return oauthResult{}, fmt.Errorf("qobuz: authentication cancelled: %w", ctx.Err())
case <-time.After(5 * time.Minute):
return oauthResult{}, errors.New("qobuz: timed out waiting for OAuth redirect")
}
}
// parseQueryParams extracts token/code/user_id from a Qobuz redirect. Qobuz has
// used several parameter names across auth flow versions.
func parseQueryParams(params url.Values) oauthResult {
var res oauthResult
if t := params.Get("user_auth_token"); t != "" {
res.Token = t
}
if t := params.Get("token"); t != "" && res.Token == "" {
res.Token = t
}
if uid := params.Get("user_id"); uid != "" {
res.UserID = uid
}
if code := params.Get("code_autorisation"); code != "" { // French spelling, Qobuz's actual param
res.Code = code
}
if code := params.Get("code"); code != "" && res.Code == "" {
res.Code = code
}
return res
}
+26
View File
@@ -0,0 +1,26 @@
package qobuz
import "sync"
// streamURLs records the signed CDN URLs that the provider has resolved via
// track/getFileUrl. The player consults IsStreamURL through a registered
// buffered-URL matcher so Qobuz FLAC streams are routed through the
// buffer-while-playing + ffmpeg pipeline (which auto-detects the codec and
// supports seeking), exactly like Navidrome's raw streams.
var streamURLs sync.Map // map[string]struct{}
// registerStreamURL marks u as a Qobuz stream URL.
func registerStreamURL(u string) {
if u == "" {
return
}
streamURLs.Store(u, struct{}{})
}
// IsStreamURL reports whether u is a Qobuz signed stream URL previously
// resolved by the provider. It is registered with the player's buffered-URL
// matcher in main.go.
func IsStreamURL(u string) bool {
_, ok := streamURLs.Load(u)
return ok
}
+24
View File
@@ -0,0 +1,24 @@
package qobuz
import "testing"
func TestRegisterAndIsStreamURL(t *testing.T) {
const u = "https://streaming-qobuz.example/file/abc123?sig=xyz"
if IsStreamURL(u) {
t.Fatalf("url should not be registered yet")
}
registerStreamURL(u)
if !IsStreamURL(u) {
t.Fatalf("url should be registered after registerStreamURL")
}
if IsStreamURL("https://other.example/track") {
t.Fatalf("unrelated url should not match")
}
}
func TestRegisterStreamURLEmpty(t *testing.T) {
registerStreamURL("")
if IsStreamURL("") {
t.Fatalf("empty url must never be registered")
}
}
+73
View File
@@ -0,0 +1,73 @@
package qobuz
import "encoding/json"
// apiArtist is the Qobuz artist object.
type apiArtist struct {
ID json.Number `json:"id"`
Name string `json:"name"`
AlbumsCount int `json:"albums_count"`
}
// apiGenre is the Qobuz genre object.
type apiGenre struct {
Name string `json:"name"`
}
// apiAlbum is the Qobuz album object. Tracks is populated only when the request
// asks for the "tracks" extra (e.g. album/get).
type apiAlbum struct {
ID string `json:"id"`
Title string `json:"title"`
TracksCount int `json:"tracks_count"`
Duration int `json:"duration"`
ReleaseDateOriginal string `json:"release_date_original"`
Genre apiGenre `json:"genre"`
Artist apiArtist `json:"artist"`
Tracks *apiTrackList `json:"tracks"`
}
// apiTrack is the Qobuz track object. Album is present in search and playlist
// responses but absent when the track is nested inside an album/get response.
type apiTrack struct {
ID json.Number `json:"id"`
Title string `json:"title"`
TrackNumber int `json:"track_number"`
Duration int `json:"duration"`
Streamable bool `json:"streamable"`
Performer apiArtist `json:"performer"`
Album *apiAlbum `json:"album"`
}
// apiTrackList is a paginated list of tracks.
type apiTrackList struct {
Items []apiTrack `json:"items"`
Total int `json:"total"`
}
// apiAlbumList is a paginated list of albums.
type apiAlbumList struct {
Items []apiAlbum `json:"items"`
Total int `json:"total"`
}
// apiArtistList is a paginated list of artists.
type apiArtistList struct {
Items []apiArtist `json:"items"`
Total int `json:"total"`
}
// apiPlaylist is the Qobuz playlist object.
type apiPlaylist struct {
ID json.Number `json:"id"`
Name string `json:"name"`
TracksCount int `json:"tracks_count"`
Duration int `json:"duration"`
Tracks *apiTrackList `json:"tracks"`
}
// apiPlaylistList is a paginated list of playlists.
type apiPlaylistList struct {
Items []apiPlaylist `json:"items"`
Total int `json:"total"`
}
+74
View File
@@ -0,0 +1,74 @@
// catalog.go provides a client for the Radio Browser API (radio-browser.info),
// a free community-maintained directory of internet radio stations.
package radio
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const radioBrowserBase = "https://de1.api.radio-browser.info/json"
// CatalogStation represents a station from the Radio Browser API.
type CatalogStation struct {
Name string `json:"name"`
URL string `json:"url_resolved"`
Country string `json:"country"`
Tags string `json:"tags"`
Codec string `json:"codec"`
Bitrate int `json:"bitrate"`
Votes int `json:"votes"`
Homepage string `json:"homepage"`
}
var catalogClient = &http.Client{Timeout: 10 * time.Second}
// SearchStations searches the Radio Browser API by station name.
func SearchStations(query string, limit int) ([]CatalogStation, error) {
if limit <= 0 {
limit = 50
}
u := fmt.Sprintf("%s/stations/byname/%s?limit=%d&order=votes&reverse=true&hidebroken=true",
radioBrowserBase, url.PathEscape(query), limit)
return fetchStations(u)
}
// TopStationsOffset returns a page of the most-voted stations starting at offset.
func TopStationsOffset(offset, limit int) ([]CatalogStation, error) {
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
u := fmt.Sprintf("%s/stations/topvote/%d?offset=%d&hidebroken=true",
radioBrowserBase, limit, offset)
return fetchStations(u)
}
func fetchStations(u string) ([]CatalogStation, error) {
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "cliamp/1.0")
resp, err := catalogClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("radio-browser: HTTP %d", resp.StatusCode)
}
var stations []CatalogStation
if err := json.NewDecoder(resp.Body).Decode(&stations); err != nil {
return nil, err
}
return stations, nil
}
+160
View File
@@ -0,0 +1,160 @@
package radio
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
type hostRewriter struct {
target *url.URL
rt http.RoundTripper
}
func (h hostRewriter) RoundTrip(req *http.Request) (*http.Response, error) {
clone := req.Clone(req.Context())
clone.URL.Scheme = h.target.Scheme
clone.URL.Host = h.target.Host
clone.Host = h.target.Host
return h.rt.RoundTrip(clone)
}
func installCatalogClient(t *testing.T, serverURL string) {
t.Helper()
u, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
old := catalogClient
catalogClient = &http.Client{
Timeout: 5 * time.Second,
Transport: hostRewriter{target: u, rt: http.DefaultTransport},
}
t.Cleanup(func() { catalogClient = old })
}
func TestSearchStationsSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/stations/byname/") {
t.Errorf("unexpected path %q", r.URL.Path)
}
if !strings.Contains(r.URL.Path, "jazz") {
t.Errorf("path should contain query 'jazz': %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"Jazz FM","url_resolved":"https://jazz.example/stream","country":"UK","bitrate":128}]`))
}))
defer srv.Close()
installCatalogClient(t, srv.URL)
stations, err := SearchStations("jazz", 10)
if err != nil {
t.Fatalf("SearchStations: %v", err)
}
if len(stations) != 1 {
t.Fatalf("got %d stations, want 1", len(stations))
}
if stations[0].Name != "Jazz FM" {
t.Errorf("Name = %q, want Jazz FM", stations[0].Name)
}
if stations[0].URL != "https://jazz.example/stream" {
t.Errorf("URL = %q", stations[0].URL)
}
if stations[0].Bitrate != 128 {
t.Errorf("Bitrate = %d, want 128", stations[0].Bitrate)
}
}
func TestSearchStationsDefaultLimit(t *testing.T) {
var gotURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotURL = r.URL.String()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
installCatalogClient(t, srv.URL)
if _, err := SearchStations("x", 0); err != nil {
t.Fatalf("SearchStations: %v", err)
}
if !strings.Contains(gotURL, "limit=50") {
t.Errorf("default limit should be 50, got URL %q", gotURL)
}
}
func TestSearchStationsHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "down", http.StatusInternalServerError)
}))
defer srv.Close()
installCatalogClient(t, srv.URL)
_, err := SearchStations("jazz", 10)
if err == nil {
t.Error("SearchStations should return error on 500")
}
}
func TestTopStationsOffset(t *testing.T) {
var gotURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotURL = r.URL.String()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"Top1","url_resolved":"http://t1/","bitrate":64}]`))
}))
defer srv.Close()
installCatalogClient(t, srv.URL)
stations, err := TopStationsOffset(50, 25)
if err != nil {
t.Fatalf("TopStationsOffset: %v", err)
}
if len(stations) != 1 || stations[0].Name != "Top1" {
t.Fatalf("stations = %+v", stations)
}
if !strings.Contains(gotURL, "topvote/25") {
t.Errorf("URL %q should use limit 25", gotURL)
}
if !strings.Contains(gotURL, "offset=50") {
t.Errorf("URL %q should contain offset=50", gotURL)
}
}
func TestTopStationsOffsetClampsNegatives(t *testing.T) {
var gotURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotURL = r.URL.String()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
installCatalogClient(t, srv.URL)
if _, err := TopStationsOffset(-10, 0); err != nil {
t.Fatalf("TopStationsOffset: %v", err)
}
if !strings.Contains(gotURL, "offset=0") {
t.Errorf("URL %q should clamp negative offset to 0", gotURL)
}
if !strings.Contains(gotURL, "topvote/50") {
t.Errorf("URL %q should use default limit 50", gotURL)
}
}
func TestFetchStationsInvalidJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{not valid`))
}))
defer srv.Close()
installCatalogClient(t, srv.URL)
_, err := SearchStations("x", 10)
if err == nil {
t.Error("SearchStations should error on invalid JSON")
}
}
+155
View File
@@ -0,0 +1,155 @@
package radio
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/tomlutil"
)
const favoritesFile = "radio_favorites.toml"
// Favorites manages a persistent set of favorite radio stations.
type Favorites struct {
stations []CatalogStation
byURL map[string]struct{}
path string
}
// LoadFavorites reads favorites from ~/.config/cliamp/radio_favorites.toml.
func LoadFavorites() *Favorites {
f := &Favorites{byURL: make(map[string]struct{})}
dir, err := appdir.Dir()
if err != nil {
return f
}
f.path = filepath.Join(dir, favoritesFile)
stations, err := loadFavoriteStations(f.path)
if err != nil {
return f
}
f.stations = stations
for _, s := range stations {
f.byURL[s.URL] = struct{}{}
}
return f
}
// Stations returns all favorite stations.
func (f *Favorites) Stations() []CatalogStation {
return f.stations
}
// Contains returns true if the station URL is in favorites.
func (f *Favorites) Contains(url string) bool {
_, ok := f.byURL[url]
return ok
}
// Add adds a station to favorites and saves to disk.
func (f *Favorites) Add(s CatalogStation) error {
if f.Contains(s.URL) {
return nil
}
f.stations = append(f.stations, s)
f.byURL[s.URL] = struct{}{}
return f.save()
}
// Remove removes a station by URL from favorites and saves to disk.
func (f *Favorites) Remove(url string) error {
if !f.Contains(url) {
return nil
}
for i, s := range f.stations {
if s.URL == url {
f.stations = append(f.stations[:i], f.stations[i+1:]...)
break
}
}
delete(f.byURL, url)
return f.save()
}
func (f *Favorites) save() error {
if f.path == "" {
dir, err := appdir.Dir()
if err != nil {
return err
}
f.path = filepath.Join(dir, favoritesFile)
}
if err := os.MkdirAll(filepath.Dir(f.path), 0o755); err != nil {
return err
}
// Build the full content in memory (writes to a Builder can't fail), then
// write a temp file and rename so a partial/failed write can never truncate
// or corrupt the existing favorites file.
var b strings.Builder
for i, s := range f.stations {
if i > 0 {
fmt.Fprintln(&b)
}
fmt.Fprintln(&b, "[[station]]")
fmt.Fprintf(&b, "name = %q\n", s.Name)
fmt.Fprintf(&b, "url = %q\n", s.URL)
if s.Country != "" {
fmt.Fprintf(&b, "country = %q\n", s.Country)
}
if s.Bitrate > 0 {
fmt.Fprintf(&b, "bitrate = %d\n", s.Bitrate)
}
if s.Codec != "" {
fmt.Fprintf(&b, "codec = %q\n", s.Codec)
}
if s.Tags != "" {
fmt.Fprintf(&b, "tags = %q\n", s.Tags)
}
if s.Homepage != "" {
fmt.Fprintf(&b, "homepage = %q\n", s.Homepage)
}
}
tmp := f.path + ".tmp"
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
return err
}
return os.Rename(tmp, f.path)
}
// loadFavoriteStations parses the favorites TOML file.
func loadFavoriteStations(path string) ([]CatalogStation, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, err
}
var stations []CatalogStation
tomlutil.ParseSections(data, "station", func(f map[string]string) {
s := CatalogStation{
Name: f["name"],
URL: f["url"],
Country: f["country"],
Codec: f["codec"],
Tags: f["tags"],
Homepage: f["homepage"],
}
if n, err := strconv.Atoi(f["bitrate"]); err == nil {
s.Bitrate = n
}
if s.Name != "" && s.URL != "" {
stations = append(stations, s)
}
})
return stations, nil
}
+103
View File
@@ -0,0 +1,103 @@
package radio
import (
"os"
"path/filepath"
"testing"
)
func TestFavoritesAddRemove(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "radio_favorites.toml")
f := &Favorites{byURL: make(map[string]struct{}), path: path}
s := CatalogStation{
Name: "Test FM",
URL: "https://test.example.com/stream",
Country: "Norway",
Bitrate: 128,
}
// Add
if err := f.Add(s); err != nil {
t.Fatalf("Add: %v", err)
}
if !f.Contains(s.URL) {
t.Fatal("expected Contains to return true after Add")
}
if len(f.Stations()) != 1 {
t.Fatalf("expected 1 station, got %d", len(f.Stations()))
}
// Add duplicate should be no-op
if err := f.Add(s); err != nil {
t.Fatalf("Add duplicate: %v", err)
}
if len(f.Stations()) != 1 {
t.Fatalf("expected 1 station after duplicate add, got %d", len(f.Stations()))
}
// Verify persistence
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if len(data) == 0 {
t.Fatal("expected non-empty favorites file")
}
// Reload from disk
stations, err := loadFavoriteStations(path)
if err != nil {
t.Fatalf("loadFavoriteStations: %v", err)
}
if len(stations) != 1 || stations[0].Name != "Test FM" {
t.Fatalf("unexpected reloaded stations: %+v", stations)
}
// Remove
if err := f.Remove(s.URL); err != nil {
t.Fatalf("Remove: %v", err)
}
if f.Contains(s.URL) {
t.Fatal("expected Contains to return false after Remove")
}
if len(f.Stations()) != 0 {
t.Fatalf("expected 0 stations after remove, got %d", len(f.Stations()))
}
// Remove non-existent should be no-op
if err := f.Remove("https://nonexistent.example.com"); err != nil {
t.Fatalf("Remove non-existent: %v", err)
}
}
func TestFavoritesRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "radio_favorites.toml")
f := &Favorites{byURL: make(map[string]struct{}), path: path}
stations := []CatalogStation{
{Name: "Jazz FM", URL: "https://jazz.example.com/stream", Country: "UK", Bitrate: 320, Codec: "mp3"},
{Name: "Rock Radio", URL: "https://rock.example.com/stream", Country: "US", Bitrate: 192, Tags: "rock,metal"},
}
for _, s := range stations {
if err := f.Add(s); err != nil {
t.Fatalf("Add %s: %v", s.Name, err)
}
}
// Reload
loaded, err := loadFavoriteStations(path)
if err != nil {
t.Fatalf("loadFavoriteStations: %v", err)
}
if len(loaded) != 2 {
t.Fatalf("expected 2 stations, got %d", len(loaded))
}
if loaded[0].Country != "UK" || loaded[1].Tags != "rock,metal" {
t.Fatalf("unexpected loaded data: %+v", loaded)
}
}
+330
View File
@@ -0,0 +1,330 @@
// Package radio implements a playlist.Provider for internet radio stations.
// It includes a built-in cliamp radio stream, user-defined stations from
// ~/.config/cliamp/radios.toml, favorites from radio_favorites.toml, and
// lazy-loaded catalog stations from the Radio Browser API.
package radio
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/tomlutil"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
)
// 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"
// Provider serves radio stations as single-track playlists.
// It combines local stations, user favorites, and catalog stations
// from the Radio Browser API into a single unified list.
type Provider struct {
mu sync.Mutex
stations []station // built-in + user-defined (radios.toml)
favorites *Favorites // user favorites (radio_favorites.toml)
catalog []CatalogStation // lazily loaded from Radio Browser API
searchResults []CatalogStation // non-nil when API search is active
}
type station struct {
name string
url string
}
// New creates a Provider with the built-in station plus any user-defined
// stations from ~/.config/cliamp/radios.toml and favorites.
func New() *Provider {
p := &Provider{
stations: []station{
{name: builtinName, url: builtinURL},
},
}
dir, err := appdir.Dir()
if err != nil {
p.favorites = &Favorites{byURL: make(map[string]struct{})}
return p
}
if extra, err := loadStations(filepath.Join(dir, "radios.toml")); err == nil {
p.stations = append(p.stations, extra...)
}
p.favorites = LoadFavorites()
return p
}
func (p *Provider) Name() string { return "Radio" }
// Playlists returns a unified list: local stations, then favorites (★ prefixed),
// then catalog stations (with metadata). IDs are prefixed: "l:", "f:", "c:".
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
p.mu.Lock()
defer p.mu.Unlock()
var out []playlist.PlaylistInfo
// When search is active, show only search results.
if p.searchResults != nil {
for i, s := range p.searchResults {
out = append(out, p.catalogEntry("s", i, s))
}
return out, nil
}
// Local stations.
for i, s := range p.stations {
out = append(out, playlist.PlaylistInfo{
ID: fmt.Sprintf("l:%d", i),
Name: s.name,
})
}
// Favorites.
for i, s := range p.favorites.Stations() {
out = append(out, playlist.PlaylistInfo{
ID: fmt.Sprintf("f:%d", i),
Name: "★ " + formatCatalogName(s),
})
}
// Catalog stations.
for i, s := range p.catalog {
out = append(out, p.catalogEntry("c", i, s))
}
return out, nil
}
// catalogEntry builds a PlaylistInfo for a CatalogStation, marking favorites with ★.
func (p *Provider) catalogEntry(prefix string, idx int, s CatalogStation) playlist.PlaylistInfo {
name := formatCatalogName(s)
if p.favorites.Contains(s.URL) {
name = "★ " + name
}
return playlist.PlaylistInfo{
ID: fmt.Sprintf("%s:%d", prefix, idx),
Name: name,
}
}
// Tracks returns a single-track playlist for the given station ID.
func (p *Provider) Tracks(id string) ([]playlist.Track, error) {
p.mu.Lock()
defer p.mu.Unlock()
prefix, idx, err := parseStationID(id)
if err != nil {
return nil, err
}
var url, title string
switch prefix {
case "l":
if idx < 0 || idx >= len(p.stations) {
return nil, errors.New("invalid local station index")
}
url, title = p.stations[idx].url, p.stations[idx].name
case "f":
favs := p.favorites.Stations()
if idx < 0 || idx >= len(favs) {
return nil, errors.New("invalid favorite index")
}
url, title = favs[idx].URL, favs[idx].Name
case "c":
if idx < 0 || idx >= len(p.catalog) {
return nil, errors.New("invalid catalog station index")
}
url, title = p.catalog[idx].URL, p.catalog[idx].Name
case "s":
if p.searchResults == nil || idx < 0 || idx >= len(p.searchResults) {
return nil, errors.New("invalid search result index")
}
url, title = p.searchResults[idx].URL, p.searchResults[idx].Name
default:
return nil, errors.New("unknown station type")
}
return []playlist.Track{{
Path: url, Title: title, Stream: true, Realtime: true,
}}, nil
}
// AppendCatalog adds catalog stations fetched from the Radio Browser API.
func (p *Provider) AppendCatalog(stations []CatalogStation) {
p.mu.Lock()
defer p.mu.Unlock()
p.catalog = append(p.catalog, stations...)
}
// ToggleFavorite toggles the favorite status of a catalog or favorite entry.
// Returns (true, name) if added, (false, name) if removed.
func (p *Provider) ToggleFavorite(id string) (added bool, name string, err error) {
p.mu.Lock()
defer p.mu.Unlock()
prefix, idx, err := parseStationID(id)
if err != nil {
return false, "", err
}
var s CatalogStation
switch prefix {
case "c":
if idx < 0 || idx >= len(p.catalog) {
return false, "", errors.New("invalid catalog index")
}
s = p.catalog[idx]
case "s":
if p.searchResults == nil || idx < 0 || idx >= len(p.searchResults) {
return false, "", errors.New("invalid search result index")
}
s = p.searchResults[idx]
case "f":
favs := p.favorites.Stations()
if idx < 0 || idx >= len(favs) {
return false, "", errors.New("invalid favorite index")
}
s = favs[idx]
default:
return false, "", errors.New("cannot favorite local stations")
}
if p.favorites.Contains(s.URL) {
return false, s.Name, p.favorites.Remove(s.URL)
}
return true, s.Name, p.favorites.Add(s)
}
// SetSearchResults activates search mode with the given results.
// Playlists() will return search results instead of catalog stations.
func (p *Provider) SetSearchResults(stations []CatalogStation) {
p.mu.Lock()
defer p.mu.Unlock()
p.searchResults = stations
}
// ClearSearch deactivates search mode, restoring the catalog view.
func (p *Provider) ClearSearch() {
p.mu.Lock()
defer p.mu.Unlock()
p.searchResults = nil
}
// IsSearching returns true if API search results are active.
func (p *Provider) IsSearching() bool {
p.mu.Lock()
defer p.mu.Unlock()
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 "").
// 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 ""
}
return prefix
}
// parseStationID splits a prefixed ID like "c:42" into its prefix and index.
// Legacy numeric IDs (no colon) are treated as "l:" local station indices.
func parseStationID(id string) (prefix string, idx int, err error) {
raw := id
prefix, idxStr, ok := strings.Cut(id, ":")
if !ok {
prefix = "l"
idxStr = raw
}
idx, err = strconv.Atoi(idxStr)
if err != nil {
return "", 0, errors.New("invalid station ID")
}
return prefix, idx, nil
}
// formatCatalogName builds a display name from a CatalogStation.
func formatCatalogName(s CatalogStation) string {
name := s.Name
if s.Bitrate > 0 {
name += fmt.Sprintf(" [%dk]", s.Bitrate)
}
if s.Country != "" {
name += " · " + s.Country
}
return name
}
// loadStations parses a TOML file with [[station]] sections.
func loadStations(path string) ([]station, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, err
}
var stations []station
tomlutil.ParseSections(data, "station", func(f map[string]string) {
s := station{name: f["name"], url: f["url"]}
if s.name != "" && s.url != "" {
stations = append(stations, s)
}
})
return stations, nil
}
+323
View File
@@ -0,0 +1,323 @@
package radio
import (
"path/filepath"
"strings"
"testing"
)
func newTestProvider(t *testing.T) *Provider {
t.Helper()
// Point HOME at a temp dir so New() doesn't touch real state.
t.Setenv("HOME", t.TempDir())
return New()
}
func TestProviderNewHasBuiltinStation(t *testing.T) {
p := newTestProvider(t)
if p.Name() != "Radio" {
t.Errorf("Name() = %q, want Radio", p.Name())
}
infos, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists: %v", err)
}
if len(infos) == 0 {
t.Fatal("Playlists() returned none — expected built-in cliamp radio")
}
if infos[0].Name != builtinName {
t.Errorf("first playlist = %q, want %q", infos[0].Name, builtinName)
}
if !strings.HasPrefix(infos[0].ID, "l:") {
t.Errorf("first playlist ID = %q, want to start with 'l:'", infos[0].ID)
}
}
func TestProviderLoadsStationsFromTOML(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// Create radios.toml with one extra station.
cfgDir := filepath.Join(home, ".config", "cliamp")
writeFile(t, filepath.Join(cfgDir, "radios.toml"), `[[station]]
name = "Extra"
url = "https://extra.example/stream"
`)
p := New()
infos, _ := p.Playlists()
if len(infos) < 2 {
t.Fatalf("expected builtin + extra, got %d", len(infos))
}
if infos[1].Name != "Extra" {
t.Errorf("second playlist = %q, want Extra", infos[1].Name)
}
}
func TestProviderTracksLocalStation(t *testing.T) {
p := newTestProvider(t)
infos, _ := p.Playlists()
tracks, err := p.Tracks(infos[0].ID)
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(tracks))
}
if tracks[0].Path != builtinURL {
t.Errorf("Path = %q, want %q", tracks[0].Path, builtinURL)
}
if !tracks[0].Stream || !tracks[0].Realtime {
t.Errorf("Stream/Realtime = %v/%v, want true/true", tracks[0].Stream, tracks[0].Realtime)
}
}
func TestProviderTracksInvalidID(t *testing.T) {
p := newTestProvider(t)
tests := []string{
"l:99",
"c:0", // catalog empty
"s:0", // search not active
"f:5",
"x:1",
"notanid",
"l:notanumber",
}
for _, id := range tests {
t.Run(id, func(t *testing.T) {
if _, err := p.Tracks(id); err == nil {
t.Errorf("Tracks(%q) = nil err, want error", id)
}
})
}
}
func TestProviderCatalogLifecycle(t *testing.T) {
p := newTestProvider(t)
p.AppendCatalog([]CatalogStation{
{Name: "Radio A", URL: "http://a/", Bitrate: 192, Country: "NO"},
{Name: "Radio B", URL: "http://b/"},
})
infos, _ := p.Playlists()
// builtin (1) + catalog (2) = 3
if len(infos) != 3 {
t.Fatalf("len(infos) = %d, want 3 (builtin + 2 catalog)", len(infos))
}
if !strings.Contains(infos[1].ID, "c:0") {
t.Errorf("catalog id[0] = %q, want c:0", infos[1].ID)
}
// Tracks for catalog entry.
tracks, err := p.Tracks("c:0")
if err != nil {
t.Fatalf("Tracks(c:0): %v", err)
}
if tracks[0].Path != "http://a/" {
t.Errorf("Path = %q, want http://a/", tracks[0].Path)
}
// ToggleFavorite on a catalog entry should add it.
added, name, err := p.ToggleFavorite("c:0")
if err != nil {
t.Fatalf("ToggleFavorite: %v", err)
}
if !added || name != "Radio A" {
t.Errorf("ToggleFavorite = (%v, %q), want (true, Radio A)", added, name)
}
// Toggle again removes.
added, _, err = p.ToggleFavorite("c:0")
if err != nil {
t.Fatalf("ToggleFavorite 2: %v", err)
}
if added {
t.Error("second ToggleFavorite should remove")
}
}
func TestProviderToggleFavoriteLocalRejected(t *testing.T) {
p := newTestProvider(t)
_, _, err := p.ToggleFavorite("l:0")
if err == nil {
t.Error("ToggleFavorite on local station should error")
}
}
func TestProviderToggleFavoriteInvalidIdx(t *testing.T) {
p := newTestProvider(t)
_, _, err := p.ToggleFavorite("c:99")
if err == nil {
t.Error("ToggleFavorite on out-of-range catalog idx should error")
}
}
func TestProviderSearchLifecycle(t *testing.T) {
p := newTestProvider(t)
if p.IsSearching() {
t.Error("fresh provider should not be searching")
}
p.SetSearchResults([]CatalogStation{
{Name: "Hit", URL: "http://hit/"},
})
if !p.IsSearching() {
t.Error("SetSearchResults should put provider into searching mode")
}
infos, _ := p.Playlists()
if len(infos) != 1 || !strings.HasPrefix(infos[0].ID, "s:") {
t.Fatalf("Playlists during search = %+v, want single s:0 entry", infos)
}
// Tracks for a search result.
tracks, err := p.Tracks("s:0")
if err != nil {
t.Fatalf("Tracks(s:0): %v", err)
}
if tracks[0].Path != "http://hit/" {
t.Errorf("Path = %q, want http://hit/", tracks[0].Path)
}
p.ClearSearch()
if p.IsSearching() {
t.Error("ClearSearch should leave searching = false")
}
}
func TestIsCatalogOrFavID(t *testing.T) {
tests := []struct {
id string
want bool
}{
{"c:0", true},
{"f:3", true},
{"s:1", true},
{"l:0", false},
{"123", false},
{"", false},
}
for _, tt := range tests {
if got := IsCatalogOrFavID(tt.id); got != tt.want {
t.Errorf("IsCatalogOrFavID(%q) = %v, want %v", tt.id, got, tt.want)
}
}
}
func TestProviderIDPrefix(t *testing.T) {
p := newTestProvider(t)
tests := []struct {
id string
want string
}{
{"c:0", "c"},
{"l:5", "l"},
{"f:3", "f"},
{"s:0", "s"},
{"noprefix", ""},
}
for _, tt := range tests {
if got := p.IDPrefix(tt.id); got != tt.want {
t.Errorf("IDPrefix(%q) = %q, want %q", tt.id, got, tt.want)
}
}
}
func TestProviderIsFavoritableID(t *testing.T) {
p := newTestProvider(t)
if !p.IsFavoritableID("c:0") {
t.Error("c:0 should be favoritable")
}
if p.IsFavoritableID("l:0") {
t.Error("l:0 should not be favoritable")
}
}
func TestParseStationIDLegacyNumeric(t *testing.T) {
prefix, idx, err := parseStationID("5")
if err != nil {
t.Fatalf("parseStationID(5): %v", err)
}
if prefix != "l" || idx != 5 {
t.Errorf("parseStationID(5) = (%q, %d), want (l, 5)", prefix, idx)
}
}
func TestParseStationIDError(t *testing.T) {
_, _, err := parseStationID("c:notnumber")
if err == nil {
t.Error("parseStationID('c:notnumber') should error")
}
}
func TestFormatCatalogName(t *testing.T) {
tests := []struct {
in CatalogStation
want string
}{
{CatalogStation{Name: "Jazz"}, "Jazz"},
{CatalogStation{Name: "Jazz", Bitrate: 128}, "Jazz [128k]"},
{CatalogStation{Name: "Jazz", Country: "UK"}, "Jazz · UK"},
{CatalogStation{Name: "Jazz", Bitrate: 320, Country: "US"}, "Jazz [320k] · US"},
}
for _, tt := range tests {
if got := formatCatalogName(tt.in); got != tt.want {
t.Errorf("formatCatalogName(%+v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestLoadStationsIgnoresMissingFile(t *testing.T) {
stations, err := loadStations(filepath.Join(t.TempDir(), "nope.toml"))
if err != nil {
t.Errorf("loadStations on missing file should not error, got %v", err)
}
if stations != nil {
t.Errorf("missing file should return nil stations, got %+v", stations)
}
}
func TestLoadStationsParsesMultiple(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "radios.toml")
writeFile(t, path, `# leading comment
[[station]]
name = "A"
url = "http://a/"
[[station]]
name = "B"
url = "http://b/"
# trailing comment
`)
stations, err := loadStations(path)
if err != nil {
t.Fatalf("loadStations: %v", err)
}
if len(stations) != 2 {
t.Fatalf("len = %d, want 2", len(stations))
}
if stations[0].name != "A" || stations[1].name != "B" {
t.Errorf("names = %+v", stations)
}
}
func TestLoadStationsSkipsIncompleteEntries(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "radios.toml")
writeFile(t, path, `[[station]]
name = "no-url"
[[station]]
name = "complete"
url = "http://c/"
`)
stations, err := loadStations(path)
if err != nil {
t.Fatalf("loadStations: %v", err)
}
if len(stations) != 1 || stations[0].name != "complete" {
t.Errorf("expected only 'complete', got %+v", stations)
}
}
+18
View File
@@ -0,0 +1,18 @@
package radio
import (
"os"
"path/filepath"
"testing"
)
// writeFile creates parent dirs and writes content to path.
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
package radio
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
os.Unsetenv("CLIAMP_CONFIG_DIR")
os.Unsetenv("XDG_CONFIG_HOME")
os.Exit(m.Run())
}
+202
View File
@@ -0,0 +1,202 @@
// Package radiometa pulls now-playing metadata for radio stations that do not
// carry inline ICY StreamTitle metadata and instead publish the current track
// (or show) via a separate JSON API. It currently covers NTS and FIP.
//
// Resolver matches a stream URL to a fetch function; the player polls it and
// feeds titles through the same path as ICY metadata, so no display code needs
// to know where the title came from.
package radiometa
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
var client = &http.Client{Timeout: 8 * time.Second}
const userAgent = "cliamp/1.0 (https://github.com/bjarneo/cliamp)"
// Resolver reports how to fetch now-playing metadata for streamURL, or ok=false
// when the URL is not a recognized broadcaster. It satisfies
// player.StreamMetadataResolver.
func Resolver(streamURL string) (fetch func(ctx context.Context) (string, error), interval time.Duration, ok bool) {
u := strings.ToLower(streamURL)
switch {
case matchNTS(u):
channel := ntsChannel(u)
return func(ctx context.Context) (string, error) {
return ntsNowPlaying(ctx, channel)
}, 30 * time.Second, true
case matchFIP(u):
station := fipStationID(u)
return func(ctx context.Context) (string, error) {
return fipNowPlaying(ctx, station, time.Now().Unix())
}, 15 * time.Second, true
}
return nil, 0, false
}
func getJSON(ctx context.Context, url string, v any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(v)
}
// --- NTS -------------------------------------------------------------------
// matchNTS reports whether url is an NTS Radio stream. The directory entry
// points at ntslive.net (which 302-redirects to radiomast), so match either.
func matchNTS(url string) bool {
return strings.Contains(url, "ntslive.net") ||
strings.Contains(url, "radiomast.io/nts") ||
strings.Contains(url, "/nts1") || strings.Contains(url, "/nts2")
}
// ntsChannel returns the NTS channel ("1" or "2") encoded in the stream URL.
// The channel-2 stream is "stream2" (ntslive) or "nts2" (radiomast).
func ntsChannel(url string) string {
if strings.Contains(url, "stream2") || strings.Contains(url, "nts2") {
return "2"
}
return "1"
}
type ntsLive struct {
Results []struct {
Channel string `json:"channel_name"`
Now struct {
BroadcastTitle string `json:"broadcast_title"`
Embeds struct {
Details struct {
Name string `json:"name"`
} `json:"details"`
} `json:"embeds"`
} `json:"now"`
} `json:"results"`
}
func ntsNowPlaying(ctx context.Context, channel string) (string, error) {
var live ntsLive
if err := getJSON(ctx, "https://www.nts.live/api/v2/live", &live); err != nil {
return "", err
}
return parseNTS(&live, channel), nil
}
// parseNTS returns the current show title for the given channel. NTS is live DJ
// radio with no per-track tagging, so the show/broadcast name is the best
// available now-playing string. Prefers the nicely-cased details name.
func parseNTS(live *ntsLive, channel string) string {
for _, r := range live.Results {
if r.Channel != channel {
continue
}
if name := strings.TrimSpace(r.Now.Embeds.Details.Name); name != "" {
return name
}
return strings.TrimSpace(r.Now.BroadcastTitle)
}
return ""
}
// --- FIP (Radio France) ----------------------------------------------------
// fipStations maps a URL slug fragment to the Radio France livemeta station ID.
// Order matters: specific genres are checked before the plain FIP default.
var fipStations = []struct {
slug string
id int
}{
{"fipjazz", 65},
{"fipgroove", 66},
{"fiprock", 64},
{"fipreggae", 71},
{"fipelectro", 74},
{"fipmetal", 77},
{"fipnouveau", 70}, // fipnouveautes
{"fipworld", 69},
{"fipmonde", 69},
}
// matchFIP reports whether url is a Radio France FIP stream.
func matchFIP(url string) bool {
return strings.Contains(url, "radiofrance.fr") && strings.Contains(url, "fip")
}
// fipStationID returns the livemeta station ID for a FIP stream URL, defaulting
// to 7 (the main FIP channel) when no sub-channel slug matches.
func fipStationID(url string) int {
for _, s := range fipStations {
if strings.Contains(url, s.slug) {
return s.id
}
}
return 7
}
type fipLive struct {
Steps map[string]struct {
Title string `json:"title"`
Authors string `json:"authors"`
Start int64 `json:"start"`
End int64 `json:"end"`
} `json:"steps"`
}
func fipNowPlaying(ctx context.Context, station int, now int64) (string, error) {
var live fipLive
url := fmt.Sprintf("https://api.radiofrance.fr/livemeta/pull/%d", station)
if err := getJSON(ctx, url, &live); err != nil {
return "", err
}
return parseFIP(&live, now), nil
}
// parseFIP returns "Artist - Title" for the step playing at now. It picks the
// step whose [start,end) window contains now; if none does (clock skew, gaps),
// it falls back to the most recent step that has already started.
func parseFIP(live *fipLive, now int64) string {
var bestStart int64 = -1
var artist, title string
for _, s := range live.Steps {
if s.Start <= now && now < s.End {
return formatTrack(s.Authors, s.Title)
}
if s.Start <= now && s.Start > bestStart {
bestStart = s.Start
artist, title = s.Authors, s.Title
}
}
return formatTrack(artist, title)
}
// formatTrack joins artist and title as "Artist - Title", matching the ICY
// StreamTitle convention the UI splits on. Falls back gracefully when either
// field is empty.
func formatTrack(artist, title string) string {
artist = strings.TrimSpace(artist)
title = strings.TrimSpace(title)
switch {
case artist != "" && title != "":
return artist + " - " + title
case title != "":
return title
default:
return artist
}
}
+143
View File
@@ -0,0 +1,143 @@
package radiometa
import (
"encoding/json"
"testing"
"time"
)
func TestResolverMatching(t *testing.T) {
tests := []struct {
name string
url string
wantOK bool
wantEvery time.Duration
}{
{"nts relay", "http://stream-relay-geo.ntslive.net/stream", true, 30 * time.Second},
{"nts radiomast", "https://streams.radiomast.io/nts1", true, 30 * time.Second},
{"fip aac", "http://icecast.radiofrance.fr/fip-hifi.aac", true, 15 * time.Second},
{"fip jazz", "http://icecast.radiofrance.fr/fipjazz-hifi.aac", true, 15 * time.Second},
{"kexp aac", "https://kexp.streamguys1.com/kexp160.aac", false, 0},
{"local file", "/home/user/song.mp3", false, 0},
{"random http", "https://example.com/stream.mp3", false, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fetch, every, ok := Resolver(tt.url)
if ok != tt.wantOK {
t.Fatalf("Resolver(%q) ok = %v, want %v", tt.url, ok, tt.wantOK)
}
if ok && fetch == nil {
t.Errorf("Resolver(%q) returned ok but nil fetch", tt.url)
}
if every != tt.wantEvery {
t.Errorf("Resolver(%q) interval = %v, want %v", tt.url, every, tt.wantEvery)
}
})
}
}
func TestNTSChannel(t *testing.T) {
tests := []struct {
url string
want string
}{
{"http://stream-relay-geo.ntslive.net/stream", "1"},
{"http://stream-relay-geo.ntslive.net/stream2", "2"},
{"https://streams.radiomast.io/nts1", "1"},
{"https://streams.radiomast.io/nts2", "2"},
}
for _, tt := range tests {
if got := ntsChannel(tt.url); got != tt.want {
t.Errorf("ntsChannel(%q) = %q, want %q", tt.url, got, tt.want)
}
}
}
func TestFIPStationID(t *testing.T) {
tests := []struct {
url string
want int
}{
{"http://icecast.radiofrance.fr/fip-hifi.aac", 7},
{"http://icecast.radiofrance.fr/fip-midfi.mp3", 7},
{"http://icecast.radiofrance.fr/fipjazz-hifi.aac", 65},
{"http://icecast.radiofrance.fr/fiprock-hifi.aac", 64},
{"http://icecast.radiofrance.fr/fipgroove-hifi.aac", 66},
{"http://icecast.radiofrance.fr/fipreggae-hifi.aac", 71},
{"http://icecast.radiofrance.fr/fipelectro-hifi.aac", 74},
{"http://icecast.radiofrance.fr/fipmetal-hifi.aac", 77},
{"http://icecast.radiofrance.fr/fipnouveautes-hifi.aac", 70},
{"http://icecast.radiofrance.fr/fipworld-hifi.aac", 69},
}
for _, tt := range tests {
if got := fipStationID(tt.url); got != tt.want {
t.Errorf("fipStationID(%q) = %d, want %d", tt.url, got, tt.want)
}
}
}
func TestParseNTS(t *testing.T) {
const payload = `{"results":[
{"channel_name":"1","now":{"broadcast_title":"CHARISSE C","embeds":{"details":{"name":"Charisse C"}}}},
{"channel_name":"2","now":{"broadcast_title":"OLIVIA O.","embeds":{"details":{"name":""}}}}
]}`
var live ntsLive
if err := json.Unmarshal([]byte(payload), &live); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := parseNTS(&live, "1"); got != "Charisse C" {
t.Errorf("channel 1 = %q, want %q (prefer details name)", got, "Charisse C")
}
if got := parseNTS(&live, "2"); got != "OLIVIA O." {
t.Errorf("channel 2 = %q, want %q (fall back to broadcast_title)", got, "OLIVIA O.")
}
if got := parseNTS(&live, "3"); got != "" {
t.Errorf("unknown channel = %q, want empty", got)
}
}
func TestParseFIP(t *testing.T) {
const payload = `{"steps":{
"a":{"title":"Past Song","authors":"Old Artist","start":100,"end":200},
"b":{"title":"Obecanje","authors":"Boban Markovic Orkestar","start":200,"end":300},
"c":{"title":"Future Song","authors":"Next Artist","start":300,"end":400}
}}`
var live fipLive
if err := json.Unmarshal([]byte(payload), &live); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := parseFIP(&live, 250); got != "Boban Markovic Orkestar - Obecanje" {
t.Errorf("now=250 = %q, want in-window step b", got)
}
if got := parseFIP(&live, 350); got != "Next Artist - Future Song" {
t.Errorf("now=350 = %q, want in-window step c", got)
}
// now=500 is past every window: fall back to the most recent started step (c).
if got := parseFIP(&live, 500); got != "Next Artist - Future Song" {
t.Errorf("now=500 = %q, want most-recent-started fallback", got)
}
// now=50 is before any step: nothing has started, so empty.
if got := parseFIP(&live, 50); got != "" {
t.Errorf("now=50 = %q, want empty (nothing started)", got)
}
}
func TestFormatTrack(t *testing.T) {
tests := []struct {
artist, title, want string
}{
{"Daft Punk", "Aerodynamic", "Daft Punk - Aerodynamic"},
{"", "Just A Title", "Just A Title"},
{"Just An Artist", "", "Just An Artist"},
{" Spaced ", " Out ", "Spaced - Out"},
{"", "", ""},
}
for _, tt := range tests {
if got := formatTrack(tt.artist, tt.title); got != tt.want {
t.Errorf("formatTrack(%q, %q) = %q, want %q", tt.artist, tt.title, got, tt.want)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
// Package soundcloud implements a playlist.Provider backed by yt-dlp.
//
// SoundCloud is opt-in: it only registers when [soundcloud] enabled = true
// is set in config. Once enabled, search uses yt-dlp's "scsearch:" protocol
// and works without further configuration. When [soundcloud] user is set,
// browse exposes that profile's Tracks, Likes, and Reposts — public for most
// accounts. With cookies_from set, yt-dlp picks up the user's browser session
// for subscriber-gated content.
package soundcloud
import (
"context"
"fmt"
"net/url"
"slices"
"strings"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/resolve"
)
// Compile-time interface checks.
var (
_ playlist.Provider = (*Provider)(nil)
_ provider.Searcher = (*Provider)(nil)
)
// Config holds settings for the SoundCloud provider.
type Config struct {
Enabled bool // true only when user explicitly sets enabled = true
User string // SoundCloud username (the path segment, e.g. "yourname"). Optional.
CookiesFrom string // browser name for yt-dlp --cookies-from-browser (e.g. "firefox"). Optional.
}
// IsSet reports whether the SoundCloud provider should be exposed.
// SoundCloud is opt-in: requires enabled = true in [soundcloud].
func (c Config) IsSet() bool { return c.Enabled }
// Provider implements playlist.Provider and provider.Searcher for SoundCloud
// via yt-dlp.
type Provider struct {
user string // optional configured username for browse
}
// defaultBrowse seeds the empty-user discovery view. SoundCloud's official
// charts/discover endpoints all 404 through yt-dlp, so these are search-backed
// virtual playlists — the ID is the yt-dlp page URL passed straight to
// ResolveYTDLBatch.
var defaultBrowse = []playlist.PlaylistInfo{
{ID: "scsearch50:trending", Name: "Trending", Section: "Browse"},
{ID: "scsearch50:hip hop", Name: "Hip-Hop", Section: "Browse"},
{ID: "scsearch50:electronic", Name: "Electronic", Section: "Browse"},
{ID: "scsearch50:house", Name: "House", Section: "Browse"},
{ID: "scsearch50:lo-fi", Name: "Lo-Fi", Section: "Browse"},
{ID: "scsearch50:indie", Name: "Indie", Section: "Browse"},
{ID: "scsearch50:pop", Name: "Pop", Section: "Browse"},
}
// NewFromConfig returns a provider, or nil when SoundCloud is not enabled.
// Sets resolve's yt-dlp cookies as a side effect when CookiesFrom is non-empty
// so any yt-dlp invocation (search, browse, playback) uses the user's
// signed-in session.
func NewFromConfig(cfg Config) *Provider {
if !cfg.Enabled {
return nil
}
if cfg.CookiesFrom != "" {
resolve.SetYTDLCookiesFrom(cfg.CookiesFrom)
}
return &Provider{user: strings.TrimSpace(cfg.User)}
}
func (p *Provider) Name() string { return "SoundCloud" }
// Playlists exposes the configured user's Tracks, Likes, and Reposts when a
// username is set; otherwise a curated set of genre searches so the empty
// state has something playable. Ctrl+F always opens search regardless.
func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
if p.user == "" {
return slices.Clone(defaultBrowse), nil
}
base := "https://soundcloud.com/" + url.PathEscape(p.user)
return []playlist.PlaylistInfo{
{ID: base + "/tracks", Name: "Tracks", Section: p.user},
{ID: base + "/likes", Name: "Likes", Section: p.user},
{ID: base + "/reposts", Name: "Reposts", Section: p.user},
}, nil
}
// Tracks resolves a SoundCloud page URL (or scsearch query) via yt-dlp. The
// playlistID is the value produced by Playlists.
func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) {
if playlistID == "" {
return nil, fmt.Errorf("soundcloud: empty playlist id")
}
return resolve.ResolveYTDLBatch(playlistID, 0, 0)
}
// SearchTracks runs `yt-dlp scsearch{limit}:{query}` and returns matched
// tracks. Implements provider.Searcher.
func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
q := strings.TrimSpace(query)
if q == "" {
return nil, nil
}
if limit <= 0 {
limit = 10
}
return resolve.ResolveYTDLBatch(fmt.Sprintf("scsearch%d:%s", limit, q), 0, 0)
}
+146
View File
@@ -0,0 +1,146 @@
package soundcloud
import (
"context"
"strings"
"testing"
"github.com/bjarneo/cliamp/resolve"
)
func TestNewFromConfig(t *testing.T) {
tests := []struct {
name string
cfg Config
want bool // want non-nil
}{
{"default (not enabled)", Config{}, false},
{"explicitly enabled", Config{Enabled: true}, true},
{"enabled with user", Config{Enabled: true, User: "alice"}, true},
{"enabled trims user", Config{Enabled: true, User: " bob "}, true},
{"user without enabled stays nil", Config{User: "alice"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NewFromConfig(tt.cfg)
if (got != nil) != tt.want {
t.Fatalf("NewFromConfig(%+v) non-nil = %v, want %v", tt.cfg, got != nil, tt.want)
}
if got != nil && tt.cfg.User != "" && got.user != "bob" && got.user != "alice" {
if got.user == "" {
t.Errorf("user not propagated, got empty")
}
}
})
}
}
func TestProviderName(t *testing.T) {
p := NewFromConfig(Config{Enabled: true})
if got := p.Name(); got != "SoundCloud" {
t.Errorf("Name() = %q, want SoundCloud", got)
}
}
func TestPlaylistsWithoutUserShowsBrowse(t *testing.T) {
p := NewFromConfig(Config{Enabled: true})
pls, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists() error = %v", err)
}
if len(pls) == 0 {
t.Fatal("Playlists() returned 0 entries, want curated browse list")
}
for _, pl := range pls {
if pl.Section != "Browse" {
t.Errorf("playlist %q has Section %q, want Browse", pl.Name, pl.Section)
}
if !strings.HasPrefix(pl.ID, "scsearch") {
t.Errorf("browse playlist %q ID = %q, want scsearch prefix", pl.Name, pl.ID)
}
}
}
func TestPlaylistsWithUserShowsOnlyUserEntries(t *testing.T) {
p := NewFromConfig(Config{Enabled: true, User: "alice"})
pls, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists() error = %v", err)
}
if len(pls) != 3 {
t.Fatalf("Playlists() = %d entries, want 3 (user-only when User is set)", len(pls))
}
wantNames := map[string]string{
"https://soundcloud.com/alice/tracks": "Tracks",
"https://soundcloud.com/alice/likes": "Likes",
"https://soundcloud.com/alice/reposts": "Reposts",
}
for _, pl := range pls {
want, ok := wantNames[pl.ID]
if !ok {
t.Errorf("unexpected playlist ID %q", pl.ID)
continue
}
if pl.Name != want {
t.Errorf("Playlist %q Name = %q, want %q", pl.ID, pl.Name, want)
}
if pl.Section != "alice" {
t.Errorf("Playlist %q Section = %q, want alice", pl.ID, pl.Section)
}
}
}
func TestPlaylistsEscapesUsername(t *testing.T) {
p := NewFromConfig(Config{Enabled: true, User: "weird name"})
pls, _ := p.Playlists()
if len(pls) == 0 {
t.Fatal("expected non-empty playlists")
}
for _, pl := range pls {
if got, want := pl.ID, "https://soundcloud.com/weird%20name/"; len(got) < len(want) || got[:len(want)] != want {
t.Errorf("playlist ID %q not URL-escaped", pl.ID)
}
}
}
func TestSearchTracksEmptyQuery(t *testing.T) {
p := NewFromConfig(Config{Enabled: true})
tracks, err := p.SearchTracks(context.Background(), " ", 10)
if err != nil {
t.Fatalf("SearchTracks(empty) error = %v", err)
}
if len(tracks) != 0 {
t.Errorf("SearchTracks(empty) returned %d tracks, want 0", len(tracks))
}
}
func TestTracksRejectsEmptyID(t *testing.T) {
p := NewFromConfig(Config{Enabled: true})
_, err := p.Tracks("")
if err == nil {
t.Fatal("Tracks(\"\") expected error, got nil")
}
}
func TestConfigIsSet(t *testing.T) {
if (Config{}).IsSet() {
t.Error("Config{} should not be set (opt-in)")
}
if !(Config{Enabled: true}).IsSet() {
t.Error("Config{Enabled:true} should report set")
}
}
func TestNewFromConfigPropagatesCookies(t *testing.T) {
t.Cleanup(func() { resolve.SetYTDLCookiesFrom("") })
resolve.SetYTDLCookiesFrom("") // baseline
NewFromConfig(Config{Enabled: true, CookiesFrom: "firefox"})
// The exported getter is internal; we verify by side effect: a subsequent
// resolve call would emit --cookies-from-browser firefox. We can't easily
// invoke yt-dlp from a unit test, so the smoke test is that the setter
// accepts our value without panic and the constructor succeeds.
// (Full plumbing is exercised manually; resolve.ytdlCookiesFrom is private.)
}
+49
View File
@@ -0,0 +1,49 @@
//go:build !windows
package spotify
import (
"context"
"errors"
"fmt"
"testing"
"github.com/devgianlu/go-librespot/audio"
)
// TestIsAuthError pins down which errors trigger Spotify re-authentication.
// Rapid track skipping cancels in-flight stream creation, surfacing
// context.DeadlineExceeded / context.Canceled — these MUST NOT be treated as
// auth errors, otherwise the streamer escalates to a browser re-auth flow
// even though the session is healthy. See the regression discussion in
// fix/spotify-rapid-skip-reauth.
func TestIsAuthError(t *testing.T) {
wrappedDeadline := fmt.Errorf("librespot: fetch chunk: %w", context.DeadlineExceeded)
wrappedCanceled := fmt.Errorf("librespot: fetch chunk: %w", context.Canceled)
keyErr := &audio.KeyProviderError{Code: 1}
wrappedKeyErr := fmt.Errorf("spotify: %w", keyErr)
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"context.DeadlineExceeded (skip cancellation)", context.DeadlineExceeded, false},
{"wrapped DeadlineExceeded", wrappedDeadline, false},
{"context.Canceled (skip cancellation)", context.Canceled, false},
{"wrapped Canceled", wrappedCanceled, false},
{"plain network error", errors.New("connection reset by peer"), false},
{"KeyProviderError (real auth signal)", keyErr, true},
{"wrapped KeyProviderError", wrappedKeyErr, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isAuthError(tt.err)
if got != tt.want {
t.Fatalf("isAuthError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+41
View File
@@ -0,0 +1,41 @@
package spotify
import (
"errors"
"os"
"path/filepath"
"github.com/bjarneo/cliamp/internal/appdir"
)
// DefaultClientID is the librespot keymaster client_id, shared by spotify-player
// and other librespot-based players. Used when the user hasn't configured their
// own client_id — Spotify's loopback exception lets it work with any 127.0.0.1
// port, and it predates the Nov 27, 2024 dev-mode quota restriction so /v1/search
// and other catalog endpoints stay accessible.
const DefaultClientID = "65b708073fc0480ea92a077233ca87bd"
// CredsPath returns the absolute path to the stored Spotify credentials file.
func CredsPath() (string, error) {
dir, err := appdir.Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "spotify_credentials.json"), nil
}
// DeleteCreds removes the stored Spotify credentials file.
// Returns true if a file was removed, false if it did not exist.
func DeleteCreds() (bool, error) {
path, err := CredsPath()
if err != nil {
return false, err
}
if err := os.Remove(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return true, nil
}
+704
View File
@@ -0,0 +1,704 @@
//go:build !windows
package spotify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
librespot "github.com/devgianlu/go-librespot"
"github.com/devgianlu/go-librespot/audio"
"github.com/gopxl/beep/v2"
"github.com/bjarneo/cliamp/applog"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/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.
// SpotifyProvider implements playlist.Provider using the Spotify Web API
// for playlist/track metadata and go-librespot for audio streaming.
// playlistCache holds a snapshot_id and the fetched tracks for a playlist,
// allowing us to skip re-fetching playlists that haven't changed.
type playlistCache struct {
snapshotID string
tracks []playlist.Track
}
type SpotifyProvider struct {
session *Session
clientID string
bitrate int
userID string // Spotify user ID, fetched lazily on first Playlists() call
meFetched bool // /v1/me has been attempted this session; suppresses retry on failure
mu sync.Mutex
trackCache map[string]*playlistCache // playlist ID → cache entry
authCancel context.CancelFunc // cancels any in-progress OAuth flow
// Playlist list cache to avoid redundant API calls on provider switch.
listCache []playlist.PlaylistInfo
listCacheAt time.Time
}
const playlistListCacheTTL = 5 * time.Minute
// New creates a SpotifyProvider. If session is nil, authentication is
// deferred until the user first selects the Spotify provider.
// bitrate sets the preferred Spotify stream quality in kbps (96, 160, or 320).
func New(session *Session, clientID string, bitrate int) *SpotifyProvider {
return &SpotifyProvider{
session: session,
clientID: clientID,
bitrate: bitrate,
trackCache: make(map[string]*playlistCache),
}
}
// ensureSession tries to create a session using stored credentials only
// (no browser). Returns playlist.ErrNeedsAuth if interactive sign-in is needed.
func (p *SpotifyProvider) ensureSession() error {
p.mu.Lock()
if p.session != nil {
p.mu.Unlock()
return nil
}
clientID := p.clientID
p.mu.Unlock()
if clientID == "" {
return fmt.Errorf("spotify: no client ID available")
}
sess, err := NewSessionSilent(context.Background(), clientID)
if err != nil {
return playlist.ErrNeedsAuth
}
p.mu.Lock()
p.session = sess
p.resetSessionScopedStateLocked()
p.mu.Unlock()
return nil
}
// Authenticate runs the interactive sign-in flow (opens browser, waits for callback).
// Any previous in-progress OAuth flow is cancelled first to free the callback port.
func (p *SpotifyProvider) Authenticate() error {
p.mu.Lock()
if p.session != nil {
p.mu.Unlock()
return nil
}
if p.authCancel != nil {
p.authCancel()
p.authCancel = nil
}
clientID := p.clientID
p.mu.Unlock()
if clientID == "" {
return fmt.Errorf("spotify: no client ID available")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
p.mu.Lock()
p.authCancel = cancel
p.mu.Unlock()
sess, err := NewSession(ctx, clientID)
p.mu.Lock()
p.authCancel = nil
p.mu.Unlock()
cancel()
if err != nil {
return err
}
p.mu.Lock()
p.session = sess
p.resetSessionScopedStateLocked()
p.mu.Unlock()
return nil
}
// Close releases the session if one was created.
func (p *SpotifyProvider) Close() {
p.mu.Lock()
defer p.mu.Unlock()
if p.authCancel != nil {
p.authCancel()
p.authCancel = nil
}
if p.session != nil {
p.session.Close()
p.session = nil
p.resetSessionScopedStateLocked()
}
}
// resetSessionScopedStateLocked clears /v1/me-derived caches when the session
// changes. p.mu must be held.
func (p *SpotifyProvider) resetSessionScopedStateLocked() {
p.userID = ""
p.meFetched = false
}
func (p *SpotifyProvider) Name() string { return "Spotify" }
// currentUserID returns the authenticated user's Spotify ID, fetched from
// /v1/me at most once per session. Failures are remembered so a network blip
// during the first call doesn't trigger a request on every later use.
// userID is used by playlistAccessible to filter playlists the user doesn't
// own (which 403 on Tracks() for dev-mode apps).
func (p *SpotifyProvider) currentUserID(ctx context.Context) string {
p.mu.Lock()
if p.meFetched {
id := p.userID
p.mu.Unlock()
return id
}
p.mu.Unlock()
var me struct {
ID string `json:"id"`
}
if resp, err := p.webAPI(ctx, "GET", "/v1/me", nil); err == nil {
_ = decodeBody(resp, &me)
}
p.mu.Lock()
defer p.mu.Unlock()
p.userID = me.ID
p.meFetched = true
return p.userID
}
// Playlists returns the authenticated user's Spotify playlists.
// Only playlists owned by the user or marked as collaborative are returned;
// playlists saved from other users are excluded because the Spotify API
// returns 403 when trying to list their tracks.
func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) {
if err := p.ensureSession(); err != nil {
return nil, err
}
p.mu.Lock()
if p.listCache != nil && time.Since(p.listCacheAt) < playlistListCacheTTL {
cached := slices.Clone(p.listCache)
p.mu.Unlock()
return cached, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
userID := p.currentUserID(ctx) // empty string if fetch fails → no filtering
var all []playlist.PlaylistInfo
offset := 0
limit := spotifyPlaylistPageSize
// List of Playlists only includes created playlists by the User.
// This doesn't include the 'Liked Songs' playlist.
resp, err := p.webAPI(ctx, "GET", "/v1/me/tracks", nil)
if err != nil {
return nil, fmt.Errorf("spotify: your music: %w", err)
}
var result struct {
Total int `json:"total"`
}
if err := decodeBody(resp, &result); err != nil {
return nil, fmt.Errorf("spotify: parse playlists: %w", err)
}
// Unfortunately, the Spotify API doesn't expose the localized display name.
// i.e. 'Liked Songs' or 'Lieblingssongs' etc.
// For the moment, "Your Music" must sufficice without adding a localization
// map.
all = append(all, playlist.PlaylistInfo{
ID: "YOUR MUSIC",
Name: "Your Music",
TrackCount: result.Total,
Section: "Library",
})
for {
query := url.Values{
"limit": {fmt.Sprintf("%d", limit)},
"offset": {fmt.Sprintf("%d", offset)},
// Include owner.id and collaborative to filter inaccessible playlists.
"fields": {"items(id,name,snapshot_id,collaborative,owner(id),items.total),total"},
}
resp, err := p.webAPI(ctx, "GET", "/v1/me/playlists", query)
if err != nil {
return nil, fmt.Errorf("spotify: list playlists: %w", err)
}
var result struct {
Items []spotifyPlaylistItem `json:"items"`
Total int `json:"total"`
}
if err := decodeBody(resp, &result); err != nil {
return nil, fmt.Errorf("spotify: parse playlists: %w", err)
}
p.mu.Lock()
for _, item := range result.Items {
if !playlistAccessible(item, userID) {
continue
}
count := 0
if item.Items != nil {
count = item.Items.Total
}
section := "Followed playlists"
if userID != "" && item.Owner.ID == userID {
section = "Your playlists"
}
all = append(all, playlist.PlaylistInfo{
ID: item.ID,
Name: item.Name,
TrackCount: count,
Section: section,
})
// Update snapshot_id in cache; if it changed, invalidate cached tracks.
if cached, ok := p.trackCache[item.ID]; ok {
if cached.snapshotID != item.SnapshotID {
delete(p.trackCache, item.ID)
}
}
// Store snapshot_id for later cache checks in Tracks().
if _, ok := p.trackCache[item.ID]; !ok && item.SnapshotID != "" {
p.trackCache[item.ID] = &playlistCache{snapshotID: item.SnapshotID}
}
}
p.mu.Unlock()
if offset+limit >= result.Total {
break
}
offset += limit
}
// Group playlists by section so the UI can emit one header per group.
// Library first, then owned, then followed; preserve API order within.
sectionOrder := map[string]int{
"Library": 0,
"Your playlists": 1,
"Followed playlists": 2,
}
sort.SliceStable(all, func(i, j int) bool {
return sectionOrder[all[i].Section] < sectionOrder[all[j].Section]
})
p.mu.Lock()
p.listCache = all
p.listCacheAt = time.Now()
p.mu.Unlock()
return slices.Clone(all), nil
}
// Tracks returns all tracks for the given Spotify playlist ID.
// Track.Path is set to the canonical spotify: URI for the player to resolve.
// Results are cached by snapshot_id; unchanged playlists skip the API call.
func (p *SpotifyProvider) Tracks(playlistID string) ([]playlist.Track, error) {
if err := p.ensureSession(); err != nil {
return nil, err
}
// Check cache — if we have tracks and the snapshot_id hasn't changed, return cached.
p.mu.Lock()
if cached, ok := p.trackCache[playlistID]; ok && cached.tracks != nil {
tracks := slices.Clone(cached.tracks)
p.mu.Unlock()
return tracks, nil
}
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
var all []playlist.Track
offset := 0
limit := spotifyTrackPageSize
for {
var (
resp *http.Response
err error
)
if playlistID == "YOUR MUSIC" {
query := url.Values{
"limit": {fmt.Sprintf("%d", limit)},
"offset": {fmt.Sprintf("%d", offset)},
}
resp, err = p.webAPI(ctx, "GET", "/v1/me/tracks", query)
} else {
query := url.Values{
"limit": {fmt.Sprintf("%d", limit)},
"offset": {fmt.Sprintf("%d", offset)},
"fields": {"items(item(id,name,type,uri,artists(name),album(name,release_date),show(name),release_date,duration_ms,track_number,is_playable,restrictions(reason))),total"},
}
path := fmt.Sprintf("/v1/playlists/%s/items", playlistID)
resp, err = p.webAPI(ctx, "GET", path, query)
}
if err != nil {
if strings.Contains(err.Error(), "403") {
return nil, fmt.Errorf("spotify: playlist not accessible: only playlists you own or collaborate on can be loaded")
}
return nil, fmt.Errorf("spotify: list tracks: %w", err)
}
var result struct {
Items []struct {
Item *spotifyItem `json:"item"`
Track *spotifyItem `json:"track"`
} `json:"items"`
Total int `json:"total"`
}
if err := decodeBody(resp, &result); err != nil {
return nil, fmt.Errorf("spotify: parse tracks: %w", err)
}
for _, item := range result.Items {
t := item.Item
if t == nil {
t = item.Track
}
if t == nil || t.ID == "" {
continue // skip local/unavailable tracks
}
all = append(all, trackFromItem(t))
}
if offset+limit >= result.Total {
break
}
offset += limit
}
// Cache the fetched tracks.
p.mu.Lock()
if cached, ok := p.trackCache[playlistID]; ok {
cached.tracks = all
} else {
p.trackCache[playlistID] = &playlistCache{tracks: all}
}
p.mu.Unlock()
return slices.Clone(all), nil
}
// isAuthError returns true if the error is an authentication/session-related
// failure that can be resolved by re-authenticating.
func isAuthError(err error) bool {
if err == nil {
return false
}
// context.DeadlineExceeded and context.Canceled are NOT auth errors.
// They commonly fire during rapid track skipping when a previous NewStream's
// network fetch is interrupted, and previously caused spurious re-auth
// attempts (which then escalated to opening a browser tab mid-skip).
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return false
}
var keyErr *audio.KeyProviderError
return errors.As(err, &keyErr)
}
// 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: URI (track or
// episode).
// If the stream fails due to an auth error (e.g. expired session, AES key
// rejection), the player tries a silent reconnect from cached credentials.
// If that fails — or the retry still hits an auth error — the streamer
// surfaces playlist.ErrNeedsAuth so the UI can prompt the user to sign in.
// We deliberately do NOT auto-launch a browser-based OAuth flow from this
// path: rapid track skipping can produce transient stream errors and a
// browser tab popping up mid-skip.
//
// 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
}
spotID, err := librespot.SpotifyIdFromUri(uri)
if err != nil {
return nil, beep.Format{}, 0, fmt.Errorf("spotify: invalid URI %q: %w", uri, err)
}
tryStream := func() (*spotifyStreamer, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := p.session.NewStream(ctx, *spotID, p.bitrate)
if err != nil {
return nil, err
}
return newSpotifyStreamer(stream), nil
}
s, err := tryStream()
if err == nil {
return s, s.Format(), s.Duration(), nil
}
if !isAuthError(err) {
return nil, beep.Format{}, 0, fmt.Errorf("spotify: new stream: %w", err)
}
// Auth error — try a silent reconnect from cached credentials.
applog.UserWarn("spotify: stream auth error (%v), attempting silent reconnect...", err)
reconnCtx, reconnCancel := context.WithTimeout(context.Background(), 30*time.Second)
reconnErr := p.session.Reconnect(reconnCtx)
reconnCancel()
if reconnErr != nil {
applog.UserWarn("spotify: silent reconnect failed (%v); sign-in required", reconnErr)
return nil, beep.Format{}, 0, fmt.Errorf("spotify: stream auth error, silent reconnect failed: %w", playlist.ErrNeedsAuth)
}
s, err = tryStream()
if err == nil {
return s, s.Format(), s.Duration(), nil
}
if !isAuthError(err) {
return nil, beep.Format{}, 0, fmt.Errorf("spotify: new stream after silent reconnect: %w", err)
}
// Still failing after a silent reconnect — surface ErrNeedsAuth so the
// UI can prompt the user to sign in. Do NOT open a browser from here.
applog.UserWarn("spotify: stream still failing after silent reconnect (%v); sign-in required", err)
return nil, beep.Format{}, 0, fmt.Errorf("spotify: stream auth error after silent reconnect: %w", playlist.ErrNeedsAuth)
}
// webAPI calls the Spotify Web API via the session with retry on 429.
func (p *SpotifyProvider) webAPI(ctx context.Context, method, path string, query url.Values) (*http.Response, error) {
return p.webAPIWithBody(ctx, method, path, query, nil, "", http.StatusOK)
}
// webAPIWithBody is like webAPI but accepts an optional request body, content type,
// and a set of acceptable HTTP status codes (e.g. 200, 201). Retries 429 with
// exponential backoff (honoring Retry-After when present).
func (p *SpotifyProvider) webAPIWithBody(ctx context.Context, method, path string, query url.Values, body io.Reader, contentType string, acceptStatus ...int) (*http.Response, error) {
const maxRetries = 8
// Buffer the body so it can be replayed on retry.
var bodyBytes []byte
if body != nil {
var err error
bodyBytes, err = io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("read request body: %w", err)
}
}
for attempt := range maxRetries {
var reqBody io.Reader
if bodyBytes != nil {
reqBody = bytes.NewReader(bodyBytes)
}
resp, err := p.session.webApiWithBody(ctx, method, path, query, reqBody, contentType)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close()
// On the last attempt there's no retry after the wait, so don't
// sleep (up to 128s) just to give up; fail now.
if attempt == maxRetries-1 {
break
}
wait := time.Duration(1<<uint(attempt)) * time.Second
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
wait = time.Duration(secs) * time.Second
}
}
applog.UserWarn("spotify: web api rate-limited on %s, retrying in %v (attempt %d/%d)", path, wait, attempt+1, maxRetries)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
continue
}
}
ok := slices.Contains(acceptStatus, resp.StatusCode)
if !ok {
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 512))
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("http status %s (failed to read body: %v)", resp.Status, readErr)
}
return nil, fmt.Errorf("http status %s: %s", resp.Status, string(respBody))
}
return resp, nil
}
return nil, fmt.Errorf("spotify: web api rate-limited on %s after %d retries (try re-authenticating)", path, maxRetries)
}
// friendlySearchError rewrites Spotify's misleading 400 "Invalid limit" reply
// from /v1/search into something a user can act on. Since Nov 27, 2024 Spotify
// returns this error for developer apps registered in Development Mode — the
// rest of the API (playback, playlists, library) keeps working, but the
// catalog endpoints (/v1/search etc.) are blocked. The limit value is fine.
func friendlySearchError(err error) error {
if err == nil {
return nil
}
msg := err.Error()
if strings.Contains(msg, "400") && strings.Contains(msg, "Invalid limit") {
return fmt.Errorf("spotify: search blocked — your client_id is too new. Spotify's Nov 27 2024 change blocks /v1/search for apps in Development Mode (the rest of cliamp still works on your app). Remove client_id from [spotify] in config.toml to use the built-in fallback for search, or apply for Extended Quota Mode")
}
return fmt.Errorf("spotify: search: %w", err)
}
// SearchTracks searches Spotify for tracks and podcast episodes, returning up
// to limit results of each. Episodes (e.g. podcasts) are routed through their
// spotify:episode: URI so they play correctly.
// limit is clamped to Spotify's accepted range of 1..50.
func (p *SpotifyProvider) SearchTracks(ctx context.Context, query string, limit int) ([]playlist.Track, error) {
if err := p.ensureSession(); err != nil {
return nil, err
}
if limit < 1 {
limit = 1
} else if limit > 50 {
limit = 50
}
// No market parameter: when the request carries a user OAuth token, Spotify
// implicitly scopes results to the account's country.
q := url.Values{
"q": {query},
"type": {"track,episode"},
"limit": {fmt.Sprintf("%d", limit)},
}
resp, err := p.webAPI(ctx, "GET", "/v1/search", q)
if err != nil {
return nil, friendlySearchError(err)
}
var result struct {
Tracks struct {
Items []*spotifyItem `json:"items"`
} `json:"tracks"`
Episodes struct {
Items []*spotifyItem `json:"items"`
} `json:"episodes"`
}
if err := decodeBody(resp, &result); err != nil {
return nil, fmt.Errorf("spotify: parse search: %w", err)
}
var tracks []playlist.Track
for _, items := range [][]*spotifyItem{result.Tracks.Items, result.Episodes.Items} {
for _, t := range items {
if t == nil || t.ID == "" {
continue // skip null/unavailable results
}
tracks = append(tracks, trackFromItem(t))
}
}
return tracks, nil
}
// AddTrackToPlaylist adds a track to an existing Spotify playlist.
// The track's Path is used as the Spotify URI (e.g. "spotify:track:..." or
// "spotify:episode:..."); the Spotify API accepts either.
// 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
}
body, _ := json.Marshal(map[string]any{"uris": []string{trackURI}})
path := fmt.Sprintf("/v1/playlists/%s/tracks", playlistID)
resp, err := p.webAPIWithBody(ctx, "POST", path, nil, bytes.NewReader(body), "application/json", http.StatusOK, http.StatusCreated)
if err != nil {
return fmt.Errorf("spotify: add track: %w", err)
}
resp.Body.Close()
// Invalidate caches for this playlist.
p.mu.Lock()
delete(p.trackCache, playlistID)
p.listCache = nil
p.mu.Unlock()
return nil
}
// CreatePlaylist creates a new private Spotify playlist and returns its ID.
func (p *SpotifyProvider) CreatePlaylist(ctx context.Context, name string) (string, error) {
if err := p.ensureSession(); err != nil {
return "", err
}
userID := p.currentUserID(ctx)
if userID == "" {
return "", fmt.Errorf("spotify: could not determine user ID")
}
body, _ := json.Marshal(map[string]any{"name": name, "public": false})
path := fmt.Sprintf("/v1/users/%s/playlists", userID)
resp, err := p.webAPIWithBody(ctx, "POST", path, nil, bytes.NewReader(body), "application/json", http.StatusOK, http.StatusCreated)
if err != nil {
return "", fmt.Errorf("spotify: create playlist: %w", err)
}
var result struct {
ID string `json:"id"`
}
if err := decodeBody(resp, &result); err != nil {
return "", fmt.Errorf("spotify: parse created playlist: %w", err)
}
// Invalidate playlist list cache.
p.mu.Lock()
p.listCache = nil
p.mu.Unlock()
return result.ID, nil
}
// decodeBody reads and decodes a JSON response body, then closes it.
func decodeBody(resp *http.Response, v any) error {
defer resp.Body.Close()
return json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(v)
}
+122
View File
@@ -0,0 +1,122 @@
package spotify
import (
"fmt"
"strconv"
"strings"
"github.com/bjarneo/cliamp/playlist"
)
// maxResponseBody limits JSON API responses to 10 MB.
const maxResponseBody = 10 << 20
// Pagination limits for the Spotify Web API.
const (
spotifyPlaylistPageSize = 50
// spotifyTrackPageSize is capped at 50 because /v1/playlists/{id}/items
// silently truncates larger limits; requesting more would cause the loop
// to skip items when offset advances by the requested limit.
spotifyTrackPageSize = 50
)
// spotifyPlaylistItem is the raw playlist object returned by /v1/me/playlists.
type spotifyPlaylistItem struct {
ID string `json:"id"`
Name string `json:"name"`
SnapshotID string `json:"snapshot_id"`
Collaborative bool `json:"collaborative"`
Owner struct {
ID string `json:"id"`
} `json:"owner"`
Items *struct {
Total int `json:"total"`
} `json:"items"`
}
// playlistAccessible reports whether the playlist should be shown to the user.
// Playlists saved from other users (not owned, not collaborative) are excluded
// because the Spotify API returns 403 when listing their tracks.
// When userID is empty (fetch failed), all playlists are included as a fallback.
func playlistAccessible(item spotifyPlaylistItem, userID string) bool {
if userID == "" {
return true
}
return item.Owner.ID == userID || item.Collaborative
}
type spotifyArtist struct {
Name string `json:"name"`
}
// spotifyItem is a track or podcast episode object from the Spotify Web API.
// Playlists can hold both; episodes carry a show instead of artists/album.
type spotifyItem struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "track" or "episode"
URI string `json:"uri"` // canonical spotify:track:... / spotify:episode:...
Artists []spotifyArtist `json:"artists"`
Album struct {
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
} `json:"album"`
Show struct {
Name string `json:"name"`
} `json:"show"`
ReleaseDate string `json:"release_date"` // episodes carry this at top level
DurationMs int `json:"duration_ms"`
TrackNumber int `json:"track_number"`
IsPlayable *bool `json:"is_playable"`
Restrictions struct {
Reason string `json:"reason"`
} `json:"restrictions"`
}
// trackFromItem converts a Spotify playlist/library item into a playlist.Track,
// handling both music tracks and podcast episodes. It uses the canonical uri
// the API returns (spotify:track:... or spotify:episode:...) as the path, so
// the player routes episodes to go-librespot's episode metadata path; building
// "spotify:track:<id>" for an episode makes go-librespot request track metadata
// for an episode id, which 404s. Episodes carry no artists/album, so the show
// name fills those slots for display.
func trackFromItem(t *spotifyItem) playlist.Track {
artists := make([]string, len(t.Artists))
for i, a := range t.Artists {
artists[i] = a.Name
}
artist := strings.Join(artists, ", ")
album := t.Album.Name
if t.Type == "episode" {
artist = t.Show.Name
album = t.Show.Name
}
releaseDate := t.Album.ReleaseDate
if releaseDate == "" {
releaseDate = t.ReleaseDate
}
var year int
if len(releaseDate) >= 4 {
if y, err := strconv.Atoi(releaseDate[:4]); err == nil {
year = y
}
}
path := t.URI
if path == "" {
path = fmt.Sprintf("spotify:track:%s", t.ID) // fallback if uri is absent
}
return playlist.Track{
Path: path,
Title: t.Name,
Artist: artist,
Album: album,
Year: year,
Stream: false, // must be false: true causes togglePlayPause to stop+restart instead of pause/resume
DurationSecs: t.DurationMs / 1000,
TrackNumber: t.TrackNumber,
Unplayable: (t.IsPlayable != nil && !*t.IsPlayable) || t.Restrictions.Reason != "",
}
}
+138
View File
@@ -0,0 +1,138 @@
package spotify
import "testing"
// TestSpotifyTrackPageSizeRespectsAPILimit asserts spotifyTrackPageSize stays
// within the Spotify Web API's silent 50-item cap; see the constant's comment
// in provider.go for why exceeding it silently drops tracks.
func TestSpotifyTrackPageSizeRespectsAPILimit(t *testing.T) {
tests := []struct {
name string
got int
max int
}{
{"spotifyTrackPageSize within /v1/playlists/{id}/items cap", spotifyTrackPageSize, 50},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got > tt.max {
t.Fatalf("page size = %d, want <= %d (Spotify Web API cap)", tt.got, tt.max)
}
})
}
}
// TestTrackFromItem verifies the playlist-item to Track mapping, especially
// that podcast episodes keep their spotify:episode: URI (regression for the
// 404 when episodes were forced to spotify:track:). See issue #228.
func TestTrackFromItem(t *testing.T) {
playable := true
unplayable := false
t.Run("music track", func(t *testing.T) {
item := &spotifyItem{
ID: "abc", Name: "Aerodynamic", Type: "track",
URI: "spotify:track:abc", DurationMs: 212000, TrackNumber: 3,
IsPlayable: &playable,
Artists: []spotifyArtist{{Name: "Daft Punk"}},
}
item.Album.Name = "Discovery"
item.Album.ReleaseDate = "2001-03-12"
got := trackFromItem(item)
if got.Path != "spotify:track:abc" {
t.Errorf("Path = %q, want spotify:track:abc", got.Path)
}
if got.Artist != "Daft Punk" || got.Album != "Discovery" || got.Year != 2001 {
t.Errorf("got %q / %q / %d, want Daft Punk / Discovery / 2001", got.Artist, got.Album, got.Year)
}
if got.DurationSecs != 212 {
t.Errorf("DurationSecs = %d, want 212", got.DurationSecs)
}
})
t.Run("podcast episode keeps episode uri", func(t *testing.T) {
item := &spotifyItem{
ID: "ep1", Name: "Episode 42", Type: "episode",
URI: "spotify:episode:ep1", DurationMs: 3600000, ReleaseDate: "2024-06-01",
}
item.Show.Name = "The Show"
got := trackFromItem(item)
if got.Path != "spotify:episode:ep1" {
t.Errorf("Path = %q, want spotify:episode:ep1 (not spotify:track:)", got.Path)
}
if got.Artist != "The Show" || got.Album != "The Show" {
t.Errorf("episode artist/album = %q / %q, want show name", got.Artist, got.Album)
}
if got.Year != 2024 {
t.Errorf("Year = %d, want 2024 (from top-level release_date)", got.Year)
}
})
t.Run("search episode without show name", func(t *testing.T) {
// /v1/search returns simplified episode objects with no show field.
item := &spotifyItem{
ID: "ep2", Name: "JRE #2000", Type: "episode",
URI: "spotify:episode:ep2", DurationMs: 10800000, ReleaseDate: "2023-08-01",
}
got := trackFromItem(item)
if got.Path != "spotify:episode:ep2" {
t.Errorf("Path = %q, want spotify:episode:ep2", got.Path)
}
if got.Title != "JRE #2000" {
t.Errorf("Title = %q, want JRE #2000", got.Title)
}
})
t.Run("missing uri falls back to track id", func(t *testing.T) {
got := trackFromItem(&spotifyItem{ID: "xyz", Name: "No URI"})
if got.Path != "spotify:track:xyz" {
t.Errorf("Path = %q, want spotify:track:xyz fallback", got.Path)
}
})
t.Run("unplayable track flagged", func(t *testing.T) {
got := trackFromItem(&spotifyItem{ID: "u", URI: "spotify:track:u", IsPlayable: &unplayable})
if !got.Unplayable {
t.Error("Unplayable = false, want true")
}
})
}
// TestPlaylistAccessible verifies the visibility filter that hides playlists
// the current Spotify user can't list tracks for (would otherwise return 403).
func TestPlaylistAccessible(t *testing.T) {
const me = "user123"
tests := []struct {
name string
ownerID string
collaborative bool
userID string
want bool
}{
{"own playlist", me, false, me, true},
{"own collaborative", me, true, me, true},
{"other user's playlist", "otheruser", false, me, false},
{"other user's collaborative", "otheruser", true, me, true},
{"no userID fallback", "otheruser", false, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
item := spotifyPlaylistItem{
ID: "pl1",
Name: "Test",
Collaborative: tt.collaborative,
}
item.Owner.ID = tt.ownerID
got := playlistAccessible(item, tt.userID)
if got != tt.want {
t.Errorf("playlistAccessible(owner=%q, collaborative=%v, userID=%q) = %v, want %v",
tt.ownerID, tt.collaborative, tt.userID, got, tt.want)
}
})
}
}
+559
View File
@@ -0,0 +1,559 @@
//go:build !windows
package spotify
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"sync"
"sync/atomic"
"github.com/bjarneo/cliamp/applog"
"github.com/bjarneo/cliamp/internal/browser"
"github.com/bjarneo/cliamp/playlist"
librespot "github.com/devgianlu/go-librespot"
librespotPlayer "github.com/devgianlu/go-librespot/player"
devicespb "github.com/devgianlu/go-librespot/proto/spotify/connectstate/devices"
"github.com/devgianlu/go-librespot/session"
"golang.org/x/oauth2"
spotifyoauth2 "golang.org/x/oauth2/spotify"
)
// storedCreds holds persisted Spotify credentials for re-authentication.
type storedCreds struct {
Username string `json:"username"`
Data []byte `json:"data"`
DeviceID string `json:"device_id"`
RefreshToken string `json:"refresh_token,omitempty"` // OAuth2 refresh token for silent re-auth
}
// CallbackPort is the fixed port for the OAuth2 callback server.
// Must match the redirect URI registered in the Spotify Developer app.
const CallbackPort = 19872
// authURLObserver is invoked with the OAuth URL when interactive auth begins.
// Set via SetAuthURLObserver. Used by the TUI to show the URL when the
// launched browser doesn't reach the user (containers, headless envs).
var authURLObserver atomic.Pointer[func(string)]
// SetAuthURLObserver registers a callback invoked once with the OAuth URL at
// the start of an interactive sign-in. Pass nil to remove.
func SetAuthURLObserver(fn func(string)) {
if fn == nil {
authURLObserver.Store(nil)
return
}
authURLObserver.Store(&fn)
}
func notifyAuthURL(u string) {
applog.Info("spotify: sign-in URL: %s", u)
if p := authURLObserver.Load(); p != nil {
(*p)(u)
}
}
// Session manages a go-librespot session and player for Spotify integration.
type Session struct {
mu sync.RWMutex
sess *session.Session
player *librespotPlayer.Player
devID string
clientID string // Spotify Developer app client ID
tokenSource oauth2.TokenSource // auto-refreshing OAuth2 token source
}
// NewSession creates a go-librespot session, using stored credentials if
// available, otherwise starting an interactive OAuth2 flow.
// clientID is the Spotify Developer app client ID for Web API access.
func NewSession(ctx context.Context, clientID string) (*Session, error) {
creds, err := loadCreds()
if err == nil && creds.Username != "" && len(creds.Data) > 0 {
s, err := newSessionFromStored(ctx, clientID, creds, false)
if err == nil {
return s, nil
}
// Stored credentials failed (expired/revoked), fall through to interactive.
}
return newInteractiveSession(ctx, clientID)
}
// NewSessionSilent is like NewSession but only uses stored credentials.
// Returns an error if interactive auth is required.
func NewSessionSilent(ctx context.Context, clientID string) (*Session, error) {
creds, err := loadCreds()
if err != nil || creds.Username == "" || len(creds.Data) == 0 {
return nil, fmt.Errorf("no stored credentials")
}
return newSessionFromStored(ctx, clientID, creds, true)
}
// newSessionFromStored creates a session from stored credentials.
// When silentOnly is true, it will not fall back to browser-based auth
// if the silent token refresh fails.
func newSessionFromStored(ctx context.Context, clientID string, creds *storedCreds, silentOnly bool) (*Session, error) {
devID := creds.DeviceID
if devID == "" {
devID = generateDeviceID()
}
sess, err := session.NewSessionFromOptions(ctx, &session.Options{
Log: &librespot.NullLogger{},
DeviceType: devicespb.DeviceType_COMPUTER,
DeviceId: devID,
Credentials: session.StoredCredentials{
Username: creds.Username,
Data: creds.Data,
},
})
if err != nil {
return nil, fmt.Errorf("spotify: stored auth: %w", err)
}
// For stored credentials, we need a fresh Web API token via OAuth2.
// The spclient's login5 token is NOT suitable for Web API calls.
// Try silent refresh first (no browser), fall back to interactive.
var oauthToken *oauth2.Token
var refreshErr error
if creds.RefreshToken != "" {
token, err := silentTokenRefresh(clientID, creds.RefreshToken)
if err == nil {
oauthToken = token
} else {
refreshErr = err
}
}
// Dead refresh tokens (invalid_grant) never recover — clear so we don't
// repeat the same failure on every launch.
if isInvalidGrant(refreshErr) {
applog.UserError("spotify: stored refresh token is invalid; clearing credentials, please sign in again")
if _, err := DeleteCreds(); err != nil {
applog.Warn("spotify: failed to clear stored credentials: %v", err)
}
sess.Close()
return nil, fmt.Errorf("spotify: %w", playlist.ErrNeedsAuth)
}
if oauthToken == nil {
if silentOnly {
// Continue without a token source — already-loaded tracks still stream
// via spclient; new Web API calls will return ErrNeedsAuth.
applog.UserError("spotify: stored auth no longer valid; run 'cliamp spotify reset' or sign in again to fix")
s := &Session{sess: sess, devID: devID, clientID: clientID}
if err := saveCreds(&storedCreds{
Username: sess.Username(),
Data: sess.StoredCredentials(),
DeviceID: devID,
RefreshToken: creds.RefreshToken, // preserve for next attempt
}); err != nil {
applog.UserError("spotify: failed to save credentials: %v", err)
}
if err := s.initPlayer(); err != nil {
sess.Close()
return nil, err
}
return s, nil
}
token, err := doWebAPIAuth(ctx, clientID)
if err != nil {
sess.Close()
return nil, fmt.Errorf("stored session needs fresh Web API token: %w", err)
}
oauthToken = token
}
// Create an auto-refreshing token source — handles expiry transparently.
conf := spotifyOAuthConfig(clientID)
ts := conf.TokenSource(context.Background(), oauthToken)
s := &Session{sess: sess, devID: devID, clientID: clientID, tokenSource: ts}
// Re-save credentials (including refresh token for next launch).
if err := saveCreds(&storedCreds{
Username: sess.Username(),
Data: sess.StoredCredentials(),
DeviceID: devID,
RefreshToken: oauthToken.RefreshToken,
}); err != nil {
applog.UserError("spotify: failed to save credentials: %v", err)
}
if err := s.initPlayer(); err != nil {
sess.Close()
return nil, err
}
return s, nil
}
// oauthScopes are the Spotify Web API scopes needed for cliamp.
// See: https://developer.spotify.com/documentation/web-api/concepts/scopes
var oauthScopes = []string{
// Playlist browsing
"playlist-read-collaborative",
"playlist-read-private",
// Playlist modification (save queue, create playlists)
"playlist-modify-public",
"playlist-modify-private",
// Streaming audio
"streaming",
// Library (liked songs, saved albums)
"user-library-read",
"user-library-modify",
// User profile
"user-read-private",
// Playback state (current track, queue)
"user-read-playback-state",
"user-modify-playback-state",
"user-read-currently-playing",
// Recently played / top tracks
"user-read-recently-played",
"user-top-read",
// Following (artists, users)
"user-follow-read",
"user-follow-modify",
}
// spotifyOAuthConfig returns the OAuth2 config for the given client ID.
func spotifyOAuthConfig(clientID string) *oauth2.Config {
return &oauth2.Config{
ClientID: clientID,
RedirectURL: fmt.Sprintf("http://127.0.0.1:%d/login", CallbackPort),
Scopes: oauthScopes,
Endpoint: spotifyoauth2.Endpoint,
}
}
// silentTokenRefresh uses a stored refresh token to get a new access token
// without opening a browser.
func silentTokenRefresh(clientID, refreshToken string) (*oauth2.Token, error) {
conf := spotifyOAuthConfig(clientID)
src := conf.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken})
return src.Token()
}
// isInvalidGrant reports whether err is an OAuth2 invalid_grant response
// from the token endpoint, indicating the refresh token is dead and
// retrying with the same token will not succeed.
func isInvalidGrant(err error) bool {
var rerr *oauth2.RetrieveError
if !errors.As(err, &rerr) {
return false
}
return rerr.ErrorCode == "invalid_grant"
}
// oauthCallbackHTML is the response sent to the browser after a successful OAuth2 callback.
const oauthCallbackHTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>cliamp</title></head>
<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#e0e0e0">
<div style="text-align:center">
<h2>✅ Authenticated!</h2>
<p>You can close this tab now.</p>
<script>setTimeout(function(){window.close()},1500)</script>
</div></body></html>`
// performOAuth2PKCE runs an OAuth2 PKCE flow: opens a browser for user consent,
// waits for the callback, and exchanges the code for a token.
func performOAuth2PKCE(ctx context.Context, clientID string) (*oauth2.Token, error) {
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", CallbackPort))
if err != nil {
return nil, fmt.Errorf("listen on port %d: %w", CallbackPort, err)
}
defer lis.Close() // always release the port
oauthConf := spotifyOAuthConfig(clientID)
verifier := oauth2.GenerateVerifier()
authURL := oauthConf.AuthCodeURL("", oauth2.S256ChallengeOption(verifier))
notifyAuthURL(authURL)
codeCh := make(chan string, 1)
go func() {
if err := http.Serve(lis, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code != "" {
codeCh <- code
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(oauthCallbackHTML))
})); err != nil && !errors.Is(err, net.ErrClosed) {
applog.UserError("spotify: auth callback server error: %v", err)
}
}()
_ = browser.Open(authURL) // best-effort — user can open the URL manually if this fails
var code string
select {
case code = <-codeCh:
case <-ctx.Done():
return nil, fmt.Errorf("authentication cancelled: %w", ctx.Err())
}
token, err := oauthConf.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
return nil, fmt.Errorf("token exchange: %w", err)
}
return token, nil
}
// doWebAPIAuth performs an OAuth2 PKCE flow to get a fresh Web API access token.
// Opens a browser for user consent, returns the full token (including refresh token).
func doWebAPIAuth(ctx context.Context, clientID string) (*oauth2.Token, error) {
token, err := performOAuth2PKCE(ctx, clientID)
if err != nil {
return nil, err
}
fmt.Println("Spotify: Web API token refreshed.")
return token, nil
}
func newInteractiveSession(ctx context.Context, clientID string) (*Session, error) {
devID := generateDeviceID()
token, err := performOAuth2PKCE(ctx, clientID)
if err != nil {
return nil, fmt.Errorf("spotify: %w", err)
}
username, _ := token.Extra("username").(string)
accessToken := token.AccessToken
// Create go-librespot session using the OAuth2 token.
sess, err := session.NewSessionFromOptions(ctx, &session.Options{
Log: &librespot.NullLogger{},
DeviceType: devicespb.DeviceType_COMPUTER,
DeviceId: devID,
Credentials: session.SpotifyTokenCredentials{
Username: username,
Token: accessToken,
},
})
if err != nil {
return nil, fmt.Errorf("spotify: session from token: %w", err)
}
// Persist stored credentials + refresh token for future sessions.
if err := saveCreds(&storedCreds{
Username: sess.Username(),
Data: sess.StoredCredentials(),
DeviceID: devID,
RefreshToken: token.RefreshToken,
}); err != nil {
applog.UserError("spotify: failed to save credentials: %v", err)
}
// Create an auto-refreshing token source for Web API calls.
conf := spotifyOAuthConfig(clientID)
ts := conf.TokenSource(context.Background(), token)
s := &Session{sess: sess, devID: devID, clientID: clientID, tokenSource: ts}
if err := s.initPlayer(); err != nil {
sess.Close()
return nil, err
}
return s, nil
}
// initPlayer creates the go-librespot player. We only use NewStream() for
// decoded AudioSources — audio output is routed through cliamp's Beep pipeline,
// not go-librespot's output backend.
func (s *Session) initPlayer() error {
// go-librespot uses this for media restriction checks but Premium
// accounts can play all tracks regardless.
countryCode := "US"
p, err := librespotPlayer.NewPlayer(&librespotPlayer.Options{
Spclient: s.sess.Spclient(),
AudioKey: s.sess.AudioKey(),
Events: s.sess.Events(),
Log: &librespot.NullLogger{},
CountryCode: &countryCode,
NormalisationEnabled: true,
AudioBackend: "pipe",
AudioOutputPipe: os.DevNull,
})
if err != nil {
return fmt.Errorf("spotify: player init: %w", err)
}
s.player = p
return nil
}
// NewStream creates a decoded audio stream for the given Spotify track ID.
//
// Holds s.mu.RLock() across the librespot network call. Multiple concurrent
// NewStream / webApi callers can run in parallel (RLock is shared), so rapid
// track skipping does not serialize. reconnect() and Close() take the full
// Lock and will wait for in-flight callers to finish before tearing down the
// player — without this, the swap could call oldPlayer.Close() while we are
// still reading from it.
func (s *Session) NewStream(ctx context.Context, spotID librespot.SpotifyId, bitrate int) (*librespotPlayer.Stream, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.player == nil {
return nil, fmt.Errorf("spotify: session closed")
}
return s.player.NewStream(ctx, http.DefaultClient, spotID, bitrate, 0)
}
// webApiWithBody calls the Spotify Web API using the OAuth2 access token.
//
// The spclient/login5 token from librespot is NOT accepted by the Web API
// for endpoints like /v1/search and /v1/me/playlists — Spotify returns
// misleading errors ("Invalid limit", 429) instead of a clear auth failure.
// So if there is no OAuth2 token source, fail loudly with ErrNeedsAuth
// rather than attempting the call with the wrong token.
func (s *Session) webApiWithBody(ctx context.Context, method, path string, query url.Values, body io.Reader, contentType string) (*http.Response, error) {
s.mu.RLock()
ts := s.tokenSource
s.mu.RUnlock()
if ts == nil {
return nil, fmt.Errorf("spotify: web api token unavailable, run 'cliamp spotify reset' and sign in again: %w", playlist.ErrNeedsAuth)
}
tok, err := ts.Token()
if err != nil {
return nil, fmt.Errorf("refresh access token: %w", err)
}
token := tok.AccessToken
u, _ := url.Parse("https://api.spotify.com")
u = u.JoinPath(path)
if query != nil {
u.RawQuery = query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return http.DefaultClient.Do(req)
}
// Close releases all session and player resources.
func (s *Session) Close() {
s.mu.Lock()
defer s.mu.Unlock()
if s.player != nil {
s.player.Close()
}
if s.sess != nil {
s.sess.Close()
}
}
// Reconnect rebuilds the session from stored credentials (no browser).
// Returns an error if stored credentials are missing or the refresh fails.
func (s *Session) Reconnect(ctx context.Context) error {
return s.reconnect(ctx, NewSessionSilent)
}
// ReconnectInteractive forces a fresh browser-based OAuth2 flow.
// Stored credentials are preserved until the new session succeeds —
// newInteractiveSession overwrites them via saveCreds on success.
func (s *Session) ReconnectInteractive(ctx context.Context) error {
return s.reconnect(ctx, newInteractiveSession)
}
// reconnect replaces the live session using the provided builder function.
// The new session is established before tearing down the old one to avoid a
// window where s.sess/s.player are nil (which would crash concurrent callers).
//
// The swap-and-teardown phase is done under s.mu (full Lock), which waits for
// any in-flight NewStream / webApi RLockers to drain. This guarantees that
// oldPlayer.Close() is never called while a NewStream is still using the
// old player pointer.
func (s *Session) reconnect(ctx context.Context, build func(context.Context, string) (*Session, error)) error {
s.mu.RLock()
clientID := s.clientID
s.mu.RUnlock()
newSess, err := build(ctx, clientID)
if err != nil {
return fmt.Errorf("spotify: reconnect: %w", err)
}
// Swap and tear down the old session under a single write lock so
// in-flight NewStream / webApi calls finish before oldPlayer.Close()
// runs. The expensive build() above happened lock-free.
s.mu.Lock()
oldPlayer := s.player
oldSess := s.sess
s.sess = newSess.sess
s.player = newSess.player
s.devID = newSess.devID
s.tokenSource = newSess.tokenSource
if oldPlayer != nil {
oldPlayer.Close()
}
if oldSess != nil {
oldSess.Close()
}
s.mu.Unlock()
// Prevent newSess.Close() from tearing down the resources we just adopted.
newSess.mu.Lock()
newSess.sess = nil
newSess.player = nil
newSess.mu.Unlock()
const reauthMsg = "spotify: re-authenticated successfully"
applog.Info(reauthMsg)
applog.Status(reauthMsg)
return nil
}
func generateDeviceID() string {
b := make([]byte, 20)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func loadCreds() (*storedCreds, error) {
path, err := CredsPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var creds storedCreds
if err := json.Unmarshal(data, &creds); err != nil {
return nil, err
}
return &creds, nil
}
func saveCreds(creds *storedCreds) error {
path, err := CredsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
data, err := json.Marshal(creds)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
+88
View File
@@ -0,0 +1,88 @@
//go:build !windows
package spotify
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"golang.org/x/oauth2"
)
func TestIsInvalidGrant(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"plain error", errors.New("network blip"), false},
{"oauth invalid_grant", &oauth2.RetrieveError{ErrorCode: "invalid_grant"}, true},
{"oauth invalid_request", &oauth2.RetrieveError{ErrorCode: "invalid_request"}, false},
{"wrapped invalid_grant", fmt.Errorf("refresh failed: %w", &oauth2.RetrieveError{ErrorCode: "invalid_grant"}), true},
{"wrapped non-oauth", fmt.Errorf("refresh failed: %w", errors.New("transport error")), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isInvalidGrant(tt.err)
if got != tt.want {
t.Errorf("isInvalidGrant(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestDeleteCreds(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Run("missing file", func(t *testing.T) {
removed, err := DeleteCreds()
if err != nil {
t.Errorf("DeleteCreds() on missing file returned %v, want nil", err)
}
if removed {
t.Error("DeleteCreds() reported removed=true for missing file")
}
})
t.Run("removes existing file", func(t *testing.T) {
dir := filepath.Join(home, ".config", "cliamp")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "spotify_credentials.json")
if err := os.WriteFile(path, []byte(`{"username":"x"}`), 0o600); err != nil {
t.Fatal(err)
}
removed, err := DeleteCreds()
if err != nil {
t.Fatalf("DeleteCreds() = %v, want nil", err)
}
if !removed {
t.Error("DeleteCreds() reported removed=false after removing file")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file still exists after DeleteCreds: stat err = %v", err)
}
})
}
func TestCredsPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
got, err := CredsPath()
if err != nil {
t.Fatalf("CredsPath() error = %v", err)
}
want := filepath.Join(home, ".config", "cliamp", "spotify_credentials.json")
if got != want {
t.Errorf("CredsPath() = %q, want %q", got, want)
}
}
+115
View File
@@ -0,0 +1,115 @@
//go:build !windows
// Package spotify integrates Spotify playback into cliamp via go-librespot.
package spotify
import (
"io"
"time"
librespot "github.com/devgianlu/go-librespot"
librespotPlayer "github.com/devgianlu/go-librespot/player"
"github.com/gopxl/beep/v2"
)
const (
spotifySampleRate = 44100
spotifyChannels = 2
)
// spotifyStreamer bridges a go-librespot AudioSource to beep.StreamSeekCloser.
// go-librespot outputs interleaved stereo float32 at 44100Hz; this converts
// to Beep's [][2]float64 sample format.
type spotifyStreamer struct {
source librespot.AudioSource
stream *librespotPlayer.Stream
buf []float32
durationMs int64
err error
}
// newSpotifyStreamer wraps a go-librespot Stream as a beep.StreamSeekCloser.
func newSpotifyStreamer(stream *librespotPlayer.Stream) *spotifyStreamer {
var dur int64
if stream.Media != nil {
dur = int64(stream.Media.Duration())
}
return &spotifyStreamer{
source: stream.Source,
stream: stream,
durationMs: dur,
}
}
// Stream reads interleaved float32 from the AudioSource and converts to
// [][2]float64 stereo pairs for Beep's audio pipeline.
func (s *spotifyStreamer) Stream(samples [][2]float64) (n int, ok bool) {
// Each stereo sample pair needs 2 float32 values (L, R).
needed := len(samples) * spotifyChannels
if len(s.buf) < needed {
s.buf = make([]float32, needed)
}
nRead, err := s.source.Read(s.buf[:needed])
if err != nil && err != io.EOF {
s.err = err
return 0, false
}
// Ensure we only process complete stereo pairs (drop any trailing mono sample).
nRead -= nRead % spotifyChannels
// Convert interleaved float32 [L0,R0,L1,R1,...] to [][2]float64 pairs.
pairs := nRead / spotifyChannels
for i := range pairs {
samples[i][0] = float64(s.buf[i*2])
samples[i][1] = float64(s.buf[i*2+1])
}
if pairs == 0 && err == io.EOF {
return 0, false
}
return pairs, true
}
func (s *spotifyStreamer) Err() error { return s.err }
// Len returns the total number of sample pairs (at 44100Hz stereo).
func (s *spotifyStreamer) Len() int {
return int(s.durationMs * spotifySampleRate / 1000)
}
// Position returns the current playback position in sample pairs.
func (s *spotifyStreamer) Position() int {
return int(s.source.PositionMs() * spotifySampleRate / 1000)
}
// Seek moves to sample position p (in sample pairs at 44100Hz).
func (s *spotifyStreamer) Seek(p int) error {
ms := int64(p) * 1000 / spotifySampleRate
return s.source.SetPositionMs(ms)
}
// Close releases the stream resources.
// The underlying AudioSource (vorbis.Decoder or flac.Decoder) has a Close()
// method but the AudioSource interface does not expose it. The chunked HTTP
// reader and decryption pipeline will be released when the object is GC'd.
// This is a known limitation for skipped tracks until go-librespot exposes
// Close() on the AudioSource interface.
func (s *spotifyStreamer) Close() error {
return nil
}
// Format returns the Beep audio format for Spotify streams.
func (s *spotifyStreamer) Format() beep.Format {
return beep.Format{
SampleRate: beep.SampleRate(spotifySampleRate),
NumChannels: spotifyChannels,
Precision: 4, // float32 = 4 bytes
}
}
// Duration returns the track duration.
func (s *spotifyStreamer) Duration() time.Duration {
return time.Duration(s.durationMs) * time.Millisecond
}
+71
View File
@@ -0,0 +1,71 @@
//go:build windows
// stub_windows.go provides a no-op Spotify implementation on Windows
// where go-librespot (CGO: FLAC, Vorbis, ALSA) cannot compile.
package spotify
import (
"context"
"errors"
"time"
"github.com/gopxl/beep/v2"
"github.com/bjarneo/cliamp/playlist"
)
var errSpotifyUnavailable = errors.New("spotify: unavailable on Windows (go-librespot requires CGO)")
// Session is a no-op on Windows.
type Session struct{}
// SpotifyProvider is a no-op on Windows.
type SpotifyProvider struct{}
// New returns nil — Spotify is disabled on Windows because
// go-librespot requires CGO (FLAC, Vorbis, ALSA) which cannot
// cross-compile. Callers must nil-check the return value.
// bitrate is ignored on this platform.
func New(_ *Session, _ string, _ int) *SpotifyProvider { return nil }
// Close is a no-op.
func (p *SpotifyProvider) Close() {}
// Name returns the provider name.
func (p *SpotifyProvider) Name() string { return "Spotify" }
// Playlists returns nil — Spotify is unavailable on Windows.
func (p *SpotifyProvider) Playlists() ([]playlist.PlaylistInfo, error) { return nil, nil }
// Tracks returns nil — Spotify is unavailable on Windows.
func (p *SpotifyProvider) Tracks(_ string) ([]playlist.Track, error) { return nil, nil }
// 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
}
// SearchTracks is a no-op on Windows.
func (p *SpotifyProvider) SearchTracks(_ context.Context, _ string, _ int) ([]playlist.Track, error) {
return nil, nil
}
// AddTrackToPlaylist is a no-op on Windows.
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) {
return "", nil
}
// SetAuthURLObserver is a no-op on Windows.
func SetAuthURLObserver(_ func(string)) {}
+12
View File
@@ -0,0 +1,12 @@
package spotify
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
os.Unsetenv("CLIAMP_CONFIG_DIR")
os.Unsetenv("XDG_CONFIG_HOME")
os.Exit(m.Run())
}
+111
View File
@@ -0,0 +1,111 @@
package ytmusic
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/playlist"
)
// cacheTTL is how long cached playlist/track data is considered fresh.
// After this, data is refetched from the API on next access.
const cacheTTL = 24 * time.Hour
// ytCache stores playlists and tracks on disk for fast startup.
// Path: ~/.config/cliamp/ytmusic_cache.json
type ytCache struct {
Playlists []playlistEntry `json:"playlists,omitempty"`
PlaylistsAt time.Time `json:"playlists_at"`
Tracks map[string]cachedTrackList `json:"tracks,omitempty"`
}
type cachedTrackList struct {
Items []playlist.Track `json:"items"`
FetchedAt time.Time `json:"fetched_at"`
}
func ytCachePath() string {
dir, err := appdir.Dir()
if err != nil {
return ""
}
return filepath.Join(dir, "ytmusic_cache.json")
}
func newYTCache() *ytCache {
return &ytCache{Tracks: make(map[string]cachedTrackList)}
}
func loadYTCache() *ytCache {
data, err := os.ReadFile(ytCachePath())
if err != nil {
return newYTCache()
}
var c ytCache
if err := json.Unmarshal(data, &c); err != nil {
return newYTCache()
}
if c.Tracks == nil {
c.Tracks = make(map[string]cachedTrackList)
}
return &c
}
// snapshot returns a JSON-encoded copy of the cache. Call under the mutex
// so json.Marshal doesn't race with concurrent setPlaylists/setTracks calls.
func (c *ytCache) snapshot() []byte {
data, err := json.Marshal(c)
if err != nil {
return nil
}
return data
}
// save writes previously-snapshotted data to disk. Safe to call without a lock.
func saveSnapshot(data []byte) {
if data == nil {
return
}
path := ytCachePath()
if path == "" {
return
}
os.MkdirAll(filepath.Dir(path), 0o700)
os.WriteFile(path, data, 0o600)
}
func (c *ytCache) playlistsFresh() bool {
return len(c.Playlists) > 0 && time.Since(c.PlaylistsAt) < cacheTTL
}
func (c *ytCache) tracksFresh(playlistID string) ([]playlist.Track, bool) {
ct, ok := c.Tracks[playlistID]
if !ok || len(ct.Items) == 0 {
return nil, false
}
if time.Since(ct.FetchedAt) >= cacheTTL {
return nil, false
}
return ct.Items, true
}
func (c *ytCache) setPlaylists(pl []playlistEntry) {
c.Playlists = pl
c.PlaylistsAt = time.Now()
}
func (c *ytCache) setTracks(playlistID string, tracks []playlist.Track) {
c.Tracks[playlistID] = cachedTrackList{
Items: tracks,
FetchedAt: time.Now(),
}
}
func (c *ytCache) clear() {
c.Playlists = nil
c.PlaylistsAt = time.Time{}
c.Tracks = make(map[string]cachedTrackList)
}
+174
View File
@@ -0,0 +1,174 @@
package ytmusic
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/bjarneo/cliamp/internal/appdir"
"google.golang.org/api/youtube/v3"
)
// musicCategoryID is the YouTube video category for Music.
const musicCategoryID = "10"
// classificationCache maps playlist ID → true if the playlist is music.
type classificationCache struct {
Music map[string]bool `json:"music"` // playlist ID → is music
}
// classificationCachePath returns the path to the classification cache file.
func classificationCachePath() string {
dir, err := appdir.Dir()
if err != nil {
return ""
}
return filepath.Join(dir, "ytmusic_classification.json")
}
// loadClassification loads cached playlist classifications from disk.
func loadClassification() map[string]bool {
data, err := os.ReadFile(classificationCachePath())
if err != nil {
return nil
}
var cache classificationCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil
}
return cache.Music
}
// saveClassification writes playlist classifications to disk.
func saveClassification(music map[string]bool) {
cache := classificationCache{Music: music}
data, _ := json.MarshalIndent(cache, "", " ")
path := classificationCachePath()
os.MkdirAll(filepath.Dir(path), 0o700)
os.WriteFile(path, data, 0o600)
}
// classifyPlaylists determines which playlists contain music content by
// sampling one video from each and checking its category.
// Returns a map of playlist ID → true (music) / false (not music).
// Results are cached to disk to avoid repeated API calls.
func classifyPlaylists(ctx context.Context, svc *youtube.Service, playlists []playlistEntry, existing map[string]bool) map[string]bool {
cached := existing
if cached == nil {
cached = loadClassification()
}
if cached == nil {
cached = make(map[string]bool)
}
// Find playlists that need classification.
var toClassify []playlistEntry
for _, pl := range playlists {
if _, ok := cached[pl.ID]; !ok {
toClassify = append(toClassify, pl)
}
}
if len(toClassify) == 0 {
return cached
}
// Sample one video ID from each playlist (parallel, max 10 concurrent).
type sampleResult struct {
playlistID string
videoID string
}
sampleCh := make(chan sampleResult, len(toClassify))
sem := make(chan struct{}, 10) // concurrency limit
var wg sync.WaitGroup
for _, pl := range toClassify {
wg.Go(func() {
sem <- struct{}{}
defer func() { <-sem }()
resp, err := svc.PlaylistItems.List([]string{"contentDetails"}).
PlaylistId(pl.ID).
MaxResults(1).
Context(ctx).
Do()
if err != nil || len(resp.Items) == 0 {
sampleCh <- sampleResult{playlistID: pl.ID}
return
}
sampleCh <- sampleResult{
playlistID: pl.ID,
videoID: resp.Items[0].ContentDetails.VideoId,
}
})
}
wg.Wait()
close(sampleCh)
// Collect video IDs for batch category lookup.
videoToPlaylist := make(map[string]string) // videoID → playlistID
var videoIDs []string
for s := range sampleCh {
if s.videoID != "" {
videoToPlaylist[s.videoID] = s.playlistID
videoIDs = append(videoIDs, s.videoID)
} else {
// No video found — default to non-music.
cached[s.playlistID] = false
}
}
// Batch fetch video categories.
for i := 0; i < len(videoIDs); i += youtubeAPIBatchSize {
end := min(i+youtubeAPIBatchSize, len(videoIDs))
batch := videoIDs[i:end]
vResp, err := svc.Videos.List([]string{"snippet"}).
Id(batch...).
Context(ctx).
Do()
if err != nil {
// On error, default unclassified to non-music.
for _, vid := range batch {
if plID, ok := videoToPlaylist[vid]; ok {
cached[plID] = false
}
}
continue
}
for _, v := range vResp.Items {
plID := videoToPlaylist[v.Id]
cached[plID] = (v.Snippet.CategoryId == musicCategoryID)
}
}
// Mark any remaining unclassified as non-music.
for _, pl := range toClassify {
if _, ok := cached[pl.ID]; !ok {
cached[pl.ID] = false
}
}
saveClassification(cached)
return cached
}
// playlistEntry is a minimal playlist descriptor for classification.
type playlistEntry struct {
ID string `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
}
// classifyWithTimeout runs classification with a timeout.
func classifyWithTimeout(svc *youtube.Service, playlists []playlistEntry, timeout time.Duration, existing map[string]bool) map[string]bool {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return classifyPlaylists(ctx, svc, playlists, existing)
}
+29
View File
@@ -0,0 +1,29 @@
package ytmusic
import (
"math/rand/v2"
)
// fallbackCredentials is a pool of Google Cloud OAuth2 Desktop app credentials
// used when the user has not configured their own client_id/client_secret.
// A random entry is selected each session to spread quota load across projects.
//
// These are Desktop-type OAuth2 credentials. Google allows embedding them in
// open-source desktop apps — the user still authenticates via their own Google
// account, so the credentials alone grant no access.
type oauthCreds struct {
ClientID string
ClientSecret string
}
var fallbackCredentials []oauthCreds
// FallbackCredentials returns a random credential pair from the built-in pool,
// or empty strings if the pool is empty.
func FallbackCredentials() (clientID, clientSecret string) {
if len(fallbackCredentials) == 0 {
return "", ""
}
c := fallbackCredentials[rand.IntN(len(fallbackCredentials))]
return c.ClientID, c.ClientSecret
}
+606
View File
@@ -0,0 +1,606 @@
package ytmusic
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/bjarneo/cliamp/playlist"
"google.golang.org/api/youtube/v3"
)
// itemInfo holds metadata for a single video in a playlist.
type itemInfo struct {
videoID string
title string
channel string
}
// youtubeAPIBatchSize is the maximum number of items per YouTube Data API request.
const youtubeAPIBatchSize = 50
// baseProvider holds shared state for YouTube and YouTube Music providers.
// Both providers share the same OAuth session and track cache.
type baseProvider struct {
session *Session
clientID string
clientSecret string
hasCookies bool // true when cookies_from is configured
mu sync.Mutex
trackCache map[string][]playlist.Track // playlist ID -> cached tracks
allPlaylists []playlistEntry // cached raw playlist list
classified map[string]bool // playlist ID -> is music (from classify.go)
disk *ytCache // lazy-loaded disk cache
authCancel context.CancelFunc // cancels any in-progress OAuth flow
}
func newBase(session *Session, clientID, clientSecret string, hasCookies bool) *baseProvider {
return &baseProvider{
session: session,
clientID: clientID,
clientSecret: clientSecret,
hasCookies: hasCookies,
trackCache: make(map[string][]playlist.Track),
}
}
// ensureDiskCache lazily loads the disk cache. Must be called under mu.
func (b *baseProvider) ensureDiskCache() *ytCache {
if b.disk == nil {
b.disk = loadYTCache()
}
return b.disk
}
// initSession creates a session if one doesn't exist yet. If interactive is
// false, only stored credentials are tried (returning ErrNeedsAuth on failure).
// If interactive is true, a browser-based OAuth flow is started. Any previous
// in-progress OAuth flow is cancelled first to free the callback port.
func (b *baseProvider) initSession(interactive bool) error {
b.mu.Lock()
if b.session != nil {
b.mu.Unlock()
return nil
}
// Cancel any previous in-progress auth attempt so the old listener
// on CallbackPort is released before we try to bind again.
if b.authCancel != nil {
b.authCancel()
b.authCancel = nil
}
clientID := b.clientID
clientSecret := b.clientSecret
b.mu.Unlock()
if clientID == "" {
return fmt.Errorf("ytmusic: no client ID available")
}
var sess *Session
var err error
if interactive {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
b.mu.Lock()
b.authCancel = cancel
b.mu.Unlock()
sess, err = NewSession(ctx, clientID, clientSecret)
b.mu.Lock()
b.authCancel = nil
b.mu.Unlock()
cancel()
} else {
sess, err = NewSessionSilent(context.Background(), clientID, clientSecret)
}
if err != nil {
if !interactive {
return playlist.ErrNeedsAuth
}
return err
}
b.mu.Lock()
if b.session == nil {
b.session = sess
}
b.mu.Unlock()
return nil
}
func (b *baseProvider) ensureSession() error { return b.initSession(false) }
func (b *baseProvider) authenticate() error { return b.initSession(true) }
// refresh clears playlist/track caches so the next call re-fetches from the
// API. Classification is preserved — it's on disk in a separate file, rarely
// changes, and re-classifying costs API quota.
func (b *baseProvider) refresh() {
b.mu.Lock()
b.allPlaylists = nil
b.classified = nil
clear(b.trackCache)
dc := b.ensureDiskCache()
dc.clear()
snap := dc.snapshot()
b.mu.Unlock()
saveSnapshot(snap)
}
func (b *baseProvider) close() {
b.mu.Lock()
defer b.mu.Unlock()
if b.authCancel != nil {
b.authCancel()
b.authCancel = nil
}
if b.session != nil {
b.session.Close()
b.session = nil
}
}
// fetchAndClassify loads all playlists and classifies them as music vs non-music.
// Results are cached in-memory for the session and on disk for fast startup.
// If fresh disk cache exists with full classification, no API calls or auth are needed.
func (b *baseProvider) fetchAndClassify() error {
b.mu.Lock()
if b.allPlaylists != nil {
b.mu.Unlock()
return nil
}
// Try disk cache first — no session/auth needed if data is fresh.
dc := b.ensureDiskCache()
if dc.playlistsFresh() {
classified := loadClassification()
if classified != nil {
allClassified := true
for _, pl := range dc.Playlists {
if _, ok := classified[pl.ID]; !ok {
allClassified = false
break
}
}
if allClassified {
b.allPlaylists = dc.Playlists
b.classified = classified
b.mu.Unlock()
return nil
}
}
}
b.mu.Unlock()
// Disk cache stale or incomplete — fetch from API.
if err := b.ensureSession(); err != nil {
return err
}
b.mu.Lock()
sess := b.session
b.mu.Unlock()
if sess == nil {
return fmt.Errorf("ytmusic: session unavailable")
}
svc := sess.Service()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var all []playlistEntry
seen := make(map[string]bool)
pageToken := ""
for {
call := svc.Playlists.List([]string{"snippet", "contentDetails"}).
Mine(true).
MaxResults(50).
Context(ctx)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return fmt.Errorf("ytmusic: list playlists: %w", err)
}
for _, item := range resp.Items {
count := int(item.ContentDetails.ItemCount)
if count <= 0 || seen[item.Id] {
continue
}
seen[item.Id] = true
all = append(all, playlistEntry{
ID: item.Id,
Name: item.Snippet.Title,
TrackCount: count,
})
}
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
// Classify playlists (parallel, with disk cache).
// Pass nil to let classifyPlaylists load from disk itself —
// the earlier loadClassification() was skipped because cache was stale.
classified := classifyWithTimeout(svc, all, 60*time.Second, nil)
b.mu.Lock()
if b.allPlaylists != nil {
// Another goroutine completed fetchAndClassify while we were fetching.
b.mu.Unlock()
return nil
}
b.allPlaylists = all
b.classified = classified
// Persist playlists to disk cache.
dc = b.ensureDiskCache()
dc.setPlaylists(all)
snap := dc.snapshot()
b.mu.Unlock()
// Save outside the lock — disk I/O shouldn't block other goroutines.
saveSnapshot(snap)
return nil
}
// filteredPlaylists returns playlists filtered by music classification.
func (b *baseProvider) filteredPlaylists(wantMusic bool) []playlist.PlaylistInfo {
b.mu.Lock()
defer b.mu.Unlock()
var result []playlist.PlaylistInfo
for _, pl := range b.allPlaylists {
isMusic, ok := b.classified[pl.ID]
if !ok {
isMusic = false
}
if isMusic == wantMusic {
result = append(result, playlist.PlaylistInfo{
ID: pl.ID,
Name: pl.Name,
TrackCount: pl.TrackCount,
})
}
}
return result
}
// tracks fetches tracks for a playlist (shared between both providers).
// Checks in-memory cache, then disk cache, then fetches from API.
func (b *baseProvider) tracks(playlistID string) ([]playlist.Track, error) {
b.mu.Lock()
if cached, ok := b.trackCache[playlistID]; ok {
b.mu.Unlock()
return cached, nil
}
// Check disk cache — avoids API call and auth if fresh.
dc := b.ensureDiskCache()
if tracks, ok := dc.tracksFresh(playlistID); ok {
b.trackCache[playlistID] = tracks
b.mu.Unlock()
return tracks, nil
}
b.mu.Unlock()
// Disk cache miss — fetch from API.
if err := b.ensureSession(); err != nil {
return nil, err
}
b.mu.Lock()
sess := b.session
b.mu.Unlock()
if sess == nil {
return nil, fmt.Errorf("ytmusic: session unavailable")
}
svc := sess.Service()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
var items []itemInfo
pageToken := ""
for {
call := svc.PlaylistItems.List([]string{"snippet", "contentDetails"}).
PlaylistId(playlistID).
MaxResults(50).
Context(ctx)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, fmt.Errorf("ytmusic: list playlist items: %w", err)
}
for _, item := range resp.Items {
vid := item.ContentDetails.VideoId
if vid == "" {
continue
}
title := item.Snippet.Title
if title == "Private video" || title == "Deleted video" {
continue
}
channel := item.Snippet.VideoOwnerChannelTitle
if !b.hasCookies && (channel == "Music Library Uploads" || channel == "") {
continue
}
items = append(items, itemInfo{
videoID: vid,
title: title,
channel: channel,
})
}
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
durations := b.fetchDurations(ctx, svc, items)
var tracks []playlist.Track
for _, it := range items {
tracks = append(tracks, playlist.Track{
Path: "https://music.youtube.com/watch?v=" + it.videoID,
Title: it.title,
Artist: cleanChannelName(it.channel),
Stream: false,
DurationSecs: durations[it.videoID],
})
}
// Persist to in-memory and disk cache.
b.mu.Lock()
b.trackCache[playlistID] = tracks
dc = b.ensureDiskCache()
dc.setTracks(playlistID, tracks)
snap := dc.snapshot()
b.mu.Unlock()
// Save outside the lock — disk I/O shouldn't block other goroutines.
saveSnapshot(snap)
return tracks, nil
}
func (b *baseProvider) fetchDurations(ctx context.Context, svc *youtube.Service, items []itemInfo) map[string]int {
var mu sync.Mutex
durations := make(map[string]int)
var wg sync.WaitGroup
sem := make(chan struct{}, 5) // limit concurrent API calls
for i := 0; i < len(items); i += youtubeAPIBatchSize {
end := min(i+youtubeAPIBatchSize, len(items))
batch := items[i:end]
wg.Go(func() {
sem <- struct{}{}
defer func() { <-sem }()
var ids []string
for _, it := range batch {
ids = append(ids, it.videoID)
}
vResp, err := svc.Videos.List([]string{"contentDetails"}).
Id(ids...).
Context(ctx).
Do()
if err != nil {
return
}
mu.Lock()
for _, v := range vResp.Items {
durations[v.Id] = parseISO8601Duration(v.ContentDetails.Duration)
}
mu.Unlock()
})
}
wg.Wait()
return durations
}
// Special auto-generated playlist IDs exposed by the YouTube Data API.
const (
playlistIDLikedMusic = "LM" // YouTube Music liked songs
playlistIDLikedVideos = "LL" // YouTube liked videos
)
// playlistCounts fetches item counts for the given playlist IDs in a single
// API call. IDs not exposed by the API are omitted from the result.
// Returns an empty map when no session is available (e.g. disk-cache-only boot).
func (b *baseProvider) playlistCounts(ids ...string) map[string]int {
out := make(map[string]int, len(ids))
if len(ids) == 0 {
return out
}
b.mu.Lock()
sess := b.session
b.mu.Unlock()
if sess == nil {
return out
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := sess.Service().Playlists.List([]string{"contentDetails"}).
Id(ids...).
Context(ctx).
Do()
if err != nil {
return out
}
for _, item := range resp.Items {
out[item.Id] = int(item.ContentDetails.ItemCount)
}
return out
}
// ─── YouTube Music Provider ────────────────────────────────────────────────
// YouTubeMusicProvider shows playlists classified as music content.
type YouTubeMusicProvider struct {
base *baseProvider
}
func (p *YouTubeMusicProvider) Name() string { return "YouTube Music" }
func (p *YouTubeMusicProvider) Authenticate() error { return p.base.authenticate() }
func (p *YouTubeMusicProvider) Close() { p.base.close() }
func (p *YouTubeMusicProvider) Refresh() { p.base.refresh() }
func (p *YouTubeMusicProvider) Tracks(id string) ([]playlist.Track, error) {
return p.base.tracks(id)
}
func (p *YouTubeMusicProvider) Playlists() ([]playlist.PlaylistInfo, error) {
if err := p.base.fetchAndClassify(); err != nil {
return nil, err
}
counts := p.base.playlistCounts(playlistIDLikedMusic)
all := []playlist.PlaylistInfo{{
ID: playlistIDLikedMusic,
Name: "Liked Music",
TrackCount: counts[playlistIDLikedMusic],
}}
all = append(all, p.base.filteredPlaylists(true)...)
return all, nil
}
// ─── YouTube Provider ──────────────────────────────────────────────────────
// YouTubeProvider shows playlists classified as non-music (video) content.
type YouTubeProvider struct {
base *baseProvider
}
func (p *YouTubeProvider) Name() string { return "YouTube" }
func (p *YouTubeProvider) Authenticate() error { return p.base.authenticate() }
func (p *YouTubeProvider) Close() { /* shared base; closed via music provider */ }
func (p *YouTubeProvider) Refresh() { p.base.refresh() }
func (p *YouTubeProvider) Tracks(id string) ([]playlist.Track, error) {
return p.base.tracks(id)
}
func (p *YouTubeProvider) Playlists() ([]playlist.PlaylistInfo, error) {
if err := p.base.fetchAndClassify(); err != nil {
return nil, err
}
counts := p.base.playlistCounts(playlistIDLikedVideos)
all := []playlist.PlaylistInfo{{
ID: playlistIDLikedVideos,
Name: "Liked Videos",
TrackCount: counts[playlistIDLikedVideos],
}}
all = append(all, p.base.filteredPlaylists(false)...)
return all, nil
}
// ─── YouTube All Provider ──────────────────────────────────────────────────
// YouTubeAllProvider shows all playlists regardless of classification.
type YouTubeAllProvider struct {
base *baseProvider
}
func (p *YouTubeAllProvider) Name() string { return "YouTube (All)" }
func (p *YouTubeAllProvider) Authenticate() error { return p.base.authenticate() }
func (p *YouTubeAllProvider) Close() { /* shared base; closed via music provider */ }
func (p *YouTubeAllProvider) Refresh() { p.base.refresh() }
func (p *YouTubeAllProvider) Tracks(id string) ([]playlist.Track, error) {
return p.base.tracks(id)
}
func (p *YouTubeAllProvider) Playlists() ([]playlist.PlaylistInfo, error) {
if err := p.base.fetchAndClassify(); err != nil {
return nil, err
}
counts := p.base.playlistCounts(playlistIDLikedMusic, playlistIDLikedVideos)
all := []playlist.PlaylistInfo{
{ID: playlistIDLikedMusic, Name: "Liked Music", TrackCount: counts[playlistIDLikedMusic]},
{ID: playlistIDLikedVideos, Name: "Liked Videos", TrackCount: counts[playlistIDLikedVideos]},
}
b := p.base
b.mu.Lock()
for _, pl := range b.allPlaylists {
all = append(all, playlist.PlaylistInfo{
ID: pl.ID,
Name: pl.Name,
TrackCount: pl.TrackCount,
})
}
b.mu.Unlock()
return all, nil
}
// ─── Constructor ───────────────────────────────────────────────────────────
// Providers holds the YouTube Music, YouTube, and YouTube All providers,
// sharing a single OAuth session.
type Providers struct {
Music *YouTubeMusicProvider
Video *YouTubeProvider
All *YouTubeAllProvider
}
// New creates all three YouTube providers with a shared session.
func New(session *Session, clientID, clientSecret string, hasCookies bool) Providers {
base := newBase(session, clientID, clientSecret, hasCookies)
return Providers{
Music: &YouTubeMusicProvider{base: base},
Video: &YouTubeProvider{base: base},
All: &YouTubeAllProvider{base: base},
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────
var iso8601Re = regexp.MustCompile(`P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?`)
func parseISO8601Duration(d string) int {
m := iso8601Re.FindStringSubmatch(d)
if m == nil {
return 0
}
var total int
if m[1] != "" {
v, _ := strconv.Atoi(m[1])
total += v * 86400
}
if m[2] != "" {
v, _ := strconv.Atoi(m[2])
total += v * 3600
}
if m[3] != "" {
v, _ := strconv.Atoi(m[3])
total += v * 60
}
if m[4] != "" {
v, _ := strconv.Atoi(m[4])
total += v
}
return total
}
func cleanChannelName(name string) string {
name = strings.TrimSuffix(name, " - Topic")
return name
}
+63
View File
@@ -0,0 +1,63 @@
package ytmusic
import (
"testing"
"time"
"github.com/bjarneo/cliamp/playlist"
)
func TestRefreshInvalidatesAllCaches(t *testing.T) {
t.Setenv("HOME", t.TempDir())
b := newBase(nil, "client-id", "client-secret", false)
b.allPlaylists = []playlistEntry{{ID: "p1", Name: "One", TrackCount: 5}}
b.classified = map[string]bool{"p1": true}
b.trackCache["p1"] = []playlist.Track{{Path: "https://example/v", Title: "t"}}
dc := b.ensureDiskCache()
dc.setPlaylists(b.allPlaylists)
dc.setTracks("p1", b.trackCache["p1"])
saveSnapshot(dc.snapshot())
if !dc.playlistsFresh() {
t.Fatal("disk cache should be fresh before refresh")
}
b.refresh()
if b.allPlaylists != nil {
t.Error("allPlaylists not cleared")
}
if b.classified != nil {
t.Error("classified not cleared")
}
if len(b.trackCache) != 0 {
t.Errorf("trackCache not cleared: %d entries", len(b.trackCache))
}
if b.disk.playlistsFresh() {
t.Error("disk cache still reports fresh after refresh")
}
if !b.disk.PlaylistsAt.IsZero() {
t.Errorf("PlaylistsAt should be zero, got %v", b.disk.PlaylistsAt)
}
if len(b.disk.Playlists) != 0 {
t.Errorf("disk Playlists not cleared: %d entries", len(b.disk.Playlists))
}
if len(b.disk.Tracks) != 0 {
t.Errorf("disk Tracks not cleared: %d entries", len(b.disk.Tracks))
}
reloaded := loadYTCache()
if reloaded.playlistsFresh() {
t.Error("reloaded disk cache still fresh after refresh")
}
if !reloaded.PlaylistsAt.Equal(time.Time{}) {
t.Errorf("reloaded PlaylistsAt should be zero, got %v", reloaded.PlaylistsAt)
}
if len(reloaded.Tracks) != 0 {
t.Errorf("reloaded disk Tracks not cleared: %d entries", len(reloaded.Tracks))
}
}
+251
View File
@@ -0,0 +1,251 @@
package ytmusic
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"sync"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/browser"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/option"
"google.golang.org/api/youtube/v3"
)
// storedCreds holds persisted YouTube Music credentials for re-authentication.
type storedCreds struct {
RefreshToken string `json:"refresh_token"`
}
// CallbackPort is the fixed port for the OAuth2 callback server.
// Must match the redirect URI registered in the Google Cloud console.
const CallbackPort = 19873
// Session manages a YouTube Data API v3 service for YouTube Music integration.
type Session struct {
mu sync.Mutex
clientID string
clientSecret string
service *youtube.Service
tokenSource oauth2.TokenSource
}
// oauthScopes are the YouTube API scopes needed for cliamp.
var oauthScopes = []string{
"https://www.googleapis.com/auth/youtube.readonly",
}
// googleOAuthConfig returns the OAuth2 config for the given client ID and secret.
// Google Desktop OAuth requires both a client_id and client_secret (unlike Spotify
// which supports PKCE-only public clients).
func googleOAuthConfig(clientID, clientSecret string) *oauth2.Config {
return &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: fmt.Sprintf("http://127.0.0.1:%d/callback", CallbackPort),
Scopes: oauthScopes,
Endpoint: google.Endpoint,
}
}
// NewSession creates a YouTube API session, using stored credentials if
// available, otherwise starting an interactive OAuth2 flow.
func NewSession(ctx context.Context, clientID, clientSecret string) (*Session, error) {
creds, err := loadCreds()
if err == nil && creds.RefreshToken != "" {
s, err := newSessionFromStored(ctx, clientID, clientSecret, creds)
if err == nil {
return s, nil
}
// Stored credentials failed, fall through to interactive.
}
return newInteractiveSession(ctx, clientID, clientSecret)
}
// NewSessionSilent is like NewSession but only uses stored credentials.
// Returns an error if interactive auth is required.
func NewSessionSilent(ctx context.Context, clientID, clientSecret string) (*Session, error) {
creds, err := loadCreds()
if err != nil || creds.RefreshToken == "" {
return nil, fmt.Errorf("no stored credentials")
}
return newSessionFromStored(ctx, clientID, clientSecret, creds)
}
// newSessionFromStored creates a session from stored credentials via silent refresh.
func newSessionFromStored(ctx context.Context, clientID, clientSecret string, creds *storedCreds) (*Session, error) {
token, err := silentTokenRefresh(clientID, clientSecret, creds.RefreshToken)
if err != nil {
return nil, fmt.Errorf("ytmusic: silent refresh: %w", err)
}
conf := googleOAuthConfig(clientID, clientSecret)
ts := conf.TokenSource(ctx, token)
svc, err := youtube.NewService(ctx, option.WithTokenSource(ts))
if err != nil {
return nil, fmt.Errorf("ytmusic: create service: %w", err)
}
// Re-save credentials (refresh token may have been rotated).
if token.RefreshToken != "" {
if err := saveCreds(&storedCreds{RefreshToken: token.RefreshToken}); err != nil {
fmt.Fprintf(os.Stderr, "ytmusic: failed to save credentials: %v\n", err)
}
}
return &Session{
clientID: clientID,
clientSecret: clientSecret,
service: svc,
tokenSource: ts,
}, nil
}
// silentTokenRefresh uses a stored refresh token to get a new access token
// without opening a browser.
func silentTokenRefresh(clientID, clientSecret, refreshToken string) (*oauth2.Token, error) {
conf := googleOAuthConfig(clientID, clientSecret)
src := conf.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken})
return src.Token()
}
// newInteractiveSession performs an OAuth2 flow to authenticate.
func newInteractiveSession(ctx context.Context, clientID, clientSecret string) (*Session, error) {
token, err := doOAuth(ctx, clientID, clientSecret)
if err != nil {
return nil, err
}
conf := googleOAuthConfig(clientID, clientSecret)
ts := conf.TokenSource(ctx, token)
svc, err := youtube.NewService(ctx, option.WithTokenSource(ts))
if err != nil {
return nil, fmt.Errorf("ytmusic: create service: %w", err)
}
// Persist refresh token for future sessions.
if err := saveCreds(&storedCreds{RefreshToken: token.RefreshToken}); err != nil {
fmt.Fprintf(os.Stderr, "ytmusic: failed to save credentials: %v\n", err)
}
return &Session{
clientID: clientID,
clientSecret: clientSecret,
service: svc,
tokenSource: ts,
}, nil
}
// doOAuth performs an OAuth2 flow: starts localhost server, opens browser,
// exchanges code for token. The context controls cancellation — if ctx is
// cancelled (e.g. the user retries auth), the listener is closed and the
// function returns promptly, freeing the callback port.
func doOAuth(ctx context.Context, clientID, clientSecret string) (*oauth2.Token, error) {
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", CallbackPort))
if err != nil {
return nil, fmt.Errorf("ytmusic: listen on port %d (is another instance running?): %w", CallbackPort, err)
}
defer lis.Close() // always release the port
oauthConf := googleOAuthConfig(clientID, clientSecret)
verifier := oauth2.GenerateVerifier()
authURL := oauthConf.AuthCodeURL("", oauth2.S256ChallengeOption(verifier), oauth2.AccessTypeOffline)
codeCh := make(chan string, 1)
go func() {
if err := http.Serve(lis, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code != "" {
codeCh <- code
}
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>cliamp</title></head>
<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#e0e0e0">
<div style="text-align:center">
<h2>Authenticated!</h2>
<p>You can close this tab now.</p>
<script>setTimeout(function(){window.close()},1500)</script>
</div></body></html>`))
})); err != nil && !errors.Is(err, net.ErrClosed) {
fmt.Fprintf(os.Stderr, "ytmusic: auth callback server error: %v\n", err)
}
}()
_ = browser.Open(authURL) // best-effort — user can open the URL manually if this fails
var code string
select {
case code = <-codeCh:
case <-ctx.Done():
return nil, fmt.Errorf("ytmusic: authentication cancelled: %w", ctx.Err())
}
token, err := oauthConf.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
return nil, fmt.Errorf("ytmusic: token exchange: %w", err)
}
fmt.Println("YouTube Music: authenticated.")
return token, nil
}
// Service returns the YouTube API service, holding the lock briefly.
func (s *Session) Service() *youtube.Service {
s.mu.Lock()
defer s.mu.Unlock()
return s.service
}
// Close is a no-op for YouTube Music sessions (no persistent connections).
func (s *Session) Close() {}
func credsPath() (string, error) {
dir, err := appdir.Dir()
if err != nil {
return "", err
}
return filepath.Join(dir, "ytmusic_credentials.json"), nil
}
func loadCreds() (*storedCreds, error) {
path, err := credsPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var creds storedCreds
if err := json.Unmarshal(data, &creds); err != nil {
return nil, err
}
return &creds, nil
}
func saveCreds(creds *storedCreds) error {
path, err := credsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
data, err := json.Marshal(creds)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
+12
View File
@@ -0,0 +1,12 @@
package ytmusic
import (
"os"
"testing"
)
func TestMain(m *testing.M) {
os.Unsetenv("CLIAMP_CONFIG_DIR")
os.Unsetenv("XDG_CONFIG_HOME")
os.Exit(m.Run())
}