fix: database migration, config validation and provider client defects

Completes the correctness pass.

Download database migration no longer destroys dedupe state
  The pre-source-column migration carried every legacy row over with
  source='' and then dropped downloads_legacy, while IsDownloaded matched
  source exactly. The result was that every previously recorded download
  looked new, the user silently re-downloaded their whole library, and the
  original rows were gone. Now the file is copied to <path>.bak-<unixtime>
  first, downloads_legacy is kept, and IsDownloaded falls back to the
  sourceless rows (over-matching across sources costs a skipped re-download,
  which is the cheaper error). Schema state is tracked in PRAGMA user_version
  so later migrations can be ordered explicitly. Databases already migrated by
  the old code are detected on open.

  Also enables WAL and a 10s busy_timeout and caps the pool at one connection,
  so a concurrent reader no longer gets SQLITE_BUSY with no retry, and builds
  the DSN through url.URL so a path containing '?' is not parsed as query
  parameters.

--quality now applies to every source
  applyGlobalConfigOverrides set only qobuz, tidal, deezer and soundcloud, so
  `rip -q 4 url <beatport-url>` was a silent no-op even though
  qualityForSource reads Yandex and Beatport. All six are set, with the
  per-source floor applied and reported rather than silently ignored.

config: validation and a fixed reset
  Load checked nothing but misc.version, so a negative max_connections or a
  bogus conversion codec was accepted and only surfaced mid-rip. Adds
  Validate() covering per-source quality ranges, max_connections,
  requests_per_minute and truncate_to, and normalizes the conversion codec
  from the file through the same path as --codec. `config reset` assigned one
  value to both Config.File and Config.Session, so their Secrets slices shared
  a backing array; it now takes separate copies.

  Tests cover the shipped defaults and config.toml.example, which is how the
  over-strict artwork max-width bound in the first draft was caught: -1 is the
  documented "do not downscale" sentinel.

soundcloud: use the shared HTTP client
  It was the only provider constructing its own http.Client, so it silently
  ignored downloads.verify_ssl and max_connections and bypassed the transport
  that redacts credentials from -vv logs. It also had no rate limiter at all.

qobuz: mobile fallback could not download a long track
  http.Client.Timeout bounds the whole request including the body read, so the
  30s API client aborted any track that took longer than 30s to stream. The
  body copy now uses a no-timeout client, as internal/download already does.

qobuz: HKDF no longer fails silently
  hexDecodeOrNil discarded its error, so a non-hex app secret keyed HKDF on
  nil, derived a wrong KEK and produced silently corrupt audio instead of an
  error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JorenandClaude Opus 5 committed 2026-10-03 16:58:59 +02:00
1 parent c030d1be09
commit 201aa5daa7
9 files changed
+570 -51

No files matched your search

+195
View File
@@ -2,7 +2,12 @@ package store
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
@@ -48,3 +53,193 @@ func TestSQLiteStore(t *testing.T) {
t.Fatalf("MarkFailed() error = %v", err)
}
}
// makeLegacyDB creates a database with the pre-source-column schema and the
// given ids, as a pre-migration install would have on disk.
func makeLegacyDB(t *testing.T, path string, ids ...string) {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
if _, err = db.Exec(`CREATE TABLE downloads (id TEXT NOT NULL PRIMARY KEY)`); err != nil {
t.Fatal(err)
}
for _, id := range ids {
if _, err = db.Exec(`INSERT INTO downloads(id) VALUES (?)`, id); err != nil {
t.Fatal(err)
}
}
}
// TestLegacyMigrationPreservesHistory is the regression test for the migration
// that silently wiped the dedupe state: rows were carried over with source=empty
// while IsDownloaded matched source exactly, so every previously downloaded
// track looked new and the whole library was re-downloaded.
func TestLegacyMigrationPreservesHistory(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "downloads.db")
makeLegacyDB(t, path, "old-1", "old-2")
s, err := NewSQLite(path)
if err != nil {
t.Fatalf("NewSQLite() error = %v", err)
}
defer func() { _ = s.Close() }()
for _, id := range []string{"old-1", "old-2"} {
ok, err := s.IsDownloaded(ctx, "qobuz", id)
if err != nil {
t.Fatalf("IsDownloaded(%q) error = %v", id, err)
}
if !ok {
t.Errorf("pre-migration download %q reported as not downloaded", id)
}
}
ok, err := s.IsDownloaded(ctx, "qobuz", "never-seen")
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("unknown id reported as downloaded")
}
}
// TestLegacyMigrationKeepsBackupAndLegacyTable asserts the migration is
// recoverable: the original file is copied aside and downloads_legacy survives.
func TestLegacyMigrationKeepsBackupAndLegacyTable(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "downloads.db")
makeLegacyDB(t, path, "old-1")
s, err := NewSQLite(path)
if err != nil {
t.Fatalf("NewSQLite() error = %v", err)
}
defer func() { _ = s.Close() }()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
backups := 0
for _, e := range entries {
if strings.Contains(e.Name(), ".bak-") {
backups++
}
}
if backups != 1 {
t.Errorf("expected exactly 1 backup file, found %d in %v", backups, entries)
}
var n int
if err = s.db.QueryRow(`SELECT COUNT(1) FROM downloads_legacy`).Scan(&n); err != nil {
t.Fatalf("downloads_legacy was dropped: %v", err)
}
if n != 1 {
t.Errorf("downloads_legacy row count = %d, want 1", n)
}
if v, err := s.userVersion(); err != nil || v != schemaVersion {
t.Errorf("user_version = %d (err %v), want %d", v, err, schemaVersion)
}
}
// TestLegacyRowsDetectedOnReopen covers a database already migrated by the
// earlier, lossy code path: the sourceless rows are on disk but nothing in
// this process migrated them.
func TestLegacyRowsDetectedOnReopen(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "downloads.db")
makeLegacyDB(t, path, "old-1")
s, err := NewSQLite(path)
if err != nil {
t.Fatal(err)
}
_ = s.Close()
reopened, err := NewSQLite(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = reopened.Close() }()
if !reopened.hasLegacyRows {
t.Fatal("legacy rows not detected on reopen")
}
ok, err := reopened.IsDownloaded(ctx, "tidal", "old-1")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Error("legacy row not honored after reopen")
}
}
// TestConcurrentAccess exercises the mutex and connection handling under load.
func TestConcurrentAccess(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "downloads.db")
s, err := NewSQLite(path)
if err != nil {
t.Fatal(err)
}
defer func() { _ = s.Close() }()
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
id := fmt.Sprintf("t%d", n)
if err := s.MarkDownloaded(ctx, "qobuz", id); err != nil {
t.Errorf("MarkDownloaded: %v", err)
return
}
if _, err := s.IsDownloaded(ctx, "qobuz", id); err != nil {
t.Errorf("IsDownloaded: %v", err)
}
if err := s.MarkFailed(ctx, "qobuz", "track", id); err != nil {
t.Errorf("MarkFailed: %v", err)
}
}(i)
}
wg.Wait()
}
func TestSQLiteDSNPragmasApplied(t *testing.T) {
// A path containing '?' must not be parsed as query parameters.
dir := t.TempDir()
path := filepath.Join(dir, "weird?name#x.db")
s, err := NewSQLite(path)
if err != nil {
t.Fatalf("NewSQLite(%q) = %v", path, err)
}
defer func() { _ = s.Close() }()
var mode string
if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
t.Fatal(err)
}
if mode != "wal" {
t.Errorf("journal_mode = %q, want wal", mode)
}
var timeout int
if err := s.db.QueryRow(`PRAGMA busy_timeout`).Scan(&timeout); err != nil {
t.Fatal(err)
}
if timeout != 10000 {
t.Errorf("busy_timeout = %d, want 10000", timeout)
}
if err := s.MarkDownloaded(context.Background(), "qobuz", "a"); err != nil {
t.Fatalf("write to odd path failed: %v", err)
}
}