Files
NaviWatcher/internal/navidrome/client.go

132 lines
3.8 KiB
Go

package navidrome
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"time"
"naviwatcher/internal/config"
"github.com/delucks/go-subsonic"
)
// ArtistInfo represents a simplified artist from the Subsonic API.
type ArtistInfo struct {
ID string
Name string
}
// AlbumInfo represents a simplified album from the Subsonic API.
type AlbumInfo struct {
ID string
Name string
ArtistID string
}
// NavidromeClient wraps the go-subsonic Client with application-specific configuration.
type NavidromeClient struct {
client *subsonic.Client
}
// NewClient creates a new NavidromeClient from the given configuration.
// It authenticates with the server immediately, returning an error if auth fails.
func NewClient(cfg config.NavidromeConfig) (*NavidromeClient, error) {
client := &subsonic.Client{
Client: &http.Client{Timeout: 30 * time.Second},
BaseUrl: cfg.URL,
User: cfg.User,
ClientName: "naviwatcher",
}
if err := client.Authenticate(cfg.Password); err != nil {
return nil, fmt.Errorf("authenticate with navidrome: %w", err)
}
return &NavidromeClient{client: client}, nil
}
// NewClientUnauthenticated builds a NavidromeClient without contacting the
// server. It is intended for dependency injection in tests (where the
// navidromeClientFactory seam in main is overridden) and for callers that want
// to defer or skip authentication. Production wiring should prefer NewClient.
func NewClientUnauthenticated(cfg config.NavidromeConfig) *NavidromeClient {
return &NavidromeClient{client: &subsonic.Client{
Client: &http.Client{Timeout: 30 * time.Second},
BaseUrl: cfg.URL,
User: cfg.User,
ClientName: "naviwatcher",
}}
}
// Ping checks connectivity to the Navidrome server.
// Returns nil if the server is reachable and responds with a valid Subsonic OK status.
func (nc *NavidromeClient) Ping() error {
resp, err := nc.client.Request("GET", "ping", nil)
if err != nil {
return fmt.Errorf("navidrome server is unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("navidrome server returned HTTP %d", resp.StatusCode)
}
// Check Subsonic application-level status: the server can return HTTP 200
// with status="failed" for auth errors or other issues.
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return fmt.Errorf("read ping response: %w", err)
}
var parsed subsonic.Response
if err := xml.Unmarshal(body, &parsed); err != nil {
return fmt.Errorf("parse ping response XML: %w", err)
}
if parsed.Status != "ok" && parsed.Error != nil {
return fmt.Errorf("navidrome ping failed: code %d: %s",
parsed.Error.Code, parsed.Error.Message)
}
return nil
}
// GetArtists fetches all artists from the Navidrome server.
// Returns a slice of ArtistInfo with ID and Name populated.
func (nc *NavidromeClient) GetArtists() ([]ArtistInfo, error) {
artists, err := nc.client.GetArtists(nil)
if err != nil {
return nil, fmt.Errorf("get artists: %w", err)
}
var result []ArtistInfo
for _, index := range artists.Index {
for _, artist := range index.Artist {
result = append(result, ArtistInfo{
ID: artist.ID,
Name: artist.Name,
})
}
}
return result, nil
}
// GetArtistAlbums fetches all albums for a given artist from the Navidrome server.
// The artistID should be the Subsonic ID of the artist.
// Returns a slice of AlbumInfo with ID, Name, and ArtistID populated.
func (nc *NavidromeClient) GetArtistAlbums(artistID string) ([]AlbumInfo, error) {
artist, err := nc.client.GetArtist(artistID)
if err != nil {
return nil, fmt.Errorf("get artist %s: %w", artistID, err)
}
var result []AlbumInfo
for _, album := range artist.Album {
result = append(result, AlbumInfo{
ID: album.ID,
Name: album.Name,
ArtistID: album.ArtistID,
})
}
return result, nil
}