# 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