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:
2026-05-21 09:45:27 +03:00
parent 735ff0828e
commit 0065057514
13 changed files with 1388 additions and 12 deletions

View File

@@ -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"`

View File

@@ -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)
}
}

View 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
}