Compare commits
13 Commits
navidrome-
...
foundation
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2aadaab6 | |||
| c23b698f67 | |||
| 47d4ec4e32 | |||
| fb74b5fbba | |||
| 2cb377614f | |||
| 3206b10bd9 | |||
| 68660e4d83 | |||
| 3b87bfce96 | |||
| a052ccf11e | |||
| da6668204f | |||
| 6ff4ec3045 | |||
| eb266ed5ec | |||
| 44f2652e7c |
@@ -111,11 +111,9 @@ Based on the specification (docs/Specification.md), the application follows a mo
|
|||||||
|
|
||||||
4. **Database Layer** (`internal/database/` or similar)
|
4. **Database Layer** (`internal/database/` or similar)
|
||||||
- SQLite 3 integration via github.com/mattn/go-sqlite3
|
- SQLite 3 integration via github.com/mattn/go-sqlite3
|
||||||
- Subsonic API client via github.com/delucks/go-subsonic
|
|
||||||
- Manages schema migrations
|
- Manages schema migrations
|
||||||
- Handles tables:
|
- Handles tables:
|
||||||
- `artist_settings`: Artist monitoring configuration
|
- `artist_settings`: Artist monitoring configuration
|
||||||
- `local_albums`: Navidrome albums synced via Subsonic API (id, artist_id, title)
|
|
||||||
- `external_releases`: Cached MusicBrainz data
|
- `external_releases`: Cached MusicBrainz data
|
||||||
- `notifications_sent`: Sent notification tracking
|
- `notifications_sent`: Sent notification tracking
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| **Navidrome Client** | Subsonic API v1.16.1 communication (token-based auth) |
|
||||||
| **MusicBrainz Provider** | Discography fetching with rate limiting and caching |
|
| **MusicBrainz Provider** | Discography fetching with rate limiting and caching |
|
||||||
| **Scanner Engine** | String normalization and fuzzy comparison |
|
| **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 |
|
| **Notifier** | Scheduled Telegram notifications |
|
||||||
| **Web UI** | Dashboard for browsing and managing missing releases |
|
| **Web UI** | Dashboard for browsing and managing missing releases |
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ scanner:
|
|||||||
| **Navidrome Client** | Взаимодействие с Subsonic API v1.16.1 (токенная аутентификация) |
|
| **Navidrome Client** | Взаимодействие с Subsonic API v1.16.1 (токенная аутентификация) |
|
||||||
| **MusicBrainz Provider** | Загрузка дискографий с кэшированием и rate limiting |
|
| **MusicBrainz Provider** | Загрузка дискографий с кэшированием и rate limiting |
|
||||||
| **Scanner Engine** | Нормализация строк и нечёткое сравнение |
|
| **Scanner Engine** | Нормализация строк и нечёткое сравнение |
|
||||||
| **Database Layer** | SQLite: `artist_settings`, `local_albums` (синхронизация из Navidrome), `external_releases` (кэш MusicBrainz), `notifications_sent` |
|
| **Database Layer** | Хранение настроек, кэша и состояния в SQLite |
|
||||||
| **Notifier** | Планировщик уведомлений в Telegram |
|
| **Notifier** | Планировщик уведомлений в Telegram |
|
||||||
| **Web UI** | Панель управления отсутствющими релизами |
|
| **Web UI** | Панель управления отсутствющими релизами |
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,10 @@
|
|||||||
|
|
||||||
|
|
||||||
## 2. Технологический стек
|
## 2. Технологический стек
|
||||||
* **Язык программирования:** Go 1.25+
|
* **Язык программирования:** Go 1.21+
|
||||||
* **База данных:** SQLite 3 (для хранения кэша, настроек и состояний).
|
* **База данных:** SQLite 3 (для хранения кэша, настроек и состояний).
|
||||||
* **HTTP-сервер:** Стандартная библиотека Go (`net/http`) + `html/template`.
|
* **HTTP-сервер:** Стандартная библиотека Go (`net/http`) + `html/template`.
|
||||||
* **Внешние зависимости (библиотеки):**
|
* **Внешние зависимости (библиотеки):**
|
||||||
* `github.com/delucks/go-subsonic` — клиент Subsonic API (getArtists, getArtist, ping).
|
|
||||||
* `github.com/mattn/go-sqlite3` — драйвер базы данных.
|
* `github.com/mattn/go-sqlite3` — драйвер базы данных.
|
||||||
* `github.com/lithammer/fuzzysearch` — библиотека для нечеткого сравнения строк.
|
* `github.com/lithammer/fuzzysearch` — библиотека для нечеткого сравнения строк.
|
||||||
* `gopkg.in/yaml.v3` — парсинг конфигурационных файлов.
|
* `gopkg.in/yaml.v3` — парсинг конфигурационных файлов.
|
||||||
@@ -90,14 +89,6 @@ NaviWatcher взаимодействует с Navidrome через **Subsonic AP
|
|||||||
* `release_date`: string
|
* `release_date`: string
|
||||||
* `is_ignored`: boolean (флаг скрытия из списка новинок)
|
* `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`
|
### Таблица `notifications_sent`
|
||||||
* `rgid`: string (FK)
|
* `rgid`: string (FK)
|
||||||
* `sent_at`: datetime
|
* `sent_at`: datetime
|
||||||
|
|||||||
@@ -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
1
go.mod
@@ -3,7 +3,6 @@ module naviwatcher
|
|||||||
go 1.25.1
|
go 1.25.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/delucks/go-subsonic v0.0.0-20240806025900-2a743ec36238
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -57,11 +57,6 @@ func (db *DB) Conn() *sql.DB {
|
|||||||
return db.conn
|
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.
|
// migrate runs all pending schema migrations in order.
|
||||||
func (db *DB) migrate() error {
|
func (db *DB) migrate() error {
|
||||||
// Create the migrations tracking table first, unconditionally.
|
// Create the migrations tracking table first, unconditionally.
|
||||||
@@ -99,16 +94,7 @@ func (db *DB) migrate() error {
|
|||||||
);`,
|
);`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "003_create_local_albums",
|
name: "003_create_notifications_sent",
|
||||||
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",
|
|
||||||
sql: `CREATE TABLE IF NOT EXISTS notifications_sent (
|
sql: `CREATE TABLE IF NOT EXISTS notifications_sent (
|
||||||
rgid TEXT NOT NULL REFERENCES external_releases(rgid),
|
rgid TEXT NOT NULL REFERENCES external_releases(rgid),
|
||||||
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -159,6 +145,7 @@ func (db *DB) isMigrationApplied(name string) (bool, error) {
|
|||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ArtistSettings represents a row in the artist_settings table.
|
// ArtistSettings represents a row in the artist_settings table.
|
||||||
type ArtistSettings struct {
|
type ArtistSettings struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -168,13 +155,6 @@ type ArtistSettings struct {
|
|||||||
Monitored bool `json:"monitored"`
|
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.
|
// ExternalRelease represents a row in the external_releases table.
|
||||||
type ExternalRelease struct {
|
type ExternalRelease struct {
|
||||||
RGID string `json:"rgid"`
|
RGID string `json:"rgid"`
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ func TestNew_InitializationAndSchema(t *testing.T) {
|
|||||||
"_migrations",
|
"_migrations",
|
||||||
"artist_settings",
|
"artist_settings",
|
||||||
"external_releases",
|
"external_releases",
|
||||||
"local_albums",
|
|
||||||
"notifications_sent",
|
"notifications_sent",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +46,14 @@ func TestNew_MigrationIdempotency(t *testing.T) {
|
|||||||
// Verify tables still exist.
|
// Verify tables still exist.
|
||||||
var count int
|
var count int
|
||||||
err = db.Conn().QueryRow(
|
err = db.Conn().QueryRow(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN (?,?,?,?,?)",
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN (?,?,?,?)",
|
||||||
"_migrations", "artist_settings", "external_releases", "local_albums", "notifications_sent",
|
"_migrations", "artist_settings", "external_releases", "notifications_sent",
|
||||||
).Scan(&count)
|
).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query error: %v", err)
|
t.Fatalf("query error: %v", err)
|
||||||
}
|
}
|
||||||
if count != 5 {
|
if count != 4 {
|
||||||
t.Errorf("expected 5 tables, got %d", count)
|
t.Errorf("expected 4 tables, got %d", count)
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Close()
|
db.Close()
|
||||||
@@ -205,8 +204,8 @@ func TestMigrationTracking(t *testing.T) {
|
|||||||
t.Fatalf("query migrations count: %v", err)
|
t.Fatalf("query migrations count: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// We have 4 recorded migrations: artist_settings, external_releases, local_albums, notifications_sent.
|
// We have 3 recorded migrations: artist_settings, external_releases, notifications_sent.
|
||||||
if count != 4 {
|
if count != 3 {
|
||||||
t.Errorf("expected 4 applied migrations, got %d", count)
|
t.Errorf("expected 3 applied migrations, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user