Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

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()
}