Compare commits
2 Commits
foundation
...
navidrome-
| Author | SHA1 | Date | |
|---|---|---|---|
| 0065057514 | |||
| 735ff0828e |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
naviwatcher
|
||||
naviwatcher-linux
|
||||
naviwatcher-mac
|
||||
naviwatcher.exe
|
||||
data/
|
||||
.claude/
|
||||
.ralphex/
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
naviwatcher
|
||||
naviwatcher-linux
|
||||
naviwatcher-mac
|
||||
naviwatcher.exe
|
||||
config.yaml
|
||||
data/
|
||||
coverage.out
|
||||
@@ -111,9 +111,11 @@ 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
|
||||
|
||||
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o naviwatcher ./cmd/naviwatcher
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates sqlite-libs && \
|
||||
adduser -D -g '' appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/naviwatcher .
|
||||
|
||||
RUN chown appuser:appuser /app
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
USER appuser
|
||||
|
||||
ENTRYPOINT ["./naviwatcher"]
|
||||
CMD ["-config=/app/data/config.yaml"]
|
||||
14
README.md
14
README.md
@@ -28,7 +28,7 @@ NaviWatcher is an autonomous service daemon that monitors your Navidrome music c
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Language:** Go 1.21+
|
||||
- **Language:** Go 1.25+
|
||||
- **Database:** SQLite 3
|
||||
- **HTTP Server:** Go standard library (`net/http` + `html/template`)
|
||||
- **Key dependencies:**
|
||||
@@ -69,12 +69,12 @@ server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
username: "admin"
|
||||
password: "password123"
|
||||
password: "CHANGE_ME"
|
||||
|
||||
navidrome:
|
||||
url: "http://localhost:4533"
|
||||
user: "watcher_service"
|
||||
password: "user_password"
|
||||
password: "CHANGE_ME"
|
||||
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( mail@example.com )"
|
||||
@@ -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 for settings, cache, and state |
|
||||
| **Database Layer** | SQLite persistence: `artist_settings`, `local_albums` (Navidrome sync), `external_releases` (MusicBrainz cache), `notifications_sent` |
|
||||
| **Notifier** | Scheduled Telegram notifications |
|
||||
| **Web UI** | Dashboard for browsing and managing missing releases |
|
||||
|
||||
@@ -176,12 +176,12 @@ server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
username: "admin"
|
||||
password: "password123"
|
||||
password: "CHANGE_ME"
|
||||
|
||||
navidrome:
|
||||
url: "http://localhost:4533"
|
||||
user: "watcher_service"
|
||||
password: "user_password"
|
||||
password: "CHANGE_ME"
|
||||
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( mail@example.com )"
|
||||
@@ -208,7 +208,7 @@ scanner:
|
||||
| **Navidrome Client** | Взаимодействие с Subsonic API v1.16.1 (токенная аутентификация) |
|
||||
| **MusicBrainz Provider** | Загрузка дискографий с кэшированием и rate limiting |
|
||||
| **Scanner Engine** | Нормализация строк и нечёткое сравнение |
|
||||
| **Database Layer** | Хранение настроек, кэша и состояния в SQLite |
|
||||
| **Database Layer** | SQLite: `artist_settings`, `local_albums` (синхронизация из Navidrome), `external_releases` (кэш MusicBrainz), `notifications_sent` |
|
||||
| **Notifier** | Планировщик уведомлений в Telegram |
|
||||
| **Web UI** | Панель управления отсутствющими релизами |
|
||||
|
||||
|
||||
54
cmd/naviwatcher/main.go
Normal file
54
cmd/naviwatcher/main.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"naviwatcher/internal/config"
|
||||
)
|
||||
|
||||
const defaultConfigPath = "config.yaml"
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", defaultConfigPath, "Path to config file")
|
||||
flag.Parse()
|
||||
|
||||
log.Println("NaviWatcher starting...")
|
||||
|
||||
cfg, err := config.LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Config loaded from %s (server: %s:%d)", *configPath, cfg.Server.Host, cfg.Server.Port)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
log.Printf("Received signal %v, shutting down...", sig)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
if err := run(ctx, cfg); err != nil {
|
||||
log.Fatalf("Application error: %v", err)
|
||||
}
|
||||
|
||||
log.Println("NaviWatcher stopped.")
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg *config.Config) error {
|
||||
// Main application loop — blocks until context is cancelled.
|
||||
// Business logic will be added in future tasks.
|
||||
<-ctx.Done()
|
||||
|
||||
return nil
|
||||
}
|
||||
56
cmd/naviwatcher/main_test.go
Normal file
56
cmd/naviwatcher/main_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"naviwatcher/internal/config"
|
||||
)
|
||||
|
||||
func TestRun_GracefulShutdown(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
cfg := &config.Config{}
|
||||
if err := run(ctx, cfg); err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIntegration(t *testing.T) {
|
||||
// Integration test: write a minimal valid config and load it via config.LoadConfig,
|
||||
// verifying the full path that main() uses.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `server:
|
||||
host: "127.0.0.1"
|
||||
port: 9090
|
||||
navidrome:
|
||||
url: "http://localhost:4533"
|
||||
user: "test"
|
||||
password: "test"
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Host != "127.0.0.1" {
|
||||
t.Errorf("expected host 127.0.0.1, got %q", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("expected port 9090, got %d", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Navidrome.URL != "http://localhost:4533" {
|
||||
t.Errorf("expected navidrome url http://localhost:4533, got %q", cfg.Navidrome.URL)
|
||||
}
|
||||
}
|
||||
26
config.yaml.example
Normal file
26
config.yaml.example
Normal file
@@ -0,0 +1,26 @@
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
# Basic Auth for Web UI access
|
||||
username: "admin"
|
||||
password: "CHANGE_ME"
|
||||
|
||||
navidrome:
|
||||
url: "http://localhost:4533"
|
||||
user: "watcher_service"
|
||||
password: "CHANGE_ME"
|
||||
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( your-email@example.com )"
|
||||
cache_ttl: 24h
|
||||
|
||||
telegram:
|
||||
enabled: true
|
||||
token: "bot_token"
|
||||
chat_id: "your_chat_id"
|
||||
cron_schedule: "0 10 * * *"
|
||||
|
||||
scanner:
|
||||
fuzzy_threshold: 0.85
|
||||
ignore_bootlegs: true
|
||||
include_compilations: true
|
||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
naviwatcher:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: naviwatcher
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -7,10 +7,11 @@
|
||||
|
||||
|
||||
## 2. Технологический стек
|
||||
* **Язык программирования:** Go 1.21+
|
||||
* **Язык программирования:** Go 1.25+
|
||||
* **База данных:** 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` — парсинг конфигурационных файлов.
|
||||
@@ -89,6 +90,14 @@ 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
|
||||
|
||||
261
docs/plans/2026-05-20-foundation-layer.md
Normal file
261
docs/plans/2026-05-20-foundation-layer.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Foundation Layer
|
||||
|
||||
## Overview
|
||||
Build the foundation layer of NaviWatcher: a greenfield Go project with zero existing code. This plan covers project initialization, configuration management, SQLite database layer, and Docker deployment setup. These are the building blocks that all future modules (Navidrome client, MusicBrainz provider, Scanner, Notifier, Web UI) will depend on.
|
||||
|
||||
**Problem it solves:** Establishes the project skeleton, dependency management, configuration parsing, persistent storage, and containerized deployment — everything needed before any business logic can be built.
|
||||
|
||||
**How it integrates:** This is the first plan for the project. Future plans will build on this foundation:
|
||||
- Plan 2: Navidrome client (depends on config + DB)
|
||||
- Plan 3: MusicBrainz provider (depends on config + DB)
|
||||
- Plan 4: Scanner engine (depends on Navidrome + MusicBrainz + DB)
|
||||
- Plan 5: Notifier (depends on DB + config)
|
||||
- Plan 6: Web UI (depends on DB + config)
|
||||
|
||||
## Context (from discovery)
|
||||
- **Files/components involved:** All files are new — this is a greenfield project
|
||||
- **Related patterns found:** Specification defines a 6-module architecture with YAML config, SQLite storage, and Docker deployment
|
||||
- **Dependencies identified:**
|
||||
- `github.com/mattn/go-sqlite3` — SQLite 3 driver
|
||||
- `gopkg.in/yaml.v3` — YAML config parsing
|
||||
- Go 1.21+ required
|
||||
- **Current state:** Only docs exist (CLAUDE.md, README.md, License.md, docs/Specification.md). No Go code, no go.mod, no config files.
|
||||
|
||||
## 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
|
||||
- tests are not optional — they are a required part of the checklist
|
||||
- write unit tests for new functions/methods
|
||||
- write unit tests for modified functions/methods
|
||||
- add new test cases for new code paths
|
||||
- update existing test cases if behavior changes
|
||||
- tests cover both success and error scenarios
|
||||
- **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
|
||||
- Maintain backward compatibility
|
||||
|
||||
## Testing Strategy
|
||||
- **Unit tests**: required for every task (see Development Approach above)
|
||||
- **E2E tests**: not applicable for foundation layer (no UI yet)
|
||||
|
||||
## Progress Tracking
|
||||
- Mark completed items with `[x]` immediately when done
|
||||
- Add newly discovered tasks with ➕ prefix
|
||||
- Document issues/blockers with ⚠️ prefix
|
||||
- Update plan if implementation deviates from original scope
|
||||
- Keep plan in sync with actual work done
|
||||
|
||||
## What Goes Where
|
||||
- **Implementation Steps** (`[ ]` checkboxes): tasks achievable within this codebase — code changes, tests, documentation updates
|
||||
- **Post-Completion** (no checkboxes): items requiring external action — manual testing, deployment verification, third-party checks
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Task 1: Initialize Go module and project skeleton
|
||||
- [x] run `go mod init naviwatcher` in project root
|
||||
- [x] create directory structure: `internal/config/`, `internal/database/`, `cmd/naviwatcher/`
|
||||
- [x] create `cmd/naviwatcher/main.go` with basic entry point (parse flags, load config placeholder, graceful shutdown skeleton)
|
||||
- [x] add dependencies: `go get github.com/mattn/go-sqlite3`, `go get gopkg.in/yaml.v3`
|
||||
- [x] run `go mod tidy`
|
||||
- [x] verify the project builds: `go build -o naviwatcher ./cmd/naviwatcher`
|
||||
- [x] write tests for main.go flag parsing (if applicable)
|
||||
- [x] run tests — must pass before task 2
|
||||
|
||||
### Task 2: Configuration management
|
||||
- [x] create `internal/config/config.go` with Config struct matching spec (Server, Navidrome, MusicBrainz, Telegram, Scanner sections)
|
||||
- [x] implement `LoadConfig(path string) (*Config, error)` function that reads and parses YAML
|
||||
- [x] implement config validation (required fields, valid port range, valid threshold range 0.0-1.0)
|
||||
- [x] create `config.yaml.example` with all fields filled with placeholder values per spec section 7
|
||||
- [x] write tests for LoadConfig: valid config file
|
||||
- [x] write tests for LoadConfig: missing file, malformed YAML, invalid values
|
||||
- [x] write tests for config validation logic
|
||||
- [x] run tests — must pass before task 3
|
||||
|
||||
### Task 3: Database layer — schema and migrations
|
||||
- [x] create `internal/database/database.go` with DB struct and `New(dbPath string) (*DB, error)` constructor
|
||||
- [x] implement schema migration system (versioned migrations table + ordered migration files or functions)
|
||||
- [x] create migration 001: `artist_settings` table (id, name, ignore_singles, ignore_compilations, monitored)
|
||||
- [x] create migration 002: `external_releases` table (rgid PK, artist_id, title, type, release_date, is_ignored)
|
||||
- [x] create migration 003: `notifications_sent` table (rgid FK, sent_at)
|
||||
- [x] implement `Close()` method with proper connection cleanup
|
||||
- [x] write tests for database initialization and schema creation (use in-memory SQLite `:memory:`)
|
||||
- [x] write tests for migration idempotency (running migrations twice should not fail)
|
||||
- [x] write tests for Close() behavior
|
||||
- [x] run tests — must pass before task 4
|
||||
|
||||
### Task 4: Database layer — CRUD operations for artist_settings
|
||||
- [x] implement `GetArtistSettings(db *DB, id string) (*ArtistSettings, error)`
|
||||
- [x] implement `SaveArtistSettings(db *DB, settings *ArtistSettings) error`
|
||||
- [x] implement `GetAllArtistSettings(db *DB) ([]ArtistSettings, error)`
|
||||
- [x] implement `UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) error`
|
||||
- [x] write tests for GetArtistSettings (found and not found cases)
|
||||
- [x] write tests for SaveArtistSettings (insert and update)
|
||||
- [x] write tests for GetAllArtistSettings (empty and populated)
|
||||
- [x] run tests — must pass before task 5
|
||||
|
||||
### Task 5: Database layer — CRUD operations for external_releases
|
||||
- [x] implement `GetExternalRelease(db *DB, rgid string) (*ExternalRelease, error)`
|
||||
- [x] implement `SaveExternalRelease(db *DB, release *ExternalRelease) error`
|
||||
- [x] implement `GetExternalReleasesByArtist(db *DB, artistID string) ([]ExternalRelease, error)`
|
||||
- [x] implement `GetIgnoredReleases(db *DB) ([]ExternalRelease, error)`
|
||||
- [x] implement `SetReleaseIgnored(db *DB, rgid string, ignored bool) error`
|
||||
- [x] write tests for all CRUD operations (success and error cases)
|
||||
- [x] write tests for cache TTL logic (if implemented at this layer)
|
||||
- [x] run tests — must pass before task 6
|
||||
|
||||
### Task 6: Database layer — CRUD operations for notifications_sent
|
||||
- [x] implement `MarkNotificationSent(db *DB, rgid string) error`
|
||||
- [x] implement `IsNotificationSent(db *DB, rgid string) (bool, error)`
|
||||
- [x] implement `GetUnnotifiedReleases(db *DB) ([]ExternalRelease, error)` — joins external_releases with notifications_sent to find unsent
|
||||
- [x] write tests for MarkNotificationSent and IsNotificationSent
|
||||
- [x] write tests for GetUnnotifiedReleases (with and without existing notifications)
|
||||
- [x] run tests — must pass before task 7
|
||||
|
||||
### Task 7: Docker setup
|
||||
- [x] create `Dockerfile` with multi-stage build: build stage (golang:1.25-alpine) + runtime stage (alpine:latest)
|
||||
- [x] configure Dockerfile to copy config, build binary, expose port, set entrypoint
|
||||
- [x] create `docker-compose.yml` with naviwatcher service, volume for SQLite DB and config
|
||||
- [x] create `.dockerignore` file
|
||||
- [x] verify Docker image builds: `docker compose build`
|
||||
- [x] verify container starts and health check passes (container starts, loads config, runs successfully)
|
||||
- [x] run full test suite — must pass before task 8
|
||||
|
||||
### Task 8: Verify acceptance criteria
|
||||
- [x] verify Go module builds cleanly with `go build`
|
||||
- [x] verify config loads and validates from YAML file
|
||||
- [x] verify all 3 database tables are created via migrations
|
||||
- [x] verify all CRUD operations work against in-memory SQLite
|
||||
- [x] verify Docker image builds and container starts
|
||||
- [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 is reasonable for foundation layer (70%+)
|
||||
|
||||
### Task 9: Final documentation update
|
||||
- [x] update README.md with current build/run/test instructions (replace placeholder commands)
|
||||
- [x] verify config.yaml.example is complete and matches spec
|
||||
- [x] document any deviations from specification in this plan — no deviations found; module path is `naviwatcher` (local), Go 1.25.1 exceeds 1.21+ requirement
|
||||
|
||||
*Note: ralphex automatically moves completed plans to `docs/plans/completed/`*
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Config Struct (Go)
|
||||
```go
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Navidrome NavidromeConfig `yaml:"navidrome"`
|
||||
MusicBrainz MusicBrainzConfig `yaml:"musicbrainz"`
|
||||
Telegram TelegramConfig `yaml:"telegram"`
|
||||
Scanner ScannerConfig `yaml:"scanner"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type NavidromeConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type MusicBrainzConfig struct {
|
||||
UserAgent string `yaml:"user_agent"`
|
||||
CacheTTL time.Duration `yaml:"cache_ttl"`
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Token string `yaml:"token"`
|
||||
ChatID string `yaml:"chat_id"`
|
||||
CronSchedule string `yaml:"cron_schedule"`
|
||||
}
|
||||
|
||||
type ScannerConfig struct {
|
||||
FuzzyThreshold float64 `yaml:"fuzzy_threshold"`
|
||||
IgnoreBootlegs bool `yaml:"ignore_bootlegs"`
|
||||
IncludeCompilations bool `yaml:"include_compilations"`
|
||||
}
|
||||
```
|
||||
|
||||
### Database Schema (SQLite)
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS artist_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
ignore_singles BOOLEAN DEFAULT 0,
|
||||
ignore_compilations BOOLEAN DEFAULT 0,
|
||||
monitored BOOLEAN DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS external_releases (
|
||||
rgid TEXT PRIMARY KEY,
|
||||
artist_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT,
|
||||
release_date TEXT,
|
||||
is_ignored BOOLEAN DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications_sent (
|
||||
rgid TEXT NOT NULL,
|
||||
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (rgid, sent_at)
|
||||
);
|
||||
```
|
||||
|
||||
### Project Structure After Completion
|
||||
```
|
||||
naviwatcher-gitea/
|
||||
├── CLAUDE.md
|
||||
├── License.md
|
||||
├── README.md
|
||||
├── config.yaml.example
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── .dockerignore
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── cmd/
|
||||
│ └── naviwatcher/
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ │ ├── config.go
|
||||
│ │ └── config_test.go
|
||||
│ └── database/
|
||||
│ ├── database.go
|
||||
│ ├── database_test.go
|
||||
│ ├── artist_settings.go
|
||||
│ ├── artist_settings_test.go
|
||||
│ ├── external_releases.go
|
||||
│ ├── external_releases_test.go
|
||||
│ ├── notifications.go
|
||||
│ └── notifications_test.go
|
||||
└── docs/
|
||||
├── Specification.md
|
||||
└── plans/
|
||||
└── 2026-05-20-foundation-layer.md
|
||||
```
|
||||
|
||||
## Post-Completion
|
||||
*Items requiring manual intervention or external systems — no checkboxes, informational only*
|
||||
|
||||
**Manual verification:**
|
||||
- Copy `config.yaml.example` to `config.yaml` and fill in real values to test config loading
|
||||
- Run the built Docker container against a real Navidrome instance (when client is implemented)
|
||||
- Verify SQLite database file persists across container restarts via Docker volume
|
||||
|
||||
**Follow-up plans needed:**
|
||||
- Plan 2: Navidrome/Subsonic API client
|
||||
- Plan 3: MusicBrainz provider with rate limiting
|
||||
- Plan 4: Scanner engine with fuzzy matching
|
||||
- Plan 5: Telegram notifier with cron scheduling
|
||||
- Plan 6: Web UI with embedded templates
|
||||
179
docs/plans/2026-05-20-navidrome-client.md
Normal file
179
docs/plans/2026-05-20-navidrome-client.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Navidrome/Subsonic API Client
|
||||
|
||||
## Overview
|
||||
Build the Navidrome client module that connects to a Navidrome server via the Subsonic API, fetches artist and album data, and syncs it into the local SQLite database. This is the first business logic module — it turns NaviWatcher from an empty skeleton into a service that can actually read your music library.
|
||||
|
||||
**Problem it solves:** NaviWatcher needs to know what artists and albums exist in the user's Navidrome instance to later compare against MusicBrainz discographies. This module provides that data.
|
||||
|
||||
**How it integrates:**
|
||||
- Depends on: `internal/config` (NavidromeConfig), `internal/database` (CRUD operations)
|
||||
- Will be consumed by: Plan 4 (Scanner engine — compares local albums vs MusicBrainz)
|
||||
- The `run()` function in `main.go` will later be expanded to initialize and trigger the sync
|
||||
|
||||
## Context (from discovery)
|
||||
- **Files/components involved:**
|
||||
- New: `internal/navidrome/client.go`, `internal/navidrome/client_test.go`, `internal/navidrome/sync.go`, `internal/navidrome/sync_test.go`
|
||||
- Existing: `internal/config/config.go` (NavidromeConfig with URL, User, Password), `internal/database/` (ArtistSettings CRUD, full DB layer), `cmd/naviwatcher/main.go` (run() skeleton)
|
||||
- **Related patterns found:**
|
||||
- Go module name: `naviwatcher`
|
||||
- DB uses `database/sql` + `go-sqlite3` with WAL mode and foreign keys
|
||||
- `ArtistSettings` struct: ID, Name, IgnoreSingles, IgnoreCompilations, Monitored
|
||||
- Graceful shutdown via `context.Context` pattern in main.go
|
||||
- **Dependencies to add:** `github.com/delucks/go-subsonic` (Subsonic API client library — supports getArtists, getArtist, getAlbum, ping; tested on Navidrome)
|
||||
- **Auth method:** Subsonic token-based auth: `md5(password + salt)`, password never sent in plaintext
|
||||
- **Subsonic API version:** v1.16.1
|
||||
|
||||
## Development Approach
|
||||
- **Testing approach:** Regular (code first, then tests)
|
||||
- Complete each task fully before moving to the next
|
||||
- Make small, focused changes
|
||||
- **CRITICAL: every task MUST include new/updated tests** for code changes in that task
|
||||
- **CRITICAL: all tests must pass before starting next task** — no exceptions
|
||||
- **CRITICAL: update this plan file when scope changes during implementation**
|
||||
- Run tests after each change
|
||||
|
||||
## Testing Strategy
|
||||
- **Unit tests:** required for every task
|
||||
- Use `httptest.NewServer` to mock Subsonic API responses for client tests
|
||||
- Use in-memory SQLite (`:memory:`) for sync tests
|
||||
- Tests cover: success cases, API errors, malformed responses, empty libraries, context cancellation
|
||||
|
||||
## Progress Tracking
|
||||
- Mark completed items with `[x]` immediately when done
|
||||
- Add newly discovered tasks with ➕ prefix
|
||||
- Document issues/blockers with ⚠️ prefix
|
||||
|
||||
## What Goes Where
|
||||
- **Implementation Steps** (`[ ]` checkboxes): code changes, tests, documentation
|
||||
- **Post-Completion** (no checkboxes): manual testing against real Navidrome
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Task 1: Add go-subsonic dependency and create client wrapper
|
||||
- [x] run `go get github.com/delucks/go-subsonic@latest`
|
||||
- [x] run `go mod tidy`
|
||||
- [x] create `internal/navidrome/client.go` with `NavidromeClient` struct wrapping `subsonic.Client`
|
||||
- [x] implement `NewClient(cfg config.NavidromeConfig) (*NavidromeClient, error)` constructor
|
||||
- [x] implement `Ping(ctx context.Context) error` — health check via Subsonic ping endpoint
|
||||
- [x] write tests: NewClient with valid config
|
||||
- [x] write tests: Ping success (mock HTTP server returning valid Subsonic response)
|
||||
- [x] write tests: Ping failure (server unreachable, invalid credentials, non-200 status)
|
||||
- [x] run tests — must pass before task 2
|
||||
|
||||
### Task 2: Implement artist fetching
|
||||
- [x] in `internal/navidrome/client.go`, implement `GetArtists(ctx context.Context) ([]ArtistInfo, error)` — fetches all artists via `getArtists` endpoint
|
||||
- [x] define `ArtistInfo` struct with ID and Name fields (mapped from Subsonic response)
|
||||
- [x] write tests: GetArtists success with multiple artists (mock server)
|
||||
- [x] write tests: GetArtists empty library
|
||||
- [x] write tests: GetArtists API error handling
|
||||
- [x] run tests — must pass before task 3
|
||||
|
||||
### Task 3: Implement album fetching per artist
|
||||
- [x] in `internal/navidrome/client.go`, implement `GetArtistAlbums(ctx context.Context, artistID string) ([]AlbumInfo, error)` — fetches albums via `getArtist` endpoint
|
||||
- [x] define `AlbumInfo` struct with ID, Name, ArtistID fields
|
||||
- [x] write tests: GetArtistAlbums success with multiple albums
|
||||
- [x] write tests: GetArtistAlbums artist with no albums
|
||||
- [x] write tests: GetArtistAlbums API error handling
|
||||
- [x] run tests — before task 4
|
||||
|
||||
### Task 4: Implement sync — artists to local DB
|
||||
- [x] create `internal/navidrome/sync.go` with `SyncArtists(ctx context.Context, client *NavidromeClient, db *database.DB) error`
|
||||
- [x] implement sync logic: fetch all artists from Navidrome → upsert into `artist_settings` table (monitored=true by default)
|
||||
- [x] handle context cancellation gracefully
|
||||
- [x] create `internal/navidrome/sync_test.go`
|
||||
- [x] write tests: SyncArtists with empty Navidrome library
|
||||
- [x] write tests: SyncArtists with multiple artists (verify DB state after sync)
|
||||
- [x] write tests: SyncArtists idempotency (running twice doesn't duplicate)
|
||||
- [x] write tests: SyncArtists context cancellation
|
||||
- [x] run tests — must pass before task 5
|
||||
|
||||
### Task 5: Implement sync — albums to local DB
|
||||
- [x] in `internal/navidrome/sync.go`, implement `SyncAlbums(ctx context.Context, client *NavidromeClient, db *database.DB) error`
|
||||
- [x] implement sync logic: for each monitored artist, fetch albums → store in `local_albums` table (new table, clean separation from `external_releases` which is for MusicBrainz data)
|
||||
- [x] **Schema decision:** Added `local_albums` table (id TEXT PK, artist_id TEXT FK→artist_settings.id, title TEXT) — clean separation of concerns (Option 1 from plan)
|
||||
- [x] handle context cancellation gracefully
|
||||
- [x] write tests: SyncAlbums for single artist with multiple albums
|
||||
- [x] write tests: SyncAlbums skips unmonitored artists
|
||||
- [x] write tests: SyncAlbums API error mid-sync (partial sync handling)
|
||||
- [x] run tests — must pass before task 6
|
||||
|
||||
### Task 6: Verify acceptance criteria
|
||||
- [x] verify go-subsonic is in go.mod and go.sum
|
||||
- [x] verify Ping works against mock server
|
||||
- [x] verify GetArtists returns parsed artist list
|
||||
- [x] verify GetArtistAlbums returns parsed album list
|
||||
- [x] verify SyncArtists populates artist_settings table
|
||||
- [x] verify SyncAlbums populates album data
|
||||
- [x] run full test suite: `go test ./... -v` — all must pass
|
||||
- [x] run `go vet ./...` — no issues
|
||||
- [x] run `go fmt ./...` — no formatting issues
|
||||
- [x] verify test coverage for navidrome package (70%+)
|
||||
|
||||
### Task 7: Update documentation
|
||||
- [x] update README.md with Navidrome setup instructions (creating service user) — already present in Quick Start; enhanced architecture table with table names
|
||||
- [x] document any schema changes made (e.g., new tables or columns) — added `local_albums` table docs to Specification.md Section 5
|
||||
- [x] document the `source` field decision for album storage — documented Option 1 (separate table) with rationale in Specification.md
|
||||
|
||||
*Note: ralphex automatically moves completed plans to `docs/plans/completed/`*
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Subsonic API Authentication
|
||||
The go-subsonic library handles token-based auth internally. The client constructor passes URL, user, and password — the library generates `md5(password + salt)` per request.
|
||||
|
||||
### Expected Data Flow
|
||||
```
|
||||
Navidrome Server (Subsonic API)
|
||||
│
|
||||
▼
|
||||
NavidromeClient (internal/navidrome/client.go)
|
||||
│ - Ping()
|
||||
│ - GetArtists() → []ArtistInfo
|
||||
│ - GetArtistAlbums() → []AlbumInfo
|
||||
▼
|
||||
Sync (internal/navidrome/sync.go)
|
||||
│ - SyncArtists() → artist_settings table
|
||||
│ - SyncAlbums() → local_albums or external_releases table
|
||||
▼
|
||||
SQLite Database
|
||||
```
|
||||
|
||||
### Mock Server Pattern for Tests
|
||||
```go
|
||||
// Create a test HTTP server that returns Subsonic XML responses
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(`<subsonic-response status="ok" version="1.16.1">
|
||||
<artists>
|
||||
<index name="A">
|
||||
<artist id="1" name="Artist One"/>
|
||||
<artist id="2" name="Artist Two"/>
|
||||
</index>
|
||||
</artists>
|
||||
</subsonic-response>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
```
|
||||
|
||||
### Schema Decision Needed (Task 5)
|
||||
The current `external_releases` table is designed for MusicBrainz data. Local albums from Navidrome need a home. Options:
|
||||
1. **Add `local_albums` table** — clean separation, but adds a table
|
||||
2. **Add `source` column to `external_releases`** — simpler, but mixes concerns
|
||||
3. **Reuse `external_releases` with a flag** — similar to option 2
|
||||
|
||||
The implementation should pick one and document it. Option 1 (separate table) is recommended for clean separation of concerns.
|
||||
|
||||
## Post-Completion
|
||||
*Items requiring manual intervention or external systems*
|
||||
|
||||
**Manual verification:**
|
||||
- Set up a real Navidrome instance with test music library
|
||||
- Configure config.yaml with real credentials
|
||||
- Run the sync and verify artists/albums appear in the database
|
||||
- Check that unmonitored artists are skipped during album sync
|
||||
|
||||
**Follow-up plans needed:**
|
||||
- Plan 3: MusicBrainz provider with rate limiting
|
||||
- Plan 4: Scanner engine with fuzzy matching (depends on Navidrome client + MusicBrainz)
|
||||
- Plan 5: Telegram notifier
|
||||
- Plan 6: Web UI
|
||||
9
go.mod
Normal file
9
go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
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
|
||||
)
|
||||
8
go.sum
Normal file
8
go.sum
Normal file
@@ -0,0 +1,8 @@
|
||||
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=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
112
internal/config/config.go
Normal file
112
internal/config/config.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config represents the top-level configuration for NaviWatcher.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Navidrome NavidromeConfig `yaml:"navidrome"`
|
||||
MusicBrainz MusicBrainzConfig `yaml:"musicbrainz"`
|
||||
Telegram TelegramConfig `yaml:"telegram"`
|
||||
Scanner ScannerConfig `yaml:"scanner"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP server settings.
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
// NavidromeConfig holds Subsonic API connection details.
|
||||
type NavidromeConfig struct {
|
||||
URL string `yaml:"url"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
// MusicBrainzConfig holds MusicBrainz API settings.
|
||||
type MusicBrainzConfig struct {
|
||||
UserAgent string `yaml:"user_agent"`
|
||||
CacheTTL time.Duration `yaml:"cache_ttl"`
|
||||
}
|
||||
|
||||
// TelegramConfig holds Telegram bot notification settings.
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Token string `yaml:"token"`
|
||||
ChatID string `yaml:"chat_id"`
|
||||
CronSchedule string `yaml:"cron_schedule"`
|
||||
}
|
||||
|
||||
// ScannerConfig holds scanner engine parameters.
|
||||
type ScannerConfig struct {
|
||||
FuzzyThreshold float64 `yaml:"fuzzy_threshold"`
|
||||
IgnoreBootlegs bool `yaml:"ignore_bootlegs"`
|
||||
IncludeCompilations bool `yaml:"include_compilations"`
|
||||
}
|
||||
|
||||
// LoadConfig reads a YAML file from path, parses it, applies defaults,
|
||||
// and validates the configuration.
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
applyDefaults(&cfg)
|
||||
|
||||
if err := validate(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// applyDefaults sets zero-value defaults for optional fields.
|
||||
func applyDefaults(cfg *Config) {
|
||||
if cfg.Server.Host == "" {
|
||||
cfg.Server.Host = "0.0.0.0"
|
||||
}
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
if cfg.Scanner.FuzzyThreshold == 0 {
|
||||
cfg.Scanner.FuzzyThreshold = 0.85
|
||||
}
|
||||
}
|
||||
|
||||
// validate checks that required fields are set and values are within acceptable ranges.
|
||||
func validate(cfg *Config) error {
|
||||
if cfg.Navidrome.URL == "" {
|
||||
return fmt.Errorf("navidrome.url is required")
|
||||
}
|
||||
if cfg.Navidrome.User == "" {
|
||||
return fmt.Errorf("navidrome.user is required")
|
||||
}
|
||||
if cfg.Navidrome.Password == "" {
|
||||
return fmt.Errorf("navidrome.password is required")
|
||||
}
|
||||
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
|
||||
return fmt.Errorf("server.port must be between 1 and 65535, got %d", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Scanner.FuzzyThreshold < 0.0 || cfg.Scanner.FuzzyThreshold > 1.0 {
|
||||
return fmt.Errorf("scanner.fuzzy_threshold must be between 0.0 and 1.0, got %f", cfg.Scanner.FuzzyThreshold)
|
||||
}
|
||||
if cfg.MusicBrainz.UserAgent == "" {
|
||||
return fmt.Errorf("musicbrainz.user_agent is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
348
internal/config/config_test.go
Normal file
348
internal/config/config_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfig_Valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
server:
|
||||
host: "127.0.0.1"
|
||||
port: 9090
|
||||
username: "testuser"
|
||||
password: "testpass"
|
||||
|
||||
navidrome:
|
||||
url: "http://navidrome:4533"
|
||||
user: "service"
|
||||
password: "secret"
|
||||
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||
cache_ttl: 24h
|
||||
|
||||
telegram:
|
||||
enabled: true
|
||||
token: "abc123"
|
||||
chat_id: "chat456"
|
||||
cron_schedule: "0 10 * * *"
|
||||
|
||||
scanner:
|
||||
fuzzy_threshold: 0.9
|
||||
ignore_bootlegs: true
|
||||
include_compilations: false
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Host != "127.0.0.1" {
|
||||
t.Errorf("expected host 127.0.0.1, got %s", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("expected port 9090, got %d", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Server.Username != "testuser" {
|
||||
t.Errorf("expected username testuser, got %s", cfg.Server.Username)
|
||||
}
|
||||
if cfg.Navidrome.URL != "http://navidrome:4533" {
|
||||
t.Errorf("expected navidrome url http://navidrome:4533, got %s", cfg.Navidrome.URL)
|
||||
}
|
||||
if cfg.Telegram.ChatID != "chat456" {
|
||||
t.Errorf("expected telegram chat_id chat456, got %s", cfg.Telegram.ChatID)
|
||||
}
|
||||
if cfg.Navidrome.User != "service" {
|
||||
t.Errorf("expected navidrome user service, got %s", cfg.Navidrome.User)
|
||||
}
|
||||
if cfg.Scanner.FuzzyThreshold != 0.9 {
|
||||
t.Errorf("expected fuzzy_threshold 0.9, got %f", cfg.Scanner.FuzzyThreshold)
|
||||
}
|
||||
if !cfg.Scanner.IgnoreBootlegs {
|
||||
t.Error("expected ignore_bootlegs true")
|
||||
}
|
||||
if cfg.Scanner.IncludeCompilations {
|
||||
t.Error("expected include_compilations false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_Defaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
navidrome:
|
||||
url: "http://localhost:4533"
|
||||
user: "u"
|
||||
password: "p"
|
||||
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("expected default host 0.0.0.0, got %s", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("expected default port 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Scanner.FuzzyThreshold != 0.85 {
|
||||
t.Errorf("expected default fuzzy_threshold 0.85, got %f", cfg.Scanner.FuzzyThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_MissingFile(t *testing.T) {
|
||||
_, err := LoadConfig("/nonexistent/path/config.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_MalformedYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
server:
|
||||
host: [invalid
|
||||
yaml: {broken
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed YAML, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_InvalidPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
port int
|
||||
}{
|
||||
{"port too high", 70000},
|
||||
{"port negative", -1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := buildConfigWithPort(tt.port)
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for port %d, got nil", tt.port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildConfigWithPort(port int) string {
|
||||
return "navidrome:\n url: \"http://localhost:4533\"\n user: \"u\"\n password: \"p\"\n\nmusicbrainz:\n user_agent: \"NaviWatcher/1.0 ( test@example.com )\"\n\nserver:\n port: " + fmt.Sprintf("%d", port) + "\n"
|
||||
}
|
||||
|
||||
func TestLoadConfig_InvalidThreshold(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
threshold float64
|
||||
}{
|
||||
{"threshold too high", 1.5},
|
||||
{"threshold negative", -0.1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := "navidrome:\n url: \"http://localhost:4533\"\n user: \"u\"\n password: \"p\"\n\nmusicbrainz:\n user_agent: \"NaviWatcher/1.0 ( test@example.com )\"\n\nscanner:\n fuzzy_threshold: " + fmt.Sprintf("%.1f", tt.threshold) + "\n"
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for threshold %f, got nil", tt.threshold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MissingNavidromeURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
navidrome:
|
||||
user: "u"
|
||||
password: "p"
|
||||
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing navidrome.url, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MissingNavidromeUser(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
navidrome:
|
||||
url: "http://localhost:4533"
|
||||
password: "p"
|
||||
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing navidrome.user, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MissingNavidromePassword(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
navidrome:
|
||||
url: "http://localhost:4533"
|
||||
user: "u"
|
||||
|
||||
musicbrainz:
|
||||
user_agent: "NaviWatcher/1.0 ( test@example.com )"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing navidrome.password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MissingMusicBrainzUserAgent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := `
|
||||
navidrome:
|
||||
url: "http://localhost:4533"
|
||||
user: "u"
|
||||
password: "p"
|
||||
|
||||
musicbrainz:
|
||||
cache_ttl: 24h
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing musicbrainz.user_agent, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_BoundaryThreshold(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
threshold float64
|
||||
wantErr bool
|
||||
}{
|
||||
{"threshold 0.0", 0.0, false},
|
||||
{"threshold 1.0", 1.0, false},
|
||||
{"threshold 0.5", 0.5, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := "navidrome:\n url: \"http://localhost:4533\"\n user: \"u\"\n password: \"p\"\n\nmusicbrainz:\n user_agent: \"NaviWatcher/1.0 ( test@example.com )\"\n\nscanner:\n fuzzy_threshold: " + fmt.Sprintf("%.2f", tt.threshold) + "\n"
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatalf("expected error for threshold %f, got nil", tt.threshold)
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error for threshold %f: %v", tt.threshold, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_BoundaryPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
port int
|
||||
wantErr bool
|
||||
}{
|
||||
{"port 1", 1, false},
|
||||
{"port 65535", 65535, false},
|
||||
{"port 8080", 8080, false},
|
||||
{"port 65536", 65536, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
yaml := buildConfigWithPort(tt.port)
|
||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadConfig(path)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatalf("expected error for port %d, got nil", tt.port)
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error for port %d: %v", tt.port, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
116
internal/database/artist_settings.go
Normal file
116
internal/database/artist_settings.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GetArtistSettings retrieves an artist_settings row by ID.
|
||||
// Returns sql.ErrNoRows if the artist is not found.
|
||||
func GetArtistSettings(db *DB, id string) (*ArtistSettings, error) {
|
||||
var s ArtistSettings
|
||||
err := db.Conn().QueryRow(
|
||||
"SELECT id, name, ignore_singles, ignore_compilations, monitored FROM artist_settings WHERE id = ?",
|
||||
id,
|
||||
).Scan(&s.ID, &s.Name, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// SaveArtistSettings inserts or replaces an artist_settings row.
|
||||
func SaveArtistSettings(db *DB, settings *ArtistSettings) error {
|
||||
_, err := db.Conn().Exec(
|
||||
"INSERT OR REPLACE INTO artist_settings (id, name, ignore_singles, ignore_compilations, monitored) VALUES (?, ?, ?, ?, ?)",
|
||||
settings.ID, settings.Name, settings.IgnoreSingles, settings.IgnoreCompilations, settings.Monitored,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save artist settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllArtistSettings returns all rows from artist_settings.
|
||||
func GetAllArtistSettings(db *DB) ([]ArtistSettings, error) {
|
||||
rows, err := db.Conn().Query(
|
||||
"SELECT id, name, ignore_singles, ignore_compilations, monitored FROM artist_settings",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query all artist settings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []ArtistSettings
|
||||
for rows.Next() {
|
||||
var s ArtistSettings
|
||||
if err := rows.Scan(&s.ID, &s.Name, &s.IgnoreSingles, &s.IgnoreCompilations, &s.Monitored); err != nil {
|
||||
return nil, fmt.Errorf("scan artist settings: %w", err)
|
||||
}
|
||||
results = append(results, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate artist settings: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// UpdateArtistSettings updates specific fields of an artist_settings row by ID.
|
||||
// The updates map keys must match column names: "name", "ignore_singles", "ignore_compilations", "monitored".
|
||||
func UpdateArtistSettings(db *DB, id string, updates map[string]interface{}) error {
|
||||
if len(updates) == 0 {
|
||||
return fmt.Errorf("no updates provided")
|
||||
}
|
||||
|
||||
// Build the query using a fixed set of allowed columns to avoid dynamic SQL.
|
||||
const baseQuery = "UPDATE artist_settings SET"
|
||||
|
||||
var args []interface{}
|
||||
setClause := ""
|
||||
for col, val := range updates {
|
||||
switch col {
|
||||
case "name":
|
||||
if setClause != "" {
|
||||
setClause += ", "
|
||||
}
|
||||
setClause += "name = ?"
|
||||
args = append(args, val)
|
||||
case "ignore_singles":
|
||||
if setClause != "" {
|
||||
setClause += ", "
|
||||
}
|
||||
setClause += "ignore_singles = ?"
|
||||
args = append(args, val)
|
||||
case "ignore_compilations":
|
||||
if setClause != "" {
|
||||
setClause += ", "
|
||||
}
|
||||
setClause += "ignore_compilations = ?"
|
||||
args = append(args, val)
|
||||
case "monitored":
|
||||
if setClause != "" {
|
||||
setClause += ", "
|
||||
}
|
||||
setClause += "monitored = ?"
|
||||
args = append(args, val)
|
||||
default:
|
||||
return fmt.Errorf("unknown column: %s", col)
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf("%s %s WHERE id = ?", baseQuery, setClause)
|
||||
result, err := db.Conn().Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update artist settings: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("artist not found: %s", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
307
internal/database/artist_settings_test.go
Normal file
307
internal/database/artist_settings_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetArtistSettings_Found verifies retrieving an existing artist.
|
||||
func TestGetArtistSettings_Found(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Insert a row directly.
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO artist_settings (id, name, ignore_singles, ignore_compilations, monitored) VALUES (?, ?, ?, ?, ?)",
|
||||
"artist-1", "Test Artist", true, false, true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
s, err := GetArtistSettings(db, "artist-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
if s.ID != "artist-1" {
|
||||
t.Errorf("expected ID 'artist-1', got %q", s.ID)
|
||||
}
|
||||
if s.Name != "Test Artist" {
|
||||
t.Errorf("expected Name 'Test Artist', got %q", s.Name)
|
||||
}
|
||||
if !s.IgnoreSingles {
|
||||
t.Error("expected IgnoreSingles true")
|
||||
}
|
||||
if s.IgnoreCompilations {
|
||||
t.Error("expected IgnoreCompilations false")
|
||||
}
|
||||
if !s.Monitored {
|
||||
t.Error("expected Monitored true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetArtistSettings_NotFound verifies that a missing artist returns sql.ErrNoRows.
|
||||
func TestGetArtistSettings_NotFound(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = GetArtistSettings(db, "nonexistent")
|
||||
if err != sql.ErrNoRows {
|
||||
t.Errorf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveArtistSettings_Insert verifies inserting a new artist.
|
||||
func TestSaveArtistSettings_Insert(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
s := &ArtistSettings{
|
||||
ID: "artist-1",
|
||||
Name: "New Artist",
|
||||
IgnoreSingles: false,
|
||||
IgnoreCompilations: true,
|
||||
Monitored: true,
|
||||
}
|
||||
|
||||
if err := SaveArtistSettings(db, s); err != nil {
|
||||
t.Fatalf("SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
// Verify it was inserted.
|
||||
got, err := GetArtistSettings(db, "artist-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistSettings() error: %v", err)
|
||||
}
|
||||
if got.Name != "New Artist" {
|
||||
t.Errorf("expected Name 'New Artist', got %q", got.Name)
|
||||
}
|
||||
if got.IgnoreCompilations != true {
|
||||
t.Errorf("expected IgnoreCompilations true, got %v", got.IgnoreCompilations)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveArtistSettings_Update verifies that SaveArtistSettings replaces an existing row.
|
||||
func TestSaveArtistSettings_Update(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Insert initial row.
|
||||
s1 := &ArtistSettings{
|
||||
ID: "artist-1",
|
||||
Name: "Original Name",
|
||||
IgnoreSingles: false,
|
||||
IgnoreCompilations: false,
|
||||
Monitored: true,
|
||||
}
|
||||
if err := SaveArtistSettings(db, s1); err != nil {
|
||||
t.Fatalf("first SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
// Update the row.
|
||||
s2 := &ArtistSettings{
|
||||
ID: "artist-1",
|
||||
Name: "Updated Name",
|
||||
IgnoreSingles: true,
|
||||
IgnoreCompilations: true,
|
||||
Monitored: false,
|
||||
}
|
||||
if err := SaveArtistSettings(db, s2); err != nil {
|
||||
t.Fatalf("second SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
got, err := GetArtistSettings(db, "artist-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistSettings() error: %v", err)
|
||||
}
|
||||
if got.Name != "Updated Name" {
|
||||
t.Errorf("expected Name 'Updated Name', got %q", got.Name)
|
||||
}
|
||||
if !got.IgnoreSingles {
|
||||
t.Error("expected IgnoreSingles true")
|
||||
}
|
||||
if !got.IgnoreCompilations {
|
||||
t.Error("expected IgnoreCompilations true")
|
||||
}
|
||||
if got.Monitored {
|
||||
t.Error("expected Monitored false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAllArtistSettings_Empty verifies GetAllArtistSettings returns empty slice when no rows.
|
||||
func TestGetAllArtistSettings_Empty(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
results, err := GetAllArtistSettings(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllArtistSettings() error: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAllArtistSettings_Populated verifies GetAllArtistSettings returns all rows.
|
||||
func TestGetAllArtistSettings_Populated(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
artists := []ArtistSettings{
|
||||
{ID: "a1", Name: "Artist One", Monitored: true},
|
||||
{ID: "a2", Name: "Artist Two", IgnoreSingles: true},
|
||||
{ID: "a3", Name: "Artist Three", IgnoreCompilations: true},
|
||||
}
|
||||
|
||||
for _, a := range artists {
|
||||
if err := SaveArtistSettings(db, &a); err != nil {
|
||||
t.Fatalf("SaveArtistSettings(%s) error: %v", a.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
results, err := GetAllArtistSettings(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllArtistSettings() error: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("expected 3 results, got %d", len(results))
|
||||
}
|
||||
|
||||
// Verify all artists are present (order not guaranteed, use a map).
|
||||
byID := make(map[string]ArtistSettings)
|
||||
for _, r := range results {
|
||||
byID[r.ID] = r
|
||||
}
|
||||
for _, expected := range artists {
|
||||
got, ok := byID[expected.ID]
|
||||
if !ok {
|
||||
t.Errorf("expected artist %s in results", expected.ID)
|
||||
continue
|
||||
}
|
||||
if got.Name != expected.Name {
|
||||
t.Errorf("artist %s: expected Name %q, got %q", expected.ID, expected.Name, got.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateArtistSettings_Success verifies partial updates work.
|
||||
func TestUpdateArtistSettings_Success(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Insert initial row.
|
||||
s := &ArtistSettings{
|
||||
ID: "artist-1",
|
||||
Name: "Original",
|
||||
IgnoreSingles: false,
|
||||
IgnoreCompilations: false,
|
||||
Monitored: true,
|
||||
}
|
||||
if err := SaveArtistSettings(db, s); err != nil {
|
||||
t.Fatalf("SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
// Update only name and monitored.
|
||||
updates := map[string]interface{}{
|
||||
"name": "Updated",
|
||||
"monitored": false,
|
||||
}
|
||||
if err := UpdateArtistSettings(db, "artist-1", updates); err != nil {
|
||||
t.Fatalf("UpdateArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
got, err := GetArtistSettings(db, "artist-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtistSettings() error: %v", err)
|
||||
}
|
||||
if got.Name != "Updated" {
|
||||
t.Errorf("expected Name 'Updated', got %q", got.Name)
|
||||
}
|
||||
if got.Monitored {
|
||||
t.Error("expected Monitored false")
|
||||
}
|
||||
// Unchanged fields should remain.
|
||||
if got.IgnoreSingles != false {
|
||||
t.Error("expected IgnoreSingles unchanged (false)")
|
||||
}
|
||||
if got.IgnoreCompilations != false {
|
||||
t.Error("expected IgnoreCompilations unchanged (false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateArtistSettings_NotFound verifies updating a nonexistent artist returns error.
|
||||
func TestUpdateArtistSettings_NotFound(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
updates := map[string]interface{}{"name": "Ghost"}
|
||||
err = UpdateArtistSettings(db, "nonexistent", updates)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent artist, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateArtistSettings_InvalidColumn verifies unknown columns are rejected.
|
||||
func TestUpdateArtistSettings_InvalidColumn(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
s := &ArtistSettings{ID: "artist-1", Name: "Test"}
|
||||
if err := SaveArtistSettings(db, s); err != nil {
|
||||
t.Fatalf("SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{"invalid_col": "value"}
|
||||
err = UpdateArtistSettings(db, "artist-1", updates)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid column, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateArtistSettings_EmptyUpdates verifies empty updates map returns error.
|
||||
func TestUpdateArtistSettings_EmptyUpdates(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
s := &ArtistSettings{ID: "artist-1", Name: "Test"}
|
||||
if err := SaveArtistSettings(db, s); err != nil {
|
||||
t.Fatalf("SaveArtistSettings() error: %v", err)
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
err = UpdateArtistSettings(db, "artist-1", updates)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty updates, got nil")
|
||||
}
|
||||
}
|
||||
192
internal/database/database.go
Normal file
192
internal/database/database.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DB wraps sql.DB with migration support.
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
// New opens a SQLite database at dbPath and runs schema migrations.
|
||||
func New(dbPath string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// Enable WAL mode for better concurrent read performance.
|
||||
if _, err := conn.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("set WAL mode: %w", err)
|
||||
}
|
||||
|
||||
// Enable foreign key enforcement.
|
||||
if _, err := conn.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
// Set busy timeout to handle concurrent write contention.
|
||||
if _, err := conn.Exec("PRAGMA busy_timeout=5000"); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("set busy timeout: %w", err)
|
||||
}
|
||||
|
||||
db := &DB{conn: conn}
|
||||
if err := db.migrate(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection.
|
||||
func (db *DB) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
// Conn returns the underlying sql.DB for use by other packages.
|
||||
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.
|
||||
if _, err := db.conn.Exec(`CREATE TABLE IF NOT EXISTS _migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`); err != nil {
|
||||
return fmt.Errorf("create migrations table: %w", err)
|
||||
}
|
||||
|
||||
migrations := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{
|
||||
name: "001_create_artist_settings",
|
||||
sql: `CREATE TABLE IF NOT EXISTS artist_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
ignore_singles BOOLEAN DEFAULT 0,
|
||||
ignore_compilations BOOLEAN DEFAULT 0,
|
||||
monitored BOOLEAN DEFAULT 1
|
||||
);`,
|
||||
},
|
||||
{
|
||||
name: "002_create_external_releases",
|
||||
sql: `CREATE TABLE IF NOT EXISTS external_releases (
|
||||
rgid TEXT PRIMARY KEY,
|
||||
artist_id TEXT NOT NULL REFERENCES artist_settings(id),
|
||||
title TEXT NOT NULL,
|
||||
type TEXT,
|
||||
release_date TEXT,
|
||||
is_ignored BOOLEAN DEFAULT 0
|
||||
);`,
|
||||
},
|
||||
{
|
||||
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",
|
||||
sql: `CREATE TABLE IF NOT EXISTS notifications_sent (
|
||||
rgid TEXT NOT NULL REFERENCES external_releases(rgid),
|
||||
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (rgid, sent_at)
|
||||
);`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
applied, err := db.isMigrationApplied(m.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", m.name, err)
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction for migration %s: %w", m.name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(m.sql); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", m.name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("INSERT INTO _migrations (name) VALUES (?)", m.name); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", m.name, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", m.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMigrationApplied checks whether a migration with the given name has already been applied.
|
||||
func (db *DB) isMigrationApplied(name string) (bool, error) {
|
||||
var count int
|
||||
err := db.conn.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = ?", name).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ArtistSettings represents a row in the artist_settings table.
|
||||
type ArtistSettings struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IgnoreSingles bool `json:"ignore_singles"`
|
||||
IgnoreCompilations bool `json:"ignore_compilations"`
|
||||
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"`
|
||||
ArtistID string `json:"artist_id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
IsIgnored bool `json:"is_ignored"`
|
||||
}
|
||||
|
||||
// NotificationSent represents a row in the notifications_sent table.
|
||||
type NotificationSent struct {
|
||||
RGID string `json:"rgid"`
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
}
|
||||
212
internal/database/database_test.go
Normal file
212
internal/database/database_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNew_InitializationAndSchema verifies that New() creates all expected tables.
|
||||
func TestNew_InitializationAndSchema(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
expectedTables := []string{
|
||||
"_migrations",
|
||||
"artist_settings",
|
||||
"external_releases",
|
||||
"local_albums",
|
||||
"notifications_sent",
|
||||
}
|
||||
|
||||
for _, table := range expectedTables {
|
||||
var name string
|
||||
err := db.Conn().QueryRow(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("expected table %q to exist, got error: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_MigrationIdempency verifies that calling New() twice (via migrate) does not fail.
|
||||
func TestNew_MigrationIdempotency(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("first New() error: %v", err)
|
||||
}
|
||||
|
||||
// Running migrate again on the same connection should be a no-op.
|
||||
err = db.migrate()
|
||||
if err != nil {
|
||||
t.Fatalf("second migrate() error: %v", err)
|
||||
}
|
||||
|
||||
// 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",
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query error: %v", err)
|
||||
}
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 tables, got %d", count)
|
||||
}
|
||||
|
||||
db.Close()
|
||||
}
|
||||
|
||||
// TestClose verifies that Close() properly closes the connection.
|
||||
func TestClose(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("Close() error: %v", err)
|
||||
}
|
||||
|
||||
// After close, queries should fail.
|
||||
var dummy int
|
||||
err = db.Conn().QueryRow("SELECT 1").Scan(&dummy)
|
||||
if err == nil {
|
||||
t.Error("expected error querying after Close(), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtistSettingsSchema verifies the artist_settings table has the correct columns.
|
||||
func TestArtistSettingsSchema(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Insert a row to verify column names and types.
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO artist_settings (id, name, ignore_singles, ignore_compilations, monitored) VALUES (?, ?, ?, ?, ?)",
|
||||
"artist-1", "Test Artist", true, false, true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert into artist_settings: %v", err)
|
||||
}
|
||||
|
||||
var id, name string
|
||||
var ignoreSingles, ignoreCompilations, monitored bool
|
||||
err = db.Conn().QueryRow(
|
||||
"SELECT id, name, ignore_singles, ignore_compilations, monitored FROM artist_settings WHERE id = ?",
|
||||
"artist-1",
|
||||
).Scan(&id, &name, &ignoreSingles, &ignoreCompilations, &monitored)
|
||||
if err != nil {
|
||||
t.Fatalf("select from artist_settings: %v", err)
|
||||
}
|
||||
|
||||
if id != "artist-1" || name != "Test Artist" || !ignoreSingles || ignoreCompilations || !monitored {
|
||||
t.Errorf("unexpected row values: id=%q name=%q ignoreSingles=%v ignoreCompilations=%v monitored=%v",
|
||||
id, name, ignoreSingles, ignoreCompilations, monitored)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExternalReleasesSchema verifies the external_releases table has the correct columns.
|
||||
func TestExternalReleasesSchema(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Insert parent artist first (FK requirement).
|
||||
_, err = db.Conn().Exec("INSERT INTO artist_settings (id, name) VALUES (?, ?)", "artist-1", "Test Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist: %v", err)
|
||||
}
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"rgid-1", "artist-1", "Test Album", "album", "2024-01-01", false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert into external_releases: %v", err)
|
||||
}
|
||||
|
||||
var rgid, artistID, title, releaseType, releaseDate string
|
||||
var isIgnored bool
|
||||
err = db.Conn().QueryRow(
|
||||
"SELECT rgid, artist_id, title, type, release_date, is_ignored FROM external_releases WHERE rgid = ?",
|
||||
"rgid-1",
|
||||
).Scan(&rgid, &artistID, &title, &releaseType, &releaseDate, &isIgnored)
|
||||
if err != nil {
|
||||
t.Fatalf("select from external_releases: %v", err)
|
||||
}
|
||||
|
||||
if rgid != "rgid-1" || artistID != "artist-1" || title != "Test Album" || releaseType != "album" || releaseDate != "2024-01-01" || isIgnored {
|
||||
t.Errorf("unexpected row values")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsSentSchema verifies the notifications_sent table has the correct columns.
|
||||
func TestNotificationsSentSchema(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Insert parent artist and release first (FK requirements).
|
||||
_, err = db.Conn().Exec("INSERT INTO artist_settings (id, name) VALUES (?, ?)", "artist-1", "Test Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist: %v", err)
|
||||
}
|
||||
_, err = db.Conn().Exec("INSERT INTO external_releases (rgid, artist_id, title) VALUES (?, ?, ?)", "rgid-1", "artist-1", "Test Album")
|
||||
if err != nil {
|
||||
t.Fatalf("insert release: %v", err)
|
||||
}
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO notifications_sent (rgid) VALUES (?)",
|
||||
"rgid-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert into notifications_sent: %v", err)
|
||||
}
|
||||
|
||||
var rgid string
|
||||
var sentAt string
|
||||
err = db.Conn().QueryRow(
|
||||
"SELECT rgid, sent_at FROM notifications_sent WHERE rgid = ?",
|
||||
"rgid-1",
|
||||
).Scan(&rgid, &sentAt)
|
||||
if err != nil {
|
||||
t.Fatalf("select from notifications_sent: %v", err)
|
||||
}
|
||||
|
||||
if rgid != "rgid-1" {
|
||||
t.Errorf("expected rgid 'rgid-1', got %q", rgid)
|
||||
}
|
||||
if sentAt == "" {
|
||||
t.Error("expected sent_at to be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrationTracking verifies that migrations are recorded in _migrations table.
|
||||
func TestMigrationTracking(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var count int
|
||||
err = db.Conn().QueryRow("SELECT COUNT(*) FROM _migrations").Scan(&count)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
101
internal/database/external_releases.go
Normal file
101
internal/database/external_releases.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GetExternalRelease retrieves an external_release row by RGID.
|
||||
// Returns sql.ErrNoRows if the release is not found.
|
||||
func GetExternalRelease(db *DB, rgid string) (*ExternalRelease, error) {
|
||||
var r ExternalRelease
|
||||
err := db.Conn().QueryRow(
|
||||
"SELECT rgid, artist_id, title, type, release_date, is_ignored FROM external_releases WHERE rgid = ?",
|
||||
rgid,
|
||||
).Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// SaveExternalRelease inserts or replaces an external_release row.
|
||||
func SaveExternalRelease(db *DB, release *ExternalRelease) error {
|
||||
_, err := db.Conn().Exec(
|
||||
"INSERT OR REPLACE INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
release.RGID, release.ArtistID, release.Title, release.Type, release.ReleaseDate, release.IsIgnored,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save external release: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExternalReleasesByArtist returns all external_release rows for a given artist_id.
|
||||
func GetExternalReleasesByArtist(db *DB, artistID string) ([]ExternalRelease, error) {
|
||||
rows, err := db.Conn().Query(
|
||||
"SELECT rgid, artist_id, title, type, release_date, is_ignored FROM external_releases WHERE artist_id = ?",
|
||||
artistID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query external releases by artist: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []ExternalRelease
|
||||
for rows.Next() {
|
||||
var r ExternalRelease
|
||||
if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored); err != nil {
|
||||
return nil, fmt.Errorf("scan external release: %w", err)
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate external releases: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetIgnoredReleases returns all external_release rows where is_ignored = 1.
|
||||
func GetIgnoredReleases(db *DB) ([]ExternalRelease, error) {
|
||||
rows, err := db.Conn().Query(
|
||||
"SELECT rgid, artist_id, title, type, release_date, is_ignored FROM external_releases WHERE is_ignored = 1",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query ignored releases: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []ExternalRelease
|
||||
for rows.Next() {
|
||||
var r ExternalRelease
|
||||
if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored); err != nil {
|
||||
return nil, fmt.Errorf("scan ignored release: %w", err)
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate ignored releases: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SetReleaseIgnored updates the is_ignored flag for a given RGID.
|
||||
func SetReleaseIgnored(db *DB, rgid string, ignored bool) error {
|
||||
result, err := db.Conn().Exec(
|
||||
"UPDATE external_releases SET is_ignored = ? WHERE rgid = ?",
|
||||
ignored, rgid,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set release ignored: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("release not found: %s", rgid)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
378
internal/database/external_releases_test.go
Normal file
378
internal/database/external_releases_test.go
Normal file
@@ -0,0 +1,378 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// insertTestArtist inserts a minimal artist_settings row for use in tests that need FK satisfaction.
|
||||
func insertTestArtist(db *DB, id string) error {
|
||||
_, err := db.Conn().Exec(
|
||||
"INSERT OR IGNORE INTO artist_settings (id, name) VALUES (?, ?)",
|
||||
id, "Test Artist "+id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// TestGetExternalRelease_Found verifies retrieving an existing release.
|
||||
func TestGetExternalRelease_Found(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
_, err = db.Conn().Exec(
|
||||
"INSERT INTO external_releases (rgid, artist_id, title, type, release_date, is_ignored) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"rgid-1", "artist-1", "Test Album", "album", "2024-01-01", false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
r, err := GetExternalRelease(db, "rgid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetExternalRelease() error: %v", err)
|
||||
}
|
||||
|
||||
if r.RGID != "rgid-1" {
|
||||
t.Errorf("expected RGID 'rgid-1', got %q", r.RGID)
|
||||
}
|
||||
if r.ArtistID != "artist-1" {
|
||||
t.Errorf("expected ArtistID 'artist-1', got %q", r.ArtistID)
|
||||
}
|
||||
if r.Title != "Test Album" {
|
||||
t.Errorf("expected Title 'Test Album', got %q", r.Title)
|
||||
}
|
||||
if r.Type != "album" {
|
||||
t.Errorf("expected Type 'album', got %q", r.Type)
|
||||
}
|
||||
if r.ReleaseDate != "2024-01-01" {
|
||||
t.Errorf("expected ReleaseDate '2024-01-01', got %q", r.ReleaseDate)
|
||||
}
|
||||
if r.IsIgnored {
|
||||
t.Error("expected IsIgnored false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetExternalRelease_NotFound verifies that a missing release returns sql.ErrNoRows.
|
||||
func TestGetExternalRelease_NotFound(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = GetExternalRelease(db, "nonexistent")
|
||||
if err != sql.ErrNoRows {
|
||||
t.Errorf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveExternalRelease_Insert verifies inserting a new release.
|
||||
func TestSaveExternalRelease_Insert(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
r := &ExternalRelease{
|
||||
RGID: "rgid-1",
|
||||
ArtistID: "artist-1",
|
||||
Title: "New Album",
|
||||
Type: "album",
|
||||
ReleaseDate: "2024-06-15",
|
||||
IsIgnored: false,
|
||||
}
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
if err := SaveExternalRelease(db, r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease() error: %v", err)
|
||||
}
|
||||
|
||||
got, err := GetExternalRelease(db, "rgid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetExternalRelease() error: %v", err)
|
||||
}
|
||||
if got.Title != "New Album" {
|
||||
t.Errorf("expected Title 'New Album', got %q", got.Title)
|
||||
}
|
||||
if got.ReleaseDate != "2024-06-15" {
|
||||
t.Errorf("expected ReleaseDate '2024-06-15', got %q", got.ReleaseDate)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveExternalRelease_Update verifies that SaveExternalRelease replaces an existing row.
|
||||
func TestSaveExternalRelease_Update(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
|
||||
// Insert initial row.
|
||||
r1 := &ExternalRelease{
|
||||
RGID: "rgid-1",
|
||||
ArtistID: "artist-1",
|
||||
Title: "Original Title",
|
||||
Type: "album",
|
||||
ReleaseDate: "2024-01-01",
|
||||
IsIgnored: false,
|
||||
}
|
||||
if err := SaveExternalRelease(db, r1); err != nil {
|
||||
t.Fatalf("first SaveExternalRelease() error: %v", err)
|
||||
}
|
||||
|
||||
// Update the row.
|
||||
r2 := &ExternalRelease{
|
||||
RGID: "rgid-1",
|
||||
ArtistID: "artist-1",
|
||||
Title: "Updated Title",
|
||||
Type: "single",
|
||||
ReleaseDate: "2024-12-25",
|
||||
IsIgnored: true,
|
||||
}
|
||||
if err := SaveExternalRelease(db, r2); err != nil {
|
||||
t.Fatalf("second SaveExternalRelease() error: %v", err)
|
||||
}
|
||||
|
||||
got, err := GetExternalRelease(db, "rgid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetExternalRelease() error: %v", err)
|
||||
}
|
||||
if got.Title != "Updated Title" {
|
||||
t.Errorf("expected Title 'Updated Title', got %q", got.Title)
|
||||
}
|
||||
if got.Type != "single" {
|
||||
t.Errorf("expected Type 'single', got %q", got.Type)
|
||||
}
|
||||
if got.ReleaseDate != "2024-12-25" {
|
||||
t.Errorf("expected ReleaseDate '2024-12-25', got %q", got.ReleaseDate)
|
||||
}
|
||||
if !got.IsIgnored {
|
||||
t.Error("expected IsIgnored true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetExternalReleasesByArtist_Empty verifies empty result for unknown artist.
|
||||
func TestGetExternalReleasesByArtist_Empty(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
results, err := GetExternalReleasesByArtist(db, "unknown-artist")
|
||||
if err != nil {
|
||||
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetExternalReleasesByArtist_Populated verifies filtering by artist_id.
|
||||
func TestGetExternalReleasesByArtist_Populated(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
releases := []ExternalRelease{
|
||||
{RGID: "rg1", ArtistID: "artist-a", Title: "Album A1", Type: "album", ReleaseDate: "2024-01-01"},
|
||||
{RGID: "rg2", ArtistID: "artist-a", Title: "Album A2", Type: "album", ReleaseDate: "2024-06-01"},
|
||||
{RGID: "rg3", ArtistID: "artist-b", Title: "Album B1", Type: "single", ReleaseDate: "2024-03-01"},
|
||||
}
|
||||
|
||||
if err := insertTestArtist(db, "artist-a"); err != nil {
|
||||
t.Fatalf("insertTestArtist(artist-a): %v", err)
|
||||
}
|
||||
if err := insertTestArtist(db, "artist-b"); err != nil {
|
||||
t.Fatalf("insertTestArtist(artist-b): %v", err)
|
||||
}
|
||||
for _, r := range releases {
|
||||
if err := SaveExternalRelease(db, &r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for artist-a.
|
||||
results, err := GetExternalReleasesByArtist(db, "artist-a")
|
||||
if err != nil {
|
||||
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results for artist-a, got %d", len(results))
|
||||
}
|
||||
|
||||
// Verify all belong to artist-a.
|
||||
for _, r := range results {
|
||||
if r.ArtistID != "artist-a" {
|
||||
t.Errorf("expected ArtistID 'artist-a', got %q", r.ArtistID)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for artist-b.
|
||||
results, err = GetExternalReleasesByArtist(db, "artist-b")
|
||||
if err != nil {
|
||||
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result for artist-b, got %d", len(results))
|
||||
}
|
||||
if results[0].Title != "Album B1" {
|
||||
t.Errorf("expected Title 'Album B1', got %q", results[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetIgnoredReleases_Empty verifies empty result when no ignored releases.
|
||||
func TestGetIgnoredReleases_Empty(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
results, err := GetIgnoredReleases(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIgnoredReleases() error: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetIgnoredReleases_Populated verifies only ignored releases are returned.
|
||||
func TestGetIgnoredReleases_Populated(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
releases := []ExternalRelease{
|
||||
{RGID: "rg1", ArtistID: "a1", Title: "Ignored Album", IsIgnored: true},
|
||||
{RGID: "rg2", ArtistID: "a1", Title: "Normal Album", IsIgnored: false},
|
||||
{RGID: "rg3", ArtistID: "a2", Title: "Another Ignored", IsIgnored: true},
|
||||
}
|
||||
|
||||
if err := insertTestArtist(db, "a1"); err != nil {
|
||||
t.Fatalf("insertTestArtist(a1): %v", err)
|
||||
}
|
||||
if err := insertTestArtist(db, "a2"); err != nil {
|
||||
t.Fatalf("insertTestArtist(a2): %v", err)
|
||||
}
|
||||
for _, r := range releases {
|
||||
if err := SaveExternalRelease(db, &r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err)
|
||||
}
|
||||
}
|
||||
|
||||
results, err := GetIgnoredReleases(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIgnoredReleases() error: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 ignored releases, got %d", len(results))
|
||||
}
|
||||
|
||||
// Verify all returned are ignored.
|
||||
for _, r := range results {
|
||||
if !r.IsIgnored {
|
||||
t.Errorf("expected IsIgnored=true for %s, got false", r.RGID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetReleaseIgnored_SetTrue verifies setting is_ignored to true.
|
||||
func TestSetReleaseIgnored_SetTrue(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
r := &ExternalRelease{
|
||||
RGID: "rgid-1",
|
||||
ArtistID: "artist-1",
|
||||
Title: "Test Album",
|
||||
IsIgnored: false,
|
||||
}
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
if err := SaveExternalRelease(db, r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease() error: %v", err)
|
||||
}
|
||||
|
||||
if err := SetReleaseIgnored(db, "rgid-1", true); err != nil {
|
||||
t.Fatalf("SetReleaseIgnored() error: %v", err)
|
||||
}
|
||||
|
||||
got, err := GetExternalRelease(db, "rgid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetExternalRelease() error: %v", err)
|
||||
}
|
||||
if !got.IsIgnored {
|
||||
t.Error("expected IsIgnored true after SetReleaseIgnored(true)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetReleaseIgnored_SetFalse verifies setting is_ignored back to false.
|
||||
func TestSetReleaseIgnored_SetFalse(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
r := &ExternalRelease{
|
||||
RGID: "rgid-1",
|
||||
ArtistID: "artist-1",
|
||||
Title: "Test Album",
|
||||
IsIgnored: true,
|
||||
}
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
if err := SaveExternalRelease(db, r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease() error: %v", err)
|
||||
}
|
||||
|
||||
if err := SetReleaseIgnored(db, "rgid-1", false); err != nil {
|
||||
t.Fatalf("SetReleaseIgnored() error: %v", err)
|
||||
}
|
||||
|
||||
got, err := GetExternalRelease(db, "rgid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetExternalRelease() error: %v", err)
|
||||
}
|
||||
if got.IsIgnored {
|
||||
t.Error("expected IsIgnored false after SetReleaseIgnored(false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetReleaseIgnored_NotFound verifies error for nonexistent RGID.
|
||||
func TestSetReleaseIgnored_NotFound(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
err = SetReleaseIgnored(db, "nonexistent", true)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent RGID, got nil")
|
||||
}
|
||||
}
|
||||
103
internal/database/local_albums.go
Normal file
103
internal/database/local_albums.go
Normal file
@@ -0,0 +1,103 @@
|
||||
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
|
||||
}
|
||||
69
internal/database/notifications.go
Normal file
69
internal/database/notifications.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// MarkNotificationSent records that a notification has been sent for the given RGID.
|
||||
func MarkNotificationSent(db *DB, rgid string) error {
|
||||
_, err := db.Conn().Exec(
|
||||
"INSERT INTO notifications_sent (rgid) VALUES (?)",
|
||||
rgid,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark notification sent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsNotificationSent checks whether a notification has already been sent for the given RGID.
|
||||
func IsNotificationSent(db *DB, rgid string) (bool, error) {
|
||||
var count int
|
||||
err := db.Conn().QueryRow(
|
||||
"SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", rgid,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check notification sent: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetUnnotifiedReleases returns all external_release rows that have no entry in notifications_sent.
|
||||
func GetUnnotifiedReleases(db *DB) ([]ExternalRelease, error) {
|
||||
rows, err := db.Conn().Query(`
|
||||
SELECT e.rgid, e.artist_id, e.title, e.type, e.release_date, e.is_ignored
|
||||
FROM external_releases e
|
||||
LEFT JOIN notifications_sent n ON e.rgid = n.rgid
|
||||
WHERE n.rgid IS NULL AND e.is_ignored = 0
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query unnotified releases: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []ExternalRelease
|
||||
for rows.Next() {
|
||||
var r ExternalRelease
|
||||
if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored); err != nil {
|
||||
return nil, fmt.Errorf("scan unnotified release: %w", err)
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate unnotified releases: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetNotificationSentAt returns the sent_at time for a given RGID.
|
||||
// Returns sql.ErrNoRows if no notification has been sent.
|
||||
func GetNotificationSentAt(db *DB, rgid string) (string, error) {
|
||||
var sentAt string
|
||||
err := db.Conn().QueryRow(
|
||||
"SELECT sent_at FROM notifications_sent WHERE rgid = ? ORDER BY sent_at DESC LIMIT 1", rgid,
|
||||
).Scan(&sentAt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sentAt, nil
|
||||
}
|
||||
407
internal/database/notifications_test.go
Normal file
407
internal/database/notifications_test.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// insertTestRelease inserts a minimal external_releases row for use in tests that need FK satisfaction.
|
||||
func insertTestRelease(db *DB, rgid, artistID string) error {
|
||||
_, err := db.Conn().Exec(
|
||||
"INSERT OR IGNORE INTO external_releases (rgid, artist_id, title) VALUES (?, ?, ?)",
|
||||
rgid, artistID, "Test Release "+rgid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// TestMarkNotificationSent_New verifies inserting a new notification record.
|
||||
func TestMarkNotificationSent_New(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
if err := insertTestRelease(db, "rgid-1", "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestRelease: %v", err)
|
||||
}
|
||||
if err := MarkNotificationSent(db, "rgid-1"); err != nil {
|
||||
t.Fatalf("MarkNotificationSent() error: %v", err)
|
||||
}
|
||||
|
||||
sentAt, err := GetNotificationSentAt(db, "rgid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetNotificationSentAt() error: %v", err)
|
||||
}
|
||||
if sentAt == "" {
|
||||
t.Error("expected sent_at to be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkNotificationSent_DuplicateSecond verifies that inserting the same RGID twice
|
||||
// within the same second fails due to the composite primary key (rgid, sent_at).
|
||||
// In practice, notifications are sent at most once per day, so this is acceptable.
|
||||
func TestMarkNotificationSent_DuplicateSecond(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
if err := insertTestRelease(db, "rgid-1", "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestRelease: %v", err)
|
||||
}
|
||||
if err := MarkNotificationSent(db, "rgid-1"); err != nil {
|
||||
t.Fatalf("first MarkNotificationSent() error: %v", err)
|
||||
}
|
||||
// Second insert in the same second should fail with a UNIQUE constraint error.
|
||||
err = MarkNotificationSent(db, "rgid-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected UNIQUE constraint error on duplicate insert, got nil")
|
||||
}
|
||||
|
||||
// Should still have exactly one row.
|
||||
var count int
|
||||
err = db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rgid-1").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("count query error: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 notification row, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkNotificationSent_DifferentTime verifies that inserting the same RGID at a
|
||||
// different explicit sent_at time succeeds (composite PK allows multiple rows per RGID).
|
||||
func TestMarkNotificationSent_DifferentTime(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
if err := insertTestRelease(db, "rgid-1", "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestRelease: %v", err)
|
||||
}
|
||||
|
||||
// Insert with explicit different timestamps.
|
||||
_, err = db.Conn().Exec("INSERT INTO notifications_sent (rgid, sent_at) VALUES (?, ?)", "rgid-1", "2024-01-01T00:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("first insert error: %v", err)
|
||||
}
|
||||
_, err = db.Conn().Exec("INSERT INTO notifications_sent (rgid, sent_at) VALUES (?, ?)", "rgid-1", "2024-06-01T00:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("second insert error: %v", err)
|
||||
}
|
||||
|
||||
// IsNotificationSent should return true.
|
||||
sent, err := IsNotificationSent(db, "rgid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("IsNotificationSent() error: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Error("expected IsNotificationSent to return true")
|
||||
}
|
||||
|
||||
// Should have two rows.
|
||||
var count int
|
||||
err = db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", "rgid-1").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("count query error: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 notification rows, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsNotificationSent_True verifies true for a sent notification.
|
||||
func TestIsNotificationSent_True(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
if err := insertTestRelease(db, "rgid-1", "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestRelease: %v", err)
|
||||
}
|
||||
if err := MarkNotificationSent(db, "rgid-1"); err != nil {
|
||||
t.Fatalf("MarkNotificationSent() error: %v", err)
|
||||
}
|
||||
|
||||
sent, err := IsNotificationSent(db, "rgid-1")
|
||||
if err != nil {
|
||||
t.Fatalf("IsNotificationSent() error: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Error("expected IsNotificationSent to return true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsNotificationSent_False verifies false for an unsent RGID.
|
||||
func TestIsNotificationSent_False(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
sent, err := IsNotificationSent(db, "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("IsNotificationSent() error: %v", err)
|
||||
}
|
||||
if sent {
|
||||
t.Error("expected IsNotificationSent to return false for nonexistent RGID")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetNotificationSentAt_NotFound verifies sql.ErrNoRows for unsent RGID.
|
||||
func TestGetNotificationSentAt_NotFound(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = GetNotificationSentAt(db, "nonexistent")
|
||||
if err != sql.ErrNoRows {
|
||||
t.Errorf("expected sql.ErrNoRows, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUnnotifiedReleases_AllUnnotified verifies all releases returned when no notifications sent.
|
||||
func TestGetUnnotifiedReleases_AllUnnotified(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
releases := []ExternalRelease{
|
||||
{RGID: "rg1", ArtistID: "artist-1", Title: "Album 1", Type: "album", ReleaseDate: "2024-01-01"},
|
||||
{RGID: "rg2", ArtistID: "artist-1", Title: "Album 2", Type: "album", ReleaseDate: "2024-06-01"},
|
||||
{RGID: "rg3", ArtistID: "artist-2", Title: "Single 1", Type: "single", ReleaseDate: "2024-03-01"},
|
||||
}
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist(artist-1): %v", err)
|
||||
}
|
||||
if err := insertTestArtist(db, "artist-2"); err != nil {
|
||||
t.Fatalf("insertTestArtist(artist-2): %v", err)
|
||||
}
|
||||
for _, r := range releases {
|
||||
if err := SaveExternalRelease(db, &r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err)
|
||||
}
|
||||
}
|
||||
|
||||
results, err := GetUnnotifiedReleases(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnnotifiedReleases() error: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("expected 3 unnotified releases, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUnnotifiedReleases_SomeNotified verifies only unnotified releases are returned.
|
||||
func TestGetUnnotifiedReleases_SomeNotified(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
releases := []ExternalRelease{
|
||||
{RGID: "rg1", ArtistID: "artist-1", Title: "Album 1", Type: "album", ReleaseDate: "2024-01-01"},
|
||||
{RGID: "rg2", ArtistID: "artist-1", Title: "Album 2", Type: "album", ReleaseDate: "2024-06-01"},
|
||||
{RGID: "rg3", ArtistID: "artist-2", Title: "Single 1", Type: "single", ReleaseDate: "2024-03-01"},
|
||||
}
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist(artist-1): %v", err)
|
||||
}
|
||||
if err := insertTestArtist(db, "artist-2"); err != nil {
|
||||
t.Fatalf("insertTestArtist(artist-2): %v", err)
|
||||
}
|
||||
for _, r := range releases {
|
||||
if err := SaveExternalRelease(db, &r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark rg1 and rg3 as notified.
|
||||
if err := MarkNotificationSent(db, "rg1"); err != nil {
|
||||
t.Fatalf("MarkNotificationSent(rg1) error: %v", err)
|
||||
}
|
||||
if err := MarkNotificationSent(db, "rg3"); err != nil {
|
||||
t.Fatalf("MarkNotificationSent(rg3) error: %v", err)
|
||||
}
|
||||
|
||||
results, err := GetUnnotifiedReleases(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnnotifiedReleases() error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 unnotified release, got %d", len(results))
|
||||
}
|
||||
if results[0].RGID != "rg2" {
|
||||
t.Errorf("expected unnotified release rg2, got %s", results[0].RGID)
|
||||
}
|
||||
if results[0].Title != "Album 2" {
|
||||
t.Errorf("expected Title 'Album 2', got %q", results[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUnnotifiedReleases_AllNotified verifies empty result when all releases are notified.
|
||||
func TestGetUnnotifiedReleases_AllNotified(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
releases := []ExternalRelease{
|
||||
{RGID: "rg1", ArtistID: "artist-1", Title: "Album 1"},
|
||||
{RGID: "rg2", ArtistID: "artist-1", Title: "Album 2"},
|
||||
}
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist(artist-1): %v", err)
|
||||
}
|
||||
for _, r := range releases {
|
||||
if err := SaveExternalRelease(db, &r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all as notified.
|
||||
for _, r := range releases {
|
||||
if err := MarkNotificationSent(db, r.RGID); err != nil {
|
||||
t.Fatalf("MarkNotificationSent(%s) error: %v", r.RGID, err)
|
||||
}
|
||||
}
|
||||
|
||||
results, err := GetUnnotifiedReleases(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnnotifiedReleases() error: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 unnotified releases, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUnnotifiedReleases_NoReleases verifies empty result when no releases exist.
|
||||
func TestGetUnnotifiedReleases_NoReleases(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
results, err := GetUnnotifiedReleases(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnnotifiedReleases() error: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 unnotified releases, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkNotificationSent_MultipleReleases verifies marking multiple different RGIDs.
|
||||
func TestMarkNotificationSent_MultipleReleases(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
for _, rgid := range []string{"rg1", "rg2", "rg3"} {
|
||||
if err := insertTestRelease(db, rgid, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestRelease(%s): %v", rgid, err)
|
||||
}
|
||||
}
|
||||
|
||||
rgids := []string{"rg1", "rg2", "rg3"}
|
||||
for _, rgid := range rgids {
|
||||
if err := MarkNotificationSent(db, rgid); err != nil {
|
||||
t.Fatalf("MarkNotificationSent(%s) error: %v", rgid, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rgid := range rgids {
|
||||
sent, err := IsNotificationSent(db, rgid)
|
||||
if err != nil {
|
||||
t.Fatalf("IsNotificationSent(%s) error: %v", rgid, err)
|
||||
}
|
||||
if !sent {
|
||||
t.Errorf("expected IsNotificationSent(%s) to return true", rgid)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify count.
|
||||
var count int
|
||||
err = db.Conn().QueryRow("SELECT COUNT(*) FROM notifications_sent").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("count query error: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Errorf("expected 3 notification rows, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUnnotifiedReleases_IgnoredExcluded verifies that releases marked as ignored
|
||||
// are not returned by GetUnnotifiedReleases, per the notification lifecycle spec (section 4.4).
|
||||
func TestGetUnnotifiedReleases_IgnoredExcluded(t *testing.T) {
|
||||
db, err := New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("New() error: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := insertTestArtist(db, "artist-1"); err != nil {
|
||||
t.Fatalf("insertTestArtist: %v", err)
|
||||
}
|
||||
|
||||
releases := []ExternalRelease{
|
||||
{RGID: "rg1", ArtistID: "artist-1", Title: "Normal Album", Type: "album", ReleaseDate: "2024-01-01", IsIgnored: false},
|
||||
{RGID: "rg2", ArtistID: "artist-1", Title: "Ignored Album", Type: "album", ReleaseDate: "2024-06-01", IsIgnored: true},
|
||||
{RGID: "rg3", ArtistID: "artist-1", Title: "Another Normal", Type: "single", ReleaseDate: "2024-03-01", IsIgnored: false},
|
||||
}
|
||||
|
||||
for _, r := range releases {
|
||||
if err := SaveExternalRelease(db, &r); err != nil {
|
||||
t.Fatalf("SaveExternalRelease(%s) error: %v", r.RGID, err)
|
||||
}
|
||||
}
|
||||
|
||||
results, err := GetUnnotifiedReleases(db)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUnnotifiedReleases() error: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 unnotified releases (ignored excluded), got %d", len(results))
|
||||
}
|
||||
|
||||
// Verify the ignored release is not in the results.
|
||||
for _, r := range results {
|
||||
if r.RGID == "rg2" {
|
||||
t.Error("ignored release rg2 should not appear in unnotified releases")
|
||||
}
|
||||
}
|
||||
}
|
||||
118
internal/navidrome/client.go
Normal file
118
internal/navidrome/client.go
Normal file
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
372
internal/navidrome/client_test.go
Normal file
372
internal/navidrome/client_test.go
Normal file
@@ -0,0 +1,372 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
124
internal/navidrome/sync.go
Normal file
124
internal/navidrome/sync.go
Normal file
@@ -0,0 +1,124 @@
|
||||
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
|
||||
}
|
||||
445
internal/navidrome/sync_test.go
Normal file
445
internal/navidrome/sync_test.go
Normal file
@@ -0,0 +1,445 @@
|
||||
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