13 Commits

Author SHA1 Message Date
5c2aadaab6 fix: address code review findings
- Add is_ignored=0 filter to GetUnnotifiedReleases query per spec section 4.4
  (notification lifecycle must exclude ignored releases)
- Add FK constraint on notifications_sent.rgid referencing external_releases(rgid)
  per spec schema definition
- Wrap migration application + recording in transactions for atomicity
- Add config.yaml to .gitignore to prevent accidental secret commits
- Pin Dockerfile base image to alpine:3.21 and add non-root appuser
- Add test TestGetUnnotifiedReleases_IgnoredExcluded

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:46:51 +03:00
c23b698f67 fix: address code review findings
- Fix notifications_sent schema: change to composite PK (rgid, sent_at) per plan spec, remove FK constraint
- Change MarkNotificationSent from INSERT OR REPLACE to INSERT (composite PK semantics)
- Update test: replace idempotent test with duplicate-second and different-time tests
- Refactor UpdateArtistSettings to use switch-based column validation instead of fmt.Sprintf with map lookup
- Remove generated coverage.out from repo, add to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:23:22 +03:00
47d4ec4e32 fix: address code review findings
- Fix notifications_sent PK: changed from (rgid, sent_at) to rgid-only PK
  to prevent duplicate RGID rows across seconds. Use INSERT OR REPLACE
  instead of INSERT OR IGNORE for true idempotency.
- Add foreign key constraints to DDL (artist_id references artist_settings,
  rgid references external_releases) per specification.
- Enable PRAGMA foreign_keys=ON and PRAGMA busy_timeout=5000 for concurrent
  access safety.
- Fix GetNotificationSentAt query: add ORDER BY sent_at DESC LIMIT 1 for
  deterministic results.
- Fix config test: change YAML key from 'chat' to 'chat_id' to match struct
  tag, add ChatID assertion.
- Fix migration tracking test: correct error message from "expected 4" to
  "expected 3".
- Remove dead code in TestLoadConfig_InvalidPort: eliminate unused YAML
  template and remove port 0 case (valid, not invalid).
- Remove unused path parameter from buildConfigWithPort helper.
- Remove pointless 100ms sleep in run() and unused time import.
- Remove tautological TestDefaultConfigPath test.
- Update README.md: Go version 1.21+ to 1.25+, placeholder passwords to
  CHANGE_ME.
- Update config.yaml.example: placeholder passwords to CHANGE_ME.
- Update all database tests to insert parent rows first for FK satisfaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 11:07:28 +03:00
fb74b5fbba feat: complete foundation layer documentation update (task 9)
Mark Task 9 checkboxes complete: README already has accurate build/run/test
instructions, config.yaml.example matches spec, no deviations from
specification found in the foundation layer implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:54:40 +03:00
2cb377614f feat: verify acceptance criteria for foundation layer
Mark Task 8 complete after verifying: go build succeeds, config loads
from YAML, all 3 database tables created via migrations, all CRUD
operations work, Docker builds, all 53 tests pass, go vet clean,
gofmt clean, coverage exceeds 70% for foundation packages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:51:02 +03:00
3206b10bd9 feat: add Docker setup with multi-stage build and docker-compose
- Add Dockerfile with golang:1.25-alpine build stage + alpine runtime stage
- Add docker-compose.yml with naviwatcher service and data volume
- Add .dockerignore for build context cleanup
- Add .gitignore for build artifacts and data directory
- Update plan: mark Task 7 checkboxes as complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:41:54 +03:00
68660e4d83 feat: add CRUD operations for notifications_sent table
Implement MarkNotificationSent, IsNotificationSent, and
GetUnnotifiedReleases with LEFT JOIN query. Includes 10
table-driven tests covering success, error, idempotent,
and edge cases. Also go fmt cleanup on existing files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:27:08 +03:00
3b87bfce96 feat: add CRUD operations for external_releases table
Implement GetExternalRelease, SaveExternalRelease,
GetExternalReleasesByArtist, GetIgnoredReleases, and
SetReleaseIgnored with 12 table-driven tests covering
success and error cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:15:37 +03:00
a052ccf11e feat: add CRUD operations for artist_settings table
Implements GetArtistSettings, SaveArtistSettings, GetAllArtistSettings,
and UpdateArtistSettings with full test coverage (10 new tests).
All 17 database tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 09:04:51 +03:00
da6668204f feat: add database layer with schema migrations
Add internal/database package with SQLite3 connection management,
versioned migration system, and three schema tables (artist_settings,
external_releases, notifications_sent). Includes 7 tests covering
initialization, migration idempotency, Close() behavior, and schema
validation using in-memory SQLite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 08:58:23 +03:00
6ff4ec3045 feat: add configuration management with YAML parsing and validation
Add internal/config package with Config struct (Server, Navidrome,
MusicBrainz, Telegram, Scanner sections), LoadConfig function for
YAML parsing, config validation (required fields, port range,
threshold range), and defaults. Include config.yaml.example matching
spec. Update main.go to use real config package instead of
placeholders. Add comprehensive tests covering valid configs,
defaults, missing files, malformed YAML, and validation boundaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 08:47:04 +03:00
eb266ed5ec feat: initialize Go module and project skeleton
- Initialize naviwatcher Go module
- Create directory structure: internal/config/, internal/database/, cmd/naviwatcher/
- Add main.go entry point with flag parsing, config loading placeholder, and graceful shutdown skeleton
- Add dependencies: go-sqlite3, yaml.v3
- Write tests for config loading and flag parsing
- All tests pass, go vet clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-20 08:33:08 +03:00
44f2652e7c add plan: foundation-layer 2026-05-20 08:27:13 +03:00
13 changed files with 12 additions and 1388 deletions

View File

@@ -111,11 +111,9 @@ Based on the specification (docs/Specification.md), the application follows a mo
4. **Database Layer** (`internal/database/` or similar)
- SQLite 3 integration via github.com/mattn/go-sqlite3
- Subsonic API client via github.com/delucks/go-subsonic
- Manages schema migrations
- Handles tables:
- `artist_settings`: Artist monitoring configuration
- `local_albums`: Navidrome albums synced via Subsonic API (id, artist_id, title)
- `external_releases`: Cached MusicBrainz data
- `notifications_sent`: Sent notification tracking

View File

@@ -101,7 +101,7 @@ See [docs/Specification.md](docs/Specification.md) for the full configuration re
| **Navidrome Client** | Subsonic API v1.16.1 communication (token-based auth) |
| **MusicBrainz Provider** | Discography fetching with rate limiting and caching |
| **Scanner Engine** | String normalization and fuzzy comparison |
| **Database Layer** | SQLite persistence: `artist_settings`, `local_albums` (Navidrome sync), `external_releases` (MusicBrainz cache), `notifications_sent` |
| **Database Layer** | SQLite persistence for settings, cache, and state |
| **Notifier** | Scheduled Telegram notifications |
| **Web UI** | Dashboard for browsing and managing missing releases |
@@ -208,7 +208,7 @@ scanner:
| **Navidrome Client** | Взаимодействие с Subsonic API v1.16.1 (токенная аутентификация) |
| **MusicBrainz Provider** | Загрузка дискографий с кэшированием и rate limiting |
| **Scanner Engine** | Нормализация строк и нечёткое сравнение |
| **Database Layer** | SQLite: `artist_settings`, `local_albums` (синхронизация из Navidrome), `external_releases` (кэш MusicBrainz), `notifications_sent` |
| **Database Layer** | Хранение настроек, кэша и состояния в SQLite |
| **Notifier** | Планировщик уведомлений в Telegram |
| **Web UI** | Панель управления отсутствющими релизами |

View File

@@ -7,11 +7,10 @@
## 2. Технологический стек
* **Язык программирования:** Go 1.25+
* **Язык программирования:** Go 1.21+
* **База данных:** SQLite 3 (для хранения кэша, настроек и состояний).
* **HTTP-сервер:** Стандартная библиотека Go (`net/http`) + `html/template`.
* **Внешние зависимости (библиотеки):**
* `github.com/delucks/go-subsonic` — клиент Subsonic API (getArtists, getArtist, ping).
* `github.com/mattn/go-sqlite3` — драйвер базы данных.
* `github.com/lithammer/fuzzysearch` — библиотека для нечеткого сравнения строк.
* `gopkg.in/yaml.v3` — парсинг конфигурационных файлов.
@@ -90,14 +89,6 @@ NaviWatcher взаимодействует с Navidrome через **Subsonic AP
* `release_date`: string
* `is_ignored`: boolean (флаг скрытия из списка новинок)
### Таблица `local_albums`
Локальные альбомы, синхронизированные из Navidrome через Subsonic API.
* `id`: string — Subsonic album ID, Primary Key.
* `artist_id`: string — FK → `artist_settings.id`.
* `title`: string — название альбома.
**Решение по хранению локальных альбомов:** Для локальных альбомов используется отдельная таблица `local_albums` (вариант 1 из трёх рассмотренных). Это обеспечивает чистое разделение ответственности: `external_releases` хранит данные MusicBrainz (Release Groups), а `local_albums` — данные Navidrome. Смешивание этих сущностей в одной таблице (через колонку `source` или флаг) усложнило бы запросы и фильтрацию, а также привело бы к неоднородности схемы (разные типы ID, разные наборы полей).
### Таблица `notifications_sent`
* `rgid`: string (FK)
* `sent_at`: datetime

View File

@@ -1,179 +0,0 @@
# 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

1
go.mod
View File

@@ -3,7 +3,6 @@ module naviwatcher
go 1.25.1
require (
github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238
github.com/mattn/go-sqlite3 v1.14.22
gopkg.in/yaml.v3 v3.0.1
)

2
go.sum
View File

@@ -1,5 +1,3 @@
github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238 h1:uejyepOdHISrJTw7P84Y7yEC0FMyv1q3KNDRxWsviKw=
github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238/go.mod h1:vnbEuj6Z20PLcHB4rrLQAOXGMjtULfMGhRVSFPcSdUo=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -57,11 +57,6 @@ func (db *DB) Conn() *sql.DB {
return db.conn
}
// Begin starts a new database transaction.
func (db *DB) Begin() (*sql.Tx, error) {
return db.conn.Begin()
}
// migrate runs all pending schema migrations in order.
func (db *DB) migrate() error {
// Create the migrations tracking table first, unconditionally.
@@ -99,16 +94,7 @@ func (db *DB) migrate() error {
);`,
},
{
name: "003_create_local_albums",
sql: `CREATE TABLE IF NOT EXISTS local_albums (
id TEXT PRIMARY KEY,
artist_id TEXT NOT NULL REFERENCES artist_settings(id),
title TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_local_albums_artist_id ON local_albums(artist_id);`,
},
{
name: "004_create_notifications_sent",
name: "003_create_notifications_sent",
sql: `CREATE TABLE IF NOT EXISTS notifications_sent (
rgid TEXT NOT NULL REFERENCES external_releases(rgid),
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -159,6 +145,7 @@ func (db *DB) isMigrationApplied(name string) (bool, error) {
return count > 0, nil
}
// ArtistSettings represents a row in the artist_settings table.
type ArtistSettings struct {
ID string `json:"id"`
@@ -168,13 +155,6 @@ type ArtistSettings struct {
Monitored bool `json:"monitored"`
}
// LocalAlbum represents a row in the local_albums table.
type LocalAlbum struct {
ID string `json:"id"`
ArtistID string `json:"artist_id"`
Title string `json:"title"`
}
// ExternalRelease represents a row in the external_releases table.
type ExternalRelease struct {
RGID string `json:"rgid"`

View File

@@ -16,7 +16,6 @@ func TestNew_InitializationAndSchema(t *testing.T) {
"_migrations",
"artist_settings",
"external_releases",
"local_albums",
"notifications_sent",
}
@@ -47,14 +46,14 @@ func TestNew_MigrationIdempotency(t *testing.T) {
// Verify tables still exist.
var count int
err = db.Conn().QueryRow(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN (?,?,?,?,?)",
"_migrations", "artist_settings", "external_releases", "local_albums", "notifications_sent",
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN (?,?,?,?)",
"_migrations", "artist_settings", "external_releases", "notifications_sent",
).Scan(&count)
if err != nil {
t.Fatalf("query error: %v", err)
}
if count != 5 {
t.Errorf("expected 5 tables, got %d", count)
if count != 4 {
t.Errorf("expected 4 tables, got %d", count)
}
db.Close()
@@ -205,8 +204,8 @@ func TestMigrationTracking(t *testing.T) {
t.Fatalf("query migrations count: %v", err)
}
// We have 4 recorded migrations: artist_settings, external_releases, local_albums, notifications_sent.
if count != 4 {
t.Errorf("expected 4 applied migrations, got %d", count)
// We have 3 recorded migrations: artist_settings, external_releases, notifications_sent.
if count != 3 {
t.Errorf("expected 3 applied migrations, got %d", count)
}
}

View File

@@ -1,103 +0,0 @@
package database
import (
"database/sql"
"fmt"
)
// SaveLocalAlbum inserts or replaces a local_albums row.
func SaveLocalAlbum(db *DB, album *LocalAlbum) error {
_, err := db.Conn().Exec(
"INSERT OR REPLACE INTO local_albums (id, artist_id, title) VALUES (?, ?, ?)",
album.ID, album.ArtistID, album.Title,
)
if err != nil {
return fmt.Errorf("save local album: %w", err)
}
return nil
}
// SaveLocalAlbumTx inserts or replaces a local_albums row within an existing transaction.
func SaveLocalAlbumTx(tx *sql.Tx, album *LocalAlbum) error {
_, err := tx.Exec(
"INSERT OR REPLACE INTO local_albums (id, artist_id, title) VALUES (?, ?, ?)",
album.ID, album.ArtistID, album.Title,
)
if err != nil {
return fmt.Errorf("save local album: %w", err)
}
return nil
}
// DeleteLocalAlbumsByArtistTx removes all local_albums rows for a given artist within an existing transaction.
func DeleteLocalAlbumsByArtistTx(tx *sql.Tx, artistID string) error {
_, err := tx.Exec(
"DELETE FROM local_albums WHERE artist_id = ?",
artistID,
)
if err != nil {
return fmt.Errorf("delete local albums for artist %s: %w", artistID, err)
}
return nil
}
// DeleteLocalAlbumsByArtist removes all local_albums rows for a given artist.
func DeleteLocalAlbumsByArtist(db *DB, artistID string) error {
_, err := db.Conn().Exec(
"DELETE FROM local_albums WHERE artist_id = ?",
artistID,
)
if err != nil {
return fmt.Errorf("delete local albums for artist %s: %w", artistID, err)
}
return nil
}
// GetLocalAlbumsByArtist returns all local_albums for a given artist.
func GetLocalAlbumsByArtist(db *DB, artistID string) ([]LocalAlbum, error) {
rows, err := db.Conn().Query(
"SELECT id, artist_id, title FROM local_albums WHERE artist_id = ?",
artistID,
)
if err != nil {
return nil, fmt.Errorf("query local albums for artist %s: %w", artistID, err)
}
defer rows.Close()
var results []LocalAlbum
for rows.Next() {
var a LocalAlbum
if err := rows.Scan(&a.ID, &a.ArtistID, &a.Title); err != nil {
return nil, fmt.Errorf("scan local album: %w", err)
}
results = append(results, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate local albums: %w", err)
}
return results, nil
}
// GetAllLocalAlbums returns all rows from local_albums.
func GetAllLocalAlbums(db *DB) ([]LocalAlbum, error) {
rows, err := db.Conn().Query(
"SELECT id, artist_id, title FROM local_albums",
)
if err != nil {
return nil, fmt.Errorf("query all local albums: %w", err)
}
defer rows.Close()
var results []LocalAlbum
for rows.Next() {
var a LocalAlbum
if err := rows.Scan(&a.ID, &a.ArtistID, &a.Title); err != nil {
return nil, fmt.Errorf("scan local album: %w", err)
}
results = append(results, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate local albums: %w", err)
}
return results, nil
}

View File

@@ -1,118 +0,0 @@
package navidrome
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"time"
"naviwatcher/internal/config"
"github.com/delucks/go-subsonic"
)
// ArtistInfo represents a simplified artist from the Subsonic API.
type ArtistInfo struct {
ID string
Name string
}
// AlbumInfo represents a simplified album from the Subsonic API.
type AlbumInfo struct {
ID string
Name string
ArtistID string
}
// NavidromeClient wraps the go-subsonic Client with application-specific configuration.
type NavidromeClient struct {
client *subsonic.Client
}
// NewClient creates a new NavidromeClient from the given configuration.
// It authenticates with the server immediately, returning an error if auth fails.
func NewClient(cfg config.NavidromeConfig) (*NavidromeClient, error) {
client := &subsonic.Client{
Client: &http.Client{Timeout: 30 * time.Second},
BaseUrl: cfg.URL,
User: cfg.User,
ClientName: "naviwatcher",
}
if err := client.Authenticate(cfg.Password); err != nil {
return nil, fmt.Errorf("authenticate with navidrome: %w", err)
}
return &NavidromeClient{client: client}, nil
}
// Ping checks connectivity to the Navidrome server.
// Returns nil if the server is reachable and responds with a valid Subsonic OK status.
func (nc *NavidromeClient) Ping() error {
resp, err := nc.client.Request("GET", "ping", nil)
if err != nil {
return fmt.Errorf("navidrome server is unreachable: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("navidrome server returned HTTP %d", resp.StatusCode)
}
// Check Subsonic application-level status: the server can return HTTP 200
// with status="failed" for auth errors or other issues.
body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return fmt.Errorf("read ping response: %w", err)
}
var parsed subsonic.Response
if err := xml.Unmarshal(body, &parsed); err != nil {
return fmt.Errorf("parse ping response XML: %w", err)
}
if parsed.Status != "ok" && parsed.Error != nil {
return fmt.Errorf("navidrome ping failed: code %d: %s",
parsed.Error.Code, parsed.Error.Message)
}
return nil
}
// GetArtists fetches all artists from the Navidrome server.
// Returns a slice of ArtistInfo with ID and Name populated.
func (nc *NavidromeClient) GetArtists() ([]ArtistInfo, error) {
artists, err := nc.client.GetArtists(nil)
if err != nil {
return nil, fmt.Errorf("get artists: %w", err)
}
var result []ArtistInfo
for _, index := range artists.Index {
for _, artist := range index.Artist {
result = append(result, ArtistInfo{
ID: artist.ID,
Name: artist.Name,
})
}
}
return result, nil
}
// GetArtistAlbums fetches all albums for a given artist from the Navidrome server.
// The artistID should be the Subsonic ID of the artist.
// Returns a slice of AlbumInfo with ID, Name, and ArtistID populated.
func (nc *NavidromeClient) GetArtistAlbums(artistID string) ([]AlbumInfo, error) {
artist, err := nc.client.GetArtist(artistID)
if err != nil {
return nil, fmt.Errorf("get artist %s: %w", artistID, err)
}
var result []AlbumInfo
for _, album := range artist.Album {
result = append(result, AlbumInfo{
ID: album.ID,
Name: album.Name,
ArtistID: album.ArtistID,
})
}
return result, nil
}

View File

@@ -1,372 +0,0 @@
package navidrome
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/delucks/go-subsonic"
"naviwatcher/internal/config"
)
func TestNewClient_ValidConfig(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
</subsonic-response>`))
}))
defer server.Close()
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
client, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if client == nil {
t.Fatal("NewClient() returned nil client")
}
if client.client == nil {
t.Fatal("NewClient() returned client with nil subsonic client")
}
}
func TestNewClient_InvalidCredentials(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1">
<error code="40" message="Wrong username or password."/>
</subsonic-response>`))
}))
defer server.Close()
cfg := config.NavidromeConfig{
URL: server.URL,
User: "baduser",
Password: "badpass",
}
_, err := NewClient(cfg)
if err == nil {
t.Fatal("NewClient() expected error for invalid credentials, got nil")
}
}
func TestPing_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
</subsonic-response>`))
}))
defer server.Close()
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
client, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
if err := client.Ping(); err != nil {
t.Errorf("Ping() error = %v", err)
}
}
func TestPing_ServerUnreachable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
serverURL := server.URL
server.Close()
client := &NavidromeClient{
client: &subsonic.Client{
Client: &http.Client{},
BaseUrl: serverURL,
User: "testuser",
ClientName: "naviwatcher",
},
}
err := client.Ping()
if err == nil {
t.Error("Ping() expected error for unreachable server, got nil")
}
}
func TestPing_Non200Status(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
}))
defer server.Close()
client := &NavidromeClient{
client: &subsonic.Client{
Client: &http.Client{},
BaseUrl: server.URL,
User: "testuser",
ClientName: "naviwatcher",
},
}
err := client.Ping()
if err == nil {
t.Error("Ping() expected error for non-200 status, got nil")
}
}
func TestGetArtists_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
<artists ignoredArticles="The El La Los Las Le Les">
<index name="A">
<artist id="1" name="Artist One" albumCount="3"/>
<artist id="2" name="Artist Two" albumCount="1"/>
</index>
<index name="B">
<artist id="3" name="Band Three" albumCount="5"/>
</index>
</artists>
</subsonic-response>`))
} else {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
</subsonic-response>`))
}
}))
defer server.Close()
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
nc, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
artists, err := nc.GetArtists()
if err != nil {
t.Fatalf("GetArtists() error = %v", err)
}
if len(artists) != 3 {
t.Fatalf("GetArtists() returned %d artists, want 3", len(artists))
}
expected := []ArtistInfo{
{ID: "1", Name: "Artist One"},
{ID: "2", Name: "Artist Two"},
{ID: "3", Name: "Band Three"},
}
for i, a := range artists {
if a.ID != expected[i].ID || a.Name != expected[i].Name {
t.Errorf("GetArtists()[%d] = {ID: %q, Name: %q}, want {ID: %q, Name: %q}",
i, a.ID, a.Name, expected[i].ID, expected[i].Name)
}
}
}
func TestGetArtists_EmptyLibrary(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
<artists ignoredArticles="The El La Los Las Le Les">
</artists>
</subsonic-response>`))
}))
defer server.Close()
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
nc, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
artists, err := nc.GetArtists()
if err != nil {
t.Fatalf("GetArtists() error = %v", err)
}
if len(artists) != 0 {
t.Errorf("GetArtists() returned %d artists, want 0", len(artists))
}
}
func TestGetArtists_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1">
<error code="70" message="Requested resource not found"/>
</subsonic-response>`))
return
}
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
</subsonic-response>`))
}))
defer server.Close()
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
nc, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = nc.GetArtists()
if err == nil {
t.Fatal("GetArtists() expected error for API failure, got nil")
}
}
func TestGetArtistAlbums_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtist" {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
<artist id="1" name="Artist One" albumCount="2">
<album id="101" name="First Album" artist="Artist One" artistId="1" songCount="10" duration="3600" created="2023-01-15T10:30:00Z"/>
<album id="102" name="Second Album" artist="Artist One" artistId="1" songCount="8" duration="2800" created="2024-03-20T14:00:00Z"/>
</artist>
</subsonic-response>`))
} else {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
</subsonic-response>`))
}
}))
defer server.Close()
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
nc, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
albums, err := nc.GetArtistAlbums("1")
if err != nil {
t.Fatalf("GetArtistAlbums() error = %v", err)
}
if len(albums) != 2 {
t.Fatalf("GetArtistAlbums() returned %d albums, want 2", len(albums))
}
expected := []AlbumInfo{
{ID: "101", Name: "First Album", ArtistID: "1"},
{ID: "102", Name: "Second Album", ArtistID: "1"},
}
for i, a := range albums {
if a.ID != expected[i].ID || a.Name != expected[i].Name || a.ArtistID != expected[i].ArtistID {
t.Errorf("GetArtistAlbums()[%d] = {ID: %q, Name: %q, ArtistID: %q}, want {ID: %q, Name: %q, ArtistID: %q}",
i, a.ID, a.Name, a.ArtistID, expected[i].ID, expected[i].Name, expected[i].ArtistID)
}
}
}
func TestGetArtistAlbums_NoAlbums(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtist" {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
<artist id="5" name="Lonely Artist" albumCount="0">
</artist>
</subsonic-response>`))
} else {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
</subsonic-response>`))
}
}))
defer server.Close()
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
nc, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
albums, err := nc.GetArtistAlbums("5")
if err != nil {
t.Fatalf("GetArtistAlbums() error = %v", err)
}
if len(albums) != 0 {
t.Errorf("GetArtistAlbums() returned %d albums, want 0", len(albums))
}
}
func TestGetArtistAlbums_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtist" {
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1">
<error code="70" message="Requested resource not found"/>
</subsonic-response>`))
return
}
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">
</subsonic-response>`))
}))
defer server.Close()
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
nc, err := NewClient(cfg)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = nc.GetArtistAlbums("999")
if err == nil {
t.Fatal("GetArtistAlbums() expected error for API failure, got nil")
}
}

View File

@@ -1,124 +0,0 @@
package navidrome
import (
"context"
"database/sql"
"errors"
"fmt"
"naviwatcher/internal/database"
)
// SyncAlbums fetches all albums from Navidrome for each monitored artist and
// stores them in the local_albums table. For each artist, existing local albums
// are deleted before inserting the fresh set, so the table always reflects the
// current Navidrome state. Unmonitored artists are skipped.
// Context cancellation is checked before each artist's album fetch.
func SyncAlbums(ctx context.Context, client *NavidromeClient, db *database.DB) error {
if err := ctx.Err(); err != nil {
return fmt.Errorf("sync albums: %w", err)
}
// Get all artists from the local database.
artists, err := database.GetAllArtistSettings(db)
if err != nil {
return fmt.Errorf("sync albums: get artists: %w", err)
}
for _, artist := range artists {
if err := ctx.Err(); err != nil {
return fmt.Errorf("sync albums: %w", err)
}
// Skip unmonitored artists.
if !artist.Monitored {
continue
}
albums, err := client.GetArtistAlbums(artist.ID)
if err != nil {
return fmt.Errorf("sync albums: get albums for artist %s: %w", artist.ID, err)
}
// Delete existing albums for this artist and insert fresh set within
// a transaction to prevent partial sync state on failure.
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("sync albums: begin transaction for artist %s: %w", artist.ID, err)
}
if err := database.DeleteLocalAlbumsByArtistTx(tx, artist.ID); err != nil {
tx.Rollback()
return fmt.Errorf("sync albums: delete existing for artist %s: %w", artist.ID, err)
}
for _, album := range albums {
if err := ctx.Err(); err != nil {
tx.Rollback()
return fmt.Errorf("sync albums: %w", err)
}
localAlbum := &database.LocalAlbum{
ID: album.ID,
ArtistID: artist.ID,
Title: album.Name,
}
if err := database.SaveLocalAlbumTx(tx, localAlbum); err != nil {
tx.Rollback()
return fmt.Errorf("sync albums: save album %s: %w", album.ID, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("sync albums: commit for artist %s: %w", artist.ID, err)
}
}
return nil
}
// SyncArtists fetches all artists from Navidrome and upserts them into the
// local artist_settings table. New artists are inserted with monitored=true.
// Existing artists have their name refreshed but their monitored/ignore
// settings are preserved.
// Context cancellation is checked before the API call and between individual
// artist upserts.
func SyncArtists(ctx context.Context, client *NavidromeClient, db *database.DB) error {
// Check context before making the API call.
if err := ctx.Err(); err != nil {
return fmt.Errorf("sync artists: %w", err)
}
artists, err := client.GetArtists()
if err != nil {
return fmt.Errorf("sync artists: %w", err)
}
for _, artist := range artists {
// Check context cancellation between each upsert to allow
// graceful interruption on large libraries.
if err := ctx.Err(); err != nil {
return fmt.Errorf("sync artists: %w", err)
}
// Preserve existing user settings (monitored, ignore_singles,
// ignore_compilations) if the row already exists.
settings := &database.ArtistSettings{
ID: artist.ID,
Name: artist.Name,
Monitored: true,
}
existing, err := database.GetArtistSettings(db, artist.ID)
if err == nil {
settings.Monitored = existing.Monitored
settings.IgnoreSingles = existing.IgnoreSingles
settings.IgnoreCompilations = existing.IgnoreCompilations
} else if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("sync artists: get settings for artist %s: %w", artist.ID, err)
}
if err := database.SaveArtistSettings(db, settings); err != nil {
return fmt.Errorf("sync artists: save artist %s: %w", artist.ID, err)
}
}
return nil
}

View File

@@ -1,445 +0,0 @@
package navidrome
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
)
// newTestServerAndClient creates a mock Subsonic server and a NavidromeClient
// pointing at it. The handler receives the raw HTTP requests so tests can
// inspect them if needed.
func newTestServerAndClient(handler http.HandlerFunc) (*httptest.Server, *NavidromeClient) {
server := httptest.NewServer(handler)
cfg := config.NavidromeConfig{
URL: server.URL,
User: "testuser",
Password: "testpass",
}
nc, err := NewClient(cfg)
if err != nil {
server.Close()
panic(fmt.Sprintf("NewClient() in test setup failed: %v", err))
}
return server, nc
}
// subsonicOKResponse returns a minimal valid Subsonic XML response.
func subsonicOKResponse(body string) string {
return `<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1">` +
body + `</subsonic-response>`
}
func TestSyncArtists_EmptyLibrary(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
return
}
w.Write([]byte(subsonicOKResponse("")))
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
ctx := context.Background()
if err := SyncArtists(ctx, nc, db); err != nil {
t.Fatalf("SyncArtists() error: %v", err)
}
// Verify no artists in DB.
artists, err := database.GetAllArtistSettings(db)
if err != nil {
t.Fatalf("GetAllArtistSettings() error: %v", err)
}
if len(artists) != 0 {
t.Errorf("expected 0 artists in DB, got %d", len(artists))
}
}
func TestSyncArtists_MultipleArtists(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(subsonicOKResponse(`
<artists ignoredArticles="The">
<index name="A">
<artist id="1" name="Artist One" albumCount="3"/>
<artist id="2" name="Artist Two" albumCount="1"/>
</index>
<index name="B">
<artist id="3" name="Band Three" albumCount="5"/>
</index>
</artists>`)))
return
}
w.Write([]byte(subsonicOKResponse("")))
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
ctx := context.Background()
if err := SyncArtists(ctx, nc, db); err != nil {
t.Fatalf("SyncArtists() error: %v", err)
}
artists, err := database.GetAllArtistSettings(db)
if err != nil {
t.Fatalf("GetAllArtistSettings() error: %v", err)
}
if len(artists) != 3 {
t.Fatalf("expected 3 artists in DB, got %d", len(artists))
}
// Build a map for order-independent comparison.
byID := make(map[string]database.ArtistSettings)
for _, a := range artists {
byID[a.ID] = a
}
expected := map[string]database.ArtistSettings{
"1": {ID: "1", Name: "Artist One", Monitored: true},
"2": {ID: "2", Name: "Artist Two", Monitored: true},
"3": {ID: "3", Name: "Band Three", Monitored: true},
}
for id, exp := range expected {
got, ok := byID[id]
if !ok {
t.Errorf("expected artist %s in DB", id)
continue
}
if got.Name != exp.Name {
t.Errorf("artist %s: expected Name %q, got %q", id, exp.Name, got.Name)
}
if !got.Monitored {
t.Errorf("artist %s: expected Monitored=true, got false", id)
}
}
}
func TestSyncArtists_Idempotency(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(subsonicOKResponse(`
<artists ignoredArticles="The">
<index name="A">
<artist id="1" name="Artist One" albumCount="2"/>
</index>
</artists>`)))
return
}
w.Write([]byte(subsonicOKResponse("")))
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
ctx := context.Background()
// First sync.
if err := SyncArtists(ctx, nc, db); err != nil {
t.Fatalf("first SyncArtists() error: %v", err)
}
artists1, err := database.GetAllArtistSettings(db)
if err != nil {
t.Fatalf("GetAllArtistSettings() after first sync error: %v", err)
}
if len(artists1) != 1 {
t.Fatalf("expected 1 artist after first sync, got %d", len(artists1))
}
// Manually change monitored to false to verify it is preserved across syncs.
if err := database.UpdateArtistSettings(db, "1", map[string]interface{}{"monitored": false}); err != nil {
t.Fatalf("UpdateArtistSettings() error: %v", err)
}
// Second sync — should not duplicate, and should preserve monitored=false.
if err := SyncArtists(ctx, nc, db); err != nil {
t.Fatalf("second SyncArtists() error: %v", err)
}
artists2, err := database.GetAllArtistSettings(db)
if err != nil {
t.Fatalf("GetAllArtistSettings() after second sync error: %v", err)
}
if len(artists2) != 1 {
t.Fatalf("expected 1 artist after second sync (no duplicates), got %d", len(artists2))
}
// Verify the artist was updated (monitored setting was preserved (not reset to true)).
got, err := database.GetArtistSettings(db, "1")
if err != nil {
t.Fatalf("GetArtistSettings() error: %v", err)
}
if got.Monitored {
t.Error("expected Monitored=false to be preserved after re-sync, got true")
}
if got.Name != "Artist One" {
t.Errorf("expected Name 'Artist One', got %q", got.Name)
}
}
func TestSyncAlbums_SingleArtist(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
switch r.URL.Path {
case "/rest/getArtists":
// No artists returned — we pre-seed the DB below.
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
case "/rest/getArtist":
w.Write([]byte(subsonicOKResponse(`
<artist id="1" name="Artist One" albumCount="2">
<album id="101" name="First Album" artist="Artist One" artistId="1" songCount="10" duration="3600" created="2023-01-15T10:30:00Z"/>
<album id="102" name="Second Album" artist="Artist One" artistId="1" songCount="8" duration="2800" created="2024-03-20T14:00:00Z"/>
</artist>`)))
default:
w.Write([]byte(subsonicOKResponse("")))
}
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Pre-seed a monitored artist.
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: "1",
Name: "Artist One",
Monitored: true,
}); err != nil {
t.Fatalf("SaveArtistSettings() error: %v", err)
}
ctx := context.Background()
if err := SyncAlbums(ctx, nc, db); err != nil {
t.Fatalf("SyncAlbums() error: %v", err)
}
albums, err := database.GetLocalAlbumsByArtist(db, "1")
if err != nil {
t.Fatalf("GetLocalAlbumsByArtist() error: %v", err)
}
if len(albums) != 2 {
t.Fatalf("expected 2 albums, got %d", len(albums))
}
byID := make(map[string]database.LocalAlbum)
for _, a := range albums {
byID[a.ID] = a
}
exp1 := database.LocalAlbum{ID: "101", ArtistID: "1", Title: "First Album"}
if got, ok := byID["101"]; !ok {
t.Error("expected album 101 in DB")
} else if got != exp1 {
t.Errorf("album 101 = %+v, want %+v", got, exp1)
}
exp2 := database.LocalAlbum{ID: "102", ArtistID: "1", Title: "Second Album"}
if got, ok := byID["102"]; !ok {
t.Error("expected album 102 in DB")
} else if got != exp2 {
t.Errorf("album 102 = %+v, want %+v", got, exp2)
}
}
func TestSyncAlbums_SkipsUnmonitored(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
switch r.URL.Path {
case "/rest/getArtists":
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
case "/rest/getArtist":
// This should NOT be called for unmonitored artist.
t.Error("GetArtist should not be called for unmonitored artist")
w.Write([]byte(subsonicOKResponse(`<artist id="2" name="Unmonitored" albumCount="0"></artist>`)))
default:
w.Write([]byte(subsonicOKResponse("")))
}
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Pre-seed an unmonitored artist.
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: "2",
Name: "Unmonitored",
Monitored: false,
}); err != nil {
t.Fatalf("SaveArtistSettings() error: %v", err)
}
ctx := context.Background()
if err := SyncAlbums(ctx, nc, db); err != nil {
t.Fatalf("SyncAlbums() error: %v", err)
}
// Verify no albums were stored.
allAlbums, err := database.GetAllLocalAlbums(db)
if err != nil {
t.Fatalf("GetAllLocalAlbums() error: %v", err)
}
if len(allAlbums) != 0 {
t.Errorf("expected 0 albums for unmonitored artist, got %d", len(allAlbums))
}
}
func TestSyncAlbums_APIErrorMidSync(t *testing.T) {
callCount := 0
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
switch r.URL.Path {
case "/rest/getArtists":
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
case "/rest/getArtist":
callCount++
if callCount == 2 {
// Fail on the second artist.
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1">
<error code="70" message="Requested resource not found"/>
</subsonic-response>`))
return
}
w.Write([]byte(subsonicOKResponse(`
<artist id="` + r.URL.Query().Get("id") + `" name="Artist" albumCount="1">
<album id="101" name="Album One" artist="Artist" artistId="1" songCount="5" duration="1800" created="2023-01-01T00:00:00Z"/>
</artist>`)))
default:
w.Write([]byte(subsonicOKResponse("")))
}
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Pre-seed two monitored artists.
for _, id := range []string{"1", "2"} {
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: id,
Name: "Artist " + id,
Monitored: true,
}); err != nil {
t.Fatalf("SaveArtistSettings() error: %v", err)
}
}
ctx := context.Background()
err = SyncAlbums(ctx, nc, db)
if err == nil {
t.Fatal("SyncAlbums() expected error for API failure mid-sync, got nil")
}
// The first artist's albums should have been stored before the error.
albums1, err := database.GetLocalAlbumsByArtist(db, "1")
if err != nil {
t.Fatalf("GetLocalAlbumsByArtist(1) error: %v", err)
}
if len(albums1) != 1 {
t.Errorf("expected 1 album for artist 1 (synced before error), got %d", len(albums1))
}
}
func TestSyncArtists_ContextCancellation(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/rest/getArtists" {
w.Write([]byte(subsonicOKResponse(`
<artists ignoredArticles="The">
<index name="A">
<artist id="1" name="Artist One" albumCount="1"/>
</index>
</artists>`)))
return
}
w.Write([]byte(subsonicOKResponse("")))
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Create a context that is already cancelled.
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = SyncArtists(ctx, nc, db)
if err == nil {
t.Fatal("SyncArtists() expected error for cancelled context, got nil")
}
}
func TestSyncAlbums_ContextCancellation(t *testing.T) {
server, nc := newTestServerAndClient(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
switch r.URL.Path {
case "/rest/getArtists":
w.Write([]byte(subsonicOKResponse(`<artists ignoredArticles="The"></artists>`)))
default:
w.Write([]byte(subsonicOKResponse("")))
}
})
defer server.Close()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
defer db.Close()
// Pre-seed a monitored artist so SyncAlbums has work to do.
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: "1",
Name: "Artist One",
Monitored: true,
}); err != nil {
t.Fatalf("SaveArtistSettings() error: %v", err)
}
// Create a context that is already cancelled.
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = SyncAlbums(ctx, nc, db)
if err == nil {
t.Fatal("SyncAlbums() expected error for cancelled context, got nil")
}
}