internal/download
The ~40-line progress-bar decorator stack was copy-pasted into three download
paths and had started to drift; it is now newDownloadBar, which returns a nil
bar when progress is off (so callers use it unconditionally) plus a finish
func to defer. The three hand-rolled read/write loops that differed only in
whether they touched a bar become copyWithProgress.
FileDeezerEncrypted and FileYandexEncrypted were ~90% identical: mkdir, GET,
status check, create output, bar, cleanup-on-failure, Content-Length check,
fsync. That scaffolding is now streamEncrypted, and each function supplies
only its decryption step. Also switches the io.EOF/io.ErrUnexpectedEOF
comparisons to errors.Is.
internal/app
Three copies of "coerce a JSON array that may be []any or []map[string]any"
become anySlice, which also reports whether the value was an array at all so
callers can still distinguish missing from empty.
The worker-pool dispatch existed three times and the copies disagreed: two
counted failures under a mutex and logged inline with the track id, while the
third collected errors in a channel and logged them all after wg.Wait()
without the id. runTrackWorkers unifies them on the inline form, which
reports failures as they happen and names the track, and keeps the mutex held
across the log so failure lines cannot interleave.
Tests
The progress-bar paths had no coverage at all, because progress is disabled
when not attached to a terminal; four new tests force it on via the existing
STREAMRIP_GO_FORCE_PROGRESS escape hatch and cover the determinate bar, the
unknown-length spinner, Deezer decryption with a bar attached, and the
failure path still removing the partial file. internal/download coverage
53% -> 63%.
Three cancellation tests pin the Ctrl-C fix. Verified as genuine regression
tests by reverting the guards: without them the rip walks all 40 tracks of a
cancelled album, logs a failure for each, and Rip() returns nil.
Adds store.SQLite.IsFailed, the read complement to MarkFailed, used by the
bookkeeping test to confirm failure records still land under a cancelled
context.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Make download dedupe source-specific to prevent cross-provider ID collisions. Also correct non-remaster filtering, avoid FLAC tagging on non-FLAC files, and use album IDs for singles folder templating.