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

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