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
9.3 KiB
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 inmain.gowill 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)
- New:
- Related patterns found:
- Go module name:
naviwatcher - DB uses
database/sql+go-sqlite3with WAL mode and foreign keys ArtistSettingsstruct: ID, Name, IgnoreSingles, IgnoreCompilations, Monitored- Graceful shutdown via
context.Contextpattern in main.go
- Go module name:
- 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.NewServerto 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
- run
go get github.com/delucks/go-subsonic@latest - run
go mod tidy - create
internal/navidrome/client.gowithNavidromeClientstruct wrappingsubsonic.Client - implement
NewClient(cfg config.NavidromeConfig) (*NavidromeClient, error)constructor - implement
Ping(ctx context.Context) error— health check via Subsonic ping endpoint - write tests: NewClient with valid config
- write tests: Ping success (mock HTTP server returning valid Subsonic response)
- write tests: Ping failure (server unreachable, invalid credentials, non-200 status)
- run tests — must pass before task 2
Task 2: Implement artist fetching
- in
internal/navidrome/client.go, implementGetArtists(ctx context.Context) ([]ArtistInfo, error)— fetches all artists viagetArtistsendpoint - define
ArtistInfostruct with ID and Name fields (mapped from Subsonic response) - write tests: GetArtists success with multiple artists (mock server)
- write tests: GetArtists empty library
- write tests: GetArtists API error handling
- run tests — must pass before task 3
Task 3: Implement album fetching per artist
- in
internal/navidrome/client.go, implementGetArtistAlbums(ctx context.Context, artistID string) ([]AlbumInfo, error)— fetches albums viagetArtistendpoint - define
AlbumInfostruct with ID, Name, ArtistID fields - write tests: GetArtistAlbums success with multiple albums
- write tests: GetArtistAlbums artist with no albums
- write tests: GetArtistAlbums API error handling
- run tests — before task 4
Task 4: Implement sync — artists to local DB
- create
internal/navidrome/sync.gowithSyncArtists(ctx context.Context, client *NavidromeClient, db *database.DB) error - implement sync logic: fetch all artists from Navidrome → upsert into
artist_settingstable (monitored=true by default) - handle context cancellation gracefully
- create
internal/navidrome/sync_test.go - write tests: SyncArtists with empty Navidrome library
- write tests: SyncArtists with multiple artists (verify DB state after sync)
- write tests: SyncArtists idempotency (running twice doesn't duplicate)
- write tests: SyncArtists context cancellation
- run tests — must pass before task 5
Task 5: Implement sync — albums to local DB
- in
internal/navidrome/sync.go, implementSyncAlbums(ctx context.Context, client *NavidromeClient, db *database.DB) error - implement sync logic: for each monitored artist, fetch albums → store in
local_albumstable (new table, clean separation fromexternal_releaseswhich is for MusicBrainz data) - Schema decision: Added
local_albumstable (id TEXT PK, artist_id TEXT FK→artist_settings.id, title TEXT) — clean separation of concerns (Option 1 from plan) - handle context cancellation gracefully
- write tests: SyncAlbums for single artist with multiple albums
- write tests: SyncAlbums skips unmonitored artists
- write tests: SyncAlbums API error mid-sync (partial sync handling)
- run tests — must pass before task 6
Task 6: Verify acceptance criteria
- verify go-subsonic is in go.mod and go.sum
- verify Ping works against mock server
- verify GetArtists returns parsed artist list
- verify GetArtistAlbums returns parsed album list
- verify SyncArtists populates artist_settings table
- verify SyncAlbums populates album data
- run full test suite:
go test ./... -v— all must pass - run
go vet ./...— no issues - run
go fmt ./...— no formatting issues - verify test coverage for navidrome package (70%+)
Task 7: Update documentation
- update README.md with Navidrome setup instructions (creating service user) — already present in Quick Start; enhanced architecture table with table names
- document any schema changes made (e.g., new tables or columns) — added
local_albumstable docs to Specification.md Section 5 - document the
sourcefield 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
// 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:
- Add
local_albumstable — clean separation, but adds a table - Add
sourcecolumn toexternal_releases— simpler, but mixes concerns - Reuse
external_releaseswith 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