chore: import upstream snapshot with attribution
This commit is contained in:
Vendored
+21
@@ -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) }
|
||||
Vendored
+201
@@ -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
|
||||
}
|
||||
Vendored
+126
@@ -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}}
|
||||
}
|
||||
Vendored
+21
@@ -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) }
|
||||
Vendored
+189
@@ -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
|
||||
}
|
||||
Vendored
+117
@@ -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}}
|
||||
}
|
||||
Vendored
+578
@@ -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
|
||||
}
|
||||
Vendored
+724
@@ -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))
|
||||
}
|
||||
}
|
||||
Vendored
+547
@@ -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()
|
||||
}
|
||||
Vendored
+604
@@ -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}
|
||||
}
|
||||
Vendored
+597
@@ -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"`
|
||||
}
|
||||
Vendored
+168
@@ -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)
|
||||
}
|
||||
Vendored
+298
@@ -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,
|
||||
}
|
||||
}
|
||||
Vendored
+409
@@ -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()
|
||||
}
|
||||
Vendored
+171
@@ -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)
|
||||
}
|
||||
Vendored
+337
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+210
@@ -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
|
||||
}
|
||||
Vendored
+28
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+466
@@ -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
|
||||
}
|
||||
Vendored
+45
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+83
@@ -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
|
||||
}
|
||||
Vendored
+20
@@ -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
|
||||
Vendored
+539
@@ -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
|
||||
}
|
||||
Vendored
+160
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+212
@@ -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
|
||||
}
|
||||
Vendored
+26
@@ -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
|
||||
}
|
||||
Vendored
+24
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+73
@@ -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"`
|
||||
}
|
||||
Vendored
+74
@@ -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
|
||||
}
|
||||
Vendored
+160
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+155
@@ -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
|
||||
}
|
||||
Vendored
+103
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+330
@@ -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
|
||||
}
|
||||
Vendored
+323
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+18
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -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())
|
||||
}
|
||||
Vendored
+202
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+143
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+111
@@ -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)
|
||||
}
|
||||
Vendored
+146
@@ -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.)
|
||||
}
|
||||
Vendored
+49
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+41
@@ -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
|
||||
}
|
||||
Vendored
+704
@@ -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)
|
||||
}
|
||||
Vendored
+122
@@ -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 != "",
|
||||
}
|
||||
}
|
||||
Vendored
+138
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Vendored
+559
@@ -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)
|
||||
}
|
||||
Vendored
+88
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+115
@@ -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
|
||||
}
|
||||
Vendored
+71
@@ -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)) {}
|
||||
Vendored
+12
@@ -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())
|
||||
}
|
||||
Vendored
+111
@@ -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)
|
||||
}
|
||||
Vendored
+174
@@ -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)
|
||||
}
|
||||
Vendored
+29
@@ -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
|
||||
}
|
||||
Vendored
+606
@@ -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
|
||||
}
|
||||
Vendored
+63
@@ -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))
|
||||
}
|
||||
}
|
||||
Vendored
+251
@@ -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)
|
||||
}
|
||||
Vendored
+12
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user