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
125 lines
3.8 KiB
Go
125 lines
3.8 KiB
Go
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
|
|
}
|