548 lines
16 KiB
Go
548 lines
16 KiB
Go
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()
|
|
}
|