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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user