feat: add Navidrome client with Subsonic API sync
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
This commit is contained in:
118
internal/navidrome/client.go
Normal file
118
internal/navidrome/client.go
Normal file
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user