180 lines
9.3 KiB
Markdown
180 lines
9.3 KiB
Markdown
# 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
|