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
|
||||
}
|
||||
372
internal/navidrome/client_test.go
Normal file
372
internal/navidrome/client_test.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package navidrome
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/delucks/go-subsonic"
|
||||
"naviwatcher/internal/config"
|
||||
)
|
||||
|
||||
func TestNewClient_ValidConfig(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
client, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("NewClient() returned nil client")
|
||||
}
|
||||
if client.client == nil {
|
||||
t.Fatal("NewClient() returned client with nil subsonic client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_InvalidCredentials(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1">
|
||||
<error code="40" message="Wrong username or password."/>
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "baduser",
|
||||
Password: "badpass",
|
||||
}
|
||||
|
||||
_, err := NewClient(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("NewClient() expected error for invalid credentials, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
client, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
if err := client.Ping(); err != nil {
|
||||
t.Errorf("Ping() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_ServerUnreachable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
serverURL := server.URL
|
||||
server.Close()
|
||||
|
||||
client := &NavidromeClient{
|
||||
client: &subsonic.Client{
|
||||
Client: &http.Client{},
|
||||
BaseUrl: serverURL,
|
||||
User: "testuser",
|
||||
ClientName: "naviwatcher",
|
||||
},
|
||||
}
|
||||
|
||||
err := client.Ping()
|
||||
if err == nil {
|
||||
t.Error("Ping() expected error for unreachable server, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPing_Non200Status(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &NavidromeClient{
|
||||
client: &subsonic.Client{
|
||||
Client: &http.Client{},
|
||||
BaseUrl: server.URL,
|
||||
User: "testuser",
|
||||
ClientName: "naviwatcher",
|
||||
},
|
||||
}
|
||||
|
||||
err := client.Ping()
|
||||
if err == nil {
|
||||
t.Error("Ping() expected error for non-200 status, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtists_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtists" {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
<artists ignoredArticles="The El La Los Las Le Les">
|
||||
<index name="A">
|
||||
<artist id="1" name="Artist One" albumCount="3"/>
|
||||
<artist id="2" name="Artist Two" albumCount="1"/>
|
||||
</index>
|
||||
<index name="B">
|
||||
<artist id="3" name="Band Three" albumCount="5"/>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>`))
|
||||
} else {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
artists, err := nc.GetArtists()
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtists() error = %v", err)
|
||||
}
|
||||
|
||||
if len(artists) != 3 {
|
||||
t.Fatalf("GetArtists() returned %d artists, want 3", len(artists))
|
||||
}
|
||||
|
||||
expected := []ArtistInfo{
|
||||
{ID: "1", Name: "Artist One"},
|
||||
{ID: "2", Name: "Artist Two"},
|
||||
{ID: "3", Name: "Band Three"},
|
||||
}
|
||||
|
||||
for i, a := range artists {
|
||||
if a.ID != expected[i].ID || a.Name != expected[i].Name {
|
||||
t.Errorf("GetArtists()[%d] = {ID: %q, Name: %q}, want {ID: %q, Name: %q}",
|
||||
i, a.ID, a.Name, expected[i].ID, expected[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtists_EmptyLibrary(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
<artists ignoredArticles="The El La Los Las Le Les">
|
||||
</artists>
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
artists, err := nc.GetArtists()
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtists() error = %v", err)
|
||||
}
|
||||
|
||||
if len(artists) != 0 {
|
||||
t.Errorf("GetArtists() returned %d artists, want 0", len(artists))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtists_APIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtists" {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1">
|
||||
<error code="70" message="Requested resource not found"/>
|
||||
</subsonic-response>`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = nc.GetArtists()
|
||||
if err == nil {
|
||||
t.Fatal("GetArtists() expected error for API failure, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtistAlbums_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtist" {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
<artist id="1" name="Artist One" albumCount="2">
|
||||
<album id="101" name="First Album" artist="Artist One" artistId="1" songCount="10" duration="3600" created="2023-01-15T10:30:00Z"/>
|
||||
<album id="102" name="Second Album" artist="Artist One" artistId="1" songCount="8" duration="2800" created="2024-03-20T14:00:00Z"/>
|
||||
</artist>
|
||||
</subsonic-response>`))
|
||||
} else {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
albums, err := nc.GetArtistAlbums("1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistAlbums() error = %v", err)
|
||||
}
|
||||
|
||||
if len(albums) != 2 {
|
||||
t.Fatalf("GetArtistAlbums() returned %d albums, want 2", len(albums))
|
||||
}
|
||||
|
||||
expected := []AlbumInfo{
|
||||
{ID: "101", Name: "First Album", ArtistID: "1"},
|
||||
{ID: "102", Name: "Second Album", ArtistID: "1"},
|
||||
}
|
||||
|
||||
for i, a := range albums {
|
||||
if a.ID != expected[i].ID || a.Name != expected[i].Name || a.ArtistID != expected[i].ArtistID {
|
||||
t.Errorf("GetArtistAlbums()[%d] = {ID: %q, Name: %q, ArtistID: %q}, want {ID: %q, Name: %q, ArtistID: %q}",
|
||||
i, a.ID, a.Name, a.ArtistID, expected[i].ID, expected[i].Name, expected[i].ArtistID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtistAlbums_NoAlbums(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtist" {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
<artist id="5" name="Lonely Artist" albumCount="0">
|
||||
</artist>
|
||||
</subsonic-response>`))
|
||||
} else {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
albums, err := nc.GetArtistAlbums("5")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistAlbums() error = %v", err)
|
||||
}
|
||||
|
||||
if len(albums) != 0 {
|
||||
t.Errorf("GetArtistAlbums() returned %d albums, want 0", len(albums))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetArtistAlbums_APIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtist" {
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1">
|
||||
<error code="70" message="Requested resource not found"/>
|
||||
</subsonic-response>`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = nc.GetArtistAlbums("999")
|
||||
if err == nil {
|
||||
t.Fatal("GetArtistAlbums() expected error for API failure, got nil")
|
||||
}
|
||||
}
|
||||
124
internal/navidrome/sync.go
Normal file
124
internal/navidrome/sync.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package navidrome
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"naviwatcher/internal/database"
|
||||
)
|
||||
|
||||
// SyncAlbums fetches all albums from Navidrome for each monitored artist and
|
||||
// stores them in the local_albums table. For each artist, existing local albums
|
||||
// are deleted before inserting the fresh set, so the table always reflects the
|
||||
// current Navidrome state. Unmonitored artists are skipped.
|
||||
// Context cancellation is checked before each artist's album fetch.
|
||||
func SyncAlbums(ctx context.Context, client *NavidromeClient, db *database.DB) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("sync albums: %w", err)
|
||||
}
|
||||
|
||||
// Get all artists from the local database.
|
||||
artists, err := database.GetAllArtistSettings(db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync albums: get artists: %w", err)
|
||||
}
|
||||
|
||||
for _, artist := range artists {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("sync albums: %w", err)
|
||||
}
|
||||
|
||||
// Skip unmonitored artists.
|
||||
if !artist.Monitored {
|
||||
continue
|
||||
}
|
||||
|
||||
albums, err := client.GetArtistAlbums(artist.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync albums: get albums for artist %s: %w", artist.ID, err)
|
||||
}
|
||||
|
||||
// Delete existing albums for this artist and insert fresh set within
|
||||
// a transaction to prevent partial sync state on failure.
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync albums: begin transaction for artist %s: %w", artist.ID, err)
|
||||
}
|
||||
if err := database.DeleteLocalAlbumsByArtistTx(tx, artist.ID); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("sync albums: delete existing for artist %s: %w", artist.ID, err)
|
||||
}
|
||||
|
||||
for _, album := range albums {
|
||||
if err := ctx.Err(); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("sync albums: %w", err)
|
||||
}
|
||||
|
||||
localAlbum := &database.LocalAlbum{
|
||||
ID: album.ID,
|
||||
ArtistID: artist.ID,
|
||||
Title: album.Name,
|
||||
}
|
||||
if err := database.SaveLocalAlbumTx(tx, localAlbum); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("sync albums: save album %s: %w", album.ID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("sync albums: commit for artist %s: %w", artist.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncArtists fetches all artists from Navidrome and upserts them into the
|
||||
// local artist_settings table. New artists are inserted with monitored=true.
|
||||
// Existing artists have their name refreshed but their monitored/ignore
|
||||
// settings are preserved.
|
||||
// Context cancellation is checked before the API call and between individual
|
||||
// artist upserts.
|
||||
func SyncArtists(ctx context.Context, client *NavidromeClient, db *database.DB) error {
|
||||
// Check context before making the API call.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("sync artists: %w", err)
|
||||
}
|
||||
|
||||
artists, err := client.GetArtists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync artists: %w", err)
|
||||
}
|
||||
|
||||
for _, artist := range artists {
|
||||
// Check context cancellation between each upsert to allow
|
||||
// graceful interruption on large libraries.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("sync artists: %w", err)
|
||||
}
|
||||
|
||||
// Preserve existing user settings (monitored, ignore_singles,
|
||||
// ignore_compilations) if the row already exists.
|
||||
settings := &database.ArtistSettings{
|
||||
ID: artist.ID,
|
||||
Name: artist.Name,
|
||||
Monitored: true,
|
||||
}
|
||||
existing, err := database.GetArtistSettings(db, artist.ID)
|
||||
if err == nil {
|
||||
settings.Monitored = existing.Monitored
|
||||
settings.IgnoreSingles = existing.IgnoreSingles
|
||||
settings.IgnoreCompilations = existing.IgnoreCompilations
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("sync artists: get settings for artist %s: %w", artist.ID, err)
|
||||
}
|
||||
|
||||
if err := database.SaveArtistSettings(db, settings); err != nil {
|
||||
return fmt.Errorf("sync artists: save artist %s: %w", artist.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
445
internal/navidrome/sync_test.go
Normal file
445
internal/navidrome/sync_test.go
Normal file
@@ -0,0 +1,445 @@
|
||||
package navidrome
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"naviwatcher/internal/config"
|
||||
"naviwatcher/internal/database"
|
||||
)
|
||||
|
||||
// newTestServerAndClient creates a mock Subsonic server and a NavidromeClient
|
||||
// pointing at it. The handler receives the raw HTTP requests so tests can
|
||||
// inspect them if needed.
|
||||
func newTestServerAndClient(handler http.HandlerFunc) (*httptest.Server, *NavidromeClient) {
|
||||
server := httptest.NewServer(handler)
|
||||
cfg := config.NavidromeConfig{
|
||||
URL: server.URL,
|
||||
User: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
nc, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
server.Close()
|
||||
panic(fmt.Sprintf("NewClient() in test setup failed: %v", err))
|
||||
}
|
||||
return server, nc
|
||||
}
|
||||
|
||||
// subsonicOKResponse returns a minimal valid Subsonic XML response.
|
||||
func subsonicOKResponse(body string) string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">` +
|
||||
body + `</subsonic-response>`
|
||||
}
|
||||
|
||||
func TestSyncArtists_EmptyLibrary(t *testing.T) {
|
||||
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtists" {
|
||||
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(subsonicOKResponse("")))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("database.New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := SyncArtists(ctx, nc, db); err != nil {
|
||||
t.Fatalf("SyncArtists() error: %v", err)
|
||||
}
|
||||
|
||||
// Verify no artists in DB.
|
||||
artists, err := database.GetAllArtistSettings(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllArtistSettings() error: %v", err)
|
||||
}
|
||||
if len(artists) != 0 {
|
||||
t.Errorf("expected 0 artists in DB, got %d", len(artists))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncArtists_MultipleArtists(t *testing.T) {
|
||||
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtists" {
|
||||
w.Write([]byte(subsonicOKResponse(`
|
||||
<artists ignoredArticles="The">
|
||||
<index name="A">
|
||||
<artist id="1" name="Artist One" albumCount="3"/>
|
||||
<artist id="2" name="Artist Two" albumCount="1"/>
|
||||
</index>
|
||||
<index name="B">
|
||||
<artist id="3" name="Band Three" albumCount="5"/>
|
||||
</index>
|
||||
</artists>`)))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(subsonicOKResponse("")))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("database.New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := SyncArtists(ctx, nc, db); err != nil {
|
||||
t.Fatalf("SyncArtists() error: %v", err)
|
||||
}
|
||||
|
||||
artists, err := database.GetAllArtistSettings(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllArtistSettings() error: %v", err)
|
||||
}
|
||||
if len(artists) != 3 {
|
||||
t.Fatalf("expected 3 artists in DB, got %d", len(artists))
|
||||
}
|
||||
|
||||
// Build a map for order-independent comparison.
|
||||
byID := make(map[string]database.ArtistSettings)
|
||||
for _, a := range artists {
|
||||
byID[a.ID] = a
|
||||
}
|
||||
|
||||
expected := map[string]database.ArtistSettings{
|
||||
"1": {ID: "1", Name: "Artist One", Monitored: true},
|
||||
"2": {ID: "2", Name: "Artist Two", Monitored: true},
|
||||
"3": {ID: "3", Name: "Band Three", Monitored: true},
|
||||
}
|
||||
|
||||
for id, exp := range expected {
|
||||
got, ok := byID[id]
|
||||
if !ok {
|
||||
t.Errorf("expected artist %s in DB", id)
|
||||
continue
|
||||
}
|
||||
if got.Name != exp.Name {
|
||||
t.Errorf("artist %s: expected Name %q, got %q", id, exp.Name, got.Name)
|
||||
}
|
||||
if !got.Monitored {
|
||||
t.Errorf("artist %s: expected Monitored=true, got false", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncArtists_Idempotency(t *testing.T) {
|
||||
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtists" {
|
||||
w.Write([]byte(subsonicOKResponse(`
|
||||
<artists ignoredArticles="The">
|
||||
<index name="A">
|
||||
<artist id="1" name="Artist One" albumCount="2"/>
|
||||
</index>
|
||||
</artists>`)))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(subsonicOKResponse("")))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("database.New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First sync.
|
||||
if err := SyncArtists(ctx, nc, db); err != nil {
|
||||
t.Fatalf("first SyncArtists() error: %v", err)
|
||||
}
|
||||
|
||||
artists1, err := database.GetAllArtistSettings(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllArtistSettings() after first sync error: %v", err)
|
||||
}
|
||||
if len(artists1) != 1 {
|
||||
t.Fatalf("expected 1 artist after first sync, got %d", len(artists1))
|
||||
}
|
||||
|
||||
// Manually change monitored to false to verify it is preserved across syncs.
|
||||
if err := database.UpdateArtistSettings(db, "1", map[string]interface{}{"monitored": false}); err != nil {
|
||||
t.Fatalf("UpdateArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
// Second sync — should not duplicate, and should preserve monitored=false.
|
||||
if err := SyncArtists(ctx, nc, db); err != nil {
|
||||
t.Fatalf("second SyncArtists() error: %v", err)
|
||||
}
|
||||
|
||||
artists2, err := database.GetAllArtistSettings(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllArtistSettings() after second sync error: %v", err)
|
||||
}
|
||||
if len(artists2) != 1 {
|
||||
t.Fatalf("expected 1 artist after second sync (no duplicates), got %d", len(artists2))
|
||||
}
|
||||
|
||||
// Verify the artist was updated (monitored setting was preserved (not reset to true)).
|
||||
got, err := database.GetArtistSettings(db, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistSettings() error: %v", err)
|
||||
}
|
||||
if got.Monitored {
|
||||
t.Error("expected Monitored=false to be preserved after re-sync, got true")
|
||||
}
|
||||
if got.Name != "Artist One" {
|
||||
t.Errorf("expected Name 'Artist One', got %q", got.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAlbums_SingleArtist(t *testing.T) {
|
||||
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
switch r.URL.Path {
|
||||
case "/rest/getArtists":
|
||||
// No artists returned — we pre-seed the DB below.
|
||||
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
|
||||
case "/rest/getArtist":
|
||||
w.Write([]byte(subsonicOKResponse(`
|
||||
<artist id="1" name="Artist One" albumCount="2">
|
||||
<album id="101" name="First Album" artist="Artist One" artistId="1" songCount="10" duration="3600" created="2023-01-15T10:30:00Z"/>
|
||||
<album id="102" name="Second Album" artist="Artist One" artistId="1" songCount="8" duration="2800" created="2024-03-20T14:00:00Z"/>
|
||||
</artist>`)))
|
||||
default:
|
||||
w.Write([]byte(subsonicOKResponse("")))
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("database.New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Pre-seed a monitored artist.
|
||||
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
|
||||
ID: "1",
|
||||
Name: "Artist One",
|
||||
Monitored: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := SyncAlbums(ctx, nc, db); err != nil {
|
||||
t.Fatalf("SyncAlbums() error: %v", err)
|
||||
}
|
||||
|
||||
albums, err := database.GetLocalAlbumsByArtist(db, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLocalAlbumsByArtist() error: %v", err)
|
||||
}
|
||||
if len(albums) != 2 {
|
||||
t.Fatalf("expected 2 albums, got %d", len(albums))
|
||||
}
|
||||
|
||||
byID := make(map[string]database.LocalAlbum)
|
||||
for _, a := range albums {
|
||||
byID[a.ID] = a
|
||||
}
|
||||
|
||||
exp1 := database.LocalAlbum{ID: "101", ArtistID: "1", Title: "First Album"}
|
||||
if got, ok := byID["101"]; !ok {
|
||||
t.Error("expected album 101 in DB")
|
||||
} else if got != exp1 {
|
||||
t.Errorf("album 101 = %+v, want %+v", got, exp1)
|
||||
}
|
||||
|
||||
exp2 := database.LocalAlbum{ID: "102", ArtistID: "1", Title: "Second Album"}
|
||||
if got, ok := byID["102"]; !ok {
|
||||
t.Error("expected album 102 in DB")
|
||||
} else if got != exp2 {
|
||||
t.Errorf("album 102 = %+v, want %+v", got, exp2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAlbums_SkipsUnmonitored(t *testing.T) {
|
||||
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
switch r.URL.Path {
|
||||
case "/rest/getArtists":
|
||||
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
|
||||
case "/rest/getArtist":
|
||||
// This should NOT be called for unmonitored artist.
|
||||
t.Error("GetArtist should not be called for unmonitored artist")
|
||||
w.Write([]byte(subsonicOKResponse(`<artist id="2" name="Unmonitored" albumCount="0"></artist>`)))
|
||||
default:
|
||||
w.Write([]byte(subsonicOKResponse("")))
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("database.New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Pre-seed an unmonitored artist.
|
||||
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
|
||||
ID: "2",
|
||||
Name: "Unmonitored",
|
||||
Monitored: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := SyncAlbums(ctx, nc, db); err != nil {
|
||||
t.Fatalf("SyncAlbums() error: %v", err)
|
||||
}
|
||||
|
||||
// Verify no albums were stored.
|
||||
allAlbums, err := database.GetAllLocalAlbums(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllLocalAlbums() error: %v", err)
|
||||
}
|
||||
if len(allAlbums) != 0 {
|
||||
t.Errorf("expected 0 albums for unmonitored artist, got %d", len(allAlbums))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAlbums_APIErrorMidSync(t *testing.T) {
|
||||
callCount := 0
|
||||
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
switch r.URL.Path {
|
||||
case "/rest/getArtists":
|
||||
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
|
||||
case "/rest/getArtist":
|
||||
callCount++
|
||||
if callCount == 2 {
|
||||
// Fail on the second artist.
|
||||
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1">
|
||||
<error code="70" message="Requested resource not found"/>
|
||||
</subsonic-response>`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(subsonicOKResponse(`
|
||||
<artist id="` + r.URL.Query().Get("id") + `" name="Artist" albumCount="1">
|
||||
<album id="101" name="Album One" artist="Artist" artistId="1" songCount="5" duration="1800" created="2023-01-01T00:00:00Z"/>
|
||||
</artist>`)))
|
||||
default:
|
||||
w.Write([]byte(subsonicOKResponse("")))
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("database.New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Pre-seed two monitored artists.
|
||||
for _, id := range []string{"1", "2"} {
|
||||
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
|
||||
ID: id,
|
||||
Name: "Artist " + id,
|
||||
Monitored: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err = SyncAlbums(ctx, nc, db)
|
||||
if err == nil {
|
||||
t.Fatal("SyncAlbums() expected error for API failure mid-sync, got nil")
|
||||
}
|
||||
|
||||
// The first artist's albums should have been stored before the error.
|
||||
albums1, err := database.GetLocalAlbumsByArtist(db, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLocalAlbumsByArtist(1) error: %v", err)
|
||||
}
|
||||
if len(albums1) != 1 {
|
||||
t.Errorf("expected 1 album for artist 1 (synced before error), got %d", len(albums1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncArtists_ContextCancellation(t *testing.T) {
|
||||
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
if r.URL.Path == "/rest/getArtists" {
|
||||
w.Write([]byte(subsonicOKResponse(`
|
||||
<artists ignoredArticles="The">
|
||||
<index name="A">
|
||||
<artist id="1" name="Artist One" albumCount="1"/>
|
||||
</index>
|
||||
</artists>`)))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(subsonicOKResponse("")))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("database.New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Create a context that is already cancelled.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err = SyncArtists(ctx, nc, db)
|
||||
if err == nil {
|
||||
t.Fatal("SyncArtists() expected error for cancelled context, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncAlbums_ContextCancellation(t *testing.T) {
|
||||
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
switch r.URL.Path {
|
||||
case "/rest/getArtists":
|
||||
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
|
||||
default:
|
||||
w.Write([]byte(subsonicOKResponse("")))
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
db, err := database.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("database.New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Pre-seed a monitored artist so SyncAlbums has work to do.
|
||||
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
|
||||
ID: "1",
|
||||
Name: "Artist One",
|
||||
Monitored: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
// Create a context that is already cancelled.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err = SyncAlbums(ctx, nc, db)
|
||||
if err == nil {
|
||||
t.Fatal("SyncAlbums() expected error for cancelled context, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user