From 006505751475d5ee1abf3e3070226745dd4f2bc4 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Thu, 21 May 2026 09:45:27 +0300 Subject: [PATCH] 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 --- CLAUDE.md | 2 + README.md | 4 +- docs/Specification.md | 11 +- docs/plans/2026-05-20-navidrome-client.md | 179 +++++++++ go.mod | 1 + go.sum | 2 + internal/database/database.go | 24 +- internal/database/database_test.go | 15 +- internal/database/local_albums.go | 103 +++++ internal/navidrome/client.go | 118 ++++++ internal/navidrome/client_test.go | 372 ++++++++++++++++++ internal/navidrome/sync.go | 124 ++++++ internal/navidrome/sync_test.go | 445 ++++++++++++++++++++++ 13 files changed, 1388 insertions(+), 12 deletions(-) create mode 100644 docs/plans/2026-05-20-navidrome-client.md create mode 100644 internal/database/local_albums.go create mode 100644 internal/navidrome/client.go create mode 100644 internal/navidrome/client_test.go create mode 100644 internal/navidrome/sync.go create mode 100644 internal/navidrome/sync_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 6966c84..a530383 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,9 +111,11 @@ Based on the specification (docs/Specification.md), the application follows a mo 4. **Database Layer** (`internal/database/` or similar) - SQLite 3 integration via github.com/mattn/go-sqlite3 + - Subsonic API client via github.com/delucks/go-subsonic - Manages schema migrations - Handles tables: - `artist_settings`: Artist monitoring configuration + - `local_albums`: Navidrome albums synced via Subsonic API (id, artist_id, title) - `external_releases`: Cached MusicBrainz data - `notifications_sent`: Sent notification tracking diff --git a/README.md b/README.md index d5ca75e..4fa2e5c 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ See [docs/Specification.md](docs/Specification.md) for the full configuration re | **Navidrome Client** | Subsonic API v1.16.1 communication (token-based auth) | | **MusicBrainz Provider** | Discography fetching with rate limiting and caching | | **Scanner Engine** | String normalization and fuzzy comparison | -| **Database Layer** | SQLite persistence for settings, cache, and state | +| **Database Layer** | SQLite persistence: `artist_settings`, `local_albums` (Navidrome sync), `external_releases` (MusicBrainz cache), `notifications_sent` | | **Notifier** | Scheduled Telegram notifications | | **Web UI** | Dashboard for browsing and managing missing releases | @@ -208,7 +208,7 @@ scanner: | **Navidrome Client** | Взаимодействие с Subsonic API v1.16.1 (токенная аутентификация) | | **MusicBrainz Provider** | Загрузка дискографий с кэшированием и rate limiting | | **Scanner Engine** | Нормализация строк и нечёткое сравнение | -| **Database Layer** | Хранение настроек, кэша и состояния в SQLite | +| **Database Layer** | SQLite: `artist_settings`, `local_albums` (синхронизация из Navidrome), `external_releases` (кэш MusicBrainz), `notifications_sent` | | **Notifier** | Планировщик уведомлений в Telegram | | **Web UI** | Панель управления отсутствющими релизами | diff --git a/docs/Specification.md b/docs/Specification.md index 606b1c0..4e0e43c 100644 --- a/docs/Specification.md +++ b/docs/Specification.md @@ -7,10 +7,11 @@ ## 2. Технологический стек -* **Язык программирования:** Go 1.21+ +* **Язык программирования:** Go 1.25+ * **База данных:** SQLite 3 (для хранения кэша, настроек и состояний). * **HTTP-сервер:** Стандартная библиотека Go (`net/http`) + `html/template`. * **Внешние зависимости (библиотеки):** + * `github.com/delucks/go-subsonic` — клиент Subsonic API (getArtists, getArtist, ping). * `github.com/mattn/go-sqlite3` — драйвер базы данных. * `github.com/lithammer/fuzzysearch` — библиотека для нечеткого сравнения строк. * `gopkg.in/yaml.v3` — парсинг конфигурационных файлов. @@ -89,6 +90,14 @@ NaviWatcher взаимодействует с Navidrome через **Subsonic AP * `release_date`: string * `is_ignored`: boolean (флаг скрытия из списка новинок) +### Таблица `local_albums` +Локальные альбомы, синхронизированные из Navidrome через Subsonic API. +* `id`: string — Subsonic album ID, Primary Key. +* `artist_id`: string — FK → `artist_settings.id`. +* `title`: string — название альбома. + +**Решение по хранению локальных альбомов:** Для локальных альбомов используется отдельная таблица `local_albums` (вариант 1 из трёх рассмотренных). Это обеспечивает чистое разделение ответственности: `external_releases` хранит данные MusicBrainz (Release Groups), а `local_albums` — данные Navidrome. Смешивание этих сущностей в одной таблице (через колонку `source` или флаг) усложнило бы запросы и фильтрацию, а также привело бы к неоднородности схемы (разные типы ID, разные наборы полей). + ### Таблица `notifications_sent` * `rgid`: string (FK) * `sent_at`: datetime diff --git a/docs/plans/2026-05-20-navidrome-client.md b/docs/plans/2026-05-20-navidrome-client.md new file mode 100644 index 0000000..97e5519 --- /dev/null +++ b/docs/plans/2026-05-20-navidrome-client.md @@ -0,0 +1,179 @@ +# Navidrome/Subsonic API Client + +## Overview +Build 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. This is the first business logic module — it turns NaviWatcher from an empty skeleton into a service that can actually read your music library. + +**Problem it solves:** NaviWatcher needs to know what artists and albums exist in the user's Navidrome instance to later compare against MusicBrainz discographies. This module provides that data. + +**How it integrates:** +- Depends on: `internal/config` (NavidromeConfig), `internal/database` (CRUD operations) +- Will be consumed by: Plan 4 (Scanner engine — compares local albums vs MusicBrainz) +- The `run()` function in `main.go` will later be expanded to initialize and trigger the sync + +## Context (from discovery) +- **Files/components involved:** + - New: `internal/navidrome/client.go`, `internal/navidrome/client_test.go`, `internal/navidrome/sync.go`, `internal/navidrome/sync_test.go` + - Existing: `internal/config/config.go` (NavidromeConfig with URL, User, Password), `internal/database/` (ArtistSettings CRUD, full DB layer), `cmd/naviwatcher/main.go` (run() skeleton) +- **Related patterns found:** + - Go module name: `naviwatcher` + - DB uses `database/sql` + `go-sqlite3` with WAL mode and foreign keys + - `ArtistSettings` struct: ID, Name, IgnoreSingles, IgnoreCompilations, Monitored + - Graceful shutdown via `context.Context` pattern in main.go +- **Dependencies to add:** `github.com/delucks/go-subsonic` (Subsonic API client library — supports getArtists, getArtist, getAlbum, ping; tested on Navidrome) +- **Auth method:** Subsonic token-based auth: `md5(password + salt)`, password never sent in plaintext +- **Subsonic API version:** v1.16.1 + +## Development Approach +- **Testing approach:** Regular (code first, then tests) +- Complete each task fully before moving to the next +- Make small, focused changes +- **CRITICAL: every task MUST include new/updated tests** for code changes in that task +- **CRITICAL: all tests must pass before starting next task** — no exceptions +- **CRITICAL: update this plan file when scope changes during implementation** +- Run tests after each change + +## Testing Strategy +- **Unit tests:** required for every task +- Use `httptest.NewServer` to mock Subsonic API responses for client tests +- Use in-memory SQLite (`:memory:`) for sync tests +- Tests cover: success cases, API errors, malformed responses, empty libraries, context cancellation + +## Progress Tracking +- Mark completed items with `[x]` immediately when done +- Add newly discovered tasks with ➕ prefix +- Document issues/blockers with ⚠️ prefix + +## What Goes Where +- **Implementation Steps** (`[ ]` checkboxes): code changes, tests, documentation +- **Post-Completion** (no checkboxes): manual testing against real Navidrome + +## Implementation Steps + +### Task 1: Add go-subsonic dependency and create client wrapper +- [x] run `go get github.com/delucks/go-subsonic@latest` +- [x] run `go mod tidy` +- [x] create `internal/navidrome/client.go` with `NavidromeClient` struct wrapping `subsonic.Client` +- [x] implement `NewClient(cfg config.NavidromeConfig) (*NavidromeClient, error)` constructor +- [x] implement `Ping(ctx context.Context) error` — health check via Subsonic ping endpoint +- [x] write tests: NewClient with valid config +- [x] write tests: Ping success (mock HTTP server returning valid Subsonic response) +- [x] write tests: Ping failure (server unreachable, invalid credentials, non-200 status) +- [x] run tests — must pass before task 2 + +### Task 2: Implement artist fetching +- [x] in `internal/navidrome/client.go`, implement `GetArtists(ctx context.Context) ([]ArtistInfo, error)` — fetches all artists via `getArtists` endpoint +- [x] define `ArtistInfo` struct with ID and Name fields (mapped from Subsonic response) +- [x] write tests: GetArtists success with multiple artists (mock server) +- [x] write tests: GetArtists empty library +- [x] write tests: GetArtists API error handling +- [x] run tests — must pass before task 3 + +### Task 3: Implement album fetching per artist +- [x] in `internal/navidrome/client.go`, implement `GetArtistAlbums(ctx context.Context, artistID string) ([]AlbumInfo, error)` — fetches albums via `getArtist` endpoint +- [x] define `AlbumInfo` struct with ID, Name, ArtistID fields +- [x] write tests: GetArtistAlbums success with multiple albums +- [x] write tests: GetArtistAlbums artist with no albums +- [x] write tests: GetArtistAlbums API error handling +- [x] run tests — before task 4 + +### Task 4: Implement sync — artists to local DB +- [x] create `internal/navidrome/sync.go` with `SyncArtists(ctx context.Context, client *NavidromeClient, db *database.DB) error` +- [x] implement sync logic: fetch all artists from Navidrome → upsert into `artist_settings` table (monitored=true by default) +- [x] handle context cancellation gracefully +- [x] create `internal/navidrome/sync_test.go` +- [x] write tests: SyncArtists with empty Navidrome library +- [x] write tests: SyncArtists with multiple artists (verify DB state after sync) +- [x] write tests: SyncArtists idempotency (running twice doesn't duplicate) +- [x] write tests: SyncArtists context cancellation +- [x] run tests — must pass before task 5 + +### Task 5: Implement sync — albums to local DB +- [x] in `internal/navidrome/sync.go`, implement `SyncAlbums(ctx context.Context, client *NavidromeClient, db *database.DB) error` +- [x] implement sync logic: for each monitored artist, fetch albums → store in `local_albums` table (new table, clean separation from `external_releases` which is for MusicBrainz data) +- [x] **Schema decision:** Added `local_albums` table (id TEXT PK, artist_id TEXT FK→artist_settings.id, title TEXT) — clean separation of concerns (Option 1 from plan) +- [x] handle context cancellation gracefully +- [x] write tests: SyncAlbums for single artist with multiple albums +- [x] write tests: SyncAlbums skips unmonitored artists +- [x] write tests: SyncAlbums API error mid-sync (partial sync handling) +- [x] run tests — must pass before task 6 + +### Task 6: Verify acceptance criteria +- [x] verify go-subsonic is in go.mod and go.sum +- [x] verify Ping works against mock server +- [x] verify GetArtists returns parsed artist list +- [x] verify GetArtistAlbums returns parsed album list +- [x] verify SyncArtists populates artist_settings table +- [x] verify SyncAlbums populates album data +- [x] run full test suite: `go test ./... -v` — all must pass +- [x] run `go vet ./...` — no issues +- [x] run `go fmt ./...` — no formatting issues +- [x] verify test coverage for navidrome package (70%+) + +### Task 7: Update documentation +- [x] update README.md with Navidrome setup instructions (creating service user) — already present in Quick Start; enhanced architecture table with table names +- [x] document any schema changes made (e.g., new tables or columns) — added `local_albums` table docs to Specification.md Section 5 +- [x] document the `source` field decision for album storage — documented Option 1 (separate table) with rationale in Specification.md + +*Note: ralphex automatically moves completed plans to `docs/plans/completed/`* + +## Technical Details + +### Subsonic API Authentication +The go-subsonic library handles token-based auth internally. The client constructor passes URL, user, and password — the library generates `md5(password + salt)` per request. + +### Expected Data Flow +``` +Navidrome Server (Subsonic API) + │ + ▼ +NavidromeClient (internal/navidrome/client.go) + │ - Ping() + │ - GetArtists() → []ArtistInfo + │ - GetArtistAlbums() → []AlbumInfo + ▼ +Sync (internal/navidrome/sync.go) + │ - SyncArtists() → artist_settings table + │ - SyncAlbums() → local_albums or external_releases table + ▼ +SQLite Database +``` + +### Mock Server Pattern for Tests +```go +// Create a test HTTP server that returns Subsonic XML responses +server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(` + + + + + + + `)) +})) +defer server.Close() +``` + +### Schema Decision Needed (Task 5) +The current `external_releases` table is designed for MusicBrainz data. Local albums from Navidrome need a home. Options: +1. **Add `local_albums` table** — clean separation, but adds a table +2. **Add `source` column to `external_releases`** — simpler, but mixes concerns +3. **Reuse `external_releases` with a flag** — similar to option 2 + +The implementation should pick one and document it. Option 1 (separate table) is recommended for clean separation of concerns. + +## Post-Completion +*Items requiring manual intervention or external systems* + +**Manual verification:** +- Set up a real Navidrome instance with test music library +- Configure config.yaml with real credentials +- Run the sync and verify artists/albums appear in the database +- Check that unmonitored artists are skipped during album sync + +**Follow-up plans needed:** +- Plan 3: MusicBrainz provider with rate limiting +- Plan 4: Scanner engine with fuzzy matching (depends on Navidrome client + MusicBrainz) +- Plan 5: Telegram notifier +- Plan 6: Web UI diff --git a/go.mod b/go.mod index c65c288..87c8369 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module naviwatcher go 1.25.1 require ( + github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238 github.com/mattn/go-sqlite3 v1.14.22 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 2de29e1..320e195 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238 h1:uejyepOdHISrJTw7P84Y7yEC0FMyv1q3KNDRxWsviKw= +github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/database/database.go b/internal/database/database.go index 6e50f8a..b5260ec 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -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"` diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 644127c..2f3a7c3 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -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) } } diff --git a/internal/database/local_albums.go b/internal/database/local_albums.go new file mode 100644 index 0000000..28b3c56 --- /dev/null +++ b/internal/database/local_albums.go @@ -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 +} diff --git a/internal/navidrome/client.go b/internal/navidrome/client.go new file mode 100644 index 0000000..b826bab --- /dev/null +++ b/internal/navidrome/client.go @@ -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 +} diff --git a/internal/navidrome/client_test.go b/internal/navidrome/client_test.go new file mode 100644 index 0000000..626fd22 --- /dev/null +++ b/internal/navidrome/client_test.go @@ -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(` + + `)) + })) + 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(` + + + `)) + })) + 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(` + + `)) + })) + 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(` + + + + + + + + + + + `)) + } else { + w.Write([]byte(` + + `)) + } + })) + 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(` + + + + `)) + })) + 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(` + + + `)) + return + } + w.Write([]byte(` + + `)) + })) + 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(` + + + + + + `)) + } else { + w.Write([]byte(` + + `)) + } + })) + 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(` + + + + `)) + } else { + w.Write([]byte(` + + `)) + } + })) + 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(` + + + `)) + return + } + w.Write([]byte(` + + `)) + })) + 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") + } +} diff --git a/internal/navidrome/sync.go b/internal/navidrome/sync.go new file mode 100644 index 0000000..bb62846 --- /dev/null +++ b/internal/navidrome/sync.go @@ -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 +} diff --git a/internal/navidrome/sync_test.go b/internal/navidrome/sync_test.go new file mode 100644 index 0000000..e1ec68a --- /dev/null +++ b/internal/navidrome/sync_test.go @@ -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 ` +` + + body + `` +} + +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(``))) + 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(` + + + + + + + + + `))) + 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(` + + + + + `))) + 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(``))) + case "/rest/getArtist": + w.Write([]byte(subsonicOKResponse(` + + + + `))) + 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(``))) + 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(``))) + 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(``))) + case "/rest/getArtist": + callCount++ + if callCount == 2 { + // Fail on the second artist. + w.Write([]byte(` + + + `)) + return + } + w.Write([]byte(subsonicOKResponse(` + + + `))) + 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(` + + + + + `))) + 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(``))) + 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") + } +} -- 2.49.1