Author SHA1 Message Date
Joren 8590a5b6b6 fix: playlist cover art, compilation tag, and year
- ripPlaylist/ripTrackCollection: pre-fetch playlist artwork once, embed in all tracks
- ripTrack: skip per-track album cover when playlist embed is provided
- buildTagMetadata: set COMPILATION=1 and year from playlist publish_date
- beatport: normalize playlistMetadata to include date fields for year extraction
- qobuz: normalize playlist image_rectangle to standard image map
2026-08-05 23:16:37 +02:00
50 changed files with 2467 additions and 5903 deletions

No files matched your search

-61
View File
@@ -1,61 +0,0 @@
name: CI
on:
push:
branches: [master]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: gofmt
run: |
out="$(gofmt -l .)"
if [ -n "$out" ]; then
echo "These files are not gofmt-clean:"
echo "$out"
exit 1
fi
- name: go vet
run: go vet ./...
- name: Build
run: go build ./...
- name: Test
run: go test ./...
- name: Test (race)
run: go test -race ./...
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# .golangci.yml uses the v2 config schema, so this needs golangci-lint
# v2.x and action v7+. Left unpinned deliberately: a golangci-lint built
# against an older Go toolchain cannot analyse this module at all (the
# same reason a locally installed staticcheck built with go1.24 fails on
# go.mod's go 1.25). Pin it once a known-good version is confirmed.
- uses: golangci/golangci-lint-action@v7
with:
version: latest
args: --timeout=5m
-21
View File
@@ -1,21 +0,0 @@
# Build output
/rip
/rip.exe
*.test
# Local runtime state
*.db
*.db-wal
*.db-shm
config.toml
*.bak-*
# Scratch files
albums.txt
*.log
coverage.out
# Editor / OS
.DS_Store
.idea/
.vscode/
-39
View File
@@ -1,39 +0,0 @@
version: "2"
run:
timeout: 5m
tests: true
linters:
default: standard
enable:
- bodyclose # unclosed HTTP response bodies: this app makes a lot of requests
- errorlint # == on errors instead of errors.Is; the download code had several
- copyloopvar
- misspell
- nilerr
- unconvert
- usestdlibvars
- whitespace
settings:
govet:
enable:
- copylocks # config.Config carries a mutex and must not be copied
- nilness
staticcheck:
checks:
- all
exclusions:
presets:
- std-error-handling
rules:
# Test helpers legitimately ignore errors on cleanup paths.
- path: _test\.go
linters:
- errcheck
formatters:
enable:
- gofmt
-63
View File
@@ -1,63 +0,0 @@
BINARY := rip
PKG := ./cmd/rip
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
.DEFAULT_GOAL := build
.PHONY: build
build: ## Build the rip binary
go build -ldflags "$(LDFLAGS)" -o $(BINARY) $(PKG)
.PHONY: install
install: ## Install rip into GOBIN
go install -ldflags "$(LDFLAGS)" $(PKG)
.PHONY: test
test: ## Run the test suite
go test ./...
.PHONY: race
race: ## Run the test suite under the race detector
go test -race ./...
.PHONY: cover
cover: ## Run tests and report per-package coverage
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | tail -1
.PHONY: fmt
fmt: ## Format all Go files
gofmt -w .
.PHONY: fmt-check
fmt-check: ## Fail if any file is not gofmt-clean
@out="$$(gofmt -l .)"; \
if [ -n "$$out" ]; then echo "not gofmt-clean:"; echo "$$out"; exit 1; fi
.PHONY: vet
vet: ## Run go vet
go vet ./...
.PHONY: lint
lint: ## Run golangci-lint if it is installed
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run; \
else \
echo "golangci-lint not installed; skipping"; \
echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
fi
.PHONY: check
check: fmt-check vet test ## Everything CI runs
.PHONY: clean
clean: ## Remove build and coverage artifacts
rm -f $(BINARY) coverage.out
.PHONY: help
help: ## List targets
@grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) | awk -F':.*?## ' '{printf " %-12s %s\n", $$1, $$2}'
-172
View File
@@ -1,172 +0,0 @@
# streamrip-go
A Go port of [streamrip](https://github.com/nathom/streamrip): a command-line
music downloader for Qobuz, Tidal, Deezer, SoundCloud, Yandex Music and
Beatport. Downloads tracks, albums, playlists, artist discographies and labels,
tags them, optionally embeds cover art and converts the result.
You need your own paid account for any source you use. This tool does not
provide or circumvent access to anything.
## Requirements
- Go 1.25 or newer (to build)
- `ffmpeg` and `ffprobe` — tagging and format conversion
- `yt-dlp` — required for SoundCloud only
```sh
# Arch
sudo pacman -S ffmpeg yt-dlp
# Debian/Ubuntu
sudo apt install ffmpeg && pipx install yt-dlp
# macOS
brew install ffmpeg yt-dlp
```
## Build
```sh
make build # -> ./rip, with version info stamped in
make install # into $(go env GOBIN)
./rip --version
```
Or without make: `go build ./cmd/rip`.
## Configuration
The first run writes a config file and tells you where. It lives at
`$XDG_CONFIG_HOME/streamrip/config.toml` (`~/Library/Application
Support/streamrip/config.toml` on macOS).
```sh
rip config path # print the path
rip config open # open in $EDITOR
rip config reset # restore defaults
```
`config.toml.example` documents every key. The file holds credentials in
plaintext — provider passwords, the Deezer ARL cookie and OAuth tokens — so it
is created `0600`. Keep it that way.
Credentials you need to set, per source:
| Source | Required config |
|------------|-----------------|
| Qobuz | `email_or_userid` + `password_or_token` (md5 of your password), or `use_auth_token = true` with an auth token |
| Tidal | `access_token` / `refresh_token` |
| Deezer | `arl` (recommended), or `email` + `password` |
| Yandex | `access_token` |
| Beatport | `username` + `password` |
| SoundCloud | none; handled via `yt-dlp` |
Lyrics (plain and synced `.lrc`-style) are fetched for Qobuz, Tidal and Deezer.
Each costs one extra API request per track, so `metadata.exclude = ["lyrics"]`
turns them off for a faster rip.
Each provider section also has vendor `app_client_id`-style keys. Leave them
empty: they default to values built into the binary. They exist so that if a
provider rotates one, you can patch your install with a config edit instead of
waiting for a release.
## Usage
```sh
rip url https://play.qobuz.com/album/abc123
rip url https://www.beatport.com/release/example/123 --force
rip id qobuz album abc123
rip search qobuz album "kind of blue"
rip search tidal track "teardrop" --first
rip file urls.txt # one URL per line, or a JSON list of IDs
rip lastfm --source qobuz <lastfm-playlist-url>
rip database downloads # what has already been downloaded
rip database failed
```
Global options (these must come *before* the command):
```
-f, --folder <path> Override downloads folder
-q, --quality <0-4> Override provider quality
-c, --codec <codec> Convert after download: ALAC, FLAC, OGG, MP3, AAC
-ndb, --no-db Ignore the download database
--no-progress Disable progress bars
--no-ssl-verify Disable TLS verification
--config-path <path> Use a custom config file
-v, -vv Verbose logging (-vv also logs HTTP requests)
-V, --version Show version
-h, --help Show help
```
Run `rip help <command>` for per-command help.
### Quality
`-q` is a 0–4 scale mapped onto each provider's own ladder. Qobuz and Beatport
have no tier 0, so `-q 0` is raised to 1 for those and reported on stderr.
| Source | Range | 0 | Top tier |
|------------|-------|---|----------|
| Qobuz | 1–4 | — | FLAC 24-bit up to 192 kHz |
| Tidal | 0–4 | LOW (HE-AAC) | HI_RES_LOSSLESS |
| Deezer | 0–4 | MP3 128 | FLAC (anything ≥ 2) |
| Yandex | 0–4 | LOW | lossless |
| Beatport | 1–4 | — | FLAC 16/44.1 |
| SoundCloud | 0–4 | ignored | provider-defined |
Each provider saturates at the top of its own ladder, so values above its
highest real tier are accepted and behave as that tier.
### Download database
Completed downloads are recorded in a SQLite database and skipped next time.
`--force` / `--ignore-db` overrides this for one run; `-ndb` disables it
entirely. Paths are configurable under `[database]`.
If you are upgrading from a build that predates source tracking, the database
is migrated on first open and a copy is kept alongside it as
`downloads.db.bak-<timestamp>`.
## Development
```sh
make check # gofmt + vet + tests, what CI runs
make test
make race
make cover
make lint # needs golangci-lint
```
The test suite runs in about a second and makes no network requests; provider
tests drive `httptest` servers.
Layout:
```
cmd/rip CLI entry point, one file per command group
internal/app orchestration: queue, resolve, rip
internal/provider/* one client per source, behind provider.Client
internal/download HTTP downloads, progress bars, Deezer/Yandex decryption
internal/audio/{tag,convert} ffmpeg tagging and conversion
internal/{naming,artwork,store,config,netutil,ratelimit,jsonutil,verbose}
```
### Known rough edges
Two deliberate pieces of technical debt, both documented rather than hidden:
- **No shared HTTP layer.** The request/decode/retry sequence is reimplemented
in each provider client, and retry on 429/5xx exists in only two of six. A
`provider/transport` package would remove roughly 700 lines.
- **Metadata is `map[string]any`.** `provider.Client` returns untyped maps, so
each client hand-builds an undeclared "Qobuz-shaped" schema that
`cmd/rip/search.go` then unpicks in a long switch. A typed media model is the
natural next step; `internal/provider/qobuz/model.go` is a started-and-parked
attempt at it.
## License
No license file is present, so this is "all rights reserved" by default. Add
one before distributing. Note that upstream streamrip is GPL-3.0.
+28 -53
View File
@@ -2,7 +2,6 @@ package main
import (
"fmt"
"os"
"strconv"
"strings"
@@ -27,7 +26,6 @@ type globalOptions struct {
noSSLVerify bool
verbose int
help bool
version bool
command string
commandArgs []string
}
@@ -51,9 +49,6 @@ func parseGlobalArgs(args []string) (globalOptions, error) {
case isHelpArg(arg):
opts.help = true
return opts, nil
case arg == "--version" || arg == "-V":
opts.version = true
return opts, nil
case arg == "-ndb" || arg == "--no-db":
opts.noDB = true
case arg == "--no-progress":
@@ -129,10 +124,16 @@ func parseGlobalArgs(args []string) (globalOptions, error) {
return opts, nil
}
// normalizeCodec delegates to config so the --codec flag and a codec set in
// the config file are validated by the same rules.
func normalizeCodec(raw string) (string, error) {
return config.NormalizeCodec(raw)
codec := strings.ToUpper(strings.TrimSpace(raw))
switch codec {
case "ALAC", "FLAC", "MP3", "AAC", "VORBIS":
return codec, nil
case "OGG":
return "VORBIS", nil
default:
return "", fmt.Errorf("unsupported codec %q (expected ALAC, FLAC, OGG, MP3, AAC)", raw)
}
}
func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
@@ -143,7 +144,10 @@ func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
cfg.Session.Database.DownloadsEnabled = false
}
if opts.qualitySet {
applyQualityOverride(cfg, opts.quality)
cfg.Session.Qobuz.Quality = opts.quality
cfg.Session.Tidal.Quality = opts.quality
cfg.Session.Deezer.Quality = opts.quality
cfg.Session.Soundcloud.Quality = opts.quality
}
if opts.codecSet {
cfg.Session.Conversion.Enabled = true
@@ -157,50 +161,6 @@ func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
}
}
// minQualityForSource is the lowest quality id each source accepts. Qobuz and
// Beatport have no equivalent of the "lowest" tier that 0 means elsewhere.
var minQualityForSource = map[string]int{
"qobuz": 1,
"beatport": 1,
}
// applyQualityOverride applies a global -q/--quality to every source.
//
// It previously set only qobuz, tidal, deezer and soundcloud, so
// `rip -q 4 url <beatport-url>` was a silent no-op even though
// Main.qualityForSource reads Yandex and Beatport too. Sources with a higher
// floor are clamped rather than erroring, because one global flag has to work
// across a mixed queue.
func applyQualityOverride(cfg *config.Config, quality int) {
clamped := map[string]int{}
for _, source := range []string{"qobuz", "tidal", "deezer", "soundcloud", "yandex", "beatport"} {
q := quality
if min, ok := minQualityForSource[source]; ok && q < min {
q = min
clamped[source] = q
}
switch source {
case "qobuz":
cfg.Session.Qobuz.Quality = q
case "tidal":
cfg.Session.Tidal.Quality = q
case "deezer":
cfg.Session.Deezer.Quality = q
case "soundcloud":
cfg.Session.Soundcloud.Quality = q
case "yandex":
cfg.Session.Yandex.Quality = q
case "beatport":
cfg.Session.Beatport.Quality = q
}
}
for _, source := range []string{"qobuz", "beatport"} {
if q, ok := clamped[source]; ok {
fmt.Fprintf(os.Stderr, "note: %s has no quality %d; using %d for %s\n", source, quality, q, source)
}
}
}
func errorWithActionableHint(err error, opts globalOptions) string {
if err == nil {
return ""
@@ -244,3 +204,18 @@ func parseQuality(raw string, min int, max int) (int, error) {
}
return q, nil
}
func asString(v any) string {
switch t := v.(type) {
case string:
return t
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return ""
}
}
-128
View File
@@ -1,128 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"streamrip-go/internal/config"
"golang.org/x/term"
)
// Commands that inspect local state: config and database.
func cmdConfig(_ context.Context, cfg *config.Config, _ globalOptions, args []string) int {
var err error
if len(args) < 1 {
printCommandHelp(os.Stdout, "config")
return 2
}
switch args[0] {
case "open":
vim := false
for _, arg := range args[1:] {
switch arg {
case "-v", "--vim":
vim = true
default:
fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg)
return 2
}
}
fmt.Printf("opening file at %s\n", cfg.Path)
if err = openConfigInEditor(cfg.Path, vim); err != nil {
fmt.Fprintf(os.Stderr, "open config error: %v\n", err)
return 1
}
case "reset":
yes := false
for _, arg := range args[1:] {
switch arg {
case "-y", "--yes":
yes = true
default:
fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg)
return 2
}
}
if !yes {
if !term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Fprintln(os.Stderr, "reset requires --yes in non-interactive mode")
return 2
}
ok, askErr := promptYesNo(fmt.Sprintf("Are you sure you want to reset the config file at %s? [y/N]: ", cfg.Path))
if askErr != nil {
fmt.Fprintf(os.Stderr, "prompt error: %v\n", askErr)
return 1
}
if !ok {
fmt.Println("reset aborted")
return 0
}
}
def := config.DefaultConfigData()
// Separate copies: assigning the same value to both halves made
// File.Qobuz.Secrets and Session.Qobuz.Secrets share one backing
// array.
cfg.File = config.CloneConfigData(def)
cfg.Session = config.CloneConfigData(def)
if err = cfg.SaveFile(); err != nil {
fmt.Fprintf(os.Stderr, "reset config error: %v\n", err)
return 1
}
fmt.Printf("reset the config file at %s\n", cfg.Path)
case "path":
if len(args) > 1 {
fmt.Fprintf(os.Stderr, "option error: unexpected argument %q\n", args[1])
return 2
}
fmt.Printf("config path: '%s'\n", cfg.Path)
default:
fmt.Fprintf(os.Stderr, "unknown config command: %s\n", args[0])
return 2
}
return 0
}
func cmdDatabase(_ context.Context, cfg *config.Config, _ globalOptions, args []string) int {
if len(args) < 2 || args[0] != "browse" {
printCommandHelp(os.Stdout, "database")
return 2
}
table := strings.ToLower(strings.TrimSpace(args[1]))
switch table {
case "downloads":
rows, listErr := listDownloadsRows(cfg.Session.Database.DownloadsPath)
if listErr != nil {
fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr)
return 1
}
fmt.Println("downloads database")
fmt.Println("row id")
for i, id := range rows {
fmt.Printf("%02d %s\n", i, id)
}
case "failed":
rows, listErr := listFailedRows(cfg.Session.Database.FailedDownloadsPath)
if listErr != nil && isNoSuchTableErr(listErr) && cfg.Session.Database.FailedDownloadsPath != cfg.Session.Database.DownloadsPath {
rows, listErr = listFailedRows(cfg.Session.Database.DownloadsPath)
}
if listErr != nil {
fmt.Fprintf(os.Stderr, "database browse error: %v\n", listErr)
return 1
}
fmt.Println("failed downloads database")
fmt.Println("row source media_type id")
for i, row := range rows {
fmt.Printf("%02d %s %s %s\n", i, row.Source, row.MediaType, row.ID)
}
default:
fmt.Fprintf(os.Stderr, "invalid database %q. choose downloads or failed\n", table)
return 2
}
return 0
}
-200
View File
@@ -1,200 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"streamrip-go/internal/app"
"streamrip-go/internal/config"
)
// Commands that queue media and rip it: url, file and id.
func cmdURL(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
if len(args) < 1 {
printCommandHelp(os.Stdout, "url")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
rawArgs := make([]string, 0, len(args))
ignoreDB := false
for _, arg := range args {
if arg == "--force" || arg == "--ignore-db" {
ignoreDB = true
continue
}
rawArgs = append(rawArgs, arg)
}
mainApp.IgnoreDB = ignoreDB || gopts.noDB
added := 0
for _, raw := range rawArgs {
if addURLToQueue(ctx, mainApp, raw) {
added++
}
}
if added == 0 {
fmt.Println("nothing to rip")
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("url rip complete (%d item(s))\n", added)
return 0
}
func cmdFile(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
if len(args) < 1 {
printCommandHelp(os.Stdout, "file")
return 2
}
ignoreDB := false
for _, arg := range args[1:] {
switch arg {
case "--force", "--ignore-db":
ignoreDB = true
default:
fmt.Fprintf(os.Stderr, "option error: unknown option %q\n", arg)
return 2
}
}
content, err := os.ReadFile(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "read file error: %v\n", err)
return 1
}
idItems, urls, repeated, jsonInput, err := parseFileInput(content)
if err != nil {
fmt.Fprintf(os.Stderr, "file parse error: %v\n", err)
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
mainApp.IgnoreDB = ignoreDB || gopts.noDB
added := 0
if jsonInput {
fmt.Printf("detected json file. loading %d item(s)\n", len(idItems))
for _, item := range idItems {
if err = mainApp.AddByID(ctx, item.Source, item.MediaType, item.ID); err != nil {
fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", item.Source, item.MediaType, item.ID, err)
continue
}
added++
}
} else {
if repeated > 0 {
fmt.Printf("found %d repeated url(s)\n", repeated)
}
fmt.Printf("detected list of urls. loading %d item(s)\n", len(urls))
for _, raw := range urls {
if addURLToQueue(ctx, mainApp, raw) {
added++
}
}
}
if added == 0 {
fmt.Println("nothing to rip")
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("file rip complete (%d item(s))\n", added)
return 0
}
func cmdID(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
if len(args) < 3 {
printCommandHelp(os.Stdout, "id")
return 2
}
source := strings.ToLower(strings.TrimSpace(args[0]))
mediaType := strings.ToLower(strings.TrimSpace(args[1]))
itemID := strings.TrimSpace(args[2])
opts, err := parseSmokeOptions(args[3:], 0, 4)
if err != nil {
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
return 2
}
if opts.qualitySet {
switch source {
case "qobuz":
if opts.quality < 1 {
fmt.Fprintf(os.Stderr, "quality error: qobuz quality must be 1-4\n")
return 2
}
cfg.Session.Qobuz.Quality = opts.quality
case "tidal":
cfg.Session.Tidal.Quality = opts.quality
case "yandex":
cfg.Session.Yandex.Quality = opts.quality
case "beatport":
if opts.quality < 1 {
fmt.Fprintf(os.Stderr, "quality error: beatport quality must be 1-4\n")
return 2
}
cfg.Session.Beatport.Quality = opts.quality
}
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
if err = mainApp.AddByID(ctx, source, mediaType, itemID); err != nil {
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
return 1
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("id rip complete: source=%s type=%s id=%s\n", source, mediaType, itemID)
return 0
}
-281
View File
@@ -1,281 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"streamrip-go/internal/app"
"streamrip-go/internal/config"
"golang.org/x/term"
)
// Commands that discover media before ripping it: search and lastfm.
func cmdSearch(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
var err error
var source, mediaType string
var sopts searchOptions
if len(args) < 3 {
if !term.IsTerminal(int(os.Stdin.Fd())) {
printCommandHelp(os.Stdout, "search")
return 2
}
source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults)
if err != nil {
fmt.Fprintf(os.Stderr, "search prompt error: %v\n", err)
return 2
}
} else {
source = strings.ToLower(strings.TrimSpace(args[0]))
mediaType = strings.ToLower(strings.TrimSpace(args[1]))
sopts, err = parseSearchArgs(args[2:], cfg.Session.CLI.MaxSearchResults)
}
if err != nil {
fmt.Fprintf(os.Stderr, "search option error: %v\n", err)
return 2
}
if sopts.first && sopts.outputFile != "" {
fmt.Fprintln(os.Stderr, "cannot choose --first and --output-file together")
return 2
}
if !isAllowedSearchSource(source) {
fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source)
return 2
}
if !isAllowedMediaType(mediaType) {
fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType)
return 2
}
if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" {
fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist")
return 2
}
if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" {
fmt.Fprintln(os.Stderr, "yandex search currently supports media types track, album, playlist, and artist")
return 2
}
if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" {
fmt.Fprintln(os.Stderr, "beatport search currently supports media types track, album, and label")
return 2
}
if sopts.query == "" {
fmt.Fprintln(os.Stderr, "search query cannot be empty")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, source)
if err != nil {
fmt.Fprintf(os.Stderr, "%s login error: %v\n", source, err)
return 1
}
pages, err := provider.Search(ctx, mediaType, sopts.query, sopts.limit)
if err != nil {
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
return 1
}
results := normalizeSearchResults(source, mediaType, pages)
if len(results) == 0 {
fmt.Println("no results")
return 0
}
if sopts.outputFile != "" {
fmt.Printf("results: %d\n", len(results))
for i, result := range results {
fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title)
}
if err = writeSearchResultsToFile(source, mediaType, results, sopts.outputFile); err != nil {
fmt.Fprintf(os.Stderr, "write results error: %v\n", err)
return 1
}
fmt.Printf("wrote %d results to %s\n", len(results), sopts.outputFile)
return 0
}
if sopts.first {
fmt.Printf("results: %d\n", len(results))
fmt.Printf(" 1. id=%s | %s\n", results[0].ID, results[0].Title)
results = results[:1]
}
if sopts.noDownload {
fmt.Printf("results: %d\n", len(results))
for i, result := range results {
fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title)
}
return 0
}
if !sopts.first {
fmt.Printf("results: %d\n", len(results))
}
if sopts.first {
selection := []int{0}
mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB
skippedDownloaded := 0
added := 0
for _, idx := range selection {
item := results[idx]
if !sopts.ignoreDB {
already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID)
if checkErr == nil && already {
skippedDownloaded++
fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title)
continue
}
}
if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil {
fmt.Printf("add failed: id=%s err=%v\n", item.ID, err)
continue
}
added++
}
if added == 0 {
if skippedDownloaded > 0 {
fmt.Println("selected item was already downloaded (use --force to redownload)")
} else {
fmt.Println("nothing selected to download")
}
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("search download complete (%d item(s))\n", added)
return 0
}
if !term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Println("non-interactive input; use `rip id` to download specific results")
return 0
}
selection, err := promptSearchSelectionMenu(source, mediaType, sopts.query, results)
if err != nil {
fmt.Fprintf(os.Stderr, "selection error: %v\n", err)
return 2
}
if len(selection) == 0 {
fmt.Println("download cancelled")
return 0
}
mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB
skippedDownloaded := 0
added := 0
for _, idx := range selection {
item := results[idx]
if !sopts.ignoreDB {
already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID)
if checkErr == nil && already {
skippedDownloaded++
fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title)
continue
}
}
if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil {
fmt.Printf("add failed: id=%s err=%v\n", item.ID, err)
continue
}
added++
}
if added == 0 {
if skippedDownloaded > 0 {
fmt.Println("all selected items were already downloaded (use --force to redownload)")
} else {
fmt.Println("nothing selected to download")
}
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("search download complete (%d item(s))\n", added)
return 0
}
func cmdLastFM(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
opts, parseErr := parseLastFMArgs(args, cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource)
if parseErr != nil {
fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr)
printCommandHelp(os.Stdout, "lastfm")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
mainApp.IgnoreDB = gopts.noDB
title, tracks, err := fetchLastFMPlaylist(ctx, cfg.Session.Downloads.VerifySSL, opts.PlaylistURL)
if err != nil {
fmt.Fprintf(os.Stderr, "lastfm parse error: %v\n", err)
return 1
}
if len(tracks) == 0 {
fmt.Println("no tracks found in playlist")
return 0
}
fmt.Printf("lastfm playlist: %s (%d tracks)\n", title, len(tracks))
resolvedTracks, err := resolveLastFMTracks(ctx, mainApp, opts, tracks)
if err != nil {
fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err)
return 1
}
if len(resolvedTracks) == 0 {
fmt.Println("no lastfm tracks resolved")
return 0
}
playlistID := fmt.Sprintf("lastfm:%s", strings.ToLower(strings.ReplaceAll(title, " ", "_")))
refs := make([]app.PlaylistTrackRef, 0, len(resolvedTracks))
for _, item := range resolvedTracks {
refs = append(refs, app.PlaylistTrackRef{Source: item.Source, ID: item.ID})
}
if addErr := mainApp.AddMixedPlaylistByTrackRefs(ctx, playlistID, title, refs); addErr != nil {
fmt.Printf("playlist queue failed: err=%v\n", addErr)
fmt.Println("no lastfm playlists queued")
return 0
}
fmt.Printf("queued lastfm playlist: %s (%d tracks)\n", title, len(refs))
if len(refs) == 0 {
fmt.Println("no lastfm playlists queued")
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
return 1
}
fmt.Printf("lastfm rip complete (%d track(s) across 1 playlist)\n", len(resolvedTracks))
return 0
}
-310
View File
@@ -1,310 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"streamrip-go/internal/app"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/provider"
)
// Developer smoke commands, listed by "rip dev-help". These exercise a single
// provider call and print the result; they are not part of the supported CLI.
func cmdSoundcloudSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
if len(args) < 1 {
fmt.Println("usage: rip soundcloud-smoke <soundcloud_url>")
return 2
}
meta, err := fetchSoundcloudOEmbed(ctx, cfg.Session.Downloads.VerifySSL, strings.TrimSpace(args[0]))
if err != nil {
fmt.Fprintf(os.Stderr, "soundcloud smoke error: %v\n", err)
return 1
}
fmt.Printf("soundcloud oembed ok: title=%q author=%q provider=%q\n", jsonutil.StringFromAny(meta["title"]), jsonutil.StringFromAny(meta["author_name"]), jsonutil.StringFromAny(meta["provider_name"]))
return 0
}
func cmdQobuzSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
if len(args) < 1 {
fmt.Println("usage: rip qobuz-smoke <track_id> [quality]")
return 2
}
opts, err := parseSmokeOptions(args[1:], 1, 4)
if err != nil {
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
return 2
}
if opts.qualitySet {
cfg.Session.Qobuz.Quality = opts.quality
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz")
if err != nil {
fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err)
return 1
}
trackID := args[0]
meta, err := provider.GetMetadata(ctx, trackID, "track")
if err != nil {
fmt.Fprintf(os.Stderr, "metadata error: %v\n", err)
return 1
}
title, _ := meta["title"].(string)
d, err := provider.GetDownloadable(ctx, trackID, cfg.Session.Qobuz.Quality)
if err != nil {
fmt.Fprintf(os.Stderr, "downloadable error: %v\n", err)
return 1
}
fmt.Printf("qobuz ok: title=%q quality=%d ext=%s\n", title, cfg.Session.Qobuz.Quality, d.Extension)
fmt.Printf("stream_url=%s\n", d.URL)
return 0
}
func cmdQobuzConvertRipSmoke(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
if len(args) < 2 {
fmt.Println("usage: rip qobuz-convert-rip-smoke <track_id> <codec> [quality] [--force|--ignore-db]")
return 2
}
opts, err := parseSmokeOptions(args[2:], 1, 4)
if err != nil {
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
return 2
}
if opts.qualitySet {
cfg.Session.Qobuz.Quality = opts.quality
}
cfg.Session.Conversion.Enabled = true
cfg.Session.Conversion.Codec = strings.ToUpper(strings.TrimSpace(args[1]))
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
trackID := args[0]
if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil {
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
return 1
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
return 1
}
fmt.Printf("qobuz convert rip smoke complete (codec=%s)\n", cfg.Session.Conversion.Codec)
return 0
}
func cmdQobuzSearchSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
if len(args) < 1 {
fmt.Println("usage: rip qobuz-search-smoke <query>")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, "qobuz")
if err != nil {
fmt.Fprintf(os.Stderr, "qobuz login error: %v\n", err)
return 1
}
query := strings.Join(args, " ")
pages, err := provider.Search(ctx, "album", query, 10)
if err != nil {
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
return 1
}
for _, page := range pages {
albums, ok := page["albums"].(map[string]any)
if !ok {
continue
}
items, ok := albums["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
id := jsonutil.StringFromAny(item["id"])
title := jsonutil.StringFromAny(item["title"])
version := jsonutil.StringFromAny(item["version"])
bitDepth := jsonutil.StringFromAny(item["maximum_bit_depth"])
sr := jsonutil.StringFromAny(item["maximum_sampling_rate"])
if version != "" {
title = title + " (" + version + ")"
}
fmt.Printf("album_id=%s | %s | %sB-%skHz\n", id, title, bitDepth, sr)
}
}
return 0
}
func cmdTidalSearchSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
if len(args) < 1 {
fmt.Println("usage: rip tidal-search-smoke <query>")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, "tidal")
if err != nil {
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
return 1
}
query := strings.Join(args, " ")
pages, err := provider.Search(ctx, "album", query, 10)
if err != nil {
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
return 1
}
for _, page := range pages {
items, ok := page["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
wrapper, ok := raw.(map[string]any)
if !ok {
continue
}
item, ok := wrapper["item"].(map[string]any)
if !ok {
item = wrapper
}
fmt.Printf("album_id=%s | %s\n", jsonutil.StringFromAny(item["id"]), jsonutil.StringFromAny(item["title"]))
}
}
return 0
}
func cmdTidalMetadataSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
if len(args) < 2 {
fmt.Println("usage: rip tidal-metadata-smoke <track|album|playlist|artist> <id>")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, "tidal")
if err != nil {
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
return 1
}
mediaType := args[0]
itemID := args[1]
meta, err := provider.GetMetadata(ctx, itemID, mediaType)
if err != nil {
fmt.Fprintf(os.Stderr, "metadata error: %v\n", err)
return 1
}
title := jsonutil.StringFromAny(meta["title"])
if title == "" {
title = jsonutil.StringFromAny(meta["name"])
}
trackCount := 0
if tracksMap, ok := meta["tracks"].(map[string]any); ok {
if items, ok := tracksMap["items"].([]map[string]any); ok {
trackCount = len(items)
} else if anyItems, ok := tracksMap["items"].([]any); ok {
trackCount = len(anyItems)
}
}
fmt.Printf("tidal metadata ok: type=%s id=%s title=%q tracks=%d\n", mediaType, itemID, title, trackCount)
return 0
}
func cmdTidalVideoSmoke(ctx context.Context, cfg *config.Config, _ globalOptions, args []string) int {
if len(args) < 1 {
fmt.Println("usage: rip tidal-video-smoke <video_id>")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
providerClient, err := mainApp.GetLoggedInProvider(ctx, "tidal")
if err != nil {
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
return 1
}
videoProvider, ok := providerClient.(interface {
GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error)
})
if !ok {
fmt.Fprintln(os.Stderr, "tidal provider does not support video downloadable")
return 1
}
videoID := strings.TrimSpace(args[0])
meta, err := providerClient.GetMetadata(ctx, videoID, "video")
if err != nil {
fmt.Fprintf(os.Stderr, "video metadata error: %v\n", err)
return 1
}
d, err := videoProvider.GetVideoDownloadable(ctx, videoID)
if err != nil {
fmt.Fprintf(os.Stderr, "video downloadable error: %v\n", err)
return 1
}
title := jsonutil.StringFromAny(meta["title"])
if title == "" {
title = jsonutil.StringFromAny(meta["name"])
}
fmt.Printf("tidal video ok: id=%s title=%q ext=%s\n", videoID, title, d.Extension)
fmt.Printf("stream_url=%s\n", d.URL)
return 0
}
-248
View File
@@ -1,248 +0,0 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"streamrip-go/internal/config"
)
// testConfig returns a config rooted in a temp dir, so command functions can
// run without touching the user's real config or download folder.
func testConfig(t *testing.T) *config.Config {
t.Helper()
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = filepath.Join(tmp, "downloads")
d.Database.DownloadsEnabled = false
d.Database.FailedDownloadsEnabled = false
d.CLI.ProgressBars = false
return &config.Config{Path: filepath.Join(tmp, "config.toml"), File: d, Session: d}
}
// The command functions became testable when main() was split into run() int
// plus one function per command; previously they were arms of a 1196-line
// switch that called os.Exit directly.
func TestCommandsRejectMissingArguments(t *testing.T) {
tests := []struct {
name string
run func(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int
args []string
}{
{"url with no args", cmdURL, nil},
{"file with no args", cmdFile, nil},
{"config with no args", cmdConfig, nil},
{"database with no args", cmdDatabase, nil},
{"database without browse", cmdDatabase, []string{"downloads"}},
{"database browse without table", cmdDatabase, []string{"browse"}},
{"id with too few args", cmdID, []string{"qobuz", "album"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.run(context.Background(), testConfig(t), globalOptions{}, tc.args)
if got != 2 {
t.Fatalf("exit code = %d, want 2 (usage error)", got)
}
})
}
}
func TestCmdDatabaseRejectsUnknownTable(t *testing.T) {
if got := cmdDatabase(context.Background(), testConfig(t), globalOptions{}, []string{"browse", "bogus"}); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
}
func TestCmdConfigRejectsUnknownSubcommand(t *testing.T) {
if got := cmdConfig(context.Background(), testConfig(t), globalOptions{}, []string{"bogus"}); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
}
func TestCmdConfigPathPrintsPath(t *testing.T) {
cfg := testConfig(t)
out := captureStdout(t, func() {
if got := cmdConfig(context.Background(), cfg, globalOptions{}, []string{"path"}); got != 0 {
t.Errorf("exit code = %d, want 0", got)
}
})
if !strings.Contains(out, cfg.Path) {
t.Fatalf("output %q does not contain the config path %q", out, cfg.Path)
}
}
func TestCmdConfigPathRejectsExtraArgument(t *testing.T) {
if got := cmdConfig(context.Background(), testConfig(t), globalOptions{}, []string{"path", "extra"}); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
}
func TestCmdFileReportsMissingFile(t *testing.T) {
missing := filepath.Join(t.TempDir(), "nope.txt")
if got := cmdFile(context.Background(), testConfig(t), globalOptions{}, []string{missing}); got != 1 {
t.Fatalf("exit code = %d, want 1 (read error)", got)
}
}
func TestCmdFileRejectsUnknownOption(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "urls.txt")
if err := os.WriteFile(path, []byte("https://play.qobuz.com/album/abc\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := cmdFile(context.Background(), testConfig(t), globalOptions{}, []string{path, "--bogus"}); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
}
func TestCmdURLRejectsInvalidURL(t *testing.T) {
// Nothing resolvable is queued, so this reports "nothing to rip" and exits 0.
out := captureStdout(t, func() {
if got := cmdURL(context.Background(), testConfig(t), globalOptions{}, []string{"not-a-url"}); got != 0 {
t.Errorf("exit code = %d, want 0", got)
}
})
if !strings.Contains(out, "nothing to rip") {
t.Fatalf("output %q should report that nothing was queued", out)
}
}
func TestCmdIDRejectsQualityBelowSourceFloor(t *testing.T) {
// Qobuz has no quality 0.
if got := cmdID(context.Background(), testConfig(t), globalOptions{}, []string{"qobuz", "album", "abc", "0"}); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
if got := cmdID(context.Background(), testConfig(t), globalOptions{}, []string{"beatport", "album", "abc", "0"}); got != 2 {
t.Fatalf("beatport exit code = %d, want 2", got)
}
}
func TestCmdIDRejectsUnknownOption(t *testing.T) {
if got := cmdID(context.Background(), testConfig(t), globalOptions{}, []string{"qobuz", "album", "abc", "--bogus"}); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
}
func TestSmokeCommandsRejectMissingArguments(t *testing.T) {
tests := []struct {
name string
run func(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int
args []string
}{
{"soundcloud", cmdSoundcloudSmoke, nil},
{"qobuz", cmdQobuzSmoke, nil},
{"qobuz convert", cmdQobuzConvertRipSmoke, []string{"id"}},
{"qobuz search", cmdQobuzSearchSmoke, nil},
{"tidal search", cmdTidalSearchSmoke, nil},
{"tidal metadata", cmdTidalMetadataSmoke, []string{"track"}},
{"tidal video", cmdTidalVideoSmoke, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.run(context.Background(), testConfig(t), globalOptions{}, tc.args); got != 2 {
t.Fatalf("exit code = %d, want 2", got)
}
})
}
}
// captureStdout swaps os.Stdout for a pipe while fn runs. The commands print
// user-facing output with fmt.Println rather than taking a writer.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
done := make(chan string, 1)
go func() {
var sb strings.Builder
buf := make([]byte, 4096)
for {
n, readErr := r.Read(buf)
if n > 0 {
sb.Write(buf[:n])
}
if readErr != nil {
break
}
}
done <- sb.String()
}()
fn()
_ = w.Close()
os.Stdout = orig
out := <-done
_ = r.Close()
return out
}
func TestVersionOutput(t *testing.T) {
var sb strings.Builder
printVersion(&sb)
out := sb.String()
if !strings.HasPrefix(out, "rip ") {
t.Errorf("version output should start with \"rip \": %q", out)
}
for _, want := range []string{"go:", "target:"} {
if !strings.Contains(out, want) {
t.Errorf("version output missing %q: %q", want, out)
}
}
}
func TestParseGlobalArgsVersionFlag(t *testing.T) {
for _, flag := range []string{"--version", "-V"} {
opts, err := parseGlobalArgs([]string{flag})
if err != nil {
t.Fatalf("parseGlobalArgs(%q) error = %v", flag, err)
}
if !opts.version {
t.Errorf("parseGlobalArgs(%q) did not set version", flag)
}
}
}
func TestApplyQualityOverrideCoversEverySource(t *testing.T) {
cfg := testConfig(t)
applyQualityOverride(cfg, 4)
got := map[string]int{
"qobuz": cfg.Session.Qobuz.Quality,
"tidal": cfg.Session.Tidal.Quality,
"deezer": cfg.Session.Deezer.Quality,
"soundcloud": cfg.Session.Soundcloud.Quality,
"yandex": cfg.Session.Yandex.Quality,
"beatport": cfg.Session.Beatport.Quality,
}
for source, q := range got {
if q != 4 {
t.Errorf("%s quality = %d, want 4; -q must reach every source", source, q)
}
}
}
func TestApplyQualityOverrideClampsSourceFloor(t *testing.T) {
cfg := testConfig(t)
applyQualityOverride(cfg, 0)
if cfg.Session.Qobuz.Quality != 1 {
t.Errorf("qobuz quality = %d, want it clamped to 1", cfg.Session.Qobuz.Quality)
}
if cfg.Session.Beatport.Quality != 1 {
t.Errorf("beatport quality = %d, want it clamped to 1", cfg.Session.Beatport.Quality)
}
if cfg.Session.Tidal.Quality != 0 {
t.Errorf("tidal quality = %d, want 0 preserved", cfg.Session.Tidal.Quality)
}
}
+4 -5
View File
@@ -44,7 +44,6 @@ Global options:
--no-ssl-verify Disable TLS verification
--config-path <path> Use a custom config file
-v, -vv Verbose logging
-V, --version Show version
-h, --help Show help
Run 'rip help <command>' for command-specific help.
@@ -152,10 +151,10 @@ Inspect local download databases.
func printDeveloperHelp(w io.Writer) {
fmt.Fprint(w, `Developer smoke commands:
soundcloud-smoke
qobuz-smoke, qobuz-convert-rip-smoke, qobuz-search-smoke
qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke
qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke
qobuz-search-smoke
tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke
The per-media-type rip smoke commands were removed; use the equivalent
rip id <source> <media-type> <id>
tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke
`)
}
+4 -5
View File
@@ -13,7 +13,6 @@ import (
"strings"
"streamrip-go/internal/app"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/urlparse"
)
@@ -95,12 +94,12 @@ func parseFileInput(content []byte) ([]fileIDItem, []string, int, bool, error) {
if !ok {
return nil, nil, 0, true, fmt.Errorf("json item %d must be an object", i+1)
}
source := strings.ToLower(strings.TrimSpace(jsonutil.StringFromAny(entry["source"])))
mediaType := strings.ToLower(strings.TrimSpace(jsonutil.StringFromAny(entry["media_type"])))
source := strings.ToLower(strings.TrimSpace(asString(entry["source"])))
mediaType := strings.ToLower(strings.TrimSpace(asString(entry["media_type"])))
if mediaType == "" {
mediaType = strings.ToLower(strings.TrimSpace(jsonutil.StringFromAny(entry["mediaType"])))
mediaType = strings.ToLower(strings.TrimSpace(asString(entry["mediaType"])))
}
id := strings.TrimSpace(jsonutil.StringFromAny(entry["id"]))
id := strings.TrimSpace(asString(entry["id"]))
if source == "" || mediaType == "" || id == "" {
return nil, nil, 0, true, fmt.Errorf("json item %d missing source/media_type/id", i+1)
}
+13 -5
View File
@@ -13,7 +13,6 @@ import (
"time"
"streamrip-go/internal/app"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
)
@@ -242,8 +241,8 @@ func extractLastFMTitleArtistPairs(page string) []lastFMTrack {
if len(dataPairs) > 0 {
out := make([]lastFMTrack, 0, len(dataPairs))
for _, m := range dataPairs {
title := html.UnescapeString(strings.TrimSpace(jsonutil.FirstNonEmpty(m[1], m[2])))
artist := html.UnescapeString(strings.TrimSpace(jsonutil.FirstNonEmpty(m[3], m[4])))
title := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[1], m[2])))
artist := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[3], m[4])))
if title == "" || artist == "" {
continue
}
@@ -257,8 +256,8 @@ func extractLastFMTitleArtistPairs(page string) []lastFMTrack {
titles := lastFMTitleTagsRe.FindAllStringSubmatch(page, -1)
out := make([]lastFMTrack, 0, len(titles)/2)
for i := 0; i+1 < len(titles); i += 2 {
titleRaw := strings.TrimSpace(jsonutil.FirstNonEmpty(titles[i][1], titles[i][2]))
artistRaw := strings.TrimSpace(jsonutil.FirstNonEmpty(titles[i+1][1], titles[i+1][2]))
titleRaw := strings.TrimSpace(firstNonEmpty(titles[i][1], titles[i][2]))
artistRaw := strings.TrimSpace(firstNonEmpty(titles[i+1][1], titles[i+1][2]))
if strings.EqualFold(titleRaw, "Play on YouTube") || strings.EqualFold(artistRaw, "Play on YouTube") {
continue
}
@@ -272,6 +271,15 @@ func extractLastFMTitleArtistPairs(page string) []lastFMTrack {
return out
}
func firstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
return ""
}
func extractLastFMTracksFromMirrorMarkdown(md string) (string, []lastFMTrack) {
lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n")
title := ""
+1132 -43
View File
File diff suppressed because it is too large. Load diff
+213 -90
View File
@@ -110,6 +110,84 @@ func parseSearchArgs(args []string, defaultLimit int) (searchOptions, error) {
}, nil
}
func promptSearchSelection(results []searchResult) ([]int, error) {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("Select results to download (e.g. 1,3-5; a=all; q=cancel): ")
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
if line == "" || strings.EqualFold(line, "q") || strings.EqualFold(line, "quit") {
return nil, nil
}
if strings.EqualFold(line, "a") || strings.EqualFold(line, "all") {
out := make([]int, 0, len(results))
for i := range results {
out = append(out, i)
}
return out, nil
}
selected := map[int]struct{}{}
chunks := strings.Split(line, ",")
ok := true
for _, raw := range chunks {
part := strings.TrimSpace(raw)
if part == "" {
continue
}
if strings.Contains(part, "-") {
bounds := strings.SplitN(part, "-", 2)
if len(bounds) != 2 {
ok = false
break
}
start, err1 := strconv.Atoi(strings.TrimSpace(bounds[0]))
end, err2 := strconv.Atoi(strings.TrimSpace(bounds[1]))
if err1 != nil || err2 != nil || start <= 0 || end <= 0 || start > end {
ok = false
break
}
for i := start; i <= end; i++ {
if i > len(results) {
ok = false
break
}
selected[i-1] = struct{}{}
}
if !ok {
break
}
continue
}
idx, err := strconv.Atoi(part)
if err != nil || idx <= 0 || idx > len(results) {
ok = false
break
}
selected[idx-1] = struct{}{}
}
if !ok || len(selected) == 0 {
fmt.Println("Invalid selection, try again.")
continue
}
out := make([]int, 0, len(selected))
for idx := range selected {
out = append(out, idx)
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out, nil
}
}
func promptSearchSelectionMenu(source, mediaType, query string, results []searchResult) ([]int, error) {
if len(results) == 0 {
return nil, nil
@@ -330,38 +408,38 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
if !ok {
continue
}
id := jsonutil.StringFromAny(itm["id"])
title := jsonutil.StringFromAny(itm["title"])
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = jsonutil.StringFromAny(itm["name"])
title = asString(itm["name"])
}
if version := jsonutil.StringFromAny(itm["version"]); version != "" {
if version := asString(itm["version"]); version != "" {
title += " (" + version + ")"
}
artist := jsonutil.NestedString(itm, "artist", "name")
artist := nestedSearchString(itm, "artist", "name")
if artist == "" {
artist = jsonutil.NestedString(itm, "performer", "name")
artist = nestedSearchString(itm, "performer", "name")
}
album := jsonutil.NestedString(itm, "album", "title")
trackCount := jsonutil.IntFromAny(itm["tracks_count"])
album := nestedSearchString(itm, "album", "title")
trackCount := searchInt(itm["tracks_count"])
if trackCount == 0 {
trackCount = jsonutil.IntFromAny(itm["track_count"])
trackCount = searchInt(itm["track_count"])
}
explicit := searchBool(itm["parental_warning"])
date := jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(itm["release_date_original"]),
jsonutil.StringFromAny(itm["release_date"]),
jsonutil.StringFromAny(itm["releaseDate"]),
jsonutil.StringFromAny(itm["streamStartDate"]),
jsonutil.NestedString(itm, "album", "release_date_original"),
jsonutil.NestedString(itm, "album", "release_date"),
date := firstNonEmpty(
asString(itm["release_date_original"]),
asString(itm["release_date"]),
asString(itm["releaseDate"]),
asString(itm["streamStartDate"]),
nestedSearchString(itm, "album", "release_date_original"),
nestedSearchString(itm, "album", "release_date"),
)
releases := 0
if mediaType == "artist" {
releases = jsonutil.FirstPositiveInt(
jsonutil.IntFromAny(itm["albums_count"]),
jsonutil.IntFromAny(itm["albumsCount"]),
jsonutil.IntFromAny(jsonutil.NestedAny(itm, "albums", "total")),
releases = firstPositiveInt(
searchInt(itm["albums_count"]),
searchInt(itm["albumsCount"]),
nestedSearchInt(itm, "albums", "total"),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
@@ -379,39 +457,39 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
if wrapped, ok := itm["item"].(map[string]any); ok {
itm = wrapped
}
id := jsonutil.StringFromAny(itm["id"])
title := jsonutil.StringFromAny(itm["title"])
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = jsonutil.StringFromAny(itm["name"])
title = asString(itm["name"])
}
artist := jsonutil.NestedString(itm, "artist", "name")
artist := nestedSearchString(itm, "artist", "name")
if artist == "" {
if artists, ok := itm["artists"].([]any); ok && len(artists) > 0 {
if a0, ok := artists[0].(map[string]any); ok {
artist = jsonutil.StringFromAny(a0["name"])
artist = asString(a0["name"])
}
}
}
album := jsonutil.NestedString(itm, "album", "title")
trackCount := jsonutil.IntFromAny(itm["numberOfTracks"])
album := nestedSearchString(itm, "album", "title")
trackCount := searchInt(itm["numberOfTracks"])
if trackCount == 0 {
trackCount = jsonutil.IntFromAny(itm["tracks_count"])
trackCount = searchInt(itm["tracks_count"])
}
explicit := searchBool(itm["explicit"])
date := jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(itm["releaseDate"]),
jsonutil.StringFromAny(itm["streamStartDate"]),
jsonutil.StringFromAny(itm["release_date"]),
jsonutil.NestedString(itm, "album", "releaseDate"),
jsonutil.NestedString(itm, "album", "release_date"),
date := firstNonEmpty(
asString(itm["releaseDate"]),
asString(itm["streamStartDate"]),
asString(itm["release_date"]),
nestedSearchString(itm, "album", "releaseDate"),
nestedSearchString(itm, "album", "release_date"),
)
releases := 0
if mediaType == "artist" {
releases = jsonutil.FirstPositiveInt(
jsonutil.IntFromAny(itm["numberOfAlbums"]),
jsonutil.IntFromAny(itm["albums_count"]),
jsonutil.IntFromAny(itm["albumsCount"]),
jsonutil.IntFromAny(jsonutil.NestedAny(itm, "albums", "total")),
releases = firstPositiveInt(
searchInt(itm["numberOfAlbums"]),
searchInt(itm["albums_count"]),
searchInt(itm["albumsCount"]),
nestedSearchInt(itm, "albums", "total"),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
@@ -431,25 +509,25 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
if !ok {
continue
}
id := jsonutil.StringFromAny(itm["id"])
title := jsonutil.StringFromAny(itm["title"])
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = jsonutil.StringFromAny(itm["name"])
title = asString(itm["name"])
}
artist := jsonutil.NestedString(itm, "artist", "name")
album := jsonutil.NestedString(itm, "album", "title")
trackCount := jsonutil.IntFromAny(itm["nb_tracks"])
artist := nestedSearchString(itm, "artist", "name")
album := nestedSearchString(itm, "album", "title")
trackCount := searchInt(itm["nb_tracks"])
explicit := searchBool(itm["explicit_lyrics"])
date := jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(itm["release_date"]),
jsonutil.NestedString(itm, "album", "release_date"),
date := firstNonEmpty(
asString(itm["release_date"]),
nestedSearchString(itm, "album", "release_date"),
)
releases := 0
if mediaType == "artist" {
releases = jsonutil.FirstPositiveInt(
jsonutil.IntFromAny(itm["nb_album"]),
jsonutil.IntFromAny(itm["albums_count"]),
jsonutil.IntFromAny(itm["numberOfAlbums"]),
releases = firstPositiveInt(
searchInt(itm["nb_album"]),
searchInt(itm["albums_count"]),
searchInt(itm["numberOfAlbums"]),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
@@ -464,13 +542,13 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
if !ok {
continue
}
id := jsonutil.StringFromAny(itm["id"])
title := jsonutil.StringFromAny(itm["title"])
artist := jsonutil.NestedString(itm, "artist", "name")
trackCount := jsonutil.IntFromAny(itm["tracks_count"])
date := jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(itm["release_date"]),
jsonutil.StringFromAny(itm["display_date"]),
id := asString(itm["id"])
title := asString(itm["title"])
artist := nestedSearchString(itm, "artist", "name")
trackCount := searchInt(itm["tracks_count"])
date := firstNonEmpty(
asString(itm["release_date"]),
asString(itm["display_date"]),
)
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Date: date, TrackCount: trackCount})
}
@@ -484,34 +562,34 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
if !ok {
continue
}
id := jsonutil.StringFromAny(itm["id"])
title := jsonutil.StringFromAny(itm["title"])
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = jsonutil.StringFromAny(itm["name"])
title = asString(itm["name"])
}
artist := jsonutil.NestedString(itm, "artist", "name")
artist := nestedSearchString(itm, "artist", "name")
if artist == "" {
artist = jsonutil.NestedString(itm, "performer", "name")
artist = nestedSearchString(itm, "performer", "name")
}
album := jsonutil.NestedString(itm, "album", "title")
trackCount := jsonutil.FirstPositiveInt(
jsonutil.IntFromAny(itm["trackCount"]),
jsonutil.IntFromAny(itm["track_count"]),
jsonutil.IntFromAny(itm["tracks_count"]),
album := nestedSearchString(itm, "album", "title")
trackCount := firstPositiveInt(
searchInt(itm["trackCount"]),
searchInt(itm["track_count"]),
searchInt(itm["tracks_count"]),
)
explicit := searchBool(itm["explicit"])
date := jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(itm["release_date"]),
jsonutil.StringFromAny(itm["releaseDate"]),
jsonutil.NestedString(itm, "album", "release_date"),
jsonutil.NestedString(itm, "album", "releaseDate"),
date := firstNonEmpty(
asString(itm["release_date"]),
asString(itm["releaseDate"]),
nestedSearchString(itm, "album", "release_date"),
nestedSearchString(itm, "album", "releaseDate"),
)
releases := 0
if mediaType == "artist" {
releases = jsonutil.FirstPositiveInt(
jsonutil.IntFromAny(itm["albums_count"]),
jsonutil.IntFromAny(itm["numberOfAlbums"]),
jsonutil.IntFromAny(jsonutil.NestedAny(itm, "albums", "total")),
releases = firstPositiveInt(
searchInt(itm["albums_count"]),
searchInt(itm["numberOfAlbums"]),
nestedSearchInt(itm, "albums", "total"),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
@@ -526,18 +604,18 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
if !ok {
continue
}
id := jsonutil.StringFromAny(itm["id"])
title := jsonutil.StringFromAny(itm["title"])
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = jsonutil.StringFromAny(itm["name"])
title = asString(itm["name"])
}
if version := jsonutil.StringFromAny(itm["version"]); version != "" {
if version := asString(itm["version"]); version != "" {
title += " (" + version + ")"
}
artist := jsonutil.NestedString(itm, "artist", "name")
album := jsonutil.NestedString(itm, "album", "title")
trackCount := jsonutil.FirstPositiveInt(jsonutil.IntFromAny(itm["tracks_count"]), jsonutil.IntFromAny(itm["track_count"]))
date := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(itm["release_date_original"]), jsonutil.StringFromAny(itm["release_date"]), jsonutil.NestedString(itm, "album", "release_date_original"))
artist := nestedSearchString(itm, "artist", "name")
album := nestedSearchString(itm, "album", "title")
trackCount := firstPositiveInt(searchInt(itm["tracks_count"]), searchInt(itm["track_count"]))
date := firstNonEmpty(asString(itm["release_date_original"]), asString(itm["release_date"]), nestedSearchString(itm, "album", "release_date_original"))
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, TrackCount: trackCount})
}
}
@@ -608,10 +686,55 @@ func formatSearchOptionLabel(index int, mediaType string, r searchResult) string
}
}
// searchBool is deliberately stricter than jsonutil.BoolFromAny: it accepts
// only a real bool. The explicit/parental-warning fields it reads are proper
// booleans in every provider's search payload, and widening this to coerce
// strings and numbers would change how results are labelled.
func nestedSearchString(v map[string]any, keys ...string) string {
cur := any(v)
for _, key := range keys {
m, ok := cur.(map[string]any)
if !ok {
return ""
}
cur = m[key]
}
return asString(cur)
}
func nestedSearchInt(v map[string]any, keys ...string) int {
cur := any(v)
for _, key := range keys {
m, ok := cur.(map[string]any)
if !ok {
return 0
}
cur = m[key]
}
return searchInt(cur)
}
func firstPositiveInt(values ...int) int {
for _, v := range values {
if v > 0 {
return v
}
}
return 0
}
func searchInt(v any) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
case string:
i, _ := strconv.Atoi(t)
return i
default:
return 0
}
}
func searchBool(v any) bool {
b, ok := v.(bool)
return ok && b
-56
View File
@@ -1,56 +0,0 @@
package main
import (
"fmt"
"io"
"runtime"
"runtime/debug"
)
// version is overridden at build time:
//
// go build -ldflags "-X main.version=$(git describe --tags --always --dirty)"
//
// It stays "dev" for a plain `go build`. See the Makefile.
var version = "dev"
// commit and date are optional build-time stamps, also set with -X.
var (
commit = ""
date = ""
)
// buildVersion resolves the version string, preferring the ldflags value and
// falling back to the VCS revision the Go toolchain embeds for `go install`.
func buildVersion() string {
v := version
c := commit
if c == "" {
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
if s.Key == "vcs.revision" {
c = s.Value
}
if s.Key == "vcs.time" && date == "" {
date = s.Value
}
}
}
}
if len(c) > 12 {
c = c[:12]
}
if c != "" {
v += " (" + c + ")"
}
return v
}
func printVersion(w io.Writer) {
fmt.Fprintf(w, "rip %s\n", buildVersion())
if date != "" {
fmt.Fprintf(w, "built: %s\n", date)
}
fmt.Fprintf(w, "go: %s\n", runtime.Version())
fmt.Fprintf(w, "target: %s/%s\n", runtime.GOOS, runtime.GOARCH)
}
+19 -40
View File
@@ -10,13 +10,8 @@ concurrency = true
# The maximum number of tracks to download at once
# Set to -1 for no limit
max_connections = 6
# Max number of API requests per source per minute. Set to -1 for no limit.
#
# This is the usual cause of a large album feeling slow: every track needs a
# stream-URL lookup, so a 68-track album is ~69 requests and at 60/minute that
# is a minute of waiting before the audio is even reached. Requests are
# allowed to burst up to max_connections, so raising this is what lets several
# tracks download at once. Raise it if your provider tolerates it.
# Max number of API requests per source per minute
# Set to -1 for no limit
requests_per_minute = 60
# Verify SSL certificates for API connections
# Set to false only if certificate verification fails (not recommended)
@@ -26,6 +21,9 @@ verify_ssl = true
# Quality ladder (global scale is 0-4, Qobuz supports 1-4):
# 1 = MP3 320, 2 = FLAC 16/44.1, 3 = FLAC 24-bit <=96kHz, 4 = FLAC 24-bit <=192kHz
quality = 3
# Download booklet PDFs when available
download_booklets = true
# Authenticate using Qobuz auth token instead of email/password hash
use_auth_token = false
# If use_auth_token=true, set your user id. Otherwise set your email.
@@ -36,11 +34,6 @@ password_or_token = ""
app_id = ""
# Managed automatically by streamrip-go
secrets = []
# Vendor app credentials. Leave empty to use the values built into
# streamrip-go; set them only if Qobuz rotates theirs and you need to
# patch your install without waiting for a new release.
mobile_app_id = ""
mobile_app_secret = ""
[tidal]
# Quality ladder:
@@ -60,11 +53,6 @@ access_token = ""
refresh_token = ""
# Unix timestamp when access_token expires
token_expiry = 0
# Vendor app credentials. Leave empty to use the values built into
# streamrip-go; set them only if Tidal rotates theirs and you need to
# patch your install without waiting for a new release.
app_client_id = ""
app_client_secret = ""
[deezer]
# Quality ladder:
@@ -79,13 +67,6 @@ email = ""
password = ""
# Optional cached Deezer refresh token. Managed automatically when available.
refresh_token = ""
# Vendor app credentials. Leave empty to use the values built into
# streamrip-go; set them only if Deezer rotates theirs and you need to
# patch your install without waiting for a new release.
api_key = ""
gateway_key = ""
# Must be exactly 16 characters if set.
blowfish_secret = ""
[yandex]
# Quality ladder:
@@ -95,11 +76,6 @@ quality = 2
access_token = ""
# Cached current account uid. Managed automatically when available.
user_id = ""
# Vendor app credentials. Leave empty to use the values built into
# streamrip-go; set them only if Yandex rotates theirs and you need to
# patch your install without waiting for a new release.
sign_key = ""
legacy_mp3_sign_salt = ""
[beatport]
# Quality ladder:
@@ -117,14 +93,21 @@ access_token = ""
refresh_token = ""
# Unix timestamp when access_token expires
token_expiry = 0
# Vendor app credentials. Leave empty to use the values built into
# streamrip-go; set them only if Beatport rotates theirs and you need to
# patch your install without waiting for a new release.
app_client_id = ""
[soundcloud]
# Quality is currently provider-defined (keep 0)
quality = 0
# Managed automatically when available
client_id = ""
app_version = ""
[youtube]
# Only 0 is currently supported
quality = 0
# Download video streams together with audio when supported
download_videos = false
# Folder used for video outputs
video_downloads_folder = "/path/to/StreamripDownloads/YouTubeVideos"
[database]
# Track IDs already downloaded are stored here and skipped next time
@@ -174,13 +157,7 @@ set_playlist_to_album = true
renumber_playlist_tracks = true
# Separator used when a provider exposes multiple artists as separate values
artist_separator = "; "
# Metadata fields to exclude from tagging.
#
# "lyrics" is worth knowing about: Qobuz, Tidal and Deezer each serve lyrics
# from a separate endpoint, so fetching them costs one extra API request per
# track and is subject to requests_per_minute above. Set
# exclude = ["lyrics"] if you do not want lyrics and would rather have the
# faster rip.
# Metadata fields to exclude from tagging
exclude = []
[filepaths]
@@ -212,3 +189,5 @@ max_search_results = 100
[misc]
# Metadata used for config compatibility checks
version = "2.2.0"
# Notify when a new version is available
check_for_updates = true
+295 -371
View File
@@ -53,13 +53,13 @@ type ripTrackOptions struct {
albumEmbedCover string
albumArtist string
prefetched *provider.Downloadable
prefetchedMeta map[string]any
index int
total int
albumDiscTotal int
forPlaylist bool
playlistName string
playlistPos int
playlistYear int
}
type folderAudioValues struct {
@@ -89,29 +89,7 @@ var (
)
type trackTagger interface {
TagFLAC(ctx context.Context, path string, meta tag.Metadata, coverPath string) error
}
// trackLyricsProvider is implemented by providers that can fetch lyrics for a
// track independently of its metadata lookup.
//
// Tidal and Deezer attach lyrics inside GetMetadata, which album rips skip
// when they reuse the album payload's track objects. Qobuz exposes this
// instead, so lyrics work on both paths.
type trackLyricsProvider interface {
TrackLyrics(ctx context.Context, trackID string) (plain string, synced string, err error)
}
// albumTracksSelfDescribing is implemented by providers whose album payload
// embeds track objects complete enough to tag from, making the per-track
// metadata call redundant.
//
// This is opt-in per provider rather than assumed, because it is only true
// where it has been checked against the live API, and because some providers
// do extra work inside their track lookup: Tidal, for instance, fetches lyrics
// there, which an album payload does not carry.
type albumTracksSelfDescribing interface {
AlbumTracksAreComplete() bool
TagFLAC(path string, meta tag.Metadata, coverPath string) error
}
type videoDownloadableProvider interface {
@@ -152,9 +130,6 @@ func New(cfg *config.Config) (*Main, error) {
Pending: []media.Pending{},
Media: []media.Media{},
}
// The downloader does the Deezer decryption, so it needs the protocol
// secret from config rather than a constant of its own.
m.DL.DeezerBlowfishSecret = cfg.Session.Deezer.BlowfishSecret
verbose.SetSink(func(msg string) { m.DL.Logf("%s", msg) })
return m, nil
}
@@ -188,251 +163,6 @@ func (m *Main) logf(format string, args ...any) {
fmt.Printf(format, args...)
}
// bookkeepingTimeout bounds the detached store writes below. They must not
// inherit a cancelled context, or a single Ctrl-C silently loses the record of
// which tracks failed.
const bookkeepingTimeout = 5 * time.Second
// markFailed records a failed download. It deliberately detaches from ctx so
// the write still lands when the rip was cancelled, which is exactly when the
// failure record matters most.
func (m *Main) markFailed(ctx context.Context, source, mediaType, id string) {
if m.Store == nil {
return
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), bookkeepingTimeout)
defer cancel()
if err := m.Store.MarkFailed(writeCtx, source, mediaType, id); err != nil {
m.logf("warning: could not record failure for %s/%s/%s: %v\n", source, mediaType, id, err)
}
}
// markDownloaded records a completed download, detached from ctx for the same
// reason as markFailed: the file is already on disk, so losing the record would
// cause a needless re-download later.
func (m *Main) markDownloaded(ctx context.Context, source, id string) error {
if m.Store == nil {
return nil
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), bookkeepingTimeout)
defer cancel()
return m.Store.MarkDownloaded(writeCtx, source, id)
}
// defaultMaxWorkers is used when max_connections is unset or nonsensical.
const defaultMaxWorkers = 6
// runTrackWorkers rips ids, either serially or across max_connections workers
// depending on config, and returns how many failed.
//
// This replaces three copy-pasted dispatch blocks that had drifted apart: two
// counted failures under a mutex and logged inline, while the third collected
// errors in a channel and logged them all after wg.Wait() without the track id.
// The inline form is kept, since it reports failures as they happen and names
// the track. pos is 1-based to match the progress output's numbering.
func (m *Main) runTrackWorkers(ctx context.Context, ids []string, rip func(pos int, id string) error) int {
failures := 0
maxWorkers := m.Config.Session.Downloads.MaxConnections
if !m.Config.Session.Downloads.Concurrency || maxWorkers == 1 {
for i, id := range ids {
if ctx.Err() != nil {
break
}
if err := rip(i+1, id); err != nil {
failures++
m.logf("track failed: id=%s reason=%v\n", id, err)
}
}
return failures
}
if maxWorkers <= 0 {
maxWorkers = defaultMaxWorkers
}
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
var mu sync.Mutex
for i, id := range ids {
if ctx.Err() != nil {
break
}
wg.Add(1)
sem <- struct{}{}
go func(pos int, tid string) {
defer wg.Done()
defer func() { <-sem }()
if err := rip(pos, tid); err != nil {
// Holding mu across the log keeps failure lines from
// interleaving with each other.
mu.Lock()
failures++
m.logf("track failed: id=%s reason=%v\n", tid, err)
mu.Unlock()
}
}(i+1, id)
}
wg.Wait()
return failures
}
// metadataExcluded reports whether a tag was opted out via
// metadata.exclude in the config.
func (m *Main) metadataExcluded(field string) bool {
for _, k := range m.Config.Session.Metadata.Exclude {
if strings.EqualFold(strings.TrimSpace(k), field) {
return true
}
}
return false
}
// attachLyrics fills in lyrics for providers that fetch them separately from
// track metadata. It is a no-op when the metadata already carries lyrics (as
// Tidal's and Deezer's does), when the provider has no lyrics support, or when
// the user excluded them.
func (m *Main) attachLyrics(ctx context.Context, p provider.Client, meta map[string]any, trackID string) {
if meta == nil || m.metadataExcluded("lyrics") {
return
}
if jsonutil.StringFromAny(meta["lyrics"]) != "" || jsonutil.StringFromAny(meta["lyrics_synced"]) != "" {
return
}
lp, ok := p.(trackLyricsProvider)
if !ok {
return
}
plain, synced, err := lp.TrackLyrics(ctx, trackID)
if err != nil {
// Lyrics are cosmetic; never fail a download over them.
m.logf("lyrics unavailable for id=%s: %v\n", trackID, err)
return
}
if plain != "" {
meta["lyrics"] = plain
}
if synced != "" {
meta["lyrics_synced"] = synced
}
}
// trackMetaUsable reports whether a track object carried over from an album
// payload is complete enough to tag and name a file from.
//
// It is a belt-and-braces check on top of the provider opting in: title and
// track number determine the output filename, so if either is missing the
// caller refetches rather than writing a wrongly named file.
func trackMetaUsable(meta map[string]any) bool {
if len(meta) == 0 {
return false
}
if strings.TrimSpace(jsonutil.StringFromAny(meta["title"])) == "" {
return false
}
return jsonutil.IntFromAny(meta["track_number"]) > 0
}
// albumTrackMetadata builds the per-track metadata for an album rip from the
// track objects already embedded in the album payload.
//
// Verified against the live Qobuz API: the only field a dedicated track
// lookup adds that this app reads is the nested album object, which is the
// parent album we already have. The album copy has its own track list removed,
// so attaching it to every track does not duplicate the whole album n times.
func albumTrackMetadata(albumMeta map[string]any, rawItems []any) map[string]map[string]any {
albumForTrack := make(map[string]any, len(albumMeta))
for k, v := range albumMeta {
if k == "tracks" {
continue
}
albumForTrack[k] = v
}
out := make(map[string]map[string]any, len(rawItems))
for _, raw := range rawItems {
item, ok := raw.(map[string]any)
if !ok {
continue
}
id := jsonutil.StringFromAny(item["id"])
if id == "" {
continue
}
meta := make(map[string]any, len(item)+1)
for k, v := range item {
meta[k] = v
}
if _, has := meta["album"]; !has {
meta["album"] = albumForTrack
}
out[id] = meta
}
return out
}
// anySlice normalizes a JSON array that may have been decoded as either
// []any or []map[string]any, which differs per provider because some clients
// hand-build their metadata maps instead of round-tripping through encoding/json.
//
// It reports false when the value is neither, so callers can distinguish
// "missing" from "empty".
func anySlice(v any) ([]any, bool) {
switch items := v.(type) {
case []any:
return items, true
case []map[string]any:
out := make([]any, 0, len(items))
for _, item := range items {
out = append(out, item)
}
return out, true
default:
return nil, false
}
}
// safeJoin joins base with parts and asserts the result stays inside the
// configured downloads folder.
//
// naming.CleanName already rejects "." and ".." components, so this should
// never fire; it is a second line of defence because every component here is
// built from provider-supplied metadata, and writing outside the download root
// is the one failure mode worth being paranoid about.
func (m *Main) safeJoin(base string, parts ...string) string {
joined := filepath.Join(append([]string{base}, parts...)...)
root := strings.TrimSpace(m.Config.Session.Downloads.Folder)
if root == "" {
return joined
}
if pathWithin(root, joined) {
return joined
}
m.logf("warning: refusing to write outside the download folder (%q); using %q instead\n", joined, root)
return filepath.Join(root, "Unknown")
}
// pathWithin reports whether target is root itself or sits beneath it.
func pathWithin(root, target string) bool {
absRoot, err := filepath.Abs(root)
if err != nil {
return false
}
absTarget, err := filepath.Abs(target)
if err != nil {
return false
}
rel, err := filepath.Rel(absRoot, absTarget)
if err != nil {
return false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false
}
return true
}
func (m *Main) GetLoggedInProvider(ctx context.Context, source string) (provider.Client, error) {
p, ok := m.Providers[source]
if !ok {
@@ -456,7 +186,7 @@ func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error
ResolveFn: func(ctx context.Context) (media.Media, error) {
meta, err := p.GetMetadata(ctx, id, mediaType)
if err != nil {
m.markFailed(ctx, source, mediaType, id)
_ = m.Store.MarkFailed(ctx, source, mediaType, id)
return nil, err
}
@@ -496,6 +226,47 @@ func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error
return nil
}
func (m *Main) AddPlaylistByTrackIDs(ctx context.Context, source, playlistID, playlistName string, trackIDs []string) error {
p, err := m.GetLoggedInProvider(ctx, source)
if err != nil {
return err
}
if strings.TrimSpace(playlistName) == "" {
playlistName = playlistID
}
ids := make([]string, 0, len(trackIDs))
for _, id := range trackIDs {
id = strings.TrimSpace(id)
if id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return fmt.Errorf("playlist %q has no track ids", playlistName)
}
pending := media.PendingFunc{
ResolveFn: func(context.Context) (media.Media, error) {
metaItems := make([]any, 0, len(ids))
for _, id := range ids {
metaItems = append(metaItems, map[string]any{"id": id})
}
playlistMeta := map[string]any{
"name": playlistName,
"tracks": map[string]any{"items": metaItems},
}
return media.MediaFunc{RipFn: func(ctx context.Context) error {
if m.Config.Session.CLI.TextOutput {
m.logf("Downloading playlist: %s\n", playlistName)
}
return m.ripPlaylist(ctx, p, source, playlistID, playlistMeta)
}}, nil
},
}
m.Pending = append(m.Pending, pending)
return nil
}
func (m *Main) AddMixedPlaylistByTrackRefs(ctx context.Context, playlistID, playlistName string, refs []PlaylistTrackRef) error {
if strings.TrimSpace(playlistName) == "" {
playlistName = playlistID
@@ -577,13 +348,20 @@ func (m *Main) ripTrackCollection(ctx context.Context, p provider.Client, source
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, jsonutil.TitleCase(source))
}
folder := m.safeJoin(base, naming.CleanName(name, naming.Config{RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, TruncateTo: m.Config.Session.Filepaths.TruncateTo}))
folder := filepath.Join(base, naming.CleanName(name, naming.Config{RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, TruncateTo: m.Config.Session.Filepaths.TruncateTo}))
tracksMap, ok := meta["tracks"].(map[string]any)
if !ok {
return fmt.Errorf("%s missing tracks data", strings.ToLower(kind))
}
rawItems, ok := anySlice(tracksMap["items"])
if !ok {
rawItems := make([]any, 0)
switch items := tracksMap["items"].(type) {
case []any:
rawItems = items
case []map[string]any:
for _, item := range items {
rawItems = append(rawItems, item)
}
default:
return fmt.Errorf("%s tracks missing items", strings.ToLower(kind))
}
ids := make([]string, 0, len(rawItems))
@@ -596,16 +374,63 @@ func (m *Main) ripTrackCollection(ctx context.Context, p provider.Client, source
ids = append(ids, id)
}
}
var artRes artwork.Result
var playlistYear int
if playlistLike {
artRes, _ = artwork.Prepare(ctx, m.DL, folder, meta, m.Config.Session.Artwork, true)
playlistYear = extractYear(meta)
}
m.logf("%s: %s (%d tracks)\n", kind, name, len(ids))
failures := m.runTrackWorkers(ctx, ids, func(pos int, trackID string) error {
opts := ripTrackOptions{albumFolder: folder, index: pos, total: len(ids)}
failures := 0
runOne := func(i int, trackID string) {
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, index: i, total: len(ids)}
if playlistLike {
opts.forPlaylist = true
opts.playlistName = name
opts.playlistPos = pos
opts.playlistPos = i
opts.playlistYear = playlistYear
}
return m.ripTrack(ctx, p, source, trackID, "", opts)
})
if err := m.ripTrack(ctx, p, source, trackID, "", opts); err != nil {
failures++
m.logf("track failed: id=%s reason=%v\n", trackID, err)
}
}
if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 {
for i, trackID := range ids {
runOne(i+1, trackID)
}
} else {
maxWorkers := m.Config.Session.Downloads.MaxConnections
if maxWorkers <= 0 {
maxWorkers = 6
}
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
var mu sync.Mutex
for i, trackID := range ids {
wg.Add(1)
sem <- struct{}{}
go func(pos int, tid string) {
defer wg.Done()
defer func() { <-sem }()
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, index: pos, total: len(ids)}
if playlistLike {
opts.forPlaylist = true
opts.playlistName = name
opts.playlistPos = pos
opts.playlistYear = playlistYear
}
if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil {
mu.Lock()
failures++
m.logf("track failed: id=%s reason=%v\n", tid, err)
mu.Unlock()
}
}(i+1, trackID)
}
wg.Wait()
}
if failures > 0 {
m.logf("%s done with %d failed track(s)\n", kind, failures)
}
@@ -626,18 +451,18 @@ func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID
d, err := vp.GetVideoDownloadable(ctx, videoID)
if err != nil {
m.markFailed(ctx, source, "video", videoID)
_ = m.Store.MarkFailed(ctx, source, "video", videoID)
return fmt.Errorf("id=%s get_video_downloadable: %w", videoID, err)
}
title := titleFromMetadata(meta, videoID)
outPath := m.videoOutputPath(source, videoID, title, d.Extension)
if err = m.DL.FileVideo(ctx, d.URL, outPath); err != nil {
m.markFailed(ctx, source, "video", videoID)
_ = m.Store.MarkFailed(ctx, source, "video", videoID)
return fmt.Errorf("id=%s title=%q video download: %w", videoID, title, err)
}
if err = m.markDownloaded(ctx, source, videoID); err != nil {
if err = m.Store.MarkDownloaded(ctx, source, videoID); err != nil {
return err
}
return nil
@@ -750,11 +575,23 @@ func extractAlbumIDs(meta map[string]any) []string {
return nil
}
var items []any
if nested, isMap := albumsObj.(map[string]any); isMap {
items, _ = anySlice(nested["items"])
} else {
items, _ = anySlice(albumsObj)
items := make([]any, 0)
switch a := albumsObj.(type) {
case map[string]any:
switch v := a["items"].(type) {
case []any:
items = v
case []map[string]any:
for _, it := range v {
items = append(items, it)
}
}
case []any:
items = a
case []map[string]any:
for _, it := range a {
items = append(items, it)
}
}
out := make([]string, 0, len(items))
@@ -805,17 +642,11 @@ func (m *Main) Rip(ctx context.Context) error {
m.logf("Ripping %d media item(s)\n", len(m.Media))
failures := 0
for _, item := range m.Media {
if err := ctx.Err(); err != nil {
return err
}
if err := item.Rip(ctx); err != nil {
failures++
m.logf("media item failed: %v\n", err)
}
}
if err := ctx.Err(); err != nil {
return err
}
if failures > 0 {
return fmt.Errorf("%d media item(s) failed", failures)
}
@@ -847,8 +678,14 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
if !ok {
return fmt.Errorf("album missing tracks data")
}
rawItems, ok := anySlice(tracksMap["items"])
if !ok {
rawItems := make([]any, 0)
if itemsAny, ok := tracksMap["items"].([]any); ok {
rawItems = itemsAny
} else if itemsMap, ok := tracksMap["items"].([]map[string]any); ok {
for _, item := range itemsMap {
rawItems = append(rawItems, item)
}
} else {
return fmt.Errorf("album tracks missing items")
}
trackIDs := make([]string, 0, len(rawItems))
@@ -877,31 +714,54 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
if discTotal == 0 {
discTotal = jsonutil.IntFromAny(albumMeta["numberOfVolumes"])
}
// Providers that say their album payload embeds complete track objects let
// us skip one throttled API call per track.
var embeddedMeta map[string]map[string]any
if sd, ok := p.(albumTracksSelfDescribing); ok && sd.AlbumTracksAreComplete() {
embeddedMeta = albumTrackMetadata(albumMeta, rawItems)
m.logf("Album: %s (%d tracks)\n", albumTitle, total)
failures := 0
if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 {
for i, trackID := range trackIDs {
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, albumArtist: albumArtist, index: i + 1, total: total, albumDiscTotal: discTotal}
if i == 0 {
opts.prefetched = prefetched
}
if err := m.ripTrack(ctx, p, source, trackID, "", opts); err != nil {
failures++
m.logf("track failed: id=%s reason=%v\n", trackID, err)
}
}
if failures > 0 {
m.logf("Album done with %d failed track(s)\n", failures)
}
return nil
}
m.logf("Album: %s (%d tracks)\n", albumTitle, total)
failures := m.runTrackWorkers(ctx, trackIDs, func(pos int, trackID string) error {
opts := ripTrackOptions{
albumFolder: folder,
albumEmbedCover: artRes.EmbedPath,
albumArtist: albumArtist,
index: pos,
total: total,
albumDiscTotal: discTotal,
prefetchedMeta: embeddedMeta[trackID],
}
// The first track's downloadable was already fetched to decide the
// album folder's audio values, so reuse it instead of refetching.
if pos == 1 {
opts.prefetched = prefetched
}
return m.ripTrack(ctx, p, source, trackID, "", opts)
})
maxWorkers := m.Config.Session.Downloads.MaxConnections
if maxWorkers <= 0 {
maxWorkers = 6
}
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
errCh := make(chan error, len(trackIDs))
for i, trackID := range trackIDs {
wg.Add(1)
sem <- struct{}{}
go func(idx int, tid string) {
defer wg.Done()
defer func() { <-sem }()
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, albumArtist: albumArtist, index: idx, total: total, albumDiscTotal: discTotal}
if idx == 1 {
opts.prefetched = prefetched
}
if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil {
errCh <- err
}
}(i+1, trackID)
}
wg.Wait()
close(errCh)
for err := range errCh {
failures++
m.logf("track failed: reason=%v\n", err)
}
if failures > 0 {
m.logf("Album done with %d failed track(s)\n", failures)
}
@@ -921,7 +781,7 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, jsonutil.TitleCase(source))
}
folder := m.safeJoin(base, naming.CleanName(name, naming.Config{
folder := filepath.Join(base, naming.CleanName(name, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
}))
@@ -930,9 +790,16 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
if !ok {
return fmt.Errorf("playlist missing tracks data")
}
rawItems, ok := anySlice(tracksMap["items"])
rawItems, ok := tracksMap["items"].([]any)
if !ok {
return fmt.Errorf("playlist tracks missing items")
if itemsMap, ok2 := tracksMap["items"].([]map[string]any); ok2 {
rawItems = make([]any, 0, len(itemsMap))
for _, it := range itemsMap {
rawItems = append(rawItems, it)
}
} else {
return fmt.Errorf("playlist tracks missing items")
}
}
ids := make([]string, 0, len(rawItems))
@@ -950,19 +817,60 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
}
}
artRes, _ := artwork.Prepare(ctx, m.DL, folder, playlistMeta, m.Config.Session.Artwork, true)
playlistYear := extractYear(playlistMeta)
total := len(ids)
m.logf("Playlist: %s (%d tracks)\n", name, total)
failures := m.runTrackWorkers(ctx, ids, func(pos int, id string) error {
failures := 0
runOne := func(i int, id string) {
opts := ripTrackOptions{
albumFolder: folder,
index: pos,
total: total,
forPlaylist: true,
playlistName: name,
playlistPos: pos,
albumFolder: folder,
albumEmbedCover: artRes.EmbedPath,
index: i,
total: total,
forPlaylist: true,
playlistName: name,
playlistPos: i,
playlistYear: playlistYear,
}
return m.ripTrack(ctx, p, source, id, "", opts)
})
if err := m.ripTrack(ctx, p, source, id, "", opts); err != nil {
failures++
m.logf("track failed: id=%s reason=%v\n", id, err)
}
}
if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 {
for i, id := range ids {
runOne(i+1, id)
}
} else {
maxWorkers := m.Config.Session.Downloads.MaxConnections
if maxWorkers <= 0 {
maxWorkers = 6
}
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
var mu sync.Mutex
for i, id := range ids {
wg.Add(1)
sem <- struct{}{}
go func(pos int, tid string) {
defer wg.Done()
defer func() { <-sem }()
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, index: pos, total: total, forPlaylist: true, playlistName: name, playlistPos: pos, playlistYear: playlistYear}
if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil {
mu.Lock()
failures++
m.logf("track failed: id=%s reason=%v\n", tid, err)
mu.Unlock()
}
}(i+1, id)
}
wg.Wait()
}
if failures > 0 {
m.logf("Playlist done with %d failed track(s)\n", failures)
@@ -985,7 +893,7 @@ func (m *Main) ripPlaylistMixed(ctx context.Context, playlistID, name string, re
}
}
folder := m.safeJoin(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{
folder := filepath.Join(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
}))
@@ -1047,9 +955,6 @@ func (m *Main) requireSourceDownloadAuth(source string) error {
}
func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error {
if err := ctx.Err(); err != nil {
return err
}
if !m.IgnoreDB {
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
if err == nil && alreadyDownloaded {
@@ -1062,22 +967,12 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
}
}
// A prefetched track object from the album payload saves one API call per
// track. Every call goes through the request-per-minute limiter, so on a
// large album this is the difference between the rip being gated by the
// API and being gated by the network.
meta := opts.prefetchedMeta
var err error
if !trackMetaUsable(meta) {
meta, err = p.GetMetadata(ctx, id, "track")
if err != nil {
m.markFailed(ctx, source, "track", id)
return fmt.Errorf("id=%s metadata: %w", id, err)
}
meta, err := p.GetMetadata(ctx, id, "track")
if err != nil {
_ = m.Store.MarkFailed(ctx, source, "track", id)
return fmt.Errorf("id=%s metadata: %w", id, err)
}
m.attachLyrics(ctx, p, meta, id)
title := titleFromMetadata(meta, id)
if title == id && fallbackTitle != "" {
title = fallbackTitle
@@ -1091,7 +986,7 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
if d == nil {
d, err = p.GetDownloadable(ctx, id, m.qualityForSource(source))
if err != nil {
m.markFailed(ctx, source, "track", id)
_ = m.Store.MarkFailed(ctx, source, "track", id)
return fmt.Errorf("id=%s title=%q get_downloadable: %w", id, title, err)
}
}
@@ -1114,15 +1009,9 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
return m.DL.File(ctx, d.URL, outPath)
}
if err = downloadOnce(); err != nil {
// Never spend retries or a provider fallback on a cancelled context:
// otherwise one Ctrl-C costs three doomed attempts per remaining track.
if ctxErr := ctx.Err(); ctxErr != nil {
m.markFailed(ctx, source, "track", id)
return fmt.Errorf("id=%s title=%q download: %w", id, title, err)
}
m.logf("retry: %s (%v)\n", filepath.Base(outPath), err)
if err = downloadOnce(); err != nil {
if fallbackProvider, ok := p.(trackFallbackDownloader); ok && ctx.Err() == nil {
if fallbackProvider, ok := p.(trackFallbackDownloader); ok {
m.logf("fallback: %s via provider backup flow\n", filepath.Base(outPath))
if fbErr := fallbackProvider.DownloadTrackFallback(ctx, id, m.qualityForSource(source), outPath); fbErr == nil {
goto downloaded
@@ -1130,7 +1019,7 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
m.logf("fallback failed: %s (%v)\n", filepath.Base(outPath), fbErr)
}
}
m.markFailed(ctx, source, "track", id)
_ = m.Store.MarkFailed(ctx, source, "track", id)
return fmt.Errorf("id=%s title=%q download: %w", id, title, err)
}
}
@@ -1138,7 +1027,7 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
downloaded:
embedCoverPath := opts.albumEmbedCover
if opts.forPlaylist {
if opts.forPlaylist && embedCoverPath == "" {
parent := opts.albumFolder
if parent == "" {
parent = filepath.Dir(outPath)
@@ -1148,7 +1037,7 @@ downloaded:
embedCoverPath = res.EmbedPath
}
}
} else if embedCoverPath == "" {
} else if !opts.forPlaylist && embedCoverPath == "" {
parent := opts.albumFolder
if parent == "" {
parent = filepath.Dir(outPath)
@@ -1168,24 +1057,24 @@ downloaded:
coverPath = tag.CoverPathForTrack(outPath, opts.albumFolder)
}
}
if err = m.Tagger.TagFLAC(ctx, outPath, tagMeta, coverPath); err != nil {
if err = m.Tagger.TagFLAC(outPath, tagMeta, coverPath); err != nil {
if isFFmpegMissingError(err) {
m.markFailed(ctx, source, "track", id)
_ = m.Store.MarkFailed(ctx, source, "track", id)
return fmt.Errorf("id=%s title=%q tag: %w", id, title, err)
}
m.logf("warning: tag failed for %s: %v\n", filepath.Base(outPath), err)
}
if m.Config.Session.Conversion.Enabled {
convertedPath, convErr := convert.Convert(ctx, outPath, m.Config.Session.Conversion)
convertedPath, convErr := convert.Convert(outPath, m.Config.Session.Conversion)
if convErr != nil {
m.markFailed(ctx, source, "track", id)
_ = m.Store.MarkFailed(ctx, source, "track", id)
return fmt.Errorf("id=%s title=%q convert: %w", id, title, convErr)
}
outPath = convertedPath
}
return m.markDownloaded(ctx, source, id)
return m.Store.MarkDownloaded(ctx, source, id)
}
func (m *Main) qualityForSource(source string) int {
@@ -1334,7 +1223,7 @@ func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year st
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
})
return m.safeJoin(base, folderName)
return filepath.Join(base, folderName)
}
func (m *Main) trackOutputPath(source, id, title, ext string, d *provider.Downloadable, trackMeta map[string]any, albumFolder string, albumDiscTotal int) string {
@@ -1407,7 +1296,7 @@ func (m *Main) trackOutputPath(source, id, title, ext string, d *provider.Downlo
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
})
return m.safeJoin(base, fileName+"."+ext)
return filepath.Join(base, fileName+"."+ext)
}
func (m *Main) videoOutputPath(source, id, title, ext string) string {
@@ -1425,7 +1314,7 @@ func (m *Main) videoOutputPath(source, id, title, ext string) string {
if fileName == "" {
fileName = id
}
return m.safeJoin(base, fileName+"."+ext)
return filepath.Join(base, fileName+"."+ext)
}
func titleFromMetadata(meta map[string]any, fallback string) string {
@@ -1601,7 +1490,9 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
Artist: artist,
Artists: artistNames,
AlbumArtist: albumArtist,
Compilation: opts.forPlaylist,
OmitDiscTags: opts.forPlaylist,
Year: opts.playlistYear,
TrackNumber: trackNumber,
DiscNumber: discNumber,
TrackTotal: trackTotal,
@@ -1749,11 +1640,11 @@ func extractAlbumArtist(albumMeta map[string]any) string {
}
}
if len(names) > 0 {
return jsonutil.DisplayArtistNames(names)
return displayArtistNames(names)
}
}
if names := stringSliceFromAny(albumMeta["artist_names"]); len(names) > 0 {
return jsonutil.DisplayArtistNames(names)
return displayArtistNames(names)
}
artist := jsonutil.NestedString(albumMeta, "artist", "name")
if artist != "" {
@@ -1761,3 +1652,36 @@ func extractAlbumArtist(albumMeta map[string]any) string {
}
return "Unknown"
}
func displayArtistNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " & " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1]
}
}
func extractYear(meta map[string]any) int {
date := jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(meta["publish_date"]),
jsonutil.StringFromAny(meta["creation_date"]),
jsonutil.StringFromAny(meta["created_at"]),
jsonutil.StringFromAny(meta["release_date"]),
jsonutil.StringFromAny(meta["new_release_date"]),
)
if date == "" {
return 0
}
if t, err := time.Parse(time.RFC3339, date); err == nil {
return t.Year()
}
if t, err := time.Parse("2006-01-02", date); err == nil {
return t.Year()
}
return 0
}
+2 -585
View File
@@ -2,7 +2,6 @@ package app
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -10,7 +9,6 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"streamrip-go/internal/audio/tag"
@@ -22,13 +20,13 @@ import (
type noopTagger struct{}
func (n noopTagger) TagFLAC(context.Context, string, tag.Metadata, string) error { return nil }
func (n noopTagger) TagFLAC(string, tag.Metadata, string) error { return nil }
type failingTagger struct {
err error
}
func (f failingTagger) TagFLAC(context.Context, string, tag.Metadata, string) error { return f.err }
func (f failingTagger) TagFLAC(string, tag.Metadata, string) error { return f.err }
type fakeProvider struct {
url string
@@ -959,584 +957,3 @@ func TestBuildTagMetadataReplayGainFallsBackToDeezerGain(t *testing.T) {
t.Fatalf("track replaygain gain=%q", tags.ReplaygainTrackGain)
}
}
// countingAlbumProvider serves a large album and records how many track
// downloads were attempted, so a cancellation test can assert the rip actually
// stopped instead of grinding through the rest of the queue.
type countingAlbumProvider struct {
url string
trackCount int
mu sync.Mutex
attempts int
cancel context.CancelFunc
cancelAt int
}
func (f *countingAlbumProvider) Source() string { return "qobuz" }
func (f *countingAlbumProvider) Login(context.Context) error { return nil }
func (f *countingAlbumProvider) LoggedIn() bool { return true }
func (f *countingAlbumProvider) Close() error { return nil }
func (f *countingAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *countingAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "album" {
items := make([]any, 0, f.trackCount)
for i := 0; i < f.trackCount; i++ {
items = append(items, map[string]any{"id": fmt.Sprintf("t%d", i)})
}
return map[string]any{
"title": "Big Album",
"release_date_original": "1977-02-04",
"artist": map[string]any{"name": "Fleetwood Mac"},
"tracks": map[string]any{"items": items},
}, nil
}
// Count metadata fetches as download attempts and trip the cancel once
// enough tracks have been seen.
f.mu.Lock()
f.attempts++
n := f.attempts
f.mu.Unlock()
if f.cancel != nil && n == f.cancelAt {
f.cancel()
}
return map[string]any{
"title": "Track " + id,
"track_number": float64(1),
"performer": map[string]any{"name": "Fleetwood Mac"},
"album": map[string]any{
"title": "Big Album",
"artist": map[string]any{"name": "Fleetwood Mac"},
},
}, nil
}
func (f *countingAlbumProvider) GetDownloadable(_ context.Context, _ string, _ int) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
}
func (f *countingAlbumProvider) attemptCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.attempts
}
// TestRipStopsOnCancellation is the regression test for Ctrl-C doing nothing.
// ctx was threaded through the rip but never checked, so cancelling left the
// loops running through every remaining track (and the retry ladder retrying
// on a dead context).
func TestRipStopsOnCancellation(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("fLaC-test-bytes"))
}))
defer ts.Close()
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
// Serial, so the cancellation point is deterministic.
d.Downloads.Concurrency = false
d.Artwork.Embed = false
d.Artwork.SaveArtwork = false
cfg := &config.Config{File: d, Session: d}
const total = 40
const cancelAfter = 5
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
prov := &countingAlbumProvider{url: ts.URL, trackCount: total, cancel: cancel, cancelAt: cancelAfter}
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{"qobuz": prov},
Store: store.NewDummy(),
DL: download.New(),
Tagger: noopTagger{},
}
if err := m.AddByID(context.Background(), "qobuz", "album", "a1"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err := m.Resolve(context.Background()); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
err := m.Rip(ctx)
if err == nil {
t.Fatal("Rip() returned nil after cancellation, want an error")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("Rip() error = %v, want it to wrap context.Canceled", err)
}
// The rip must stop promptly. Without the cancellation checks it walked
// all 40 tracks; allow a small margin for the in-flight track.
if got := prov.attemptCount(); got > cancelAfter+2 {
t.Errorf("attempted %d track(s) after cancelling at %d; the rip did not stop", got, cancelAfter)
}
}
// TestRipTrackReturnsEarlyOnCancellation pins the funnel check: every worker
// path goes through ripTrack, so one guard there stops all of them.
func TestRipTrackReturnsEarlyOnCancellation(t *testing.T) {
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
cfg := &config.Config{File: d, Session: d}
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{"qobuz": &fakeProvider{}},
Store: store.NewDummy(),
DL: download.New(),
Tagger: noopTagger{},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := m.ripTrack(ctx, &fakeProvider{}, "qobuz", "t1", "", ripTrackOptions{})
if !errors.Is(err, context.Canceled) {
t.Fatalf("ripTrack() error = %v, want context.Canceled", err)
}
}
// TestBookkeepingSurvivesCancellation checks that the failure record still
// lands when the rip was cancelled, which is when it matters most. The store
// writes deliberately use a detached context.
func TestBookkeepingSurvivesCancellation(t *testing.T) {
tmp := t.TempDir()
sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = sqlite.Close() }()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
m := &Main{Config: &config.Config{File: d, Session: d}, Store: sqlite, DL: download.New()}
ctx, cancel := context.WithCancel(context.Background())
cancel()
m.markFailed(ctx, "qobuz", "track", "t1")
// Read back with a live context.
failed, err := sqlite.IsFailed(context.Background(), "qobuz", "track", "t1")
if err != nil {
t.Fatal(err)
}
if !failed {
t.Fatal("failure record not written under a cancelled context")
}
}
// countingMetaProvider records how many per-track metadata lookups happen, so
// the album-payload optimization can be asserted rather than assumed.
type countingMetaProvider struct {
url string
complete bool
mu sync.Mutex
trackMeta int
albumMeta int
downloads int
}
func (f *countingMetaProvider) Source() string { return "qobuz" }
func (f *countingMetaProvider) Login(context.Context) error { return nil }
func (f *countingMetaProvider) LoggedIn() bool { return true }
func (f *countingMetaProvider) Close() error { return nil }
func (f *countingMetaProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
// AlbumTracksAreComplete is only consulted when complete is true; the false
// case still satisfies the interface, which mirrors a provider that opts out.
func (f *countingMetaProvider) AlbumTracksAreComplete() bool { return f.complete }
func (f *countingMetaProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
f.mu.Lock()
if mediaType == "album" {
f.albumMeta++
} else {
f.trackMeta++
}
f.mu.Unlock()
if mediaType == "album" {
items := make([]any, 0, 3)
for i := 1; i <= 3; i++ {
items = append(items, map[string]any{
"id": fmt.Sprintf("t%d", i),
"title": fmt.Sprintf("Track %d", i),
"track_number": float64(i),
"media_number": float64(1),
"isrc": fmt.Sprintf("ISRC%d", i),
"copyright": "(C) Label",
"performer": map[string]any{"name": "Fleetwood Mac"},
})
}
return map[string]any{
"title": "Rumours",
"release_date_original": "1977-02-04",
"artist": map[string]any{"name": "Fleetwood Mac"},
"tracks": map[string]any{"items": items},
}, nil
}
return map[string]any{
"title": "Fetched " + id,
"track_number": float64(1),
"performer": map[string]any{"name": "Fleetwood Mac"},
"album": map[string]any{"title": "Rumours"},
}, nil
}
func (f *countingMetaProvider) GetDownloadable(_ context.Context, _ string, _ int) (*provider.Downloadable, error) {
f.mu.Lock()
f.downloads++
f.mu.Unlock()
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
}
func (f *countingMetaProvider) counts() (album, track, downloads int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.albumMeta, f.trackMeta, f.downloads
}
func runAlbumRip(t *testing.T, prov *countingMetaProvider) string {
t.Helper()
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
d.Downloads.Concurrency = false
d.Artwork.Embed = false
d.Artwork.SaveArtwork = false
cfg := &config.Config{File: d, Session: d}
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{"qobuz": prov},
Store: store.NewDummy(),
DL: download.New(),
Tagger: noopTagger{},
}
ctx := context.Background()
if err := m.AddByID(ctx, "qobuz", "album", "a1"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err := m.Resolve(ctx); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if err := m.Rip(ctx); err != nil {
t.Fatalf("Rip() error = %v", err)
}
return tmp
}
// TestAlbumRipSkipsPerTrackMetadataWhenEmbedded is the performance regression
// test: a provider that embeds complete track objects in its album payload
// must not be asked for each track again. Every such call goes through the
// requests-per-minute limiter, so on a 68-track album the redundant lookups
// were ~68 seconds of pure waiting.
func TestAlbumRipSkipsPerTrackMetadataWhenEmbedded(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("fLaC"))
}))
defer ts.Close()
prov := &countingMetaProvider{url: ts.URL, complete: true}
tmp := runAlbumRip(t, prov)
album, track, _ := prov.counts()
if album != 1 {
t.Errorf("album metadata fetches = %d, want 1", album)
}
if track != 0 {
t.Errorf("per-track metadata fetches = %d, want 0 (album payload is complete)", track)
}
// The embedded titles must be what lands on disk, not the fetched ones.
albumDir := soleSubdir(t, tmp)
for i := 1; i <= 3; i++ {
name := fmt.Sprintf("%02d. Fleetwood Mac - Track %d.flac", i, i)
if _, err := os.Stat(filepath.Join(albumDir, name)); err != nil {
entries, _ := os.ReadDir(albumDir)
var got []string
for _, e := range entries {
got = append(got, e.Name())
}
t.Fatalf("expected %q from the embedded metadata; album dir contains %v", name, got)
}
}
}
// TestAlbumRipFetchesPerTrackMetadataWhenNotEmbedded pins the opt-out path, so
// providers like Tidal (whose track lookup also fetches lyrics) keep working.
func TestAlbumRipFetchesPerTrackMetadataWhenNotEmbedded(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("fLaC"))
}))
defer ts.Close()
prov := &countingMetaProvider{url: ts.URL, complete: false}
runAlbumRip(t, prov)
_, track, _ := prov.counts()
if track != 3 {
t.Errorf("per-track metadata fetches = %d, want 3 (provider opted out)", track)
}
}
// TestAlbumTrackMetadataAttachesParentAlbum covers the one field the live
// Qobuz check showed track/get adds: the nested album object.
func TestAlbumTrackMetadataAttachesParentAlbum(t *testing.T) {
albumMeta := map[string]any{
"title": "Rumours",
"replaygain_album_gain": -7.5,
"tracks": map[string]any{"items": []any{
map[string]any{"id": "t1", "title": "Dreams", "track_number": float64(1)},
}},
}
items, _ := anySlice(albumMeta["tracks"].(map[string]any)["items"])
got := albumTrackMetadata(albumMeta, items)
meta, ok := got["t1"]
if !ok {
t.Fatal("no metadata built for t1")
}
alb, ok := meta["album"].(map[string]any)
if !ok {
t.Fatal("parent album not attached")
}
if alb["title"] != "Rumours" {
t.Errorf("album title = %v", alb["title"])
}
// Album-level ReplayGain is read through trackMeta["album"].
if alb["replaygain_album_gain"] != -7.5 {
t.Errorf("album replaygain lost: %v", alb["replaygain_album_gain"])
}
// The attached album must not carry the whole track list back in.
if _, has := alb["tracks"]; has {
t.Error("attached album still has its track list; that is O(n^2) memory on a large album")
}
}
func TestTrackMetaUsable(t *testing.T) {
tests := []struct {
name string
meta map[string]any
want bool
}{
{"nil", nil, false},
{"empty", map[string]any{}, false},
{"complete", map[string]any{"title": "A", "track_number": float64(1)}, true},
{"no title", map[string]any{"track_number": float64(1)}, false},
{"blank title", map[string]any{"title": " ", "track_number": float64(1)}, false},
{"no track number", map[string]any{"title": "A"}, false},
{"zero track number", map[string]any{"title": "A", "track_number": float64(0)}, false},
{"string track number", map[string]any{"title": "A", "track_number": "2"}, true},
}
for _, tc := range tests {
if got := trackMetaUsable(tc.meta); got != tc.want {
t.Errorf("%s: trackMetaUsable = %v, want %v", tc.name, got, tc.want)
}
}
}
// soleSubdir returns the single directory inside dir, which for an album rip
// is the generated album folder.
func soleSubdir(t *testing.T, dir string) string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
var dirs []string
for _, e := range entries {
if e.IsDir() {
dirs = append(dirs, filepath.Join(dir, e.Name()))
}
}
if len(dirs) != 1 {
t.Fatalf("expected exactly one album folder in %s, got %v", dir, dirs)
}
return dirs[0]
}
// lyricsProvider is a countingMetaProvider that also serves lyrics, to prove
// lyrics still reach the tagger on the album fast path, where the per-track
// metadata call is skipped.
type lyricsProvider struct {
*countingMetaProvider
plain string
synced string
calls int
err error
}
func (f *lyricsProvider) TrackLyrics(context.Context, string) (string, string, error) {
f.mu.Lock()
f.calls++
f.mu.Unlock()
if f.err != nil {
return "", "", f.err
}
return f.plain, f.synced, nil
}
type capturingTagger struct {
mu sync.Mutex
seen []tag.Metadata
}
func (c *capturingTagger) TagFLAC(_ context.Context, _ string, meta tag.Metadata, _ string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.seen = append(c.seen, meta)
return nil
}
func (c *capturingTagger) lyrics() []string {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]string, 0, len(c.seen))
for _, m := range c.seen {
out = append(out, m.Lyrics)
}
return out
}
func runAlbumRipWithTagger(t *testing.T, prov provider.Client, tagger trackTagger, exclude ...string) {
t.Helper()
d := config.DefaultConfigData()
d.Downloads.Folder = t.TempDir()
d.Downloads.Concurrency = false
d.Artwork.Embed = false
d.Artwork.SaveArtwork = false
d.Metadata.Exclude = exclude
cfg := &config.Config{File: d, Session: d}
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{"qobuz": prov},
Store: store.NewDummy(),
DL: download.New(),
Tagger: tagger,
}
ctx := context.Background()
if err := m.AddByID(ctx, "qobuz", "album", "a1"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err := m.Resolve(ctx); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if err := m.Rip(ctx); err != nil {
t.Fatalf("Rip() error = %v", err)
}
}
func TestAlbumRipAttachesLyricsOnFastPath(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("fLaC"))
}))
defer ts.Close()
prov := &lyricsProvider{
countingMetaProvider: &countingMetaProvider{url: ts.URL, complete: true},
plain: "line one\nline two",
synced: "[00:01.00]line one",
}
tagger := &capturingTagger{}
runAlbumRipWithTagger(t, prov, tagger)
_, trackMeta, _ := prov.counts()
if trackMeta != 0 {
t.Errorf("per-track metadata fetches = %d, want 0 (fast path must be preserved)", trackMeta)
}
if prov.calls != 3 {
t.Errorf("TrackLyrics calls = %d, want 3 (one per track)", prov.calls)
}
got := tagger.lyrics()
if len(got) != 3 {
t.Fatalf("tagged %d tracks, want 3", len(got))
}
for i, l := range got {
// Synced lyrics win over plain, matching the existing behaviour.
if l != "[00:01.00]line one" {
t.Errorf("track %d lyrics = %q", i, l)
}
}
}
func TestAlbumRipSkipsLyricsWhenExcluded(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("fLaC"))
}))
defer ts.Close()
prov := &lyricsProvider{
countingMetaProvider: &countingMetaProvider{url: ts.URL, complete: true},
plain: "should not be fetched",
}
runAlbumRipWithTagger(t, prov, noopTagger{}, "lyrics")
if prov.calls != 0 {
t.Errorf("TrackLyrics calls = %d, want 0 when metadata.exclude contains lyrics", prov.calls)
}
}
// A lyrics failure is cosmetic and must never fail the download.
func TestAlbumRipSurvivesLyricsFailure(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("fLaC"))
}))
defer ts.Close()
prov := &lyricsProvider{
countingMetaProvider: &countingMetaProvider{url: ts.URL, complete: true},
err: errors.New("lyrics service down"),
}
tagger := &capturingTagger{}
runAlbumRipWithTagger(t, prov, tagger)
if n := len(tagger.lyrics()); n != 3 {
t.Fatalf("tagged %d tracks, want 3 despite the lyrics failure", n)
}
for i, l := range tagger.lyrics() {
if l != "" {
t.Errorf("track %d should have no lyrics, got %q", i, l)
}
}
}
// Providers that already attach lyrics in their metadata (Tidal, Deezer) must
// not be asked again.
func TestLyricsNotRefetchedWhenMetadataAlreadyHasThem(t *testing.T) {
d := config.DefaultConfigData()
m := &Main{Config: &config.Config{File: d, Session: d}, DL: download.New()}
prov := &lyricsProvider{countingMetaProvider: &countingMetaProvider{}, plain: "fresh"}
meta := map[string]any{"lyrics_synced": "[00:00.00]already here"}
m.attachLyrics(context.Background(), prov, meta, "t1")
if prov.calls != 0 {
t.Errorf("TrackLyrics calls = %d, want 0 when metadata already carries lyrics", prov.calls)
}
if meta["lyrics_synced"] != "[00:00.00]already here" {
t.Errorf("existing lyrics overwritten: %v", meta["lyrics_synced"])
}
}
+13 -56
View File
@@ -6,12 +6,10 @@ import (
"fmt"
"image"
"image/jpeg"
_ "image/png"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"golang.org/x/image/draw"
@@ -33,52 +31,6 @@ var (
tempDirs = map[string]struct{}{}
)
var (
folderLocksMu sync.Mutex
folderLocks = map[string]*sync.Mutex{}
)
// lockFolder serializes Prepare calls that target the same album folder and
// returns the unlock function.
//
// ripTrack calls Prepare once per track with the same albumFolder for every
// worker in a playlist rip, and the cover handling below is check-then-write.
// Without this, two workers both saw a missing cover.jpg, both created it, and
// the one that failed removed the file the other had just finished writing.
func lockFolder(folder string) func() {
folderLocksMu.Lock()
mu, ok := folderLocks[folder]
if !ok {
mu = &sync.Mutex{}
folderLocks[folder] = mu
}
folderLocksMu.Unlock()
mu.Lock()
return mu.Unlock
}
// fetchAtomic downloads sourceURL to destPath via a unique temp file in the
// same directory, then renames it into place. Downloading straight to destPath
// meant a failed attempt could delete a file another worker had completed.
func fetchAtomic(ctx context.Context, dl Downloader, sourceURL, destPath string, maxWidth int) bool {
tmpPath := fmt.Sprintf("%s.part-%d-%d", destPath, os.Getpid(), atomic.AddUint64(&tmpCounter, 1))
if err := dl.FileNoProgress(ctx, sourceURL, tmpPath); err != nil {
_ = os.Remove(tmpPath)
return false
}
if maxWidth > 0 {
_ = downscaleImage(tmpPath, maxWidth)
}
if err := os.Rename(tmpPath, destPath); err != nil {
_ = os.Remove(tmpPath)
return false
}
return true
}
var tmpCounter uint64
func Prepare(ctx context.Context, dl Downloader, folder string, albumMeta map[string]any, cfg config.ArtworkConfig, forPlaylist bool) (Result, error) {
saveArtwork := cfg.SaveArtwork
if forPlaylist {
@@ -99,25 +51,30 @@ func Prepare(ctx context.Context, dl Downloader, folder string, albumMeta map[st
embedURL = largestURL
}
// Everything below is check-then-write against shared paths.
unlock := lockFolder(folder)
defer unlock()
result := Result{}
if saveArtwork && largestURL != "" {
savedPath := filepath.Join(folder, "cover.jpg")
if fileExists(savedPath) || fetchAtomic(ctx, dl, largestURL, savedPath, cfg.SavedMaxWidth) {
if fileExists(savedPath) {
result.SavedPath = savedPath
} else if err := dl.FileNoProgress(ctx, largestURL, savedPath); err == nil {
if cfg.SavedMaxWidth > 0 {
_ = downscaleImage(savedPath, cfg.SavedMaxWidth)
}
result.SavedPath = savedPath
}
}
if cfg.Embed && embedURL != "" {
embedDir := sessionEmbedDir(folder)
// 0o700: predictable path under the shared system temp dir.
if err := os.MkdirAll(embedDir, 0o700); err == nil {
if err := os.MkdirAll(embedDir, 0o755); err == nil {
registerTempDir(embedDir)
embedPath := filepath.Join(embedDir, embedFilename(embedURL))
if fileExists(embedPath) || fetchAtomic(ctx, dl, embedURL, embedPath, cfg.EmbedMaxWidth) {
if fileExists(embedPath) {
result.EmbedPath = embedPath
} else if err := dl.FileNoProgress(ctx, embedURL, embedPath); err == nil {
if cfg.EmbedMaxWidth > 0 {
_ = downscaleImage(embedPath, cfg.EmbedMaxWidth)
}
result.EmbedPath = embedPath
}
}
-177
View File
@@ -1,177 +0,0 @@
package artwork
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"streamrip-go/internal/config"
)
// fakeDownloader models the real downloader's behaviour for this race: the
// first attempt at a given output path succeeds, and any later attempt at that
// same path fails and removes its partial output (downloader.go does
// os.Remove(outputPath) on failure). That is exactly how a losing worker used
// to delete the cover a winning worker had just written.
type fakeDownloader struct {
payload []byte
mu sync.Mutex
seen map[string]bool
delay time.Duration
}
func newFakeDownloader(payload []byte, delay time.Duration) *fakeDownloader {
return &fakeDownloader{payload: payload, seen: map[string]bool{}, delay: delay}
}
func (f *fakeDownloader) File(ctx context.Context, sourceURL, outputPath string) error {
return f.FileNoProgress(ctx, sourceURL, outputPath)
}
func (f *fakeDownloader) FileNoProgress(_ context.Context, _, outputPath string) error {
f.mu.Lock()
first := !f.seen[outputPath]
f.seen[outputPath] = true
f.mu.Unlock()
if !first {
_ = os.Remove(outputPath)
return errors.New("download failed")
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return err
}
// Widen the window between another worker's existence check and this write.
time.Sleep(f.delay)
return os.WriteFile(outputPath, f.payload, 0o600)
}
func artworkCfg() config.ArtworkConfig {
return config.ArtworkConfig{Embed: true, EmbedSize: "large", SaveArtwork: true}
}
func albumMeta() map[string]any {
return map[string]any{"image": map[string]any{
"original": "https://example.invalid/large.jpg",
"large": "https://example.invalid/large.jpg",
}}
}
// TestPrepareConcurrentSameFolder is the regression test for the cover race:
// many workers calling Prepare for one album folder must leave exactly one
// intact cover.jpg. Run with -race.
func TestPrepareConcurrentSameFolder(t *testing.T) {
folder := t.TempDir()
payload := []byte("jpeg-bytes")
dl := newFakeDownloader(payload, 5*time.Millisecond)
const workers = 16
var wg sync.WaitGroup
results := make([]Result, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
res, err := Prepare(context.Background(), dl, folder, albumMeta(), artworkCfg(), false)
if err != nil {
t.Errorf("Prepare: %v", err)
}
results[idx] = res
}(i)
}
wg.Wait()
saved := filepath.Join(folder, "cover.jpg")
got, err := os.ReadFile(saved)
if err != nil {
t.Fatalf("cover.jpg missing after %d concurrent Prepare calls: %v", workers, err)
}
if string(got) != string(payload) {
t.Fatalf("cover.jpg corrupted: got %q want %q", got, payload)
}
// No .part- temp files may survive.
entries, err := os.ReadDir(folder)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if filepath.Ext(e.Name()) != ".jpg" {
t.Errorf("leftover temp artifact in album folder: %s", e.Name())
}
}
for i, res := range results {
if res.SavedPath != saved {
t.Errorf("worker %d: SavedPath = %q want %q", i, res.SavedPath, saved)
}
if res.EmbedPath == "" {
t.Errorf("worker %d: EmbedPath empty", i)
}
}
}
// TestPrepareFailedDownloadKeepsExistingCover covers the specific corruption
// mode: a failing download must not remove a cover that is already on disk.
func TestPrepareFailedDownloadKeepsExistingCover(t *testing.T) {
folder := t.TempDir()
saved := filepath.Join(folder, "cover.jpg")
if err := os.WriteFile(saved, []byte("good-cover"), 0o600); err != nil {
t.Fatal(err)
}
// Mark the saved path as already attempted so any download of it fails.
dl := newFakeDownloader([]byte("new"), 0)
dl.seen[saved] = true
if _, err := Prepare(context.Background(), dl, folder, albumMeta(), artworkCfg(), false); err != nil {
t.Fatalf("Prepare: %v", err)
}
got, err := os.ReadFile(saved)
if err != nil {
t.Fatalf("cover.jpg removed: %v", err)
}
if string(got) != "good-cover" {
t.Fatalf("existing cover overwritten: got %q", got)
}
}
// TestFetchAtomicFailureLeavesNoPartial asserts a failed fetch neither creates
// the destination nor leaves a temp file behind.
func TestFetchAtomicFailureLeavesNoPartial(t *testing.T) {
folder := t.TempDir()
dest := filepath.Join(folder, "cover.jpg")
dl := newFakeDownloader([]byte("x"), 0)
dl.seen[dest+".forced"] = true
// Force failure by pre-marking every temp path is impractical, so use a
// downloader that always fails instead.
always := &failingDownloader{}
if ok := fetchAtomic(context.Background(), always, "https://example.invalid/a.jpg", dest, 0); ok {
t.Fatal("fetchAtomic reported success on a failing download")
}
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Fatalf("destination should not exist, stat err = %v", err)
}
entries, err := os.ReadDir(folder)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("temp files left behind: %v", entries)
}
}
type failingDownloader struct{}
func (failingDownloader) File(context.Context, string, string) error { return errors.New("nope") }
func (failingDownloader) FileNoProgress(_ context.Context, _, outputPath string) error {
_ = os.WriteFile(outputPath, []byte("partial"), 0o600)
return errors.New("nope")
}
+2 -3
View File
@@ -1,7 +1,6 @@
package convert
import (
"context"
"fmt"
"os"
"os/exec"
@@ -28,7 +27,7 @@ var profiles = map[string]profile{
"AAC": {codecLib: "aac", ext: "m4a", lossless: false},
}
func Convert(ctx context.Context, path string, cfg config.ConversionConfig) (string, error) {
func Convert(path string, cfg config.ConversionConfig) (string, error) {
if !cfg.Enabled {
return path, nil
}
@@ -46,7 +45,7 @@ func Convert(ctx context.Context, path string, cfg config.ConversionConfig) (str
tmpPath := finalPath + ".tmp." + p.ext
args := buildFFmpegArgs(path, tmpPath, p, cfg)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
cmd := exec.Command("ffmpeg", args...)
output, err := cmd.CombinedOutput()
if err != nil {
_ = os.Remove(tmpPath)
+1 -2
View File
@@ -1,7 +1,6 @@
package convert
import (
"context"
"os"
"path/filepath"
"strings"
@@ -87,7 +86,7 @@ func TestConvertKeepsSourceWhenRenameFails(t *testing.T) {
}
cfg := config.ConversionConfig{Enabled: true, Codec: "ALAC"}
out, err := Convert(context.Background(), in, cfg)
out, err := Convert(in, cfg)
if err == nil {
t.Fatalf("expected rename failure")
}
+13 -6
View File
@@ -1,7 +1,6 @@
package tag
import (
"context"
"encoding/binary"
"fmt"
"os"
@@ -17,7 +16,9 @@ type Metadata struct {
Artist string
Artists []string
AlbumArtist string
Compilation bool
OmitDiscTags bool
Year int
TrackNumber int
DiscNumber int
TrackTotal int
@@ -46,17 +47,17 @@ func New() *Tagger {
return &Tagger{}
}
func (t *Tagger) TagFLAC(ctx context.Context, path string, meta Metadata, coverPath string) error {
func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
if _, err := exec.LookPath("ffmpeg"); err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
forceMP4Muxer := shouldForceMP4Muxer(ctx, path, ext)
forceMP4Muxer := shouldForceMP4Muxer(path, ext)
tmpPath := taggedTempPath(path)
runTag := func(cover string) ([]byte, error) {
args := buildFFmpegArgs(path, tmpPath, meta, cover, ext, forceMP4Muxer)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
cmd := exec.Command("ffmpeg", args...)
return cmd.CombinedOutput()
}
@@ -126,7 +127,7 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext
return args
}
func shouldForceMP4Muxer(ctx context.Context, path, ext string) bool {
func shouldForceMP4Muxer(path, ext string) bool {
switch strings.TrimPrefix(strings.ToLower(ext), ".") {
case "m4a", "mp4":
default:
@@ -135,7 +136,7 @@ func shouldForceMP4Muxer(ctx context.Context, path, ext string) bool {
if _, err := exec.LookPath("ffprobe"); err != nil {
return false
}
out, err := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", path).Output()
out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", path).Output()
if err != nil {
return false
}
@@ -198,6 +199,12 @@ func toTags(meta Metadata) map[string]string {
if meta.DiscTotal > 0 {
tags["disctotal"] = strconv.Itoa(meta.DiscTotal)
}
if meta.Compilation {
tags["COMPILATION"] = "1"
}
if meta.Year > 0 {
tags["year"] = strconv.Itoa(meta.Year)
}
return tags
}
+42 -261
View File
@@ -5,9 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/pelletier/go-toml/v2"
)
@@ -16,17 +13,8 @@ const CurrentConfigVersion = "2.2.0"
var ErrOutdatedConfig = errors.New("config version mismatch")
// Config holds the on-disk config (File) and the runtime view with CLI
// overrides applied (Session).
//
// mu guards File, Session and the write to Path. Provider clients refresh OAuth
// tokens from inside concurrent download workers, so both the struct fields and
// the config-file write have to be serialized; without this, two workers
// refreshing at once raced each other and issued two concurrent os.WriteFile
// calls against the same path.
type Config struct {
Path string
mu sync.Mutex
File ConfigData
Session ConfigData
}
@@ -39,6 +27,7 @@ type ConfigData struct {
Yandex YandexConfig `toml:"yandex"`
Beatport BeatportConfig `toml:"beatport"`
Soundcloud SoundcloudConfig `toml:"soundcloud"`
Youtube YoutubeConfig `toml:"youtube"`
Database DatabaseConfig `toml:"database"`
Conversion ConversionConfig `toml:"conversion"`
QobuzFilters QobuzDiscographyFilterConfig `toml:"qobuz_filters"`
@@ -61,17 +50,13 @@ type DownloadsConfig struct {
}
type QobuzConfig struct {
Quality int `toml:"quality"`
UseAuthToken bool `toml:"use_auth_token"`
EmailOrUserID string `toml:"email_or_userid"`
PasswordOrToken string `toml:"password_or_token"`
AppID string `toml:"app_id"`
Secrets []string `toml:"secrets"`
// Vendor app credentials for the mobile API fallback. Defaulted in code;
// override here when Qobuz rotates them, instead of waiting for a release.
MobileAppID string `toml:"mobile_app_id"`
MobileAppSecret string `toml:"mobile_app_secret"`
Quality int `toml:"quality"`
DownloadBooklets bool `toml:"download_booklets"`
UseAuthToken bool `toml:"use_auth_token"`
EmailOrUserID string `toml:"email_or_userid"`
PasswordOrToken string `toml:"password_or_token"`
AppID string `toml:"app_id"`
Secrets []string `toml:"secrets"`
}
type TidalConfig struct {
@@ -83,10 +68,6 @@ type TidalConfig struct {
AccessToken string `toml:"access_token"`
RefreshToken string `toml:"refresh_token"`
TokenExpiry int64 `toml:"token_expiry"`
// Vendor OAuth app credentials. Defaulted in code; override on rotation.
AppClientID string `toml:"app_client_id"`
AppClientSecret string `toml:"app_client_secret"`
}
type DeezerConfig struct {
@@ -96,21 +77,12 @@ type DeezerConfig struct {
Email string `toml:"email"`
Password string `toml:"password"`
RefreshToken string `toml:"refresh_token"`
// Vendor protocol keys. Defaulted in code; override on rotation.
APIKey string `toml:"api_key"`
GatewayKey string `toml:"gateway_key"`
BlowfishSecret string `toml:"blowfish_secret"`
}
type YandexConfig struct {
Quality int `toml:"quality"`
AccessToken string `toml:"access_token"`
UserID string `toml:"user_id"`
// Vendor request-signing keys. Defaulted in code; override on rotation.
SignKey string `toml:"sign_key"`
LegacyMP3SignSalt string `toml:"legacy_mp3_sign_salt"`
}
type BeatportConfig struct {
@@ -121,15 +93,18 @@ type BeatportConfig struct {
AccessToken string `toml:"access_token"`
RefreshToken string `toml:"refresh_token"`
TokenExpiry int64 `toml:"token_expiry"`
// Vendor OAuth app credential. Defaulted in code; override on rotation.
AppClientID string `toml:"app_client_id"`
}
// SoundcloudConfig has no client_id/app_version: soundcloud is handled via
// yt-dlp, which supplies its own, so those keys were read by no code.
type SoundcloudConfig struct {
Quality int `toml:"quality"`
Quality int `toml:"quality"`
ClientID string `toml:"client_id"`
AppVersion string `toml:"app_version"`
}
type YoutubeConfig struct {
Quality int `toml:"quality"`
DownloadVideos bool `toml:"download_videos"`
VideoDownloadsFolder string `toml:"video_downloads_folder"`
}
type DatabaseConfig struct {
@@ -191,7 +166,8 @@ type CLIConfig struct {
}
type MiscConfig struct {
Version string `toml:"version"`
Version string `toml:"version"`
CheckForUpdates bool `toml:"check_for_updates"`
}
func Load(path string) (*Config, error) {
@@ -226,219 +202,34 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("%w: need to update from %q to %q", ErrOutdatedConfig, data.Misc.Version, CurrentConfigVersion)
}
if err = Validate(&data); err != nil {
return nil, err
}
return &Config{Path: resolvedPath, File: data, Session: cloneConfigData(data)}, nil
}
// Vendor app credentials and protocol keys.
//
// These are the provider apps' own identifiers and signing keys, not user
// secrets, and they are baked in so the tool works out of the box. They are
// mirrored into the config so that when a provider rotates one, a user can fix
// their install with a config edit instead of waiting for a new release.
const (
DefaultQobuzMobileAppID = "312369995"
DefaultQobuzMobileAppSecret = "e79f8b9be485692b0e5f9dd895826368"
DefaultTidalAppClientID = "fX2JxdmntZWK0ixT"
DefaultTidalAppClientSecret = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="
DefaultDeezerAPIKey = "4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE"
DefaultDeezerGatewayKey = "VBK1FSUEXHTSDBJJ"
DefaultDeezerBlowfishSecret = "g4el58wc0zvf9na1"
DefaultYandexSignKey = "kzqU4XhfCaY6B6JTHODeq5"
DefaultYandexLegacyMP3Salt = "XGRlBW9FXlekgbPrRHuSiA"
DefaultBeatportAppClientID = "ryZ8LuyQVPqbK2mBX2Hwt4qSMtnWuTYSqBPO92yQ"
)
// applyVendorDefaults fills in any vendor credential the user has not set, so
// the provider clients never have to nil-check them.
func applyVendorDefaults(data *ConfigData) {
if data.Qobuz.MobileAppID == "" {
data.Qobuz.MobileAppID = DefaultQobuzMobileAppID
}
if data.Qobuz.MobileAppSecret == "" {
data.Qobuz.MobileAppSecret = DefaultQobuzMobileAppSecret
}
if data.Tidal.AppClientID == "" {
data.Tidal.AppClientID = DefaultTidalAppClientID
}
if data.Tidal.AppClientSecret == "" {
data.Tidal.AppClientSecret = DefaultTidalAppClientSecret
}
if data.Deezer.APIKey == "" {
data.Deezer.APIKey = DefaultDeezerAPIKey
}
if data.Deezer.GatewayKey == "" {
data.Deezer.GatewayKey = DefaultDeezerGatewayKey
}
if data.Deezer.BlowfishSecret == "" {
data.Deezer.BlowfishSecret = DefaultDeezerBlowfishSecret
}
if data.Yandex.SignKey == "" {
data.Yandex.SignKey = DefaultYandexSignKey
}
if data.Yandex.LegacyMP3SignSalt == "" {
data.Yandex.LegacyMP3SignSalt = DefaultYandexLegacyMP3Salt
}
if data.Beatport.AppClientID == "" {
data.Beatport.AppClientID = DefaultBeatportAppClientID
}
}
// qualityRange is the accepted quality id range per source on the global 0-4
// scale.
//
// The ceiling is 4 everywhere because each provider maps the scale onto its own
// ladder and saturates at the top: deezer's buildFormatPriority treats anything
// >= 2 as FLAC, and soundcloud ignores the value entirely. Only the floor
// differs: Qobuz and Beatport have no equivalent of tier 0.
var qualityRange = map[string][2]int{
"qobuz": {1, 4},
"tidal": {0, 4},
"deezer": {0, 4},
"soundcloud": {0, 4},
"yandex": {0, 4},
"beatport": {1, 4},
}
// Validate rejects config values that would otherwise fail much later, deep in
// a rip, or be silently patched over at individual call sites. Load previously
// checked nothing but misc.version, so a negative max_connections or a bogus
// conversion codec was accepted and only noticed mid-download.
//
// It normalizes what can be normalized (the conversion codec, which until now
// was only canonicalized when it arrived via --codec) and errors on the rest.
func Validate(data *ConfigData) error {
for source, bounds := range qualityRange {
var q int
switch source {
case "qobuz":
q = data.Qobuz.Quality
case "tidal":
q = data.Tidal.Quality
case "deezer":
q = data.Deezer.Quality
case "soundcloud":
q = data.Soundcloud.Quality
case "yandex":
q = data.Yandex.Quality
case "beatport":
q = data.Beatport.Quality
}
if q < bounds[0] || q > bounds[1] {
return fmt.Errorf("config: %s.quality is %d, must be %d-%d", source, q, bounds[0], bounds[1])
}
}
if data.Downloads.MaxConnections < 1 {
return fmt.Errorf("config: downloads.max_connections is %d, must be >= 1", data.Downloads.MaxConnections)
}
if data.Downloads.RequestsPerMinute == 0 {
return errors.New("config: downloads.requests_per_minute is 0; use a positive value, or a negative value to disable throttling")
}
if data.Filepaths.TruncateTo < 1 {
return fmt.Errorf("config: filepaths.truncate_to is %d, must be >= 1", data.Filepaths.TruncateTo)
}
if data.Conversion.Enabled {
codec, err := NormalizeCodec(data.Conversion.Codec)
if err != nil {
return fmt.Errorf("config: conversion.codec: %w", err)
}
data.Conversion.Codec = codec
}
// artwork.*_max_width uses -1 (and any non-positive value) to mean "do not
// downscale", so there is no lower bound to enforce here.
return nil
}
// NormalizeCodec canonicalizes a conversion codec name. It lives here, rather
// than only in the CLI flag parser, so a codec set in the config file goes
// through the same check.
func NormalizeCodec(raw string) (string, error) {
codec := strings.ToUpper(strings.TrimSpace(raw))
switch codec {
case "ALAC", "FLAC", "MP3", "AAC", "VORBIS":
return codec, nil
case "OGG":
return "VORBIS", nil
default:
return "", fmt.Errorf("unsupported codec %q (expected ALAC, FLAC, OGG, MP3, AAC)", raw)
}
}
// UpgradeOutdated rewrites an outdated config at the current version. It runs
// automatically, without prompting, so it copies the original aside first: the
// rewrite re-serializes the whole file from the parsed struct, which drops any
// key this version does not know about. It returns the config path and the
// backup path.
//
// This is a version stamp, not a migration framework. There are no per-version
// transforms, so a future key rename still needs explicit handling here.
func UpgradeOutdated(path string) (resolved string, backup string, err error) {
func UpgradeOutdated(path string) (string, error) {
resolvedPath, err := resolvePath(path)
if err != nil {
return "", "", err
return "", err
}
raw, err := os.ReadFile(resolvedPath)
if err != nil {
return "", "", err
return "", err
}
backupPath := fmt.Sprintf("%s.bak-%d", resolvedPath, time.Now().Unix())
if err = os.WriteFile(backupPath, raw, 0o600); err != nil {
return "", "", fmt.Errorf("could not back up %s before upgrading: %w", resolvedPath, err)
}
data := DefaultConfigData()
if err = toml.Unmarshal(raw, &data); err != nil {
return "", "", err
return "", err
}
applyRuntimeDefaults(&data)
data.Misc.Version = CurrentConfigVersion
if err = Validate(&data); err != nil {
return "", "", err
}
if err = saveConfigData(resolvedPath, data); err != nil {
return "", "", err
return "", err
}
return resolvedPath, backupPath, nil
return resolvedPath, nil
}
func (c *Config) SaveFile() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.saveFileLocked()
}
func (c *Config) saveFileLocked() error {
return saveConfigData(c.Path, c.File)
}
// UpdateTokens applies fn to both the on-disk and session copies of the config
// under the config lock, then persists the result. Provider token refresh must
// go through this rather than mutating c.File/c.Session directly, because it
// runs concurrently from download workers.
func (c *Config) UpdateTokens(fn func(file, session *ConfigData)) error {
if fn == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
fn(&c.File, &c.Session)
// An empty Path means there is no config file backing this Config (tests,
// and callers that loaded defaults only); the in-memory update still has to
// happen, but there is nothing to write.
if strings.TrimSpace(c.Path) == "" {
return nil
}
return c.saveFileLocked()
}
func DefaultConfigData() ConfigData {
home, _ := os.UserHomeDir()
appDir := defaultAppDir()
@@ -455,8 +246,9 @@ func DefaultConfigData() ConfigData {
VerifySSL: true,
},
Qobuz: QobuzConfig{
Quality: 3,
UseAuthToken: false,
Quality: 3,
DownloadBooklets: true,
UseAuthToken: false,
},
Tidal: TidalConfig{
Quality: 3,
@@ -477,6 +269,11 @@ func DefaultConfigData() ConfigData {
Soundcloud: SoundcloudConfig{
Quality: 0,
},
Youtube: YoutubeConfig{
Quality: 0,
DownloadVideos: false,
VideoDownloadsFolder: filepath.Join(downloadsFolder, "YouTubeVideos"),
},
Database: DatabaseConfig{
DownloadsEnabled: true,
DownloadsPath: filepath.Join(appDir, "downloads.db"),
@@ -520,25 +317,20 @@ func DefaultConfigData() ConfigData {
MaxSearchResults: 100,
},
Misc: MiscConfig{
Version: CurrentConfigVersion,
Version: CurrentConfigVersion,
CheckForUpdates: true,
},
}
applyVendorDefaults(&data)
return data
}
func resolvePath(path string) (string, error) {
// 0o700: this directory holds the credential-bearing config and the
// download databases.
if path != "" {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", err
}
return path, nil
return path, os.MkdirAll(filepath.Dir(path), 0o755)
}
appDir := defaultAppDir()
if err := os.MkdirAll(appDir, 0o700); err != nil {
if err := os.MkdirAll(appDir, 0o755); err != nil {
return "", err
}
return filepath.Join(appDir, "config.toml"), nil
@@ -553,24 +345,17 @@ func defaultAppDir() string {
}
func saveConfigData(path string, data ConfigData) error {
// 0o700/0o600: this file holds plaintext provider passwords, Deezer ARL
// cookies and OAuth access/refresh tokens, so it must not be world-readable.
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := toml.Marshal(data)
if err != nil {
return err
}
if err := os.WriteFile(path, b, 0o600); err != nil {
return err
}
// Tighten an existing file that a previous version created as 0o644.
return os.Chmod(path, 0o600)
return os.WriteFile(path, b, 0o644)
}
func applyRuntimeDefaults(data *ConfigData) {
applyVendorDefaults(data)
home, _ := os.UserHomeDir()
appDir := defaultAppDir()
if data.Downloads.Folder == "" {
@@ -582,13 +367,9 @@ func applyRuntimeDefaults(data *ConfigData) {
if data.Database.FailedDownloadsPath == "" {
data.Database.FailedDownloadsPath = filepath.Join(appDir, "failed_downloads.db")
}
}
// CloneConfigData returns a deep copy of in. The slice fields must not be
// shared between Config.File and Config.Session, or writing one silently
// mutates the other.
func CloneConfigData(in ConfigData) ConfigData {
return cloneConfigData(in)
if data.Youtube.VideoDownloadsFolder == "" {
data.Youtube.VideoDownloadsFolder = filepath.Join(data.Downloads.Folder, "YouTubeVideos")
}
}
func cloneConfigData(in ConfigData) ConfigData {
+1 -330
View File
@@ -1,16 +1,10 @@
package config
import (
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/pelletier/go-toml/v2"
)
func TestDefaultConfigData(t *testing.T) {
@@ -70,7 +64,7 @@ func TestUpgradeOutdatedConfig(t *testing.T) {
t.Fatalf("saveConfigData() error = %v", err)
}
resolved, backup, err := UpgradeOutdated(path)
resolved, err := UpgradeOutdated(path)
if err != nil {
t.Fatalf("UpgradeOutdated() error = %v", err)
}
@@ -78,28 +72,6 @@ func TestUpgradeOutdatedConfig(t *testing.T) {
t.Fatalf("resolved path = %q, want %q", resolved, path)
}
// The upgrade re-serializes the whole file and runs without prompting, so
// it must leave the original behind.
if backup == "" {
t.Fatal("UpgradeOutdated() returned no backup path")
}
backupRaw, err := os.ReadFile(backup)
if err != nil {
t.Fatalf("backup not written: %v", err)
}
var backedUp ConfigData
if err := toml.Unmarshal(backupRaw, &backedUp); err != nil {
t.Fatalf("backup is not valid toml: %v", err)
}
if backedUp.Misc.Version != "1.0.0" {
t.Errorf("backup version = %q, want the pre-upgrade 1.0.0", backedUp.Misc.Version)
}
if info, err := os.Stat(backup); err != nil {
t.Fatal(err)
} else if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("backup permissions = %04o, want 0600 (it holds the same credentials)", perm)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() after upgrade error = %v", err)
@@ -138,304 +110,3 @@ func TestSessionCloneDoesNotAliasSlices(t *testing.T) {
t.Fatalf("file qobuz secrets unexpectedly mutated")
}
}
func TestDefaultConfigPassesValidation(t *testing.T) {
d := DefaultConfigData()
applyRuntimeDefaults(&d)
if err := Validate(&d); err != nil {
t.Fatalf("shipped defaults fail validation: %v", err)
}
}
// TestExampleConfigIsValid guards the file users are told to copy.
func TestExampleConfigIsValid(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "config.toml.example"))
if err != nil {
t.Skipf("example config not found: %v", err)
}
data := DefaultConfigData()
if err := toml.Unmarshal(raw, &data); err != nil {
t.Fatalf("config.toml.example does not parse: %v", err)
}
applyRuntimeDefaults(&data)
if err := Validate(&data); err != nil {
t.Fatalf("config.toml.example fails validation: %v", err)
}
if data.Misc.Version != CurrentConfigVersion {
t.Errorf("config.toml.example version = %q, want %q", data.Misc.Version, CurrentConfigVersion)
}
}
func TestValidateRejectsBadValues(t *testing.T) {
tests := []struct {
name string
mutate func(*ConfigData)
want string
}{
{"qobuz quality 0", func(d *ConfigData) { d.Qobuz.Quality = 0 }, "qobuz.quality"},
{"qobuz quality 5", func(d *ConfigData) { d.Qobuz.Quality = 5 }, "qobuz.quality"},
{"beatport quality 0", func(d *ConfigData) { d.Beatport.Quality = 0 }, "beatport.quality"},
{"tidal quality 5", func(d *ConfigData) { d.Tidal.Quality = 5 }, "tidal.quality"},
{"negative quality", func(d *ConfigData) { d.Tidal.Quality = -1 }, "tidal.quality"},
{"max_connections 0", func(d *ConfigData) { d.Downloads.MaxConnections = 0 }, "max_connections"},
{"max_connections negative", func(d *ConfigData) { d.Downloads.MaxConnections = -5 }, "max_connections"},
{"requests_per_minute 0", func(d *ConfigData) { d.Downloads.RequestsPerMinute = 0 }, "requests_per_minute"},
{"truncate_to 0", func(d *ConfigData) { d.Filepaths.TruncateTo = 0 }, "truncate_to"},
{"bogus codec", func(d *ConfigData) {
d.Conversion.Enabled = true
d.Conversion.Codec = "bogus"
}, "conversion.codec"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
d := DefaultConfigData()
applyRuntimeDefaults(&d)
tc.mutate(&d)
err := Validate(&d)
if err == nil {
t.Fatalf("Validate() = nil, want an error mentioning %q", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Validate() = %q, want it to mention %q", err, tc.want)
}
})
}
}
// TestValidateAcceptsDocumentedQualityValues pins the ranges documented in
// config.toml.example. An earlier draft capped deezer at 2 and soundcloud at 1,
// which would have made a config the example itself documents fail to load.
func TestValidateAcceptsDocumentedQualityValues(t *testing.T) {
for q := 0; q <= 4; q++ {
d := DefaultConfigData()
applyRuntimeDefaults(&d)
d.Deezer.Quality = q
d.Soundcloud.Quality = q
d.Tidal.Quality = q
d.Yandex.Quality = q
if err := Validate(&d); err != nil {
t.Errorf("quality %d rejected: %v", q, err)
}
}
// Qobuz and Beatport have no tier 0.
for q := 1; q <= 4; q++ {
d := DefaultConfigData()
applyRuntimeDefaults(&d)
d.Qobuz.Quality = q
d.Beatport.Quality = q
if err := Validate(&d); err != nil {
t.Errorf("qobuz/beatport quality %d rejected: %v", q, err)
}
}
}
func TestValidateAcceptsValidValues(t *testing.T) {
d := DefaultConfigData()
applyRuntimeDefaults(&d)
// Negative requests_per_minute disables throttling, which is legitimate.
d.Downloads.RequestsPerMinute = -1
d.Soundcloud.Quality = 1
d.Deezer.Quality = 4
d.Qobuz.Quality = 4
if err := Validate(&d); err != nil {
t.Fatalf("Validate() = %v, want nil", err)
}
}
func TestValidateNormalizesCodec(t *testing.T) {
d := DefaultConfigData()
applyRuntimeDefaults(&d)
d.Conversion.Enabled = true
d.Conversion.Codec = " ogg "
if err := Validate(&d); err != nil {
t.Fatalf("Validate() = %v", err)
}
if d.Conversion.Codec != "VORBIS" {
t.Fatalf("codec = %q, want VORBIS", d.Conversion.Codec)
}
}
func TestNormalizeCodec(t *testing.T) {
for in, want := range map[string]string{
"flac": "FLAC", "FLAC": "FLAC", " mp3 ": "MP3",
"ogg": "VORBIS", "vorbis": "VORBIS", "alac": "ALAC", "aac": "AAC",
} {
got, err := NormalizeCodec(in)
if err != nil {
t.Errorf("NormalizeCodec(%q) err = %v", in, err)
continue
}
if got != want {
t.Errorf("NormalizeCodec(%q) = %q, want %q", in, got, want)
}
}
if _, err := NormalizeCodec("wav"); err == nil {
t.Error("NormalizeCodec(wav) should fail")
}
}
func TestCloneConfigDataDoesNotShareSlices(t *testing.T) {
a := DefaultConfigData()
a.Qobuz.Secrets = []string{"s1", "s2"}
a.Metadata.Exclude = []string{"e1"}
b := CloneConfigData(a)
b.Qobuz.Secrets[0] = "mutated"
b.Metadata.Exclude[0] = "mutated"
if a.Qobuz.Secrets[0] != "s1" {
t.Error("Qobuz.Secrets is shared between copies")
}
if a.Metadata.Exclude[0] != "e1" {
t.Error("Metadata.Exclude is shared between copies")
}
}
func TestSaveFileUsesOwnerOnlyPermissions(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
cfg := &Config{Path: path, File: DefaultConfigData()}
if err := cfg.SaveFile(); err != nil {
t.Fatalf("SaveFile() = %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
// The file holds plaintext passwords, ARL cookies and OAuth tokens.
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("config permissions = %04o, want 0600", perm)
}
}
func TestSaveFileTightensExistingWorldReadableFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte("version = \"x\"\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &Config{Path: path, File: DefaultConfigData()}
if err := cfg.SaveFile(); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("permissions = %04o, want 0600 after rewrite", perm)
}
}
func TestUpdateTokensIsSafeWithoutAPath(t *testing.T) {
cfg := &Config{File: DefaultConfigData(), Session: DefaultConfigData()}
err := cfg.UpdateTokens(func(file, session *ConfigData) {
file.Deezer.RefreshToken = "rt"
session.Deezer.RefreshToken = "rt"
})
if err != nil {
t.Fatalf("UpdateTokens() = %v", err)
}
// The in-memory update must still happen even with nothing to write to.
if cfg.File.Deezer.RefreshToken != "rt" || cfg.Session.Deezer.RefreshToken != "rt" {
t.Fatal("UpdateTokens did not apply the mutation")
}
}
func TestUpdateTokensPersistsAndIsConcurrencySafe(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
cfg := &Config{Path: path, File: DefaultConfigData(), Session: DefaultConfigData()}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
token := fmt.Sprintf("token-%d", n)
if err := cfg.UpdateTokens(func(file, session *ConfigData) {
file.Tidal.AccessToken = token
session.Tidal.AccessToken = token
}); err != nil {
t.Errorf("UpdateTokens: %v", err)
}
}(i)
}
wg.Wait()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var round ConfigData
if err := toml.Unmarshal(raw, &round); err != nil {
t.Fatalf("concurrent writes produced an unparseable config: %v", err)
}
if round.Tidal.AccessToken == "" {
t.Error("no token persisted")
}
}
// TestVendorDefaultsAreApplied guards the Phase-3 move of vendor credentials
// into config: an empty value must resolve to the built-in constant, or the
// providers silently authenticate and sign with empty keys.
func TestVendorDefaultsAreApplied(t *testing.T) {
d := DefaultConfigData()
checks := map[string]string{
"qobuz.mobile_app_id": d.Qobuz.MobileAppID,
"qobuz.mobile_app_secret": d.Qobuz.MobileAppSecret,
"tidal.app_client_id": d.Tidal.AppClientID,
"tidal.app_client_secret": d.Tidal.AppClientSecret,
"deezer.api_key": d.Deezer.APIKey,
"deezer.gateway_key": d.Deezer.GatewayKey,
"deezer.blowfish_secret": d.Deezer.BlowfishSecret,
"yandex.sign_key": d.Yandex.SignKey,
"yandex.legacy_sign_salt": d.Yandex.LegacyMP3SignSalt,
"beatport.app_client_id": d.Beatport.AppClientID,
}
for name, got := range checks {
if got == "" {
t.Errorf("%s is empty in DefaultConfigData()", name)
}
}
// An explicitly empty config must be backfilled too, since that is what a
// user config written before these keys existed looks like.
var blank ConfigData
applyVendorDefaults(&blank)
if blank.Qobuz.MobileAppSecret != DefaultQobuzMobileAppSecret {
t.Error("applyVendorDefaults did not backfill qobuz.mobile_app_secret")
}
if blank.Deezer.BlowfishSecret != DefaultDeezerBlowfishSecret {
t.Error("applyVendorDefaults did not backfill deezer.blowfish_secret")
}
}
func TestVendorDefaultsAreNotOverriddenWhenSet(t *testing.T) {
d := ConfigData{}
d.Tidal.AppClientID = "mine"
d.Deezer.BlowfishSecret = "0123456789abcdef"
applyVendorDefaults(&d)
if d.Tidal.AppClientID != "mine" {
t.Errorf("user value overwritten: %q", d.Tidal.AppClientID)
}
if d.Deezer.BlowfishSecret != "0123456789abcdef" {
t.Errorf("user value overwritten: %q", d.Deezer.BlowfishSecret)
}
}
// TestDeezerBlowfishSecretLength pins the length the key derivation depends
// on: it XORs 16 bytes, so a different length would panic or silently derive a
// wrong key.
func TestDeezerBlowfishSecretLength(t *testing.T) {
if got := len(DefaultDeezerBlowfishSecret); got != 16 {
t.Fatalf("DefaultDeezerBlowfishSecret is %d bytes, want 16", got)
}
}
// TestQobuzMobileAppSecretIsHex pins the format the HKDF step requires.
func TestQobuzMobileAppSecretIsHex(t *testing.T) {
if _, err := hex.DecodeString(DefaultQobuzMobileAppSecret); err != nil {
t.Fatalf("DefaultQobuzMobileAppSecret is not hex: %v", err)
}
}
+253 -198
View File
@@ -7,7 +7,6 @@ import (
"crypto/cipher"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
@@ -22,7 +21,6 @@ import (
"github.com/vbauerster/mpb/v8/decor"
"golang.org/x/term"
"streamrip-go/internal/config"
"streamrip-go/internal/netutil"
"streamrip-go/internal/verbose"
@@ -34,10 +32,6 @@ type Downloader struct {
showProgress bool
progress *mpb.Progress
barStarted atomic.Int32
// DeezerBlowfishSecret overrides the built-in Deezer protocol secret.
// Set from config by app.New; empty means use the default.
DeezerBlowfishSecret string
}
// downloadBufferSize sizes HTTP-to-disk copies. 1 MiB cuts read/write syscalls
@@ -49,6 +43,10 @@ func New() *Downloader {
return NewWithOptions(true, true, 0)
}
func NewWithVerifySSL(verifySSL bool) *Downloader {
return NewWithOptions(verifySSL, true, 0)
}
func NewWithOptions(verifySSL bool, showProgress bool, maxConnsPerHost int) *Downloader {
forceProgress := strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "1") || strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "true")
interactive := showProgress && (forceProgress || (term.IsTerminal(int(os.Stderr.Fd())) && strings.ToLower(os.Getenv("TERM")) != "dumb"))
@@ -71,24 +69,11 @@ func (d *Downloader) FileVideo(ctx context.Context, sourceURL, outputPath string
return d.file(ctx, sourceURL, outputPath, true, true)
}
// streamEncrypted performs the shared scaffolding for an encrypted download:
// create the output directory, GET the URL, check the status, create the output
// file, attach a progress bar, clean up a partial file on failure, verify the
// byte count against Content-Length and fsync.
//
// decode does the format-specific work of turning the response body into plain
// audio on dst and returns the number of bytes written. FileDeezerEncrypted and
// FileYandexEncrypted were ~90% identical before this.
func (d *Downloader) streamEncrypted(
ctx context.Context,
sourceURL, outputPath string,
decode func(dst io.Writer, src io.Reader, bar *mpb.Bar) (int64, error),
) error {
func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputPath, trackID string) error {
logDownloadStart(sourceURL, outputPath)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return err
@@ -101,7 +86,6 @@ func (d *Downloader) streamEncrypted(
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
}
out, err := os.Create(outputPath)
if err != nil {
return err
@@ -114,72 +98,97 @@ func (d *Downloader) streamEncrypted(
}
}()
bar, finishBar := d.newDownloadBar(outputPath, resp.ContentLength)
defer func() { finishBar(success) }()
var bar *mpb.Bar
if d.ProgressEnabled() {
d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54)
if resp.ContentLength > 0 {
bar = d.progress.AddBar(
resp.ContentLength,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
} else {
bar = d.progress.AddSpinner(
0,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
),
mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
defer bar.SetTotal(-1, true)
}
defer func() {
if !success && bar != nil {
bar.Abort(true)
}
}()
}
written, err := decode(out, resp.Body, bar)
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
if err != nil {
return err
}
if resp.ContentLength > 0 && written != resp.ContentLength {
buf := make([]byte, deezerBFChunkSize)
dec := make([]byte, deezerBFChunkSize)
chunkIndex := 0
totalRead := int64(0)
for {
n, readErr := io.ReadFull(resp.Body, buf)
if readErr == io.EOF {
break
}
if readErr != nil && readErr != io.ErrUnexpectedEOF {
return readErr
}
chunk := buf[:n]
if chunkIndex%3 == 0 && n == deezerBFChunkSize {
mode := cipher.NewCBCDecrypter(block, deezerBFIV)
mode.CryptBlocks(dec[:n], chunk)
chunk = dec[:n]
}
if _, err = out.Write(chunk); err != nil {
return err
}
totalRead += int64(n)
if bar != nil {
bar.IncrBy(n)
}
chunkIndex++
if readErr == io.ErrUnexpectedEOF {
break
}
}
if resp.ContentLength > 0 && totalRead != resp.ContentLength {
return io.ErrUnexpectedEOF
}
if err = out.Sync(); err != nil {
return err
}
success = true
return nil
}
// FileDeezerEncrypted downloads a Deezer stream and decrypts it. Deezer
// encrypts every third 2048-byte chunk with Blowfish-CBC; a short final chunk
// is left as-is.
func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputPath, trackID string) error {
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(d.blowfishSecret(), trackID))
if err != nil {
func (d *Downloader) FileYandexEncrypted(ctx context.Context, sourceURL, outputPath, key string) error {
logDownloadStart(sourceURL, outputPath)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
return d.streamEncrypted(ctx, sourceURL, outputPath, func(dst io.Writer, src io.Reader, bar *mpb.Bar) (int64, error) {
buf := make([]byte, deezerBFChunkSize)
dec := make([]byte, deezerBFChunkSize)
chunkIndex := 0
total := int64(0)
for {
n, readErr := io.ReadFull(src, buf)
if errors.Is(readErr, io.EOF) {
return total, nil
}
if readErr != nil && !errors.Is(readErr, io.ErrUnexpectedEOF) {
return total, readErr
}
chunk := buf[:n]
if chunkIndex%3 == 0 && n == deezerBFChunkSize {
mode := cipher.NewCBCDecrypter(block, deezerBFIV)
mode.CryptBlocks(dec[:n], chunk)
chunk = dec[:n]
}
if _, writeErr := dst.Write(chunk); writeErr != nil {
return total, writeErr
}
total += int64(n)
if bar != nil {
bar.IncrBy(n)
}
chunkIndex++
if errors.Is(readErr, io.ErrUnexpectedEOF) {
return total, nil
}
}
})
}
// FileYandexEncrypted downloads a Yandex stream and decrypts it. Yandex uses
// AES-CTR over the whole body with a zero IV.
func (d *Downloader) FileYandexEncrypted(ctx context.Context, sourceURL, outputPath, key string) error {
keyBytes, err := hex.DecodeString(strings.TrimSpace(key))
if err != nil {
return fmt.Errorf("invalid yandex key: %w", err)
@@ -191,11 +200,102 @@ func (d *Downloader) FileYandexEncrypted(ctx context.Context, sourceURL, outputP
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return err
}
resp, err := d.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
success := false
defer func() {
_ = out.Close()
if !success {
_ = os.Remove(outputPath)
}
}()
return d.streamEncrypted(ctx, sourceURL, outputPath, func(dst io.Writer, src io.Reader, bar *mpb.Bar) (int64, error) {
stream := cipher.NewCTR(block, make([]byte, aes.BlockSize))
return copyWithProgress(dst, &cipher.StreamReader{S: stream, R: src}, bar)
})
var bar *mpb.Bar
if d.ProgressEnabled() {
d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54)
if resp.ContentLength > 0 {
bar = d.progress.AddBar(
resp.ContentLength,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
} else {
bar = d.progress.AddSpinner(
0,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
),
mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
defer bar.SetTotal(-1, true)
}
defer func() {
if !success && bar != nil {
bar.Abort(true)
}
}()
}
stream := cipher.NewCTR(block, make([]byte, aes.BlockSize))
reader := &cipher.StreamReader{S: stream, R: resp.Body}
buf := make([]byte, downloadBufferSize)
totalWritten := int64(0)
for {
n, readErr := reader.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
totalWritten += int64(n)
if bar != nil {
bar.IncrBy(n)
}
}
if readErr != nil {
if readErr == io.EOF {
break
}
return readErr
}
}
if resp.ContentLength > 0 && totalWritten != resp.ContentLength {
return io.ErrUnexpectedEOF
}
if err = out.Sync(); err != nil {
return err
}
success = true
return nil
}
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error {
@@ -238,22 +338,81 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all
}
}()
var bar *mpb.Bar
finishBar := func(bool) {}
if allowProgress {
bar, finishBar = d.newDownloadBar(outputPath, resp.ContentLength)
}
defer func() { finishBar(success) }()
written, err := copyWithProgress(out, reader, bar)
if err != nil {
return err
}
if resp.ContentLength > 0 && written != resp.ContentLength {
return io.ErrUnexpectedEOF
}
if err = out.Sync(); err != nil {
return err
if d.ProgressEnabled() && allowProgress {
d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54)
var bar *mpb.Bar
if resp.ContentLength > 0 {
bar = d.progress.AddBar(
resp.ContentLength,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
} else {
bar = d.progress.AddSpinner(
0,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
),
mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
defer bar.SetTotal(-1, true)
}
defer func() {
if !success && bar != nil {
bar.Abort(true)
}
}()
buf := make([]byte, downloadBufferSize)
totalWritten := int64(0)
for {
n, readErr := reader.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
totalWritten += int64(n)
bar.IncrBy(n)
}
if readErr != nil {
if readErr == io.EOF {
break
}
return readErr
}
}
if resp.ContentLength > 0 && totalWritten != resp.ContentLength {
return io.ErrUnexpectedEOF
}
if err = out.Sync(); err != nil {
return err
}
} else {
written, copyErr := io.CopyBuffer(out, reader, make([]byte, downloadBufferSize))
if copyErr != nil {
return copyErr
}
if resp.ContentLength > 0 && written != resp.ContentLength {
return io.ErrUnexpectedEOF
}
if err = out.Sync(); err != nil {
return err
}
}
success = true
@@ -279,92 +438,6 @@ func (d *Downloader) Logf(format string, args ...any) {
fmt.Print(msg)
}
// copyWithProgress copies src to dst, reporting progress to bar when it is
// non-nil. It replaces three hand-rolled read/write loops that differed only
// in whether they touched a progress bar.
func copyWithProgress(dst io.Writer, src io.Reader, bar *mpb.Bar) (int64, error) {
if bar == nil {
return io.CopyBuffer(dst, src, make([]byte, downloadBufferSize))
}
buf := make([]byte, downloadBufferSize)
total := int64(0)
for {
n, readErr := src.Read(buf)
if n > 0 {
if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
return total, writeErr
}
total += int64(n)
bar.IncrBy(n)
}
if readErr != nil {
if errors.Is(readErr, io.EOF) {
return total, nil
}
return total, readErr
}
}
}
// newDownloadBar creates the progress bar for a download: a determinate bar
// when the content length is known, a spinner when it is not. It returns a nil
// bar when progress display is off, so callers can use the result
// unconditionally, plus a finish func to defer.
//
// This block was copy-pasted into all three download paths; the decorator
// stacks had already started to drift apart.
func (d *Downloader) newDownloadBar(outputPath string, contentLength int64) (*mpb.Bar, func(success bool)) {
if !d.ProgressEnabled() {
return nil, func(bool) {}
}
d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54)
if contentLength > 0 {
bar := d.progress.AddBar(
contentLength,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
return bar, func(success bool) {
if !success {
bar.Abort(true)
}
}
}
bar := d.progress.AddSpinner(
0,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
),
mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
// Order matches the original defers, which ran LIFO: abort first, then
// finalise the spinner total.
return bar, func(success bool) {
if !success {
bar.Abort(true)
}
bar.SetTotal(-1, true)
}
}
// logDownloadStart emits the source URL and destination filename when the
// user passed -v or higher. The transport-level logger covers the same
// requests at -vv, but this line gives a friendlier per-track summary.
@@ -762,28 +835,10 @@ const deezerBFChunkSize = 2048
var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7}
// defaultDeezerBlowfishSecret aliases the single definition in internal/config
// so the literal is not duplicated here.
const defaultDeezerBlowfishSecret = config.DefaultDeezerBlowfishSecret
// blowfishSecret returns the configured Deezer blowfish secret.
//
// The key derivation XORs 16 bytes of it, so a secret of any other length
// would panic; fall back to the built-in value rather than crash on a
// mistyped config entry.
func (d *Downloader) blowfishSecret() string {
if len(d.DeezerBlowfishSecret) == 16 {
return d.DeezerBlowfishSecret
}
if d.DeezerBlowfishSecret != "" {
verbose.Printf(verbose.V, "ignoring deezer.blowfish_secret: got %d bytes, want 16\n", len(d.DeezerBlowfishSecret))
}
return defaultDeezerBlowfishSecret
}
func deriveDeezerBlowfishKey(secret, trackID string) []byte {
func deriveDeezerBlowfishKey(trackID string) []byte {
sum := md5.Sum([]byte(trackID))
md5Hex := fmt.Sprintf("%x", sum)
secret := "g4el58wc0zvf9na1"
key := make([]byte, 16)
for i := 0; i < 16; i++ {
key[i] = md5Hex[i] ^ md5Hex[i+16] ^ secret[i]
+3 -146
View File
@@ -1,19 +1,17 @@
package download
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"encoding/hex"
"errors"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -72,7 +70,7 @@ func TestManifestDetection(t *testing.T) {
func TestDeezerBlowfishKeyDerivation(t *testing.T) {
trackID := "3135556"
key := deriveDeezerBlowfishKey(defaultDeezerBlowfishSecret, trackID)
key := deriveDeezerBlowfishKey(trackID)
if len(key) != 16 {
t.Fatalf("blowfish key len = %d, want 16", len(key))
}
@@ -87,7 +85,7 @@ func TestFileDeezerEncrypted(t *testing.T) {
enc := make([]byte, len(plain))
copy(enc, plain)
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(defaultDeezerBlowfishSecret, trackID))
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
if err != nil {
t.Fatalf("cipher error: %v", err)
}
@@ -369,144 +367,3 @@ func containsArgPair(args []string, key, value string) bool {
}
return false
}
// newProgressDownloader builds a Downloader with the progress bars actually
// enabled. The bar code paths are otherwise never exercised, because tests do
// not run on a terminal; STREAMRIP_GO_FORCE_PROGRESS is the documented escape
// hatch. Output goes to stderr, which `go test` captures.
func newProgressDownloader(t *testing.T) *Downloader {
t.Helper()
t.Setenv("STREAMRIP_GO_FORCE_PROGRESS", "1")
d := NewWithOptions(true, true, 0)
if !d.ProgressEnabled() {
t.Fatal("expected progress to be enabled")
}
t.Cleanup(d.Close)
return d
}
// TestFileWithProgressBar covers the determinate-bar path (Content-Length
// known) through the shared newDownloadBar/copyWithProgress helpers.
func TestFileWithProgressBar(t *testing.T) {
payload := bytes.Repeat([]byte("abcd"), 4096)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
_, _ = w.Write(payload)
}))
defer ts.Close()
d := newProgressDownloader(t)
out := filepath.Join(t.TempDir(), "sub", "a.flac")
if err := d.File(context.Background(), ts.URL, out); err != nil {
t.Fatalf("File() error = %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(payload))
}
}
// TestFileWithSpinnerUnknownLength covers the spinner path, taken when the
// server does not send a Content-Length.
func TestFileWithSpinnerUnknownLength(t *testing.T) {
payload := bytes.Repeat([]byte("xyz"), 3000)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Transfer-Encoding", "chunked")
_, _ = w.Write(payload)
}))
defer ts.Close()
d := newProgressDownloader(t)
out := filepath.Join(t.TempDir(), "a.flac")
if err := d.File(context.Background(), ts.URL, out); err != nil {
t.Fatalf("File() error = %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(payload))
}
}
// TestFileDeezerEncryptedWithProgressBar runs the Deezer decryption through
// the progress-enabled path, so the shared streamEncrypted scaffolding is
// covered with a bar attached as well as without.
func TestFileDeezerEncryptedWithProgressBar(t *testing.T) {
plain := make([]byte, deezerBFChunkSize*4+100)
for i := range plain {
plain[i] = byte((i * 7) % 253)
}
trackID := "123456"
enc := encryptDeezerPayload(t, plain, trackID)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", strconv.Itoa(len(enc)))
_, _ = w.Write(enc)
}))
defer ts.Close()
d := newProgressDownloader(t)
out := filepath.Join(t.TempDir(), "a.mp3")
if err := d.FileDeezerEncrypted(context.Background(), ts.URL, out, trackID); err != nil {
t.Fatalf("FileDeezerEncrypted() error = %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, plain) {
t.Fatal("decrypted output does not match the original plaintext")
}
}
// encryptDeezerPayload applies Deezer's scheme: Blowfish-CBC on every third
// full 2048-byte chunk, everything else left in the clear.
func encryptDeezerPayload(t *testing.T, plain []byte, trackID string) []byte {
t.Helper()
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(defaultDeezerBlowfishSecret, trackID))
if err != nil {
t.Fatal(err)
}
enc := make([]byte, len(plain))
copy(enc, plain)
for i, off := 0, 0; off < len(plain); i, off = i+1, off+deezerBFChunkSize {
end := off + deezerBFChunkSize
if end > len(plain) {
break
}
if i%3 != 0 {
continue
}
mode := cipher.NewCBCEncrypter(block, deezerBFIV)
mode.CryptBlocks(enc[off:end], plain[off:end])
}
return enc
}
// TestProgressBarFailurePathRemovesPartialFile checks that a mid-download
// failure still runs the bar cleanup and removes the partial output, which is
// the behaviour the finishBar(success) defer replaced.
func TestProgressBarFailurePathRemovesPartialFile(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100000")
_, _ = w.Write(bytes.Repeat([]byte("a"), 10))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}))
defer ts.Close()
d := newProgressDownloader(t)
out := filepath.Join(t.TempDir(), "a.flac")
if err := d.File(context.Background(), ts.URL, out); err == nil {
t.Fatal("expected a truncated-response error")
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatalf("partial file not removed, stat err = %v", err)
}
}
-29
View File
@@ -129,32 +129,3 @@ func TitleCase(s string) string {
}
return string(r)
}
// DisplayArtistNames joins artist names the way the providers present them:
// "A", "A & B", or "A, B & C". Previously duplicated byte-for-byte in
// internal/app, provider/yandex and provider/beatport.
func DisplayArtistNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " & " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1]
}
}
// FirstPositiveInt returns the first value greater than zero, or 0 if there is
// none. Useful for picking among optional metadata fields where 0 means
// "absent". Previously duplicated in provider/yandex, provider/beatport and
// cmd/rip.
func FirstPositiveInt(values ...int) int {
for _, v := range values {
if v > 0 {
return v
}
}
return 0
}
-203
View File
@@ -1,203 +0,0 @@
package jsonutil
import "testing"
func TestStringFromAny(t *testing.T) {
tests := []struct {
name string
in any
want string
}{
{"string", "hi", "hi"},
{"int", 7, "7"},
{"int64", int64(8), "8"},
{"float whole", float64(9), "9"},
{"float fractional", 44.1, "44.1"},
{"float no trailing zeroes", float64(100), "100"},
{"nil", nil, ""},
{"bool unsupported", true, ""},
{"map unsupported", map[string]any{}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := StringFromAny(tc.in); got != tc.want {
t.Fatalf("StringFromAny(%#v) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestIntFromAny(t *testing.T) {
tests := []struct {
in any
want int
}{
{5, 5},
{int32(6), 6},
{int64(7), 7},
{8.9, 8},
{"10", 10},
{" 11 ", 11},
{"not a number", 0},
{nil, 0},
{true, 0},
}
for _, tc := range tests {
if got := IntFromAny(tc.in); got != tc.want {
t.Errorf("IntFromAny(%#v) = %d, want %d", tc.in, got, tc.want)
}
}
}
func TestFloatFromAny(t *testing.T) {
tests := []struct {
in any
want float64
}{
{1.5, 1.5},
{2, 2},
{int64(3), 3},
{"4.5", 0}, // strings are deliberately not parsed here
{nil, 0},
}
for _, tc := range tests {
if got := FloatFromAny(tc.in); got != tc.want {
t.Errorf("FloatFromAny(%#v) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestBoolFromAny(t *testing.T) {
tests := []struct {
in any
want bool
}{
{true, true},
{false, false},
{"true", true},
{"TRUE", true},
{" yes ", true},
{"1", true},
{"0", false},
{"no", false},
{1, true},
{0, false},
{int64(2), true},
{0.0, false},
{1.5, true},
{nil, false},
}
for _, tc := range tests {
if got := BoolFromAny(tc.in); got != tc.want {
t.Errorf("BoolFromAny(%#v) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestFirstNonEmpty(t *testing.T) {
if got := FirstNonEmpty("", " ", "found", "later"); got != "found" {
t.Errorf("got %q", got)
}
if got := FirstNonEmpty(" trimmed "); got != "trimmed" {
t.Errorf("got %q, want trimmed", got)
}
if got := FirstNonEmpty("", " "); got != "" {
t.Errorf("got %q, want empty", got)
}
if got := FirstNonEmpty(); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestNestedMap(t *testing.T) {
m := map[string]any{"album": map[string]any{"title": "X"}, "n": 1}
if got := NestedMap(m, "album")["title"]; got != "X" {
t.Errorf("got %v", got)
}
if got := NestedMap(m, "missing"); len(got) != 0 {
t.Errorf("missing key should give empty map, got %v", got)
}
if got := NestedMap(m, "n"); len(got) != 0 {
t.Errorf("non-map value should give empty map, got %v", got)
}
}
func TestNestedAnyAndString(t *testing.T) {
m := map[string]any{
"album": map[string]any{
"artist": map[string]any{"name": "Nina Simone", "id": float64(42)},
},
}
if got := NestedString(m, "album", "artist", "name"); got != "Nina Simone" {
t.Errorf("got %q", got)
}
if got := NestedString(m, "album", "artist", "id"); got != "42" {
t.Errorf("numeric id = %q, want 42", got)
}
if got := NestedString(m, "album", "nope", "name"); got != "" {
t.Errorf("missing path = %q, want empty", got)
}
if got := NestedAny(m, "album", "artist", "name"); got != "Nina Simone" {
t.Errorf("got %v", got)
}
if got := NestedAny(m); got == nil {
t.Error("no keys should return the map itself")
}
// Traversing through a non-map must not panic.
if got := NestedAny(m, "album", "artist", "name", "deeper"); got != nil {
t.Errorf("over-deep path = %v, want nil", got)
}
}
func TestTitleCase(t *testing.T) {
tests := []struct{ in, want string }{
{"qobuz", "Qobuz"},
{"Tidal", "Tidal"},
{"", ""},
{"1st", "1st"},
{"ä", "ä"}, // only ASCII is uppercased
}
for _, tc := range tests {
if got := TitleCase(tc.in); got != tc.want {
t.Errorf("TitleCase(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestDisplayArtistNames(t *testing.T) {
tests := []struct {
in []string
want string
}{
{nil, ""},
{[]string{}, ""},
{[]string{"A"}, "A"},
{[]string{"A", "B"}, "A & B"},
{[]string{"A", "B", "C"}, "A, B & C"},
{[]string{"A", "B", "C", "D"}, "A, B, C & D"},
}
for _, tc := range tests {
if got := DisplayArtistNames(tc.in); got != tc.want {
t.Errorf("DisplayArtistNames(%v) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestFirstPositiveInt(t *testing.T) {
tests := []struct {
in []int
want int
}{
{nil, 0},
{[]int{0, 0}, 0},
{[]int{0, 5, 7}, 5},
{[]int{-3, 0, 2}, 2},
{[]int{9}, 9},
{[]int{-1}, 0},
}
for _, tc := range tests {
if got := FirstPositiveInt(tc.in...); got != tc.want {
t.Errorf("FirstPositiveInt(%v) = %d, want %d", tc.in, got, tc.want)
}
}
}
+2 -57
View File
@@ -6,7 +6,6 @@ import (
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
type Config struct {
@@ -15,7 +14,7 @@ type Config struct {
}
var tokenRe = regexp.MustCompile(`\{([a-z_]+)(?::0?(\d+))?\}`)
var invalidPathRe = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F\x7F]`)
var invalidPathRe = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`)
func FormatTemplate(template string, values map[string]string) string {
return tokenRe.ReplaceAllStringFunc(template, func(m string) string {
@@ -36,28 +35,6 @@ func FormatTemplate(template string, values map[string]string) string {
})
}
// maxNameBytes is the per-component limit on ext4, APFS and most other modern
// filesystems. TruncateTo counts runes, so a 120-rune CJK title is ~360 bytes
// and would fail with ENAMETOOLONG without this second, byte-based cap.
const maxNameBytes = 255
// windowsReserved are device names that cannot be used as a path component on
// Windows, with or without an extension.
var windowsReserved = map[string]struct{}{
"CON": {}, "PRN": {}, "AUX": {}, "NUL": {},
"COM1": {}, "COM2": {}, "COM3": {}, "COM4": {}, "COM5": {},
"COM6": {}, "COM7": {}, "COM8": {}, "COM9": {},
"LPT1": {}, "LPT2": {}, "LPT3": {}, "LPT4": {}, "LPT5": {},
"LPT6": {}, "LPT7": {}, "LPT8": {}, "LPT9": {},
}
// CleanName turns a remote title into a single safe path component.
//
// Everything it receives is attacker-controlled: album, playlist and track
// titles come straight from provider APIs, and on SoundCloud and Tidal they are
// user-generated. The result is joined onto the download root by the caller, so
// it must never be "." or ".." (which would escape or collapse into the parent
// directory) and must never contain a separator.
func CleanName(in string, cfg Config) string {
s := strings.TrimSpace(in)
s = invalidPathRe.ReplaceAllString(s, "_")
@@ -76,45 +53,13 @@ func CleanName(in string, cfg Config) string {
s = string(runes[:cfg.TruncateTo])
}
}
s = truncateBytes(s, maxNameBytes)
// Trailing dots and spaces are stripped by Windows, which turns "foo." into
// "foo" and makes ".." survive as a traversal component.
s = strings.TrimRight(s, ". ")
s = strings.TrimSpace(s)
if s == "" || isAllDots(s) {
if s == "" {
return "Unknown"
}
if _, bad := windowsReserved[strings.ToUpper(s)]; bad {
return s + "_"
}
return s
}
// truncateBytes shortens s to at most max bytes without splitting a rune.
func truncateBytes(s string, max int) string {
if len(s) <= max {
return s
}
cut := s[:max]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return cut
}
// isAllDots reports whether s consists only of "." characters, i.e. "." or
// ".." or any longer run that some filesystems also treat as relative.
func isAllDots(s string) bool {
for _, ch := range s {
if ch != '.' {
return false
}
}
return len(s) > 0
}
func YearFromDate(date string) string {
if len(date) >= 4 {
prefix := date[:4]
+4 -127
View File
@@ -1,10 +1,6 @@
package naming
import (
"path/filepath"
"strings"
"testing"
)
import "testing"
func TestFormatTemplate(t *testing.T) {
got := FormatTemplate("{tracknumber:02}. {artist} - {title}{explicit}", map[string]string{
@@ -19,127 +15,8 @@ func TestFormatTemplate(t *testing.T) {
}
func TestCleanName(t *testing.T) {
cfg := Config{RestrictCharacters: false, TruncateTo: 120}
tests := []struct {
name string
in string
want string
}{
{"trims and replaces separator", " Dreams/Live ", "Dreams_Live"},
{"backslash", `AC\DC`, "AC_DC"},
{"colon", "Album: Deluxe", "Album_ Deluxe"},
{"control chars", "Song\x01\x02", "Song__"},
{"del char", "Song\x7f", "Song_"},
{"empty", "", "Unknown"},
{"whitespace only", " ", "Unknown"},
// Path traversal: these must never be returned verbatim, or the
// caller's filepath.Join escapes the download root.
{"dotdot", "..", "Unknown"},
{"single dot", ".", "Unknown"},
{"many dots", "....", "Unknown"},
{"dotdot padded", " .. ", "Unknown"},
{"trailing dot", "Album.", "Album"},
{"trailing dots", "Album...", "Album"},
{"leading dot kept", ".hidden", ".hidden"},
// Windows device names must not be used as a bare component.
{"reserved CON", "CON", "CON_"},
{"reserved nul lowercase", "nul", "nul_"},
{"reserved COM1", "COM1", "COM1_"},
{"not reserved", "CONCERT", "CONCERT"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := CleanName(tc.in, cfg)
if got != tc.want {
t.Fatalf("CleanName(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestCleanNameNeverEscapesRoot is the property that actually matters: whatever
// a provider returns, joining the result onto a root must stay under that root.
func TestCleanNameNeverEscapesRoot(t *testing.T) {
root := "/music"
hostile := []string{
"..", ".", "../..", "../../etc", "..\\..", "/etc/passwd",
"....//....", " .. ", "", "...", "C:\\Windows",
"\x00evil", "a/../../b",
}
for _, in := range hostile {
for _, restrict := range []bool{false, true} {
cfg := Config{RestrictCharacters: restrict, TruncateTo: 120}
name := CleanName(in, cfg)
if strings.ContainsAny(name, `/\`) {
t.Errorf("CleanName(%q) = %q contains a path separator", in, name)
}
if name == "." || name == ".." {
t.Errorf("CleanName(%q) = %q is a relative component", in, name)
}
joined := filepath.Join(root, name)
rel, err := filepath.Rel(root, joined)
if err != nil {
t.Errorf("CleanName(%q) = %q: Rel failed: %v", in, name, err)
continue
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
t.Errorf("CleanName(%q) = %q escapes root: joined=%q rel=%q", in, name, joined, rel)
}
}
}
}
func TestCleanNameTruncation(t *testing.T) {
// TruncateTo counts runes.
got := CleanName(strings.Repeat("a", 200), Config{TruncateTo: 10})
if len(got) != 10 {
t.Fatalf("rune truncation: got %d chars (%q)", len(got), got)
}
// A long multi-byte title must still fit the 255-byte component limit,
// and must remain valid UTF-8.
long := strings.Repeat("日", 200) // 3 bytes each = 600 bytes
got = CleanName(long, Config{TruncateTo: 200})
if len(got) > maxNameBytes {
t.Fatalf("byte truncation: got %d bytes, want <= %d", len(got), maxNameBytes)
}
if !utf8Valid(got) {
t.Fatalf("truncation split a rune: %q", got)
}
}
func utf8Valid(s string) bool {
for _, r := range s {
if r == '\uFFFD' {
return false
}
}
return true
}
func TestCleanNameRestrictCharacters(t *testing.T) {
got := CleanName("Björk – Jóga", Config{RestrictCharacters: true, TruncateTo: 120})
if strings.ContainsAny(got, "ö–ó") {
t.Fatalf("non-ascii survived restrict_characters: %q", got)
}
}
func TestYearFromDate(t *testing.T) {
tests := []struct{ in, want string }{
{"1977-02-04", "1977"},
{"1977", "1977"},
{"", "Unknown"},
{"77", "Unknown"},
{"abcd-01-01", "Unknown"},
{"19x7-01-01", "Unknown"},
}
for _, tc := range tests {
if got := YearFromDate(tc.in); got != tc.want {
t.Errorf("YearFromDate(%q) = %q, want %q", tc.in, got, tc.want)
}
got := CleanName(" Dreams/Live ", Config{RestrictCharacters: false, TruncateTo: 120})
if got != "Dreams_Live" {
t.Fatalf("got %q", got)
}
}
-148
View File
@@ -1,148 +0,0 @@
package netutil
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestRedactURLHidesCredentials(t *testing.T) {
tests := []struct {
name string
raw string
contains []string
absent []string
}{
{
name: "user auth token",
raw: "https://api.example.com/track?user_auth_token=sekrit&track_id=42",
contains: []string{"user_auth_token=REDACTED", "track_id=42"},
absent: []string{"sekrit"},
},
{
name: "request signature",
raw: "https://api.example.com/x?request_sig=abc123&app_id=9",
contains: []string{"request_sig=REDACTED", "app_id=9"},
absent: []string{"abc123"},
},
{
name: "several at once",
raw: "https://api.example.com/x?password=p&access_token=a&refresh_token=r&id=1",
contains: []string{"password=REDACTED", "access_token=REDACTED", "refresh_token=REDACTED", "id=1"},
absent: []string{"password=p", "access_token=a", "refresh_token=r"},
},
{
name: "nothing sensitive is left untouched",
raw: "https://api.example.com/track/42?limit=10",
contains: []string{"limit=10", "/track/42"},
},
{
name: "no query string",
raw: "https://api.example.com/track/42",
contains: []string{"https://api.example.com/track/42"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
u, err := url.Parse(tc.raw)
if err != nil {
t.Fatal(err)
}
got := redactURL(u)
for _, want := range tc.contains {
if !strings.Contains(got, want) {
t.Errorf("redactURL(%q) = %q, missing %q", tc.raw, got, want)
}
}
for _, bad := range tc.absent {
if strings.Contains(got, bad) {
t.Errorf("redactURL(%q) = %q, leaked %q", tc.raw, got, bad)
}
}
})
}
}
func TestRedactURLNil(t *testing.T) {
if got := redactURL(nil); got != "" {
t.Errorf("redactURL(nil) = %q, want empty", got)
}
}
func TestIsSensitiveParam(t *testing.T) {
sensitive := []string{
"user_auth_token", "api_token", "access_token", "refresh_token",
"request_sig", "signature", "password", "secret", "token", "code", "auth", "key",
}
for _, name := range sensitive {
if !isSensitiveParam(name) {
t.Errorf("isSensitiveParam(%q) = false, want true", name)
}
}
for _, name := range []string{"track_id", "limit", "offset", "app_id", "quality"} {
if isSensitiveParam(name) {
t.Errorf("isSensitiveParam(%q) = true, want false", name)
}
}
}
func TestNewHTTPClientHonoursVerifySSL(t *testing.T) {
insecure := NewHTTPClient(5*time.Second, false, 4)
tr, ok := insecure.Transport.(*loggingTransport)
if !ok {
t.Fatalf("transport is %T, want *loggingTransport", insecure.Transport)
}
base, ok := tr.base.(*http.Transport)
if !ok {
t.Fatalf("base transport is %T", tr.base)
}
if !base.TLSClientConfig.InsecureSkipVerify {
t.Error("verifySSL=false should set InsecureSkipVerify")
}
secure := NewHTTPClient(5*time.Second, true, 4)
base = secure.Transport.(*loggingTransport).base.(*http.Transport)
if base.TLSClientConfig.InsecureSkipVerify {
t.Error("verifySSL=true must not skip verification")
}
if secure.Timeout != 5*time.Second {
t.Errorf("Timeout = %v, want 5s", secure.Timeout)
}
}
func TestNewHTTPClientZeroTimeoutMeansNoTimeout(t *testing.T) {
// internal/download and the qobuz body copy rely on this: a zero timeout
// must not be replaced with a default, or long track downloads abort.
c := NewHTTPClient(0, true, 0)
if c.Timeout != 0 {
t.Errorf("Timeout = %v, want 0", c.Timeout)
}
}
func TestNewHTTPClientDefaultsMaxConns(t *testing.T) {
c := NewHTTPClient(time.Second, true, 0)
base := c.Transport.(*loggingTransport).base.(*http.Transport)
if base.MaxIdleConnsPerHost != defaultMaxConnsPerHost {
t.Errorf("MaxIdleConnsPerHost = %d, want %d", base.MaxIdleConnsPerHost, defaultMaxConnsPerHost)
}
}
func TestClientPerformsRequest(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
defer ts.Close()
resp, err := NewHTTPClient(5*time.Second, true, 2).Get(ts.URL + "/x?token=sekrit")
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusTeapot {
t.Errorf("status = %d", resp.StatusCode)
}
}
+59 -78
View File
@@ -11,7 +11,6 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"streamrip-go/internal/config"
@@ -19,11 +18,11 @@ import (
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
"streamrip-go/internal/verbose"
)
const (
baseURL = "https://api.beatport.com/v4"
clientID = "ryZ8LuyQVPqbK2mBX2Hwt4qSMtnWuTYSqBPO92yQ"
tokenEndpoint = "/auth/o/token/"
authEndpoint = "/auth/o/authorize/"
loginEndpoint = "/auth/login/"
@@ -39,13 +38,8 @@ type Client struct {
http *http.Client
limiter *ratelimit.Limiter
baseURL string
loggedIn bool
sessionID string
// authMu serializes the token refresh / re-login path and guards loggedIn.
// ensureToken is reached from apiRequest, which concurrent download workers
// call, so without this several workers would refresh at once.
authMu sync.Mutex
loggedIn bool
}
func New(cfg *config.Config) *Client {
@@ -54,22 +48,16 @@ func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
http: h,
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL,
}
}
func (c *Client) Source() string { return "beatport" }
func (c *Client) LoggedIn() bool {
c.authMu.Lock()
defer c.authMu.Unlock()
return c.loggedIn
}
func (c *Client) LoggedIn() bool { return c.loggedIn }
func (c *Client) Login(ctx context.Context) error {
c.authMu.Lock()
defer c.authMu.Unlock()
b := &c.cfg.Session.Beatport
b.Username = strings.TrimSpace(b.Username)
b.Password = strings.TrimSpace(b.Password)
@@ -94,7 +82,7 @@ func (c *Client) Login(ctx context.Context) error {
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errNotLoggedIn
}
switch mediaType {
@@ -141,7 +129,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errNotLoggedIn
}
if limit <= 0 {
@@ -174,7 +162,7 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int)
}
func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errNotLoggedIn
}
q := beatportQuality(quality)
@@ -308,8 +296,11 @@ func (c *Client) apiRequest(ctx context.Context, method, endpoint string, params
return nil, err
}
if status == http.StatusUnauthorized {
if err = c.reauthorize(ctx); err != nil {
return nil, err
c.cfg.Session.Beatport.TokenExpiry = 0
if err = c.refresh(ctx); err != nil {
if loginErr := c.loginPasswordFlow(ctx); loginErr != nil {
return nil, err
}
}
resp, status, err = c.rawRequest(ctx, method, endpoint, params, payload, contentType, true)
if err != nil {
@@ -323,13 +314,6 @@ func (c *Client) apiRequest(ctx context.Context, method, endpoint string, params
}
func (c *Client) ensureToken(ctx context.Context) error {
c.authMu.Lock()
defer c.authMu.Unlock()
return c.ensureTokenLocked(ctx)
}
// ensureTokenLocked requires c.authMu to be held.
func (c *Client) ensureTokenLocked(ctx context.Context) error {
if strings.TrimSpace(c.cfg.Session.Beatport.AccessToken) != "" && time.Now().Unix()+300 < c.cfg.Session.Beatport.TokenExpiry {
return nil
}
@@ -341,21 +325,6 @@ func (c *Client) ensureTokenLocked(ctx context.Context) error {
return c.loginPasswordFlow(ctx)
}
// reauthorize forces a token refresh after a 401. Holding authMu means that
// when several workers get a 401 at once only the first re-authenticates and
// the rest reuse the resulting token.
func (c *Client) reauthorize(ctx context.Context) error {
c.authMu.Lock()
defer c.authMu.Unlock()
c.cfg.Session.Beatport.TokenExpiry = 0
if err := c.refresh(ctx); err != nil {
if loginErr := c.loginPasswordFlow(ctx); loginErr != nil {
return err
}
}
return nil
}
func (c *Client) loginPasswordFlow(ctx context.Context) error {
b := &c.cfg.Session.Beatport
if strings.TrimSpace(b.Username) == "" || strings.TrimSpace(b.Password) == "" {
@@ -375,11 +344,11 @@ func (c *Client) loginPasswordFlow(ctx context.Context) error {
if err != nil {
return err
}
return c.issueToken(ctx, map[string]string{"client_id": c.appClientID(), "grant_type": "authorization_code", "code": code})
return c.issueToken(ctx, map[string]string{"client_id": clientID, "grant_type": "authorization_code", "code": code})
}
func (c *Client) authorize(ctx context.Context) (string, error) {
params := url.Values{"client_id": []string{c.appClientID()}, "response_type": []string{"code"}}
params := url.Values{"client_id": []string{clientID}, "response_type": []string{"code"}}
resp, status, err := c.rawRequest(ctx, http.MethodGet, authEndpoint, params, nil, "", false)
if err != nil {
return "", err
@@ -396,7 +365,7 @@ func (c *Client) authorize(ctx context.Context) (string, error) {
func (c *Client) refresh(ctx context.Context) error {
b := &c.cfg.Session.Beatport
return c.issueToken(ctx, map[string]string{"client_id": c.appClientID(), "grant_type": "refresh_token", "refresh_token": b.RefreshToken})
return c.issueToken(ctx, map[string]string{"client_id": clientID, "grant_type": "refresh_token", "refresh_token": b.RefreshToken})
}
func (c *Client) issueToken(ctx context.Context, payload map[string]string) error {
@@ -416,19 +385,15 @@ func (c *Client) issueToken(ctx context.Context, payload map[string]string) erro
if expiresIn <= 0 {
expiresIn = 3600
}
expiry := time.Now().Unix() + expiresIn
if err := c.cfg.UpdateTokens(func(file, session *config.ConfigData) {
session.Beatport.AccessToken = access
if refresh != "" {
session.Beatport.RefreshToken = refresh
}
session.Beatport.TokenExpiry = expiry
file.Beatport.AccessToken = session.Beatport.AccessToken
file.Beatport.RefreshToken = session.Beatport.RefreshToken
file.Beatport.TokenExpiry = session.Beatport.TokenExpiry
}); err != nil {
verbose.Printf(verbose.V, "beatport: could not persist refreshed token: %v\n", err)
c.cfg.Session.Beatport.AccessToken = access
if refresh != "" {
c.cfg.Session.Beatport.RefreshToken = refresh
}
c.cfg.Session.Beatport.TokenExpiry = time.Now().Unix() + expiresIn
c.cfg.File.Beatport.AccessToken = c.cfg.Session.Beatport.AccessToken
c.cfg.File.Beatport.RefreshToken = c.cfg.Session.Beatport.RefreshToken
c.cfg.File.Beatport.TokenExpiry = c.cfg.Session.Beatport.TokenExpiry
_ = c.cfg.SaveFile()
return nil
}
@@ -532,7 +497,7 @@ func (c *Client) normalizeRelease(raw map[string]any, tracks []any) map[string]a
"title": jsonutil.StringFromAny(raw["name"]),
"artist": map[string]any{"name": artist},
"release_date_original": jsonutil.StringFromAny(raw["new_release_date"]),
"tracks_count": jsonutil.FirstPositiveInt(jsonutil.IntFromAny(raw["track_count"]), len(items)),
"tracks_count": firstPositiveInt(jsonutil.IntFromAny(raw["track_count"]), len(items)),
"maximum_bit_depth": 16,
"maximum_sampling_rate": "44.1",
"image": imageMap(raw["image"]),
@@ -545,7 +510,7 @@ func (c *Client) normalizeRelease(raw map[string]any, tracks []any) map[string]a
func (c *Client) normalizeTrack(raw map[string]any) map[string]any {
release := mapAny(raw["release"])
trackArtistNames := artistNames(raw["artists"])
artistName := jsonutil.DisplayArtistNames(trackArtistNames)
artistName := displayArtistNames(trackArtistNames)
artistID := firstArtistID(raw["artists"])
albumArtist := strings.TrimSpace(jsonutil.StringFromAny(release["album_artist_name"]))
if albumArtist == "" {
@@ -591,7 +556,7 @@ func (c *Client) normalizeTrackListItem(raw map[string]any) map[string]any {
"id": jsonutil.StringFromAny(raw["id"]),
"title": jsonutil.StringFromAny(raw["name"]),
"version": jsonutil.StringFromAny(raw["mix_name"]),
"artist": map[string]any{"name": jsonutil.DisplayArtistNames(names)},
"artist": map[string]any{"name": displayArtistNames(names)},
"album": map[string]any{"id": jsonutil.NestedString(raw, "release", "id"), "title": jsonutil.NestedString(raw, "release", "name")},
"track_number": jsonutil.IntFromAny(raw["number"]),
}
@@ -645,12 +610,15 @@ func (c *Client) releaseCollectionMetadata(raw map[string]any, releases []any) m
func playlistMetadata(raw map[string]any, tracks []any) map[string]any {
name := jsonutil.StringFromAny(raw["name"])
return map[string]any{
"id": jsonutil.StringFromAny(raw["id"]),
"name": name,
"title": name,
"tracks_count": jsonutil.FirstPositiveInt(jsonutil.IntFromAny(raw["track_count"]), len(tracks)),
"image": imageMap(raw["image"]),
"tracks": map[string]any{"items": tracks},
"id": jsonutil.StringFromAny(raw["id"]),
"name": name,
"title": name,
"tracks_count": firstPositiveInt(jsonutil.IntFromAny(raw["track_count"]), len(tracks)),
"image": imageMap(raw["image"]),
"publish_date": jsonutil.StringFromAny(raw["publish_date"]),
"new_release_date": jsonutil.StringFromAny(raw["new_release_date"]),
"release_date": jsonutil.StringFromAny(raw["release_date"]),
"tracks": map[string]any{"items": tracks},
}
}
@@ -711,19 +679,32 @@ func artistNames(v any) []string {
return names
}
func displayArtistNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " & " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1]
}
}
func (c *Client) releaseArtistName(v any) string {
items := sliceAny(v)
threshold := c.cfg.Session.Beatport.VariousArtistsThreshold
if threshold >= 0 && len(items) > threshold {
return "Various Artists"
}
return jsonutil.DisplayArtistNames(artistNames(v))
return displayArtistNames(artistNames(v))
}
func (c *Client) releaseArtistNameForTracks(releaseArtists any, tracks []any) string {
common := commonReleaseArtists(releaseArtists, tracks)
if len(common) > 0 {
return jsonutil.DisplayArtistNames(artistNames(common))
return displayArtistNames(artistNames(common))
}
return c.releaseArtistName(releaseArtists)
}
@@ -829,6 +810,15 @@ func sliceAny(v any) []any {
return s
}
func firstPositiveInt(vals ...int) int {
for _, v := range vals {
if v > 0 {
return v
}
}
return 0
}
func cloneValues(in url.Values) url.Values {
out := url.Values{}
for k, vals := range in {
@@ -836,12 +826,3 @@ func cloneValues(in url.Values) url.Values {
}
return out
}
// appClientID reads the vendor OAuth app credential from config, falling back
// to the built-in default.
func (c *Client) appClientID() string {
if v := strings.TrimSpace(c.cfg.Session.Beatport.AppClientID); v != "" {
return v
}
return config.DefaultBeatportAppClientID
}
+4 -15
View File
@@ -9,13 +9,12 @@ import (
"time"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
)
func newTestClient(t *testing.T, handler http.Handler) (*Client, func()) {
t.Helper()
ts := httptest.NewServer(handler)
cfg := &config.Config{Session: testConfigData(), File: testConfigData()}
cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()}
cfg.Session.Beatport.Username = "user"
cfg.Session.Beatport.Password = "pass"
cfg.Session.Beatport.AccessToken = "token"
@@ -200,7 +199,7 @@ func TestAlbumMetadataUsesArtistPresentOnEveryTrack(t *testing.T) {
}
func TestReleaseArtistCollapseCanBeDisabled(t *testing.T) {
cfg := &config.Config{Session: testConfigData(), File: testConfigData()}
cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()}
cfg.Session.Beatport.VariousArtistsThreshold = -1
c := New(cfg)
got := c.releaseArtistName([]any{
@@ -215,13 +214,13 @@ func TestReleaseArtistCollapseCanBeDisabled(t *testing.T) {
}
func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) {
got := jsonutil.DisplayArtistNames(artistNames([]any{
got := displayArtistNames(artistNames([]any{
map[string]any{"name": "A"},
map[string]any{"name": "B"},
map[string]any{"name": "C"},
}))
if got != "A, B & C" {
t.Fatalf("DisplayArtistNames() = %q", got)
t.Fatalf("displayArtistNames() = %q", got)
}
}
@@ -266,13 +265,3 @@ func TestArtistMetadataUsesReleases(t *testing.T) {
t.Fatalf("unexpected album: %+v", album)
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
+25 -51
View File
@@ -15,7 +15,6 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"streamrip-go/internal/config"
@@ -23,7 +22,6 @@ import (
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
"streamrip-go/internal/verbose"
)
var (
@@ -33,6 +31,8 @@ var (
mediaURL = "https://media.deezer.com/v1/get_url"
pipeURL = "https://pipe.deezer.com/api"
authURL = "https://auth.deezer.com/login/renew"
apiKey = "4VCYIJUCDLOUELGD1V8WBVYBNVDYOXEWSLLZDONGBBDFVXTZJRXPR29JRLQFO6ZE"
gatewayDec = "VBK1FSUEXHTSDBJJ"
deezerUAPool = []string{
"Deezer/9.0.11.4 (Android; 14; Mobile; us) Xiaomi Redmi Note 7",
"Deezer/9.0.11.4 (Android; 14; Mobile; us) Samsung SM-G991B",
@@ -47,12 +47,9 @@ var (
)
type Client struct {
cfg *config.Config
http *http.Client
limiter *ratelimit.Limiter
// authMu guards loggedIn and the login/refresh path, which download
// workers can reach concurrently.
authMu sync.Mutex
cfg *config.Config
http *http.Client
limiter *ratelimit.Limiter
loggedIn bool
ua string
deviceID string
@@ -72,7 +69,7 @@ func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
http: httpClient,
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
ua: randomDeezerUA(),
deviceID: randomHexN(32),
arl: strings.TrimSpace(cfg.Session.Deezer.ARL),
@@ -85,8 +82,6 @@ func (c *Client) Source() string {
}
func (c *Client) Login(ctx context.Context) error {
c.authMu.Lock()
defer c.authMu.Unlock()
c.arl = strings.TrimSpace(c.cfg.Session.Deezer.ARL)
c.sid = ""
c.jwt = ""
@@ -119,8 +114,6 @@ func (c *Client) ensureLaunchSession(ctx context.Context) error {
}
func (c *Client) LoggedIn() bool {
c.authMu.Lock()
defer c.authMu.Unlock()
return c.loggedIn
}
@@ -129,7 +122,7 @@ func (c *Client) Close() error {
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("deezer client not logged in")
}
if limit <= 0 {
@@ -158,7 +151,7 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int)
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("deezer client not logged in")
}
@@ -478,11 +471,10 @@ func (c *Client) persistRefreshToken() {
if rt == "" {
return
}
if err := c.cfg.UpdateTokens(func(file, session *config.ConfigData) {
file.Deezer.RefreshToken = rt
session.Deezer.RefreshToken = rt
}); err != nil {
verbose.Printf(verbose.V, "deezer: could not persist refreshed token: %v\n", err)
c.cfg.Session.Deezer.RefreshToken = rt
c.cfg.File.Deezer.RefreshToken = rt
if strings.TrimSpace(c.cfg.Path) != "" {
_ = c.cfg.SaveFile()
}
}
@@ -497,7 +489,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
if err != nil {
return err
}
authToken, err := deriveGatewayAuthToken(c.gatewayKey(), mobileToken)
authToken, err := deriveGatewayAuthToken(mobileToken)
if err != nil {
return err
}
@@ -507,7 +499,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
}
c.sid = jsonutil.FirstNonEmpty(c.sid, sid)
encryptedPassword, err := encryptPassword(c.gatewayKey(), mobileToken, password)
encryptedPassword, err := encryptPassword(mobileToken, password)
if err != nil {
return err
}
@@ -526,7 +518,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
"password": encryptedPassword,
}
params := url.Values{}
params.Set("api_key", c.apiKey())
params.Set("api_key", apiKey)
params.Set("sid", c.sid)
params.Set("method", "mobile_userAuth")
params.Set("output", "3")
@@ -735,7 +727,7 @@ func (c *Client) bootstrapSID(ctx context.Context) (string, error) {
if err != nil {
return "", err
}
authToken, err := deriveGatewayAuthToken(c.gatewayKey(), mobileToken)
authToken, err := deriveGatewayAuthToken(mobileToken)
if err != nil {
return "", err
}
@@ -747,7 +739,7 @@ func (c *Client) mobileAuth(ctx context.Context) (string, error) {
return "", err
}
params := url.Values{}
params.Set("api_key", c.apiKey())
params.Set("api_key", apiKey)
params.Set("output", "3")
params.Set("method", "mobile_auth")
params.Set("network", randomHexN(32))
@@ -800,7 +792,7 @@ func (c *Client) apiCheckToken(ctx context.Context, authToken string) (string, e
return "", err
}
params := url.Values{}
params.Set("api_key", c.apiKey())
params.Set("api_key", apiKey)
params.Set("method", "api_checkToken")
params.Set("auth_token", authToken)
params.Set("output", "3")
@@ -863,7 +855,7 @@ func (c *Client) mobileUserAutolog(ctx context.Context) error {
payload["refresh_token"] = strings.TrimSpace(c.refresh)
}
params := url.Values{}
params.Set("api_key", c.apiKey())
params.Set("api_key", apiKey)
params.Set("sid", c.sid)
params.Set("output", "3")
params.Set("input", "3")
@@ -1073,10 +1065,8 @@ func (c *Client) refreshLicenseFromPipe(ctx context.Context) error {
return nil
}
// deriveGatewayAuthToken and friends take gatewayKey explicitly so the key
// comes from config rather than a package constant.
func deriveGatewayAuthToken(gatewayKey, mobileToken string) (string, error) {
dec, err := decryptMobileToken(gatewayKey, mobileToken)
func deriveGatewayAuthToken(mobileToken string) (string, error) {
dec, err := decryptMobileToken(mobileToken)
if err != nil {
return "", err
}
@@ -1092,19 +1082,19 @@ func deriveGatewayAuthToken(gatewayKey, mobileToken string) (string, error) {
return hex.EncodeToString(enc), nil
}
func decryptMobileToken(gatewayKey, mobileToken string) ([]byte, error) {
func decryptMobileToken(mobileToken string) ([]byte, error) {
b, err := hex.DecodeString(strings.TrimSpace(mobileToken))
if err != nil {
return nil, err
}
return aesECBDecrypt([]byte(gatewayKey), b)
return aesECBDecrypt([]byte(gatewayDec), b)
}
func encryptPassword(gatewayKey, mobileToken, password string) (string, error) {
func encryptPassword(mobileToken, password string) (string, error) {
if strings.TrimSpace(password) == "" {
return "", errors.New("missing deezer password")
}
dec, err := decryptMobileToken(gatewayKey, mobileToken)
dec, err := decryptMobileToken(mobileToken)
if err != nil {
return "", err
}
@@ -1513,19 +1503,3 @@ func enrichAlbumImage(meta map[string]any) {
"original": cover,
}
}
// apiKey and gatewayKey read the vendor protocol keys from config, falling
// back to the built-in defaults.
func (c *Client) apiKey() string {
if v := strings.TrimSpace(c.cfg.Session.Deezer.APIKey); v != "" {
return v
}
return config.DefaultDeezerAPIKey
}
func (c *Client) gatewayKey() string {
if v := strings.TrimSpace(c.cfg.Session.Deezer.GatewayKey); v != "" {
return v
}
return config.DefaultDeezerGatewayKey
}
+20 -30
View File
@@ -24,7 +24,7 @@ func TestSearchTrack(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -74,7 +74,7 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -135,7 +135,7 @@ func TestGetMetadataAlbumPaginatesTracks(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -189,7 +189,7 @@ func TestGetMetadataPlaylistPaginatesTracks(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -241,7 +241,7 @@ func TestGetDownloadableNativeCipher(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -289,7 +289,7 @@ func TestGetDownloadablePrefersNoneCipherWhenAvailable(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -354,7 +354,7 @@ func TestLoginPrefersARLFlowOverRefreshShortcut(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
cfgData.Deezer.RefreshToken = "refresh-token"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -386,7 +386,7 @@ func TestLoginPrefersARLFlowOverRefreshShortcut(t *testing.T) {
}
func TestGetDownloadableRequiresARL(t *testing.T) {
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = ""
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -409,7 +409,7 @@ func TestGetDownloadableDRMError(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -448,7 +448,7 @@ func TestGetTrackTokenPrefersPipeToken(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.jwt = "jwt-token"
@@ -499,7 +499,7 @@ func TestGetDownloadableUsesPipeTrackToken(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
@@ -541,7 +541,7 @@ func TestGetMetadataAddsLyricsFromPipe(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.jwt = "jwt"
@@ -594,7 +594,7 @@ func TestLoginWithCredentials(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Deezer.Email = "tidal1@alpin.sbs"
cfgData.Deezer.Password = "tidal1@alpin.sbs"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -662,7 +662,7 @@ func TestMobileAuthIncludesAppContextParams(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
origGateway := gatewayURL
@@ -703,7 +703,7 @@ func TestMobileUserAutologIncludesRefreshToken(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.sid = "sid123"
c.userID = "42"
@@ -728,7 +728,7 @@ func TestMobileUserAutologIncludesRefreshToken(t *testing.T) {
func testMobileToken(t *testing.T) string {
t.Helper()
plain := []byte(strings.Repeat("A", 64) + strings.Repeat("B", 16) + strings.Repeat("C", 16))
enc, err := aesECBEncrypt([]byte(config.DefaultDeezerGatewayKey), plain)
enc, err := aesECBEncrypt([]byte(gatewayDec), plain)
if err != nil {
t.Fatalf("aesECBEncrypt() error = %v", err)
}
@@ -748,7 +748,7 @@ func TestLoginWithRefreshToken(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Deezer.RefreshToken = "refresh-token"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -782,7 +782,7 @@ func TestRefreshJWTHTTPError(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.refresh = "refresh-token"
@@ -802,7 +802,7 @@ func TestRefreshLicenseFromPipeGraphQLError(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.jwt = "jwt-token"
@@ -848,7 +848,7 @@ func TestRefreshLicenseFromPipeUsesMobileGraphQLShape(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.jwt = "jwt-token"
@@ -863,13 +863,3 @@ func TestRefreshLicenseFromPipeUsesMobileGraphQLShape(t *testing.T) {
t.Fatalf("license = %q, want license-token", c.license)
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
+60 -256
View File
@@ -30,7 +30,6 @@ import (
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
"streamrip-go/internal/verbose"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
@@ -40,6 +39,8 @@ import (
const baseURL = "https://www.qobuz.com/api.json/0.2"
const (
mobileAppID = "312369995"
mobileAppSecret = "e79f8b9be485692b0e5f9dd895826368"
mobileUserAgent = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717"
mobileAppVersion = "9.7.0.3"
mobileSessionProf = "qbz-1"
@@ -57,15 +58,11 @@ var (
)
type Client struct {
cfg *config.Config
http *http.Client
streamHTTP *http.Client
limiter *ratelimit.Limiter
baseURL string
fetchCfg func(ctx context.Context) (string, []string, error)
// authMu guards loggedIn and the login/refresh path, which download
// workers can reach concurrently.
authMu sync.Mutex
cfg *config.Config
http *http.Client
limiter *ratelimit.Limiter
baseURL string
fetchCfg func(ctx context.Context) (string, []string, error)
loggedIn bool
secret string
uat string
@@ -90,16 +87,11 @@ type mobileFileURL struct {
func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
// Separate client with no Timeout for streaming a whole track body.
// http.Client.Timeout bounds the entire request including the body
// read, so the 30s API client aborted any track that took longer than
// 30s to stream. internal/download does the same for this reason.
streamHTTP: netutil.NewHTTPClient(0, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
baseURL: baseURL,
fetchCfg: nil,
cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL,
fetchCfg: nil,
}
}
@@ -108,14 +100,10 @@ func (c *Client) Source() string {
}
func (c *Client) LoggedIn() bool {
c.authMu.Lock()
defer c.authMu.Unlock()
return c.loggedIn
}
func (c *Client) Login(ctx context.Context) error {
c.authMu.Lock()
defer c.authMu.Unlock()
q := &c.cfg.Session.Qobuz
q.EmailOrUserID = strings.TrimSpace(q.EmailOrUserID)
q.PasswordOrToken = strings.TrimSpace(q.PasswordOrToken)
@@ -215,18 +203,14 @@ func (c *Client) refreshAppCredentials(ctx context.Context, q *config.QobuzConfi
return errors.New("qobuz app credential refresh returned no secrets")
}
q.Secrets = append([]string(nil), clean...)
refreshedAppID := q.AppID
if err := c.cfg.UpdateTokens(func(file, session *config.ConfigData) {
file.Qobuz.AppID = refreshedAppID
file.Qobuz.Secrets = append([]string(nil), clean...)
}); err != nil {
verbose.Printf(verbose.V, "qobuz: could not persist refreshed credentials: %v\n", err)
}
c.cfg.File.Qobuz.AppID = q.AppID
c.cfg.File.Qobuz.Secrets = append([]string(nil), clean...)
_ = c.cfg.SaveFile()
return nil
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errNotLoggedIn
}
if mediaType == "playlist" {
@@ -267,8 +251,16 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
return resp, nil
}
func (c *Client) GetTrackMetadata(ctx context.Context, id string) (*TrackMetadata, error) {
raw, err := c.GetMetadata(ctx, id, "track")
if err != nil {
return nil, err
}
return ParseTrackMetadata(raw)
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errNotLoggedIn
}
if limit <= 0 {
@@ -291,7 +283,7 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int)
}
func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errNotLoggedIn
}
if quality < 1 || quality > 4 {
@@ -621,11 +613,13 @@ func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string
total, _ := intValue(resp["tracks_count"])
if total <= pageLimit {
normalizePlaylistImage(resp)
return resp, nil
}
tracksObj, ok := mapValue(resp["tracks"])
if !ok {
normalizePlaylistImage(resp)
return resp, nil
}
items, ok := tracksObj["items"].([]any)
@@ -661,9 +655,25 @@ func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string
tracksObj["items"] = items
resp["tracks"] = tracksObj
normalizePlaylistImage(resp)
return resp, nil
}
func normalizePlaylistImage(resp map[string]any) {
if resp["image"] != nil {
return
}
rect, ok := resp["image_rectangle"].([]any)
if !ok || len(rect) == 0 {
return
}
url, ok := rect[0].(string)
if !ok || url == "" {
return
}
resp["image"] = map[string]any{"original": url, "large": url}
}
func (c *Client) getLabel(ctx context.Context, labelID string) (map[string]any, error) {
pageLimit := 500
params := url.Values{}
@@ -1044,11 +1054,11 @@ func (c *Client) ensureMobileSession(ctx context.Context) error {
func (c *Client) mobileLogin(ctx context.Context, username, password string) error {
ts := time.Now().Unix()
params := url.Values{}
params.Set("app_id", c.mobileAppID())
params.Set("app_id", mobileAppID)
params.Set("username", username)
params.Set("password", password)
params.Set("request_ts", strconv.FormatInt(ts, 10))
params.Set("request_sig", mobileSignRequest(c.mobileAppSecret(), "oauth2/login", []kv{{"password", password}, {"username", username}}, ts))
params.Set("request_sig", mobileSignRequest("oauth2/login", []kv{{"password", password}, {"username", username}}, ts))
reqURL := baseURL + "/oauth2/login?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
@@ -1086,9 +1096,9 @@ func (c *Client) mobileLogin(ctx context.Context, username, password string) err
func (c *Client) mobileStartSession(ctx context.Context) error {
ts := time.Now().Unix()
params := url.Values{}
params.Set("app_id", c.mobileAppID())
params.Set("app_id", mobileAppID)
params.Set("request_ts", strconv.FormatInt(ts, 10))
params.Set("request_sig", mobileSignRequest(c.mobileAppSecret(), "session/start", []kv{{"profile", mobileSessionProf}}, ts))
params.Set("request_sig", mobileSignRequest("session/start", []kv{{"profile", mobileSessionProf}}, ts))
reqURL := baseURL + "/session/start?" + params.Encode()
form := url.Values{}
@@ -1130,12 +1140,12 @@ func (c *Client) mobileStartSession(ctx context.Context) error {
func (c *Client) mobileGetFileURL(ctx context.Context, trackID string, formatID int) (*mobileFileURL, error) {
ts := time.Now().Unix()
params := url.Values{}
params.Set("app_id", c.mobileAppID())
params.Set("app_id", mobileAppID)
params.Set("track_id", trackID)
params.Set("format_id", strconv.Itoa(formatID))
params.Set("intent", "stream")
params.Set("request_ts", strconv.FormatInt(ts, 10))
params.Set("request_sig", mobileSignRequest(c.mobileAppSecret(), "file/url", []kv{{"format_id", strconv.Itoa(formatID)}, {"intent", "stream"}, {"track_id", trackID}}, ts))
params.Set("request_sig", mobileSignRequest("file/url", []kv{{"format_id", strconv.Itoa(formatID)}, {"intent", "stream"}, {"track_id", trackID}}, ts))
reqURL := baseURL + "/file/url?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
@@ -1167,11 +1177,7 @@ func (c *Client) mobileCopyURLToWriter(ctx context.Context, sourceURL string, ou
return err
}
c.setMobileHeaders(req)
client := c.streamHTTP
if client == nil {
client = c.http
}
resp, err := client.Do(req)
resp, err := c.http.Do(req)
if err != nil {
return err
}
@@ -1257,7 +1263,7 @@ func (c *Client) mobileDownloadSegment(ctx context.Context, sourceURL string) ([
func (c *Client) setMobileHeaders(req *http.Request) {
req.Header.Set("User-Agent", mobileUserAgent)
req.Header.Set("X-App-Id", c.mobileAppID())
req.Header.Set("X-App-Id", mobileAppID)
req.Header.Set("X-App-Version", mobileAppVersion)
req.Header.Set("X-Device-Platform", "android")
req.Header.Set("X-Device-Model", "Nexus 6P")
@@ -1270,9 +1276,7 @@ func (c *Client) setMobileHeaders(req *http.Request) {
}
}
// mobileSignRequest signs a mobile API request. appSecret comes from config
// (Client.mobileAppSecret) so a rotation can be fixed without a rebuild.
func mobileSignRequest(appSecret, endpoint string, params []kv, ts int64) string {
func mobileSignRequest(endpoint string, params []kv, ts int64) string {
method := strings.ReplaceAll(endpoint, "/", "")
sortKVs(params)
var sb strings.Builder
@@ -1282,7 +1286,7 @@ func mobileSignRequest(appSecret, endpoint string, params []kv, ts int64) string
sb.WriteString(p.Value)
}
sb.WriteString(strconv.FormatInt(ts, 10))
sb.WriteString(appSecret)
sb.WriteString(mobileAppSecret)
h := md5.Sum([]byte(sb.String()))
return hex.EncodeToString(h[:])
}
@@ -1318,17 +1322,7 @@ func (c *Client) mobileDeriveTrackKey(encryptedKey string) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("decode mobile info: %w", err)
}
// A non-hex secret used to be swallowed and HKDF keyed on nil, which
// derives a wrong KEK and yields silently corrupt audio rather than an
// error.
secretKey, err := hex.DecodeString(c.mobileAppSecret())
if err != nil {
return nil, fmt.Errorf("decode mobile app secret: %w", err)
}
if len(secretKey) == 0 {
return nil, errors.New("mobile app secret is empty")
}
reader := hkdf.New(func() hash.Hash { return sha256.New() }, secretKey, salt, info)
reader := hkdf.New(func() hash.Hash { return sha256.New() }, hexDecodeOrNil(mobileAppSecret), salt, info)
kek := make([]byte, 16)
if _, err = io.ReadFull(reader, kek); err != nil {
return nil, fmt.Errorf("mobile hkdf derive failed: %w", err)
@@ -1337,6 +1331,11 @@ func (c *Client) mobileDeriveTrackKey(encryptedKey string) ([]byte, error) {
return unwrapQobuzTrackKey(encryptedKey, kek)
}
func hexDecodeOrNil(s string) []byte {
b, _ := hex.DecodeString(s)
return b
}
func unwrapQobuzTrackKey(encryptedKey string, kek []byte) ([]byte, error) {
parts := strings.SplitN(encryptedKey, ".", 3)
if len(parts) != 3 || parts[0] != mobileSessionProf {
@@ -1491,198 +1490,3 @@ func readU24BE(data []byte, off int) uint32 {
}
return uint32(data[off])<<16 | uint32(data[off+1])<<8 | uint32(data[off+2])
}
// mobileAppID and mobileAppSecret read the vendor mobile app credentials from
// config, falling back to the built-in defaults.
func (c *Client) mobileAppID() string {
if v := strings.TrimSpace(c.cfg.Session.Qobuz.MobileAppID); v != "" {
return v
}
return config.DefaultQobuzMobileAppID
}
func (c *Client) mobileAppSecret() string {
if v := strings.TrimSpace(c.cfg.Session.Qobuz.MobileAppSecret); v != "" {
return v
}
return config.DefaultQobuzMobileAppSecret
}
// AlbumTracksAreComplete reports that Qobuz's album/get response embeds track
// objects complete enough to tag from, so internal/app can skip the per-track
// track/get call.
//
// Checked against the live API for a 68-track release: the embedded items
// carry title, version, track_number, media_number, parental_warning, isrc,
// copyright, performer, performers, composer, duration, audio_info,
// maximum_bit_depth, maximum_sampling_rate and release_date_original. The only
// field track/get adds that this app reads is the nested album object, and the
// caller attaches the parent album itself. Everything else track/get returns
// (articles, created_at, has_lyrics, indexed_at) is unused.
func (c *Client) AlbumTracksAreComplete() bool { return true }
// Qobuz serves lyrics in two steps, mirroring its Android app: track/lyricsUrl
// returns a short-lived signed CloudFront URL, and that URL serves the lyrics
// document itself.
//
// Verified against the live API:
//
// GET track/lyricsUrl?track_id=&app_id=&request_ts=&request_sig=
// -> {"track_id","album_id","lyrics_url"}
// GET <lyrics_url>
// -> {"original":{"type","lang","lines":[{"line","start","end"}]},
// "translation_langs":[...],"writers","publishers":[...]}
//
// "type" is plain, lsync (line-synced) or wsync (word-synced); start and end
// are milliseconds. Passing a language parameter returns a translation
// document instead of the original, and only for languages that exist for the
// track, so this deliberately omits it and always takes the original.
const lyricsURLEndpoint = "track/lyricsUrl"
// lyricsExcluded reports whether the user opted out via
// metadata.exclude = ["lyrics"].
func (c *Client) lyricsExcluded() bool {
for _, k := range c.cfg.Session.Metadata.Exclude {
if strings.EqualFold(strings.TrimSpace(k), "lyrics") {
return true
}
}
return false
}
// TrackLyrics returns the plain and synced (LRC) lyrics for a track, or empty
// strings when Qobuz has none. It is exposed separately from GetMetadata so
// that album rips, which reuse the track objects embedded in the album
// payload, can still fetch lyrics without a full per-track metadata call.
func (c *Client) TrackLyrics(ctx context.Context, trackID string) (string, string, error) {
trackID = strings.TrimSpace(trackID)
if trackID == "" || c.lyricsExcluded() {
return "", "", nil
}
if !c.LoggedIn() {
return "", "", errNotLoggedIn
}
requestTS := strconv.FormatInt(time.Now().Unix(), 10)
sigRaw := "tracklyricsUrltrack_id" + trackID + requestTS + c.secret
sum := md5.Sum([]byte(sigRaw))
params := url.Values{}
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
params.Set("track_id", trackID)
params.Set("request_ts", requestTS)
params.Set("request_sig", hex.EncodeToString(sum[:]))
resp, status, err := c.apiRequest(ctx, lyricsURLEndpoint, params, c.authHeaders())
if err != nil {
return "", "", err
}
if status != http.StatusOK {
// A track without lyrics is not an error worth failing a rip over.
verbose.Printf(verbose.V, "qobuz: no lyrics for track %s (status=%d)\n", trackID, status)
return "", "", nil
}
docURL := strings.TrimSpace(jsonutil.StringFromAny(resp["lyrics_url"]))
if docURL == "" {
return "", "", nil
}
return c.fetchLyricsDocument(ctx, trackID, docURL)
}
// fetchLyricsDocument retrieves the signed lyrics document. The URL points at
// a CDN rather than the Qobuz API, so it deliberately bypasses the API rate
// limiter: it is a different host and does not count against the provider's
// request budget.
func (c *Client) fetchLyricsDocument(ctx context.Context, trackID, docURL string) (string, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, docURL, nil)
if err != nil {
return "", "", err
}
resp, err := c.http.Do(req)
if err != nil {
return "", "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
// The signed URL 403s when the document does not exist, which is how
// Qobuz represents "no lyrics in this language".
verbose.Printf(verbose.V, "qobuz: lyrics document for track %s unavailable (status=%d)\n", trackID, resp.StatusCode)
return "", "", nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", err
}
var doc map[string]any
if err = json.Unmarshal(body, &doc); err != nil {
return "", "", fmt.Errorf("qobuz lyrics document: %w", err)
}
plain, synced := parseQobuzLyrics(jsonutil.NestedMap(doc, "original"))
return plain, synced, nil
}
// parseQobuzLyrics turns a lyrics object into plain text and, when the lines
// carry timings, an LRC transcript.
//
// Qobuz separates verses with entries that have an empty line and no start or
// end at all. Those are kept as blank lines in the plain text, so stanzas stay
// readable, but omitted from the LRC: a synced entry needs a timestamp, and
// inventing one would park a blank line at the wrong moment.
func parseQobuzLyrics(obj map[string]any) (string, string) {
lines, ok := obj["lines"].([]any)
if !ok || len(lines) == 0 {
return "", ""
}
// wsync documents are line objects that additionally carry per-word
// timings; the line text and start sit in the same place, so both synced
// variants are handled identically.
kind := strings.ToLower(strings.TrimSpace(jsonutil.StringFromAny(obj["type"])))
synced := kind == "lsync" || kind == "wsync"
plainParts := make([]string, 0, len(lines))
lrcParts := make([]string, 0, len(lines))
for _, raw := range lines {
m, ok := raw.(map[string]any)
if !ok {
continue
}
text := strings.TrimSpace(jsonutil.StringFromAny(m["line"]))
start, hasStart := m["start"]
timed := synced && hasStart && start != nil
if text == "" {
if timed {
// A blank line that does carry a timestamp is a display-clear
// marker: it belongs in the LRC, where it blanks the lyric at
// that moment, but not in the plain text.
ms := jsonutil.IntFromAny(start)
lrcParts = append(lrcParts, fmt.Sprintf("[%02d:%05.2f]", ms/60000, float64(ms%60000)/1000.0))
continue
}
// Untimed blank: a verse separator. Keep a single blank line, and
// never a leading one.
if len(plainParts) > 0 && plainParts[len(plainParts)-1] != "" {
plainParts = append(plainParts, "")
}
continue
}
plainParts = append(plainParts, text)
if timed {
ms := jsonutil.IntFromAny(start)
lrcParts = append(lrcParts, fmt.Sprintf("[%02d:%05.2f]%s", ms/60000, float64(ms%60000)/1000.0, text))
}
}
// Drop a trailing separator.
for len(plainParts) > 0 && plainParts[len(plainParts)-1] == "" {
plainParts = plainParts[:len(plainParts)-1]
}
if len(plainParts) == 0 {
return "", ""
}
return strings.Join(plainParts, "\n"), strings.Join(lrcParts, "\n")
}
+4 -229
View File
@@ -208,7 +208,7 @@ func TestGetArtistPagination(t *testing.T) {
func newTestClient(t *testing.T) *Client {
t.Helper()
d := testConfigData()
d := config.DefaultConfigData()
d.Qobuz.AppID = "12345"
cfg := &config.Config{File: d, Session: d}
return New(cfg)
@@ -248,7 +248,7 @@ func TestLoginRefreshesAppCredentialsWhenSecretInvalid(t *testing.T) {
}))
defer ts.Close()
d := testConfigData()
d := config.DefaultConfigData()
d.Qobuz.EmailOrUserID = "user@example.com"
d.Qobuz.PasswordOrToken = "hash"
d.Qobuz.AppID = "old-app"
@@ -302,7 +302,7 @@ func TestLoginRetriesAfterRefreshingAppCredentials(t *testing.T) {
}))
defer ts.Close()
d := testConfigData()
d := config.DefaultConfigData()
d.Qobuz.EmailOrUserID = "user@example.com"
d.Qobuz.PasswordOrToken = "hash"
d.Qobuz.AppID = "old-app"
@@ -396,7 +396,7 @@ func qobuzSecretSig(requestTS, secret string) string {
}
func TestRefreshAppCredentialsRejectsEmptyData(t *testing.T) {
d := testConfigData()
d := config.DefaultConfigData()
c := New(&config.Config{File: d, Session: d})
c.fetchCfg = func(context.Context) (string, []string, error) {
return "", []string{" "}, nil
@@ -406,228 +406,3 @@ func TestRefreshAppCredentialsRejectsEmptyData(t *testing.T) {
t.Fatalf("expected error for empty refreshed app credentials")
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
// newLyricsServer serves the two-step Qobuz lyrics flow: track/lyricsUrl
// returns a URL on this same server, which then serves the document.
func newLyricsServer(t *testing.T, doc any, docStatus int) *httptest.Server {
t.Helper()
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/track/lyricsUrl":
if r.URL.Query().Get("request_sig") == "" {
t.Error("track/lyricsUrl called without a request signature")
}
if r.URL.Query().Get("language") != "" {
t.Error("language must be omitted so the original lyrics are returned, not a translation")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"track_id": r.URL.Query().Get("track_id"),
"album_id": "a1",
"lyrics_url": srv.URL + "/doc.json?Signature=x",
})
case "/doc.json":
if docStatus != http.StatusOK {
w.WriteHeader(docStatus)
_, _ = w.Write([]byte(`<?xml version="1.0"?><Error><Code>AccessDenied</Code></Error>`))
return
}
_ = json.NewEncoder(w).Encode(doc)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
func lyricsClient(t *testing.T, srv *httptest.Server, exclude ...string) *Client {
t.Helper()
d := testConfigData()
d.Metadata.Exclude = exclude
c := New(&config.Config{File: d, Session: d})
c.baseURL = srv.URL
c.loggedIn = true
c.secret = "secret"
return c
}
func TestTrackLyricsLineSynced(t *testing.T) {
srv := newLyricsServer(t, map[string]any{
"track_id": "42",
"translation_langs": []any{"fr", "de"},
"original": map[string]any{
"type": "lsync",
"lang": "en",
"lines": []any{
map[string]any{"line": "First line", "start": 5780, "end": 8710},
map[string]any{"line": "", "start": 9000, "end": 9100},
map[string]any{"line": "Second line", "start": 65250, "end": 68000},
},
},
}, http.StatusOK)
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
if err != nil {
t.Fatalf("TrackLyrics() error = %v", err)
}
// The timestamped blank is a display-clear marker: it belongs in the LRC
// but not in the readable plain text.
if plain != "First line\nSecond line" {
t.Errorf("plain = %q", plain)
}
want := "[00:05.78]First line\n[00:09.00]\n[01:05.25]Second line"
if lrc != want {
t.Errorf("lrc = %q, want %q", lrc, want)
}
}
func TestTrackLyricsPlainHasNoTimestamps(t *testing.T) {
srv := newLyricsServer(t, map[string]any{
"original": map[string]any{
"type": "plain",
"lines": []any{
map[string]any{"line": "Just text"},
map[string]any{"line": "More text"},
},
},
}, http.StatusOK)
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
if err != nil {
t.Fatal(err)
}
if plain != "Just text\nMore text" {
t.Errorf("plain = %q", plain)
}
if lrc != "" {
t.Errorf("plain lyrics must not produce an LRC transcript, got %q", lrc)
}
}
func TestTrackLyricsWordSyncedUsesLineTimings(t *testing.T) {
srv := newLyricsServer(t, map[string]any{
"original": map[string]any{
"type": "wsync",
"lines": []any{
map[string]any{
"line": "Hello world",
"start": 1500,
"words": []any{
map[string]any{"text": "Hello", "start": 1500, "end": 1800},
map[string]any{"text": "world", "start": 1800, "end": 2100},
},
},
},
},
}, http.StatusOK)
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
if err != nil {
t.Fatal(err)
}
if plain != "Hello world" {
t.Errorf("plain = %q", plain)
}
if lrc != "[00:01.50]Hello world" {
t.Errorf("lrc = %q", lrc)
}
}
// A missing document 403s on the signed URL; that is how Qobuz says "no
// lyrics", so it must not fail the rip.
func TestTrackLyricsMissingDocumentIsNotAnError(t *testing.T) {
srv := newLyricsServer(t, nil, http.StatusForbidden)
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
if err != nil {
t.Fatalf("a missing lyrics document must not be an error, got %v", err)
}
if plain != "" || lrc != "" {
t.Errorf("expected no lyrics, got plain=%q lrc=%q", plain, lrc)
}
}
func TestTrackLyricsRespectsMetadataExclude(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("no request should be made when lyrics are excluded, got %s", r.URL.Path)
}))
defer srv.Close()
plain, lrc, err := lyricsClient(t, srv, "lyrics").TrackLyrics(context.Background(), "42")
if err != nil || plain != "" || lrc != "" {
t.Fatalf("exclude should short-circuit; got plain=%q lrc=%q err=%v", plain, lrc, err)
}
}
func TestTrackLyricsEmptyIDIsNoOp(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("no request expected for an empty track id, got %s", r.URL.Path)
}))
defer srv.Close()
if plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), " "); err != nil || plain != "" || lrc != "" {
t.Fatalf("got plain=%q lrc=%q err=%v", plain, lrc, err)
}
}
// TestTrackLyricsKeepsVerseBreaks covers Qobuz's real separator shape: an
// empty line with no start or end. Those keep the plain text in stanzas but
// cannot become LRC entries, since there is no timestamp to use.
func TestTrackLyricsKeepsVerseBreaks(t *testing.T) {
srv := newLyricsServer(t, map[string]any{
"original": map[string]any{
"type": "lsync",
"lines": []any{
map[string]any{"line": "verse one line one", "start": 1000},
map[string]any{"line": "verse one line two", "start": 2000},
map[string]any{"line": ""},
map[string]any{"line": ""},
map[string]any{"line": "verse two line one", "start": 3000},
map[string]any{"line": ""},
},
},
}, http.StatusOK)
plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
if err != nil {
t.Fatal(err)
}
wantPlain := "verse one line one\nverse one line two\n\nverse two line one"
if plain != wantPlain {
t.Errorf("plain = %q, want %q", plain, wantPlain)
}
wantLRC := "[00:01.00]verse one line one\n[00:02.00]verse one line two\n[00:03.00]verse two line one"
if lrc != wantLRC {
t.Errorf("lrc = %q, want %q", lrc, wantLRC)
}
}
// TestTrackLyricsLeadingSeparatorDropped guards against a lyric tag that
// starts with a blank line.
func TestTrackLyricsLeadingSeparatorDropped(t *testing.T) {
srv := newLyricsServer(t, map[string]any{
"original": map[string]any{
"type": "plain",
"lines": []any{map[string]any{"line": ""}, map[string]any{"line": "first real line"}},
},
}, http.StatusOK)
plain, _, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42")
if err != nil {
t.Fatal(err)
}
if plain != "first real line" {
t.Errorf("plain = %q, want no leading blank", plain)
}
}
+14 -35
View File
@@ -16,9 +16,7 @@ import (
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
)
var errUnsupportedMediaType = errors.New("unsupported soundcloud media type")
@@ -28,32 +26,22 @@ var soundcloudSearchBaseURL = "https://soundcloud.com"
type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
type Client struct {
cfg *config.Config
bin string
run commandRunner
http *http.Client
limiter *ratelimit.Limiter
mu sync.Mutex
cache map[string]map[string]any
// authMu guards loggedIn. Kept separate from mu, which guards cache, so a
// cache operation can never block a login check.
authMu sync.Mutex
cfg *config.Config
loggedIn bool
bin string
run commandRunner
http *http.Client
mu sync.Mutex
cache map[string]map[string]any
}
func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
bin: "yt-dlp",
run: runCommand,
// Use the shared constructor like every other provider: rolling our own
// client here meant soundcloud silently ignored downloads.verify_ssl and
// downloads.max_connections, and bypassed the transport that logs
// requests at -vv with credentials redacted.
http: netutil.NewHTTPClient(20*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
cache: map[string]map[string]any{},
cfg: cfg,
bin: "yt-dlp",
run: runCommand,
http: &http.Client{Timeout: 20 * time.Second},
cache: map[string]map[string]any{},
}
}
@@ -62,8 +50,6 @@ func (c *Client) Source() string {
}
func (c *Client) LoggedIn() bool {
c.authMu.Lock()
defer c.authMu.Unlock()
return c.loggedIn
}
@@ -71,14 +57,12 @@ func (c *Client) Login(context.Context) error {
if _, err := exec.LookPath(c.bin); err != nil {
return fmt.Errorf("yt-dlp is required for soundcloud downloads/search. install it and ensure it is in $PATH (e.g. pipx install yt-dlp): %w", err)
}
c.authMu.Lock()
defer c.authMu.Unlock()
c.loggedIn = true
return nil
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("soundcloud client not logged in")
}
if limit <= 0 {
@@ -149,11 +133,6 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) (
}
req.Header.Set("User-Agent", "Mozilla/5.0")
if c.limiter != nil {
if err = c.limiter.Wait(ctx); err != nil {
return nil, err
}
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
@@ -222,7 +201,7 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) (
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("soundcloud client not logged in")
}
@@ -294,7 +273,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
}
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("soundcloud client not logged in")
}
info, err := c.trackInfo(ctx, item)
+6 -16
View File
@@ -14,7 +14,7 @@ import (
)
func TestGetTrackMetadataAndDownloadable(t *testing.T) {
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
@@ -49,7 +49,7 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) {
}
func TestGetPlaylistMetadata(t *testing.T) {
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
@@ -81,7 +81,7 @@ func TestGetPlaylistMetadata(t *testing.T) {
}
func TestSearchTrack(t *testing.T) {
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
@@ -122,7 +122,7 @@ func TestSearchPlaylist(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.http = ts.Client()
@@ -167,7 +167,7 @@ func TestSearchPlaylistAcceptsDotsInPath(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.http = ts.Client()
@@ -203,7 +203,7 @@ func TestSearchPlaylistAcceptsDotsInPath(t *testing.T) {
}
func TestLoginShowsYtDlpHint(t *testing.T) {
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.bin = "definitely-not-a-real-yt-dlp-bin"
err := c.Login(context.Background())
@@ -254,13 +254,3 @@ func TestCanonicalSoundcloudURLAcceptsSubdomain(t *testing.T) {
t.Fatalf("canonical url = %q, want %q", got, "https://soundcloud.com/a/b")
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
+84 -72
View File
@@ -12,7 +12,6 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"streamrip-go/internal/config"
@@ -20,7 +19,6 @@ import (
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
"streamrip-go/internal/verbose"
)
const (
@@ -28,6 +26,8 @@ const (
lyricsAPIv1 = "https://api.tidal.com/v1"
openAPIV2 = "https://openapi.tidal.com/v2"
authURL = "https://auth.tidal.com/v1/oauth2"
clientID = "fX2JxdmntZWK0ixT"
clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="
tidalRequestAttempts = 3
)
@@ -58,17 +58,14 @@ type Client struct {
baseURL string
lyricsAPI string
openAPI string
// authMu guards loggedIn and the login/refresh path, which download
// workers can reach concurrently.
authMu sync.Mutex
loggedIn bool
loggedIn bool
}
func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL,
lyricsAPI: lyricsAPIv1,
openAPI: openAPIV2,
@@ -80,14 +77,10 @@ func (c *Client) Source() string {
}
func (c *Client) LoggedIn() bool {
c.authMu.Lock()
defer c.authMu.Unlock()
return c.loggedIn
}
func (c *Client) Login(ctx context.Context) error {
c.authMu.Lock()
defer c.authMu.Unlock()
if strings.TrimSpace(c.cfg.Session.Tidal.AccessToken) == "" {
return ErrMissingTidalToken
}
@@ -118,10 +111,10 @@ func (c *Client) Login(ctx context.Context) error {
return fmt.Errorf("tidal login failed: status=%d body=%v", status, resp)
}
if v := jsonutil.StringFromAny(resp["countryCode"]); v != "" {
if v := stringify(resp["countryCode"]); v != "" {
c.cfg.Session.Tidal.CountryCode = v
}
if v := jsonutil.StringFromAny(resp["userId"]); v != "" {
if v := stringify(resp["userId"]); v != "" {
c.cfg.Session.Tidal.UserID = v
}
@@ -144,7 +137,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error {
}
form := url.Values{}
form.Set("client_id", c.appClientID())
form.Set("client_id", clientID)
form.Set("refresh_token", refresh)
form.Set("grant_type", "refresh_token")
form.Set("scope", "r_usr+w_usr+w_sub")
@@ -157,36 +150,33 @@ func (c *Client) refreshAccessToken(ctx context.Context) error {
return fmt.Errorf("tidal token refresh failed: status=%d body=%v", status, resp)
}
newToken := jsonutil.StringFromAny(resp["access_token"])
newToken := stringify(resp["access_token"])
if newToken == "" {
return errors.New("tidal token refresh missing access_token")
}
newRefresh := jsonutil.StringFromAny(resp["refresh_token"])
newRefresh := stringify(resp["refresh_token"])
expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
if expiresIn <= 0 {
expiresIn = 7 * 24 * 3600
}
expiry := time.Now().Unix() + expiresIn
if err := c.cfg.UpdateTokens(func(file, session *config.ConfigData) {
file.Tidal.AccessToken = newToken
session.Tidal.AccessToken = newToken
if newRefresh != "" {
file.Tidal.RefreshToken = newRefresh
session.Tidal.RefreshToken = newRefresh
}
file.Tidal.TokenExpiry = expiry
session.Tidal.TokenExpiry = expiry
}); err != nil {
verbose.Printf(verbose.V, "tidal: could not persist refreshed token: %v\n", err)
c.cfg.Session.Tidal.AccessToken = newToken
c.cfg.File.Tidal.AccessToken = newToken
if newRefresh != "" {
c.cfg.Session.Tidal.RefreshToken = newRefresh
c.cfg.File.Tidal.RefreshToken = newRefresh
}
expiry := time.Now().Unix() + expiresIn
c.cfg.Session.Tidal.TokenExpiry = expiry
c.cfg.File.Tidal.TokenExpiry = expiry
_ = c.cfg.SaveFile()
return nil
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("tidal client not logged in")
}
@@ -261,13 +251,13 @@ func (c *Client) fetchTrackLyrics(ctx context.Context, trackID string) (string,
return "", ""
}
lyrics := strings.TrimSpace(jsonutil.StringFromAny(resp["lyrics"]))
lrc := strings.TrimSpace(jsonutil.StringFromAny(resp["subtitles"]))
lyrics := strings.TrimSpace(stringify(resp["lyrics"]))
lrc := strings.TrimSpace(stringify(resp["subtitles"]))
return lyrics, lrc
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("tidal client not logged in")
}
if limit <= 0 {
@@ -294,7 +284,7 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int)
}
func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("tidal client not logged in")
}
if quality < 0 || quality > 4 {
@@ -337,6 +327,30 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in
return c.getDownloadableFromTrackManifest(ctx, trackID, quality)
}
func (c *Client) trackSupportsAtmos(ctx context.Context, trackID string) bool {
resp, status, err := c.apiRequest(ctx, "tracks/"+trackID, url.Values{}, c.baseURL)
if err != nil || status != http.StatusOK {
return false
}
if modes, ok := resp["audioModes"].([]any); ok {
for _, mode := range modes {
if strings.Contains(strings.ToUpper(stringify(mode)), "ATMOS") {
return true
}
}
}
if mm, ok := resp["mediaMetadata"].(map[string]any); ok {
if tags, ok := mm["tags"].([]any); ok {
for _, tag := range tags {
if strings.Contains(strings.ToUpper(stringify(tag)), "ATMOS") {
return true
}
}
}
}
return false
}
func (c *Client) getAtmosDownloadable(ctx context.Context, trackID string) (*provider.Downloadable, error) {
var lastErr error
for _, aq := range atmosAudioQualities {
@@ -381,18 +395,18 @@ func (c *Client) getAtmosDownloadable(ctx context.Context, trackID string) (*pro
}
func playbackLooksAtmos(resp map[string]any) bool {
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(resp["audioMode"])), "ATMOS") {
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
return true
}
if modes, ok := resp["audioModes"].([]any); ok {
for _, raw := range modes {
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(raw)), "ATMOS") {
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
return true
}
}
}
manifestB64 := jsonutil.StringFromAny(resp["manifest"])
manifestB64 := stringify(resp["manifest"])
if manifestB64 == "" {
return false
}
@@ -404,17 +418,17 @@ func playbackLooksAtmos(resp map[string]any) bool {
if err = json.Unmarshal(b, &manifest); err != nil {
return false
}
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(manifest["audioMode"])), "ATMOS") {
if strings.Contains(strings.ToUpper(stringify(manifest["audioMode"])), "ATMOS") {
return true
}
if modes, ok := manifest["audioModes"].([]any); ok {
for _, raw := range modes {
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(raw)), "ATMOS") {
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
return true
}
}
}
codec := strings.ToLower(jsonutil.StringFromAny(manifest["codecs"]))
codec := strings.ToLower(stringify(manifest["codecs"]))
return strings.Contains(codec, "ec-3") || strings.Contains(codec, "eac3") || strings.Contains(codec, "joc") || strings.Contains(codec, "atmos")
}
@@ -498,7 +512,7 @@ func (c *Client) fetchArtistAlbums(ctx context.Context, artistID string) ([]map[
if wrapped, ok := itm["item"].(map[string]any); ok {
itm = wrapped
}
id := jsonutil.StringFromAny(itm["id"])
id := stringify(itm["id"])
if id == "" {
continue
}
@@ -550,14 +564,14 @@ func (c *Client) getDownloadableFromTrackManifestForFormats(ctx context.Context,
if !ok {
return nil, errors.New("tidal trackManifests missing attributes")
}
uri := jsonutil.StringFromAny(attrs["uri"])
uri := stringify(attrs["uri"])
if uri == "" {
return nil, errors.New("tidal trackManifests missing uri")
}
attrFormats, _ := attrs["formats"].([]any)
ext := "m4a"
for _, f := range attrFormats {
fv := strings.ToUpper(jsonutil.StringFromAny(f))
fv := strings.ToUpper(stringify(f))
if strings.Contains(fv, "FLAC") {
ext = "flac"
break
@@ -587,7 +601,7 @@ func formatsForQuality(quality int, preferAtmos bool) []string {
}
func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("tidal client not logged in")
}
@@ -604,7 +618,7 @@ func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*pro
return nil, fmt.Errorf("tidal video playbackinfo failed: status=%d", status)
}
manifestB64 := jsonutil.StringFromAny(resp["manifest"])
manifestB64 := stringify(resp["manifest"])
if manifestB64 == "" {
return nil, errors.New("tidal video manifest missing")
}
@@ -620,7 +634,7 @@ func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*pro
if !ok || len(urls) == 0 {
return nil, errors.New("tidal video manifest urls missing")
}
masterURL := jsonutil.StringFromAny(urls[0])
masterURL := stringify(urls[0])
if masterURL == "" {
return nil, errors.New("tidal video master url missing")
}
@@ -651,7 +665,7 @@ func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*pro
}
func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadable {
manifestB64 := jsonutil.StringFromAny(resp["manifest"])
manifestB64 := stringify(resp["manifest"])
if manifestB64 == "" {
return nil
}
@@ -667,11 +681,11 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
if !ok || len(urls) == 0 {
return nil
}
streamURL := jsonutil.StringFromAny(urls[0])
streamURL := stringify(urls[0])
if streamURL == "" {
return nil
}
codec := strings.ToLower(jsonutil.StringFromAny(manifest["codecs"]))
codec := strings.ToLower(stringify(manifest["codecs"]))
ext := "m4a"
if strings.Contains(codec, "flac") {
ext = "flac"
@@ -682,14 +696,14 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
if profile.Container == "" {
profile = tidalAudioProfileFromExtension(ext)
}
audioQuality := strings.ToUpper(strings.TrimSpace(jsonutil.StringFromAny(resp["audioQuality"])))
audioQuality := strings.ToUpper(strings.TrimSpace(stringify(resp["audioQuality"])))
if audioQuality == "" {
audioQuality = strings.ToUpper(strings.TrimSpace(jsonutil.StringFromAny(manifest["audioQuality"])))
audioQuality = strings.ToUpper(strings.TrimSpace(stringify(manifest["audioQuality"])))
}
if audioQuality != "" {
profile = applyTidalAudioQuality(profile, audioQuality)
}
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(resp["audioMode"])), "ATMOS") {
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
profile = tidalAtmosAudioProfile()
}
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal", Audio: profile}
@@ -698,7 +712,7 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
func tidalAudioProfileFromFormats(formats []any) provider.AudioProfile {
best := provider.AudioProfile{}
for _, raw := range formats {
f := strings.ToUpper(strings.TrimSpace(jsonutil.StringFromAny(raw)))
f := strings.ToUpper(strings.TrimSpace(stringify(raw)))
switch {
case strings.Contains(f, "EAC3") || strings.Contains(f, "JOC") || strings.Contains(f, "ATMOS"):
return tidalAtmosAudioProfile()
@@ -970,20 +984,35 @@ func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values,
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "streamrip-go/0.1")
if basicAuth {
auth := base64.StdEncoding.EncodeToString([]byte(c.appClientID() + ":" + c.appClientSecret()))
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec))
req.Header.Set("Authorization", "Basic "+auth)
}
return req, nil
})
}
func stringify(v any) string {
switch t := v.(type) {
case string:
return t
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return ""
}
}
func enrichTidalImage(meta map[string]any) {
if _, ok := meta["image"].(map[string]any); ok {
return
}
cover := jsonutil.StringFromAny(meta["cover"])
cover := stringify(meta["cover"])
if cover == "" {
cover = jsonutil.StringFromAny(meta["squareImage"])
cover = stringify(meta["squareImage"])
}
if cover == "" {
return
@@ -1002,20 +1031,3 @@ func tidalImageMap(cover string) map[string]any {
"original": base + "/1280x1280.jpg",
}
}
// appClientID and appClientSecret read the vendor OAuth app credentials from
// config, falling back to the built-in defaults. Config is the source of truth
// so a rotation can be fixed without a rebuild.
func (c *Client) appClientID() string {
if v := strings.TrimSpace(c.cfg.Session.Tidal.AppClientID); v != "" {
return v
}
return config.DefaultTidalAppClientID
}
func (c *Client) appClientSecret() string {
if v := strings.TrimSpace(c.cfg.Session.Tidal.AppClientSecret); v != "" {
return v
}
return config.DefaultTidalAppClientSecret
}
+38 -27
View File
@@ -12,11 +12,10 @@ import (
"testing"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
)
func TestLoginMissingToken(t *testing.T) {
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = ""
c := New(&config.Config{File: cfgData, Session: cfgData})
err := c.Login(context.Background())
@@ -38,7 +37,7 @@ func TestSearch(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -74,7 +73,7 @@ func TestGetVideoDownloadable(t *testing.T) {
}))
defer server.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -144,7 +143,7 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -186,7 +185,7 @@ func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "MY"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -198,10 +197,10 @@ func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) {
if err != nil {
t.Fatalf("GetMetadata() err = %v", err)
}
if got := jsonutil.StringFromAny(meta["lyrics"]); got != "plain lyrics line" {
if got := stringify(meta["lyrics"]); got != "plain lyrics line" {
t.Fatalf("lyrics = %q, want plain lyrics line", got)
}
if got := jsonutil.StringFromAny(meta["lyrics_synced"]); got != "[00:00.00]plain lyrics line" {
if got := stringify(meta["lyrics_synced"]); got != "[00:00.00]plain lyrics line" {
t.Fatalf("lyrics_synced = %q, want synced lrc", got)
}
}
@@ -220,7 +219,7 @@ func TestGetMetadataTrackIgnoresLyricsEndpointFailure(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -258,7 +257,7 @@ func TestAPIRequestRetriesTooManyRequests(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Downloads.RequestsPerMinute = 0
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
@@ -275,8 +274,8 @@ func TestAPIRequestRetriesTooManyRequests(t *testing.T) {
if calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}
if jsonutil.StringFromAny(resp["title"]) != "Song" {
t.Fatalf("title = %q, want Song", jsonutil.StringFromAny(resp["title"]))
if stringify(resp["title"]) != "Song" {
t.Fatalf("title = %q, want Song", stringify(resp["title"]))
}
}
@@ -298,7 +297,7 @@ func TestAPIPostRetriesTooManyRequests(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Downloads.RequestsPerMinute = 0
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -312,8 +311,8 @@ func TestAPIPostRetriesTooManyRequests(t *testing.T) {
if calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}
if jsonutil.StringFromAny(resp["access_token"]) != "fresh-token" {
t.Fatalf("access_token = %q, want fresh-token", jsonutil.StringFromAny(resp["access_token"]))
if stringify(resp["access_token"]) != "fresh-token" {
t.Fatalf("access_token = %q, want fresh-token", stringify(resp["access_token"]))
}
}
@@ -343,7 +342,7 @@ func TestGetDownloadablePrefersAtmosWhenEnabled(t *testing.T) {
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
cfgData.Tidal.PreferAtmos = true
@@ -378,6 +377,28 @@ func TestPlaybackLooksAtmosFromManifest(t *testing.T) {
}
}
func TestTrackSupportsAtmosFromTags(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/tracks/42" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "audioModes": []any{"STEREO"}, "mediaMetadata": map[string]any{"tags": []any{"LOSSLESS", "DOLBY_ATMOS"}}})
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.baseURL = ts.URL + "/v1"
if !c.trackSupportsAtmos(context.Background(), "42") {
t.Fatalf("expected atmos support from mediaMetadata tags")
}
}
func TestFormatsForQuality(t *testing.T) {
tests := []struct {
name string
@@ -427,7 +448,7 @@ func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T)
}))
defer ts.Close()
cfgData := testConfigData()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
@@ -452,13 +473,3 @@ func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T)
t.Fatalf("unexpected audio profile: %+v", d.Audio)
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
+71 -59
View File
@@ -18,7 +18,6 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"streamrip-go/internal/config"
@@ -26,26 +25,25 @@ import (
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
"streamrip-go/internal/verbose"
)
const (
baseURL = "https://api.music.yandex.net"
desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2"
desktopOrigin = "music-application://desktop"
requestAttempts = 3
baseURL = "https://api.music.yandex.net"
desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2"
desktopOrigin = "music-application://desktop"
requestAttempts = 3
desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5"
legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA"
defaultEstimatedKbps = 50000
)
var ErrMissingYandexToken = errors.New("missing yandex access_token")
type Client struct {
cfg *config.Config
http *http.Client
limiter *ratelimit.Limiter
baseURL string
// authMu guards loggedIn and the login/refresh path, which download
// workers can reach concurrently.
authMu sync.Mutex
cfg *config.Config
http *http.Client
limiter *ratelimit.Limiter
baseURL string
loggedIn bool
userID string
}
@@ -54,7 +52,7 @@ func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL,
userID: strings.TrimSpace(cfg.Session.Yandex.UserID),
}
@@ -65,14 +63,10 @@ func (c *Client) Source() string {
}
func (c *Client) LoggedIn() bool {
c.authMu.Lock()
defer c.authMu.Unlock()
return c.loggedIn
}
func (c *Client) Login(ctx context.Context) error {
c.authMu.Lock()
defer c.authMu.Unlock()
if strings.TrimSpace(c.cfg.Session.Yandex.AccessToken) == "" {
return ErrMissingYandexToken
}
@@ -86,19 +80,16 @@ func (c *Client) Login(ctx context.Context) error {
result, _ := resp["result"].(map[string]any)
if uid := strings.TrimSpace(jsonutil.StringFromAny(result["uid"])); uid != "" {
c.userID = uid
if err := c.cfg.UpdateTokens(func(file, session *config.ConfigData) {
file.Yandex.UserID = uid
session.Yandex.UserID = uid
}); err != nil {
verbose.Printf(verbose.V, "yandex: could not persist user id: %v\n", err)
}
c.cfg.Session.Yandex.UserID = uid
c.cfg.File.Yandex.UserID = uid
_ = c.cfg.SaveFile()
}
c.loggedIn = true
return nil
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("yandex client not logged in")
}
switch mediaType {
@@ -116,7 +107,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("yandex client not logged in")
}
if limit <= 0 {
@@ -146,7 +137,7 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int)
}
func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) {
if !c.LoggedIn() {
if !c.loggedIn {
return nil, errors.New("yandex client not logged in")
}
trackID, _ := splitTrackRef(item)
@@ -177,7 +168,7 @@ func (c *Client) Close() error {
}
func (c *Client) getTrackMetadata(ctx context.Context, item string) (map[string]any, error) {
trackRef := strings.TrimSpace(item)
trackRef := canonicalTrackRequestID(item)
form := url.Values{}
form.Set("trackIds", trackRef)
form.Set("withMixData", "true")
@@ -343,7 +334,7 @@ func (c *Client) getLegacyDownloadable(ctx context.Context, trackID string) (*pr
if err != nil {
return nil, err
}
directURL, err := legacyDirectURL(c.legacyMP3Salt(), xmlInfo)
directURL, err := legacyDirectURL(xmlInfo)
if err != nil {
return nil, err
}
@@ -364,7 +355,7 @@ func (c *Client) getLegacyDownloadable(ctx context.Context, trackID string) (*pr
func (c *Client) getDesktopDownloadable(ctx context.Context, trackID string, quality int, transport string) (*provider.Downloadable, error) {
qualityParam, codecs := downloadRequestProfile(quality)
sign, ts := yandexDownloadSign(c.signKey(), trackID, qualityParam, codecs, transport)
sign, ts := yandexDownloadSign(trackID, qualityParam, codecs, transport)
params := url.Values{}
params.Set("trackId", trackID)
params.Set("codecs", strings.Join(codecs, ","))
@@ -430,11 +421,11 @@ func (c *Client) fetchLegacyXML(ctx context.Context, xmlURL string) (*legacyDown
return &out, nil
}
func legacyDirectURL(salt string, info *legacyDownloadInfoXML) (string, error) {
func legacyDirectURL(info *legacyDownloadInfoXML) (string, error) {
if info == nil || info.Host == "" || info.Path == "" || info.TS == "" || info.S == "" {
return "", errors.New("invalid yandex legacy xml payload")
}
sum := md5.Sum([]byte(salt + strings.TrimPrefix(info.Path, "/") + info.S))
sum := md5.Sum([]byte(legacyMP3SignSalt + strings.TrimPrefix(info.Path, "/") + info.S))
return "https://" + info.Host + "/get-mp3/" + hex.EncodeToString(sum[:]) + "/" + info.TS + info.Path, nil
}
@@ -574,14 +565,14 @@ func normalizeTrack(raw map[string]any, fallbackID string, artistSeparator strin
trackID := canonicalTrackRefFromRaw(raw, fallbackID)
albumRaw := firstAlbum(raw)
artistNames := artistNames(raw)
artistName := jsonutil.DisplayArtistNames(artistNames)
artistName := displayArtistNames(artistNames)
albumArtist := joinAlbumArtists(albumRaw, artistSeparator)
if albumArtist == "" {
albumArtist = artistName
}
trackNumber, discNumber := trackNumbers(albumRaw)
trackTotal := jsonutil.IntFromAny(albumRaw["trackCount"])
discTotal := jsonutil.FirstPositiveInt(
discTotal := firstPositiveInt(
jsonutil.IntFromAny(raw["volumesCount"]),
jsonutil.IntFromAny(albumRaw["volumesCount"]),
)
@@ -593,7 +584,7 @@ func normalizeTrack(raw map[string]any, fallbackID string, artistSeparator strin
"tracks_count": trackTotal,
"media_number": discNumber,
"numberOfVolumes": discTotal,
"release_date": jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["releaseDate"]), jsonutil.StringFromAny(albumRaw["releaseDate"])),
"release_date": firstNonEmpty(jsonutil.StringFromAny(raw["releaseDate"]), jsonutil.StringFromAny(albumRaw["releaseDate"])),
"parental_warning": isExplicit(raw),
"explicit": isExplicit(raw),
"source_track_id": jsonutil.StringFromAny(raw["realId"]),
@@ -681,9 +672,9 @@ func normalizePlaylist(raw map[string]any) map[string]any {
}
}
owner, _ := raw["owner"].(map[string]any)
ownerName := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
ownerName := firstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
return map[string]any{
"id": jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["playlistUuid"]), jsonutil.StringFromAny(raw["kind"])),
"id": firstNonEmpty(jsonutil.StringFromAny(raw["playlistUuid"]), jsonutil.StringFromAny(raw["kind"])),
"name": jsonutil.StringFromAny(raw["title"]),
"title": jsonutil.StringFromAny(raw["title"]),
"description": jsonutil.StringFromAny(raw["description"]),
@@ -726,7 +717,7 @@ func normalizeArtistSearchItem(raw map[string]any) map[string]any {
"id": jsonutil.StringFromAny(raw["id"]),
"title": jsonutil.StringFromAny(raw["name"]),
"name": jsonutil.StringFromAny(raw["name"]),
"albums_count": jsonutil.FirstPositiveInt(
"albums_count": firstPositiveInt(
jsonutil.IntFromAny(jsonutil.NestedAny(raw, "counts", "directAlbums")),
jsonutil.IntFromAny(jsonutil.NestedAny(raw, "counts", "tracks")),
),
@@ -735,8 +726,8 @@ func normalizeArtistSearchItem(raw map[string]any) map[string]any {
func normalizePlaylistSearchItem(raw map[string]any) map[string]any {
owner, _ := raw["owner"].(map[string]any)
ownerID := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(owner["uid"]), jsonutil.StringFromAny(raw["uid"]))
ownerName := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
ownerID := firstNonEmpty(jsonutil.StringFromAny(owner["uid"]), jsonutil.StringFromAny(raw["uid"]))
ownerName := firstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
return map[string]any{
"id": ownerID + ":" + jsonutil.StringFromAny(raw["kind"]),
"title": jsonutil.StringFromAny(raw["title"]),
@@ -780,7 +771,7 @@ func firstArtistID(raw map[string]any) string {
}
func joinArtists(raw map[string]any, artistSeparator string) string {
return jsonutil.DisplayArtistNames(artistNames(raw))
return displayArtistNames(artistNames(raw))
}
func artistNames(raw map[string]any) []string {
@@ -802,7 +793,7 @@ func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string {
if len(albumRaw) == 0 {
return ""
}
return jsonutil.DisplayArtistNames(albumArtistNames(albumRaw))
return displayArtistNames(albumArtistNames(albumRaw))
}
func albumArtistNames(albumRaw map[string]any) []string {
@@ -820,6 +811,19 @@ func albumArtistNames(albumRaw map[string]any) []string {
return parts
}
func displayArtistNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " & " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1]
}
}
func (c *Client) artistSeparator() string {
if c.cfg.Session.Metadata.ArtistSeparator == "" {
return "; "
@@ -833,14 +837,22 @@ func trackNumbers(albumRaw map[string]any) (int, int) {
if trackNumber > 0 {
trackNumber++
}
return trackNumber, jsonutil.FirstPositiveInt(jsonutil.IntFromAny(position["volume"]), 1)
return trackNumber, firstPositiveInt(jsonutil.IntFromAny(position["volume"]), 1)
}
func canonicalTrackRequestID(item string) string {
item = strings.TrimSpace(item)
if item == "" {
return item
}
return item
}
func canonicalTrackRefFromRaw(raw map[string]any, fallback string) string {
if strings.Contains(strings.TrimSpace(fallback), ":") {
return strings.TrimSpace(fallback)
}
trackID := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["realId"]), jsonutil.StringFromAny(raw["id"]))
trackID := firstNonEmpty(jsonutil.StringFromAny(raw["realId"]), jsonutil.StringFromAny(raw["id"]))
albumID := jsonutil.StringFromAny(firstAlbum(raw)["id"])
if trackID == "" {
trackID = strings.TrimSpace(fallback)
@@ -907,7 +919,7 @@ func imageMapFromTrack(raw map[string]any, albumRaw map[string]any) map[string]a
}
func imageMapFromURI(raw map[string]any) map[string]any {
uri := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["ogImage"]), jsonutil.StringFromAny(raw["coverUri"]))
uri := firstNonEmpty(jsonutil.StringFromAny(raw["ogImage"]), jsonutil.StringFromAny(raw["coverUri"]))
if uri == "" {
if cover, ok := raw["cover"].(map[string]any); ok {
uri = jsonutil.StringFromAny(cover["uri"])
@@ -942,11 +954,9 @@ func isExplicit(raw map[string]any) bool {
return jsonutil.BoolFromAny(raw["explicit"])
}
// yandexDownloadSign signs a download request. signKey comes from config
// (Client.signKey) so a rotation can be fixed without a rebuild.
func yandexDownloadSign(signKey, trackID, quality string, codecs []string, transport string) (string, int64) {
func yandexDownloadSign(trackID, quality string, codecs []string, transport string) (string, int64) {
ts := time.Now().Unix()
mac := hmac.New(sha256.New, []byte(signKey))
mac := hmac.New(sha256.New, []byte(desktopWindowsSignKey))
_, _ = mac.Write([]byte(strconv.FormatInt(ts, 10) + trackID + quality + strings.Join(codecs, "") + transport))
return strings.TrimRight(base64.StdEncoding.EncodeToString(mac.Sum(nil)), "="), ts
}
@@ -1019,18 +1029,20 @@ func waitRetry(ctx context.Context, delay time.Duration) error {
}
}
// signKey and legacyMP3Salt read the vendor request-signing keys from config,
// falling back to the built-in defaults.
func (c *Client) signKey() string {
if v := strings.TrimSpace(c.cfg.Session.Yandex.SignKey); v != "" {
return v
func firstPositiveInt(vals ...int) int {
for _, v := range vals {
if v > 0 {
return v
}
}
return config.DefaultYandexSignKey
return 0
}
func (c *Client) legacyMP3Salt() string {
if v := strings.TrimSpace(c.cfg.Session.Yandex.LegacyMP3SignSalt); v != "" {
return v
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return config.DefaultYandexLegacyMP3Salt
return ""
}
+11 -14
View File
@@ -15,7 +15,7 @@ import (
)
func TestYandexDownloadSignMatchesCapturedFormat(t *testing.T) {
sign, ts := yandexDownloadSign(config.DefaultYandexSignKey, "32038184", "lossless", []string{"flac", "aac", "he-aac", "mp3", "flac-mp4", "aac-mp4", "he-aac-mp4"}, "raw")
sign, ts := yandexDownloadSign("32038184", "lossless", []string{"flac", "aac", "he-aac", "mp3", "flac-mp4", "aac-mp4", "he-aac-mp4"}, "raw")
if ts <= 0 {
t.Fatalf("timestamp = %d", ts)
}
@@ -60,7 +60,7 @@ func TestGetDownloadableUsesModernGetFileInfo(t *testing.T) {
}))
defer ts.Close()
d := testConfigData()
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = 0
d.Yandex.AccessToken = "token"
c := New(&config.Config{File: d, Session: d})
@@ -132,7 +132,7 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
}))
defer ts.Close()
d := testConfigData()
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = 0
d.Yandex.AccessToken = "token"
c := New(&config.Config{File: d, Session: d})
@@ -167,8 +167,15 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
}
}
func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) {
got := displayArtistNames([]string{"A", "B", "C"})
if got != "A, B & C" {
t.Fatalf("displayArtistNames() = %q", got)
}
}
func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {
url, err := legacyDirectURL(config.DefaultYandexLegacyMP3Salt, &legacyDownloadInfoXML{
url, err := legacyDirectURL(&legacyDownloadInfoXML{
Host: "example.test",
Path: "/abc123",
TS: "1234567890",
@@ -182,13 +189,3 @@ func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {
t.Fatalf("legacyDirectURL() = %q, want %q", url, want)
}
}
// testConfigData returns defaults with request throttling disabled.
//
// At the default 60 requests/minute the rate limiter sleeps 1s per request,
// which accounted for most of this suite's runtime (deezer alone was 31s).
func testConfigData() config.ConfigData {
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = -1
return d
}
+16 -51
View File
@@ -1,5 +1,3 @@
// Package ratelimit provides the API request throttle shared by the provider
// clients.
package ratelimit
import (
@@ -8,54 +6,20 @@ import (
"time"
)
// Limiter is a token bucket: it enforces a sustained rate of
// requestsPerMinute while allowing up to burst requests to proceed
// immediately when budget has accrued.
//
// It used to space every request exactly interval apart, with no burst. That
// made max_connections meaningless for anything that goes through the API:
// with the default 60 requests/minute, six download workers each needing a
// metadata call and a stream-URL call could only start one track per two
// seconds, so an album appeared to download serially even though the transfers
// themselves were fast.
type Limiter struct {
interval time.Duration
burst int
mu sync.Mutex
// next is the time the next token becomes available. It is allowed to
// trail now by up to (burst-1)*interval, which is what accrues burst
// budget while no requests are being made.
mu sync.Mutex
next time.Time
}
// New returns a limiter allowing requestsPerMinute sustained, with burst
// requests permitted back-to-back. A non-positive requestsPerMinute disables
// throttling entirely.
//
// burst is clamped to at least 1 and to at most one minute's worth of
// requests, so a very low rate cannot be bypassed by a large burst.
func New(requestsPerMinute int, burst int) *Limiter {
func New(requestsPerMinute int) *Limiter {
if requestsPerMinute <= 0 {
return &Limiter{}
}
if burst < 1 {
burst = 1
}
if burst > requestsPerMinute {
burst = requestsPerMinute
}
return &Limiter{
interval: time.Minute / time.Duration(requestsPerMinute),
burst: burst,
}
return &Limiter{interval: time.Minute / time.Duration(requestsPerMinute)}
}
// Wait blocks until the caller may issue a request, or until ctx is done.
//
// Budget is reserved at call time, so a cancelled Wait still consumes its
// slot. That keeps the sustained rate honest under cancellation, at the cost
// of briefly under-using the budget.
func (l *Limiter) Wait(ctx context.Context) error {
if l.interval <= 0 {
return nil
@@ -63,21 +27,22 @@ func (l *Limiter) Wait(ctx context.Context) error {
l.mu.Lock()
now := time.Now()
// Accrue budget for idle time, capped at burst tokens.
earliest := now.Add(-time.Duration(l.burst-1) * l.interval)
if l.next.Before(earliest) {
l.next = earliest
}
wake := l.next
l.next = l.next.Add(l.interval)
l.mu.Unlock()
delay := time.Until(wake)
if delay <= 0 {
if l.next.IsZero() {
l.next = now.Add(l.interval)
l.mu.Unlock()
return nil
}
timer := time.NewTimer(delay)
wake := l.next
if now.After(wake) {
l.next = now.Add(l.interval)
l.mu.Unlock()
return nil
}
l.next = l.next.Add(l.interval)
l.mu.Unlock()
timer := time.NewTimer(time.Until(wake))
defer timer.Stop()
select {
+4 -92
View File
@@ -2,25 +2,19 @@ package ratelimit
import (
"context"
"sync"
"testing"
"time"
)
func TestLimiterDisabled(t *testing.T) {
for _, rpm := range []int{-1, 0} {
l := New(rpm, 6)
for i := 0; i < 10; i++ {
if err := l.Wait(context.Background()); err != nil {
t.Fatalf("New(%d).Wait() error = %v", rpm, err)
}
}
l := New(-1)
if err := l.Wait(context.Background()); err != nil {
t.Fatalf("Wait() error = %v", err)
}
}
func TestLimiterContextCancel(t *testing.T) {
// burst 1 means strict spacing, so the second request must block.
l := New(1, 1)
l := New(1)
if err := l.Wait(context.Background()); err != nil {
t.Fatalf("first Wait() error = %v", err)
}
@@ -32,85 +26,3 @@ func TestLimiterContextCancel(t *testing.T) {
t.Fatalf("expected context error")
}
}
// TestLimiterAllowsBurst is the regression test for album rips appearing
// serial: with the old strict-spacing limiter, max_connections workers could
// not get their API calls through concurrently, so only one track downloaded
// at a time.
func TestLimiterAllowsBurst(t *testing.T) {
const burst = 6
l := New(60, burst) // the shipped defaults
start := time.Now()
for i := 0; i < burst; i++ {
if err := l.Wait(context.Background()); err != nil {
t.Fatalf("Wait() %d error = %v", i, err)
}
}
elapsed := time.Since(start)
// 60 rpm is one token per second; the burst must not have waited.
if elapsed > 200*time.Millisecond {
t.Fatalf("%d burst requests took %v, want them to proceed immediately", burst, elapsed)
}
}
// TestLimiterEnforcesSustainedRate checks the burst does not become a way to
// exceed the configured rate: once the bucket is drained, requests are spaced
// by the interval again.
func TestLimiterEnforcesSustainedRate(t *testing.T) {
// 6000 rpm = 10ms per token, so this stays fast.
const burst = 3
l := New(6000, burst)
ctx := context.Background()
for i := 0; i < burst; i++ {
if err := l.Wait(ctx); err != nil {
t.Fatal(err)
}
}
// The bucket is drained; the next two must each wait about one interval.
start := time.Now()
for i := 0; i < 2; i++ {
if err := l.Wait(ctx); err != nil {
t.Fatal(err)
}
}
elapsed := time.Since(start)
if elapsed < 15*time.Millisecond {
t.Fatalf("post-burst requests took %v, want them throttled to ~10ms each", elapsed)
}
}
// TestLimiterBurstCappedByRate stops a large max_connections from letting a
// deliberately tiny rate through in one go.
func TestLimiterBurstCappedByRate(t *testing.T) {
l := New(2, 64)
if l.burst != 2 {
t.Fatalf("burst = %d, want it clamped to the rate (2)", l.burst)
}
}
func TestLimiterConcurrentCallersShareTheRate(t *testing.T) {
const burst = 8
l := New(6000, burst)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < burst; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := l.Wait(context.Background()); err != nil {
t.Errorf("Wait() error = %v", err)
}
}()
}
wg.Wait()
// All within the burst, so none should have blocked meaningfully.
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("%d concurrent burst requests took %v", burst, elapsed)
}
}
+7 -164
View File
@@ -3,40 +3,23 @@ package store
import (
"context"
"database/sql"
"fmt"
"io"
"net/url"
"os"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
type SQLite struct {
db *sql.DB
path string
mu sync.Mutex
// hasLegacyRows is set when the database was migrated from the pre-source
// schema. Those rows have no source, so IsDownloaded has to consider them
// separately or the entire pre-migration library looks undownloaded.
hasLegacyRows bool
legacyWarned bool
db *sql.DB
mu sync.Mutex
}
func NewSQLite(path string) (*SQLite, error) {
db, err := sql.Open("sqlite", sqliteDSN(path))
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
// One writer at a time. SQLite allows a single writer anyway, and the
// mutex below already serializes this process; capping the pool keeps the
// driver from opening connections that can only contend with each other.
db.SetMaxOpenConns(1)
s := &SQLite{db: db, path: path}
s := &SQLite{db: db}
if err = s.init(); err != nil {
_ = db.Close()
return nil, err
@@ -44,70 +27,6 @@ func NewSQLite(path string) (*SQLite, error) {
return s, nil
}
// sqliteDSN builds a file: URI with the pragmas this app needs.
//
// WAL lets a concurrent reader (for example `rip db browse`) run while a rip
// is writing, and busy_timeout makes a contended write wait instead of failing
// immediately with SQLITE_BUSY. The path is passed through url.URL so that a
// config path containing '?' or '#' is not parsed as query parameters.
func sqliteDSN(path string) string {
u := url.URL{Scheme: "file", Opaque: (&url.URL{Path: path}).EscapedPath()}
q := url.Values{}
q.Add("_pragma", "busy_timeout(10000)")
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "foreign_keys(on)")
return u.String() + "?" + q.Encode()
}
// schemaVersion is stored in PRAGMA user_version so later migrations can be
// ordered explicitly instead of being inferred from PRAGMA table_info.
const schemaVersion = 1
func (s *SQLite) userVersion() (int, error) {
var v int
if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&v); err != nil {
return 0, err
}
return v, nil
}
func (s *SQLite) setUserVersion(v int) error {
_, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, v))
return err
}
// backupFile copies the database aside before a destructive migration. A
// migration that loses the download history silently costs the user a full
// re-download, so keep a copy they can fall back to.
func (s *SQLite) backupFile() (string, error) {
if strings.TrimSpace(s.path) == "" {
return "", nil
}
src, err := os.Open(s.path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
defer func() { _ = src.Close() }()
dstPath := fmt.Sprintf("%s.bak-%d", s.path, time.Now().Unix())
dst, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return "", err
}
if _, err = io.Copy(dst, src); err != nil {
_ = dst.Close()
_ = os.Remove(dstPath)
return "", err
}
if err = dst.Close(); err != nil {
return "", err
}
return dstPath, nil
}
func (s *SQLite) init() error {
if err := s.ensureDownloadsSchema(); err != nil {
return err
@@ -128,20 +47,6 @@ func (s *SQLite) init() error {
}
}
// A database migrated by an earlier version has sourceless rows but no
// in-memory flag, so detect them here rather than only at migration time.
if !s.hasLegacyRows {
if n, err := s.legacyRowCount(context.Background()); err == nil && n > 0 {
s.hasLegacyRows = true
}
}
if v, err := s.userVersion(); err == nil && v < schemaVersion {
if err = s.setUserVersion(schemaVersion); err != nil {
return err
}
}
return nil
}
@@ -189,14 +94,6 @@ func (s *SQLite) ensureDownloadsSchema() error {
return err
}
// Legacy schema: a downloads table with no source column. Back the file up
// first, because the rows below can only be carried over without a source
// and that is not fully reversible.
backupPath, backupErr := s.backupFile()
if backupErr != nil {
return fmt.Errorf("could not back up %s before migrating: %w", s.path, backupErr)
}
tx, err := s.db.Begin()
if err != nil {
return err
@@ -223,27 +120,12 @@ func (s *SQLite) ensureDownloadsSchema() error {
if _, err = tx.Exec(`INSERT OR IGNORE INTO downloads(source, id) SELECT '', id FROM downloads_legacy`); err != nil {
return err
}
// downloads_legacy is deliberately kept. Dropping it used to make the
// migration unrecoverable, and IsDownloaded needs the sourceless rows to
// avoid re-downloading everything the user already has.
if err = tx.Commit(); err != nil {
if _, err = tx.Exec(`DROP TABLE downloads_legacy`); err != nil {
return err
}
s.hasLegacyRows = true
if backupPath != "" {
fmt.Fprintf(os.Stderr, "download database migrated to the source-aware schema; previous copy kept at %s\n", backupPath)
}
return nil
}
// legacyRowCount reports how many pre-migration rows exist. Rows carried over
// from the old schema have an empty source.
func (s *SQLite) legacyRowCount(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM downloads WHERE source = ''`).Scan(&n)
return n, err
err = tx.Commit()
return err
}
func (s *SQLite) IsDownloaded(ctx context.Context, source, id string) (bool, error) {
@@ -255,26 +137,6 @@ func (s *SQLite) IsDownloaded(ctx context.Context, source, id string) (bool, err
if err != nil {
return false, err
}
if count > 0 {
return true, nil
}
// Rows migrated from the pre-source schema have an empty source, so an
// exact match on (source, id) misses them and the whole pre-migration
// library would look undownloaded. Matching on id alone can only ever
// over-match across sources, which costs a skipped re-download rather than
// a duplicated library.
if !s.hasLegacyRows {
return false, nil
}
err = s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM downloads WHERE source = '' AND id = ?`, id).Scan(&count)
if err != nil {
return false, err
}
if count > 0 && !s.legacyWarned {
s.legacyWarned = true
fmt.Fprintf(os.Stderr, "note: matching downloads recorded before sources were tracked; pass --force to re-download them\n")
}
return count > 0, nil
}
@@ -300,25 +162,6 @@ func (s *SQLite) MarkFailed(ctx context.Context, source, mediaType, id string) e
return err
}
// IsFailed reports whether a previous attempt at this item was recorded as
// failed. It complements MarkFailed and is what the retry/repair workflows and
// `rip database browse failed` are built on.
func (s *SQLite) IsFailed(ctx context.Context, source, mediaType, id string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
var count int
err := s.db.QueryRowContext(
ctx,
`SELECT COUNT(1) FROM failed_downloads WHERE source = ? AND media_type = ? AND id = ?`,
source, mediaType, id,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *SQLite) Close() error {
if s.db == nil {
return nil
-195
View File
@@ -2,12 +2,7 @@ package store
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
@@ -53,193 +48,3 @@ 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)
}
}