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:
179
docs/plans/2026-05-20-navidrome-client.md
Normal file
179
docs/plans/2026-05-20-navidrome-client.md
Normal file
@@ -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(`<subsonic-response status="ok" version="1.16.1">
|
||||
<artists>
|
||||
<index name="A">
|
||||
<artist id="1" name="Artist One"/>
|
||||
<artist id="2" name="Artist Two"/>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
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
|
||||
Reference in New Issue
Block a user