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:
@@ -57,6 +57,11 @@ func (db *DB) Conn() *sql.DB {
|
||||
return db.conn
|
||||
}
|
||||
|
||||
// Begin starts a new database transaction.
|
||||
func (db *DB) Begin() (*sql.Tx, error) {
|
||||
return db.conn.Begin()
|
||||
}
|
||||
|
||||
// migrate runs all pending schema migrations in order.
|
||||
func (db *DB) migrate() error {
|
||||
// Create the migrations tracking table first, unconditionally.
|
||||
@@ -94,7 +99,16 @@ func (db *DB) migrate() error {
|
||||
);`,
|
||||
},
|
||||
{
|
||||
name: "003_create_notifications_sent",
|
||||
name: "003_create_local_albums",
|
||||
sql: `CREATE TABLE IF NOT EXISTS local_albums (
|
||||
id TEXT PRIMARY KEY,
|
||||
artist_id TEXT NOT NULL REFERENCES artist_settings(id),
|
||||
title TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_local_albums_artist_id ON local_albums(artist_id);`,
|
||||
},
|
||||
{
|
||||
name: "004_create_notifications_sent",
|
||||
sql: `CREATE TABLE IF NOT EXISTS notifications_sent (
|
||||
rgid TEXT NOT NULL REFERENCES external_releases(rgid),
|
||||
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -145,7 +159,6 @@ func (db *DB) isMigrationApplied(name string) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
|
||||
// ArtistSettings represents a row in the artist_settings table.
|
||||
type ArtistSettings struct {
|
||||
ID string `json:"id"`
|
||||
@@ -155,6 +168,13 @@ type ArtistSettings struct {
|
||||
Monitored bool `json:"monitored"`
|
||||
}
|
||||
|
||||
// LocalAlbum represents a row in the local_albums table.
|
||||
type LocalAlbum struct {
|
||||
ID string `json:"id"`
|
||||
ArtistID string `json:"artist_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// ExternalRelease represents a row in the external_releases table.
|
||||
type ExternalRelease struct {
|
||||
RGID string `json:"rgid"`
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestNew_InitializationAndSchema(t *testing.T) {
|
||||
"_migrations",
|
||||
"artist_settings",
|
||||
"external_releases",
|
||||
"local_albums",
|
||||
"notifications_sent",
|
||||
}
|
||||
|
||||
@@ -46,14 +47,14 @@ func TestNew_MigrationIdempotency(t *testing.T) {
|
||||
// Verify tables still exist.
|
||||
var count int
|
||||
err = db.Conn().QueryRow(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN (?,?,?,?)",
|
||||
"_migrations", "artist_settings", "external_releases", "notifications_sent",
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN (?,?,?,?,?)",
|
||||
"_migrations", "artist_settings", "external_releases", "local_albums", "notifications_sent",
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query error: %v", err)
|
||||
}
|
||||
if count != 4 {
|
||||
t.Errorf("expected 4 tables, got %d", count)
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 tables, got %d", count)
|
||||
}
|
||||
|
||||
db.Close()
|
||||
@@ -204,8 +205,8 @@ func TestMigrationTracking(t *testing.T) {
|
||||
t.Fatalf("query migrations count: %v", err)
|
||||
}
|
||||
|
||||
// We have 3 recorded migrations: artist_settings, external_releases, notifications_sent.
|
||||
if count != 3 {
|
||||
t.Errorf("expected 3 applied migrations, got %d", count)
|
||||
// We have 4 recorded migrations: artist_settings, external_releases, local_albums, notifications_sent.
|
||||
if count != 4 {
|
||||
t.Errorf("expected 4 applied migrations, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
103
internal/database/local_albums.go
Normal file
103
internal/database/local_albums.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SaveLocalAlbum inserts or replaces a local_albums row.
|
||||
func SaveLocalAlbum(db *DB, album *LocalAlbum) error {
|
||||
_, err := db.Conn().Exec(
|
||||
"INSERT OR REPLACE INTO local_albums (id, artist_id, title) VALUES (?, ?, ?)",
|
||||
album.ID, album.ArtistID, album.Title,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save local album: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveLocalAlbumTx inserts or replaces a local_albums row within an existing transaction.
|
||||
func SaveLocalAlbumTx(tx *sql.Tx, album *LocalAlbum) error {
|
||||
_, err := tx.Exec(
|
||||
"INSERT OR REPLACE INTO local_albums (id, artist_id, title) VALUES (?, ?, ?)",
|
||||
album.ID, album.ArtistID, album.Title,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save local album: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLocalAlbumsByArtistTx removes all local_albums rows for a given artist within an existing transaction.
|
||||
func DeleteLocalAlbumsByArtistTx(tx *sql.Tx, artistID string) error {
|
||||
_, err := tx.Exec(
|
||||
"DELETE FROM local_albums WHERE artist_id = ?",
|
||||
artistID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete local albums for artist %s: %w", artistID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLocalAlbumsByArtist removes all local_albums rows for a given artist.
|
||||
func DeleteLocalAlbumsByArtist(db *DB, artistID string) error {
|
||||
_, err := db.Conn().Exec(
|
||||
"DELETE FROM local_albums WHERE artist_id = ?",
|
||||
artistID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete local albums for artist %s: %w", artistID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLocalAlbumsByArtist returns all local_albums for a given artist.
|
||||
func GetLocalAlbumsByArtist(db *DB, artistID string) ([]LocalAlbum, error) {
|
||||
rows, err := db.Conn().Query(
|
||||
"SELECT id, artist_id, title FROM local_albums WHERE artist_id = ?",
|
||||
artistID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query local albums for artist %s: %w", artistID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []LocalAlbum
|
||||
for rows.Next() {
|
||||
var a LocalAlbum
|
||||
if err := rows.Scan(&a.ID, &a.ArtistID, &a.Title); err != nil {
|
||||
return nil, fmt.Errorf("scan local album: %w", err)
|
||||
}
|
||||
results = append(results, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate local albums: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetAllLocalAlbums returns all rows from local_albums.
|
||||
func GetAllLocalAlbums(db *DB) ([]LocalAlbum, error) {
|
||||
rows, err := db.Conn().Query(
|
||||
"SELECT id, artist_id, title FROM local_albums",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query all local albums: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []LocalAlbum
|
||||
for rows.Next() {
|
||||
var a LocalAlbum
|
||||
if err := rows.Scan(&a.ID, &a.ArtistID, &a.Title); err != nil {
|
||||
return nil, fmt.Errorf("scan local album: %w", err)
|
||||
}
|
||||
results = append(results, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate local albums: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
Reference in New Issue
Block a user