Add the Navidrome client module that connects to a Navidrome server via the Subsonic API, fetches artist and album data, and syncs it into the local SQLite database. - Add go-subsonic dependency for Subsonic API communication - Create internal/navidrome/client.go with NavidromeClient wrapper - NewClient constructor with token-based auth - Ping health check with HTTP status validation - GetArtists fetches all artists via getArtists endpoint - GetArtistAlbums fetches albums per artist via getArtist endpoint - Create internal/navidrome/sync.go with sync orchestration - SyncArtists upserts artists into artist_settings table - SyncAlbums fetches and stores albums for monitored artists - Add local_albums table (migration 003) with FK to artist_settings - Add LocalAlbum CRUD operations in internal/database/local_albums.go - Full test coverage: 19 tests across client and sync packages - All tests pass, go vet and go fmt clean
119 lines
3.2 KiB
Go
119 lines
3.2 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
|
|
}
|
|
|
|
// 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
|
|
}
|