mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-17 14:27:59 +02:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7cc4e639c
|
||
|
|
da4add57fe
|
||
|
|
26fb466afc
|
||
|
|
b5d4d5688d
|
||
|
|
78d4f0316d
|
||
|
|
ff5fb1cdb5
|
||
|
|
384f32cd67
|
||
|
|
b3b6a2eb1d
|
||
|
|
201aa5daa7
|
||
|
|
c030d1be09
|
||
|
|
eb7854bac3
|
||
|
|
5f61b1a3cf
|
||
|
|
e336bb96f1
|
||
|
|
99c531928e
|
||
|
|
150b5b5d85
|
||
|
|
80bfbe0ecb
|
||
|
|
3413de1daa
|
||
|
|
d2fa098d69
|
||
|
|
537959b6ec
|
||
|
|
2a7d259e9f
|
||
|
|
b65edb4cce
|
||
|
|
db26a40415 |
No files matched your search
@@ -0,0 +1,61 @@
|
||||
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
@@ -0,0 +1,21 @@
|
||||
# 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/
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
@@ -0,0 +1,63 @@
|
||||
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}'
|
||||
@@ -0,0 +1,172 @@
|
||||
# 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.
|
||||
+57
-28
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -25,6 +26,8 @@ type globalOptions struct {
|
||||
noProgress bool
|
||||
noSSLVerify bool
|
||||
verbose int
|
||||
help bool
|
||||
version bool
|
||||
command string
|
||||
commandArgs []string
|
||||
}
|
||||
@@ -45,6 +48,12 @@ func parseGlobalArgs(args []string) (globalOptions, error) {
|
||||
}
|
||||
|
||||
switch {
|
||||
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":
|
||||
@@ -120,16 +129,10 @@ 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) {
|
||||
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)
|
||||
}
|
||||
return config.NormalizeCodec(raw)
|
||||
}
|
||||
|
||||
func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
|
||||
@@ -140,10 +143,7 @@ func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
|
||||
cfg.Session.Database.DownloadsEnabled = false
|
||||
}
|
||||
if opts.qualitySet {
|
||||
cfg.Session.Qobuz.Quality = opts.quality
|
||||
cfg.Session.Tidal.Quality = opts.quality
|
||||
cfg.Session.Deezer.Quality = opts.quality
|
||||
cfg.Session.Soundcloud.Quality = opts.quality
|
||||
applyQualityOverride(cfg, opts.quality)
|
||||
}
|
||||
if opts.codecSet {
|
||||
cfg.Session.Conversion.Enabled = true
|
||||
@@ -157,6 +157,50 @@ 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 ""
|
||||
@@ -200,18 +244,3 @@ 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 ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isHelpArg(arg string) bool {
|
||||
return arg == "-h" || arg == "--help"
|
||||
}
|
||||
|
||||
func commandWantsHelp(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if isHelpArg(arg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func printMainHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `streamrip-go
|
||||
|
||||
Usage:
|
||||
rip [global options] <command> [command options]
|
||||
rip help [command]
|
||||
|
||||
Commands:
|
||||
url Rip one or more URLs
|
||||
file Rip URLs or IDs from a file
|
||||
id Rip by source, media type, and ID
|
||||
search Search a provider and download selected results
|
||||
lastfm Import a Last.fm playlist
|
||||
config Manage configuration
|
||||
database Inspect download databases
|
||||
|
||||
Global options:
|
||||
-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 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
|
||||
-V, --version Show version
|
||||
-h, --help Show help
|
||||
|
||||
Run 'rip help <command>' for command-specific help.
|
||||
`)
|
||||
}
|
||||
|
||||
func printCommandHelp(w io.Writer, command string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(command)) {
|
||||
case "url":
|
||||
fmt.Fprint(w, `Usage:
|
||||
rip url <url...> [--force|--ignore-db]
|
||||
|
||||
Rip one or more provider URLs.
|
||||
|
||||
Examples:
|
||||
rip url https://www.beatport.com/release/example/123
|
||||
rip url https://play.qobuz.com/album/abc --force
|
||||
|
||||
Options:
|
||||
--force, --ignore-db Redownload even if already in the database
|
||||
`)
|
||||
case "file":
|
||||
fmt.Fprint(w, `Usage:
|
||||
rip file <path> [--force|--ignore-db]
|
||||
|
||||
Rip URLs or JSON ID entries from a file.
|
||||
|
||||
Options:
|
||||
--force, --ignore-db Redownload even if already in the database
|
||||
`)
|
||||
case "id":
|
||||
fmt.Fprint(w, `Usage:
|
||||
rip id <source> <media-type> <id> [quality] [--force|--ignore-db]
|
||||
|
||||
Rip an item by provider ID.
|
||||
|
||||
Sources:
|
||||
qobuz, tidal, deezer, yandex, beatport, soundcloud
|
||||
|
||||
Media types:
|
||||
track, album, playlist, artist, label, chart, video
|
||||
|
||||
Options:
|
||||
quality Override quality for this rip, 0-4
|
||||
--force, --ignore-db Redownload even if already in the database
|
||||
`)
|
||||
case "search":
|
||||
fmt.Fprint(w, `Usage:
|
||||
rip search <source> <media-type> <query...> [options]
|
||||
|
||||
Search a provider and optionally download selected results.
|
||||
|
||||
Sources:
|
||||
qobuz, tidal, deezer, yandex, beatport, soundcloud
|
||||
|
||||
Media types:
|
||||
track, album, playlist, artist, label, chart, video
|
||||
|
||||
Options:
|
||||
--limit N Maximum results to show
|
||||
--first Download the first result without prompting
|
||||
--no-download Show results only
|
||||
--output-file <path> Write results as JSON
|
||||
--force, --ignore-db Redownload even if already in the database
|
||||
`)
|
||||
case "lastfm":
|
||||
fmt.Fprint(w, `Usage:
|
||||
rip lastfm [--source SOURCE] [--fallback-source SOURCE] <playlist_url>
|
||||
|
||||
Import a Last.fm playlist and resolve tracks through configured providers.
|
||||
|
||||
Options:
|
||||
--source SOURCE Primary lookup source
|
||||
--fallback-source SOURCE Fallback lookup source
|
||||
`)
|
||||
case "config":
|
||||
fmt.Fprint(w, `Usage:
|
||||
rip config <open|reset|path> [options]
|
||||
|
||||
Manage configuration.
|
||||
|
||||
Commands:
|
||||
open Open the config file in an editor
|
||||
reset Reset config to defaults
|
||||
path Print the config path
|
||||
|
||||
Options:
|
||||
-v, --vim Use Vim for 'rip config open'
|
||||
-y, --yes Confirm 'rip config reset' without prompting
|
||||
`)
|
||||
case "database":
|
||||
fmt.Fprint(w, `Usage:
|
||||
rip database browse <downloads|failed>
|
||||
|
||||
Inspect local download databases.
|
||||
`)
|
||||
case "dev-help":
|
||||
printDeveloperHelp(w)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func printDeveloperHelp(w io.Writer) {
|
||||
fmt.Fprint(w, `Developer smoke commands:
|
||||
soundcloud-smoke
|
||||
qobuz-smoke, qobuz-convert-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>
|
||||
`)
|
||||
}
|
||||
+6
-5
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/app"
|
||||
"streamrip-go/internal/jsonutil"
|
||||
"streamrip-go/internal/urlparse"
|
||||
)
|
||||
|
||||
@@ -65,7 +66,7 @@ func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool {
|
||||
fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind)
|
||||
return false
|
||||
}
|
||||
if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "yandex" && parsed.Source != "soundcloud" {
|
||||
if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "yandex" && parsed.Source != "beatport" && parsed.Source != "soundcloud" {
|
||||
fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw)
|
||||
return false
|
||||
}
|
||||
@@ -94,12 +95,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(asString(entry["source"])))
|
||||
mediaType := strings.ToLower(strings.TrimSpace(asString(entry["media_type"])))
|
||||
source := strings.ToLower(strings.TrimSpace(jsonutil.StringFromAny(entry["source"])))
|
||||
mediaType := strings.ToLower(strings.TrimSpace(jsonutil.StringFromAny(entry["media_type"])))
|
||||
if mediaType == "" {
|
||||
mediaType = strings.ToLower(strings.TrimSpace(asString(entry["mediaType"])))
|
||||
mediaType = strings.ToLower(strings.TrimSpace(jsonutil.StringFromAny(entry["mediaType"])))
|
||||
}
|
||||
id := strings.TrimSpace(asString(entry["id"]))
|
||||
id := strings.TrimSpace(jsonutil.StringFromAny(entry["id"]))
|
||||
if source == "" || mediaType == "" || id == "" {
|
||||
return nil, nil, 0, true, fmt.Errorf("json item %d missing source/media_type/id", i+1)
|
||||
}
|
||||
|
||||
+5
-13
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/app"
|
||||
"streamrip-go/internal/jsonutil"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
)
|
||||
@@ -241,8 +242,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(firstNonEmpty(m[1], m[2])))
|
||||
artist := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[3], m[4])))
|
||||
title := html.UnescapeString(strings.TrimSpace(jsonutil.FirstNonEmpty(m[1], m[2])))
|
||||
artist := html.UnescapeString(strings.TrimSpace(jsonutil.FirstNonEmpty(m[3], m[4])))
|
||||
if title == "" || artist == "" {
|
||||
continue
|
||||
}
|
||||
@@ -256,8 +257,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(firstNonEmpty(titles[i][1], titles[i][2]))
|
||||
artistRaw := strings.TrimSpace(firstNonEmpty(titles[i+1][1], titles[i+1][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]))
|
||||
if strings.EqualFold(titleRaw, "Play on YouTube") || strings.EqualFold(artistRaw, "Play on YouTube") {
|
||||
continue
|
||||
}
|
||||
@@ -271,15 +272,6 @@ 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 := ""
|
||||
|
||||
+63
-1124
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -206,6 +207,58 @@ func TestParseGlobalArgsNoDBBeforeCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalArgsHelp(t *testing.T) {
|
||||
opts, err := parseGlobalArgs([]string{"--help"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseGlobalArgs() error = %v", err)
|
||||
}
|
||||
if !opts.help {
|
||||
t.Fatalf("expected help=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandWantsHelp(t *testing.T) {
|
||||
if !commandWantsHelp([]string{"https://example.com", "-h"}) {
|
||||
t.Fatalf("expected command help")
|
||||
}
|
||||
if commandWantsHelp([]string{"https://example.com"}) {
|
||||
t.Fatalf("did not expect command help")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainHelpHidesDeveloperCommands(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
printMainHelp(&buf)
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "rip help [command]") || !strings.Contains(out, "Commands:") {
|
||||
t.Fatalf("main help missing expected text: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "dev-help") || strings.Contains(out, "smoke") {
|
||||
t.Fatalf("main help should hide developer commands: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandHelpURL(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if !printCommandHelp(&buf, "url") {
|
||||
t.Fatalf("expected url help")
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "rip url <url...>") || !strings.Contains(out, "--ignore-db") {
|
||||
t.Fatalf("url help missing expected text: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintCommandHelpRejectsUnknown(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if printCommandHelp(&buf, "nope") {
|
||||
t.Fatalf("unexpected help for unknown command")
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("unexpected output for unknown command: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalArgsAllOfficialFlags(t *testing.T) {
|
||||
opts, err := parseGlobalArgs([]string{
|
||||
"--config-path", "/tmp/custom.toml",
|
||||
|
||||
+113
-208
@@ -110,84 +110,6 @@ 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
|
||||
@@ -293,12 +215,12 @@ func writeSearchResultsToFile(source, mediaType string, results []searchResult,
|
||||
}
|
||||
|
||||
func isAllowedSearchSource(source string) bool {
|
||||
return source == "qobuz" || source == "tidal" || source == "deezer" || source == "yandex" || source == "soundcloud"
|
||||
return source == "qobuz" || source == "tidal" || source == "deezer" || source == "yandex" || source == "beatport" || source == "soundcloud"
|
||||
}
|
||||
|
||||
func isAllowedMediaType(mediaType string) bool {
|
||||
switch mediaType {
|
||||
case "track", "album", "playlist", "artist", "label", "video":
|
||||
case "track", "album", "playlist", "artist", "label", "video", "chart":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -318,7 +240,7 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e
|
||||
}
|
||||
|
||||
for {
|
||||
source, err := read("Source [qobuz/tidal/deezer/yandex/soundcloud]: ")
|
||||
source, err := read("Source [qobuz/tidal/deezer/yandex/beatport/soundcloud]: ")
|
||||
if err != nil {
|
||||
return "", "", searchOptions{}, err
|
||||
}
|
||||
@@ -345,6 +267,10 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e
|
||||
fmt.Println("Yandex search supports track, album, playlist, and artist only.")
|
||||
continue
|
||||
}
|
||||
if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" {
|
||||
fmt.Println("Beatport search supports track, album, and label only.")
|
||||
continue
|
||||
}
|
||||
|
||||
query, err := read("Query: ")
|
||||
if err != nil {
|
||||
@@ -404,38 +330,38 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := asString(itm["id"])
|
||||
title := asString(itm["title"])
|
||||
id := jsonutil.StringFromAny(itm["id"])
|
||||
title := jsonutil.StringFromAny(itm["title"])
|
||||
if title == "" {
|
||||
title = asString(itm["name"])
|
||||
title = jsonutil.StringFromAny(itm["name"])
|
||||
}
|
||||
if version := asString(itm["version"]); version != "" {
|
||||
if version := jsonutil.StringFromAny(itm["version"]); version != "" {
|
||||
title += " (" + version + ")"
|
||||
}
|
||||
artist := nestedSearchString(itm, "artist", "name")
|
||||
artist := jsonutil.NestedString(itm, "artist", "name")
|
||||
if artist == "" {
|
||||
artist = nestedSearchString(itm, "performer", "name")
|
||||
artist = jsonutil.NestedString(itm, "performer", "name")
|
||||
}
|
||||
album := nestedSearchString(itm, "album", "title")
|
||||
trackCount := searchInt(itm["tracks_count"])
|
||||
album := jsonutil.NestedString(itm, "album", "title")
|
||||
trackCount := jsonutil.IntFromAny(itm["tracks_count"])
|
||||
if trackCount == 0 {
|
||||
trackCount = searchInt(itm["track_count"])
|
||||
trackCount = jsonutil.IntFromAny(itm["track_count"])
|
||||
}
|
||||
explicit := searchBool(itm["parental_warning"])
|
||||
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"),
|
||||
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"),
|
||||
)
|
||||
releases := 0
|
||||
if mediaType == "artist" {
|
||||
releases = firstPositiveInt(
|
||||
searchInt(itm["albums_count"]),
|
||||
searchInt(itm["albumsCount"]),
|
||||
nestedSearchInt(itm, "albums", "total"),
|
||||
releases = jsonutil.FirstPositiveInt(
|
||||
jsonutil.IntFromAny(itm["albums_count"]),
|
||||
jsonutil.IntFromAny(itm["albumsCount"]),
|
||||
jsonutil.IntFromAny(jsonutil.NestedAny(itm, "albums", "total")),
|
||||
)
|
||||
}
|
||||
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
|
||||
@@ -453,39 +379,39 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
|
||||
if wrapped, ok := itm["item"].(map[string]any); ok {
|
||||
itm = wrapped
|
||||
}
|
||||
id := asString(itm["id"])
|
||||
title := asString(itm["title"])
|
||||
id := jsonutil.StringFromAny(itm["id"])
|
||||
title := jsonutil.StringFromAny(itm["title"])
|
||||
if title == "" {
|
||||
title = asString(itm["name"])
|
||||
title = jsonutil.StringFromAny(itm["name"])
|
||||
}
|
||||
artist := nestedSearchString(itm, "artist", "name")
|
||||
artist := jsonutil.NestedString(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 = asString(a0["name"])
|
||||
artist = jsonutil.StringFromAny(a0["name"])
|
||||
}
|
||||
}
|
||||
}
|
||||
album := nestedSearchString(itm, "album", "title")
|
||||
trackCount := searchInt(itm["numberOfTracks"])
|
||||
album := jsonutil.NestedString(itm, "album", "title")
|
||||
trackCount := jsonutil.IntFromAny(itm["numberOfTracks"])
|
||||
if trackCount == 0 {
|
||||
trackCount = searchInt(itm["tracks_count"])
|
||||
trackCount = jsonutil.IntFromAny(itm["tracks_count"])
|
||||
}
|
||||
explicit := searchBool(itm["explicit"])
|
||||
date := firstNonEmpty(
|
||||
asString(itm["releaseDate"]),
|
||||
asString(itm["streamStartDate"]),
|
||||
asString(itm["release_date"]),
|
||||
nestedSearchString(itm, "album", "releaseDate"),
|
||||
nestedSearchString(itm, "album", "release_date"),
|
||||
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"),
|
||||
)
|
||||
releases := 0
|
||||
if mediaType == "artist" {
|
||||
releases = firstPositiveInt(
|
||||
searchInt(itm["numberOfAlbums"]),
|
||||
searchInt(itm["albums_count"]),
|
||||
searchInt(itm["albumsCount"]),
|
||||
nestedSearchInt(itm, "albums", "total"),
|
||||
releases = jsonutil.FirstPositiveInt(
|
||||
jsonutil.IntFromAny(itm["numberOfAlbums"]),
|
||||
jsonutil.IntFromAny(itm["albums_count"]),
|
||||
jsonutil.IntFromAny(itm["albumsCount"]),
|
||||
jsonutil.IntFromAny(jsonutil.NestedAny(itm, "albums", "total")),
|
||||
)
|
||||
}
|
||||
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
|
||||
@@ -505,25 +431,25 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := asString(itm["id"])
|
||||
title := asString(itm["title"])
|
||||
id := jsonutil.StringFromAny(itm["id"])
|
||||
title := jsonutil.StringFromAny(itm["title"])
|
||||
if title == "" {
|
||||
title = asString(itm["name"])
|
||||
title = jsonutil.StringFromAny(itm["name"])
|
||||
}
|
||||
artist := nestedSearchString(itm, "artist", "name")
|
||||
album := nestedSearchString(itm, "album", "title")
|
||||
trackCount := searchInt(itm["nb_tracks"])
|
||||
artist := jsonutil.NestedString(itm, "artist", "name")
|
||||
album := jsonutil.NestedString(itm, "album", "title")
|
||||
trackCount := jsonutil.IntFromAny(itm["nb_tracks"])
|
||||
explicit := searchBool(itm["explicit_lyrics"])
|
||||
date := firstNonEmpty(
|
||||
asString(itm["release_date"]),
|
||||
nestedSearchString(itm, "album", "release_date"),
|
||||
date := jsonutil.FirstNonEmpty(
|
||||
jsonutil.StringFromAny(itm["release_date"]),
|
||||
jsonutil.NestedString(itm, "album", "release_date"),
|
||||
)
|
||||
releases := 0
|
||||
if mediaType == "artist" {
|
||||
releases = firstPositiveInt(
|
||||
searchInt(itm["nb_album"]),
|
||||
searchInt(itm["albums_count"]),
|
||||
searchInt(itm["numberOfAlbums"]),
|
||||
releases = jsonutil.FirstPositiveInt(
|
||||
jsonutil.IntFromAny(itm["nb_album"]),
|
||||
jsonutil.IntFromAny(itm["albums_count"]),
|
||||
jsonutil.IntFromAny(itm["numberOfAlbums"]),
|
||||
)
|
||||
}
|
||||
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
|
||||
@@ -538,13 +464,13 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
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"]),
|
||||
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"]),
|
||||
)
|
||||
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Date: date, TrackCount: trackCount})
|
||||
}
|
||||
@@ -558,38 +484,62 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := asString(itm["id"])
|
||||
title := asString(itm["title"])
|
||||
id := jsonutil.StringFromAny(itm["id"])
|
||||
title := jsonutil.StringFromAny(itm["title"])
|
||||
if title == "" {
|
||||
title = asString(itm["name"])
|
||||
title = jsonutil.StringFromAny(itm["name"])
|
||||
}
|
||||
artist := nestedSearchString(itm, "artist", "name")
|
||||
artist := jsonutil.NestedString(itm, "artist", "name")
|
||||
if artist == "" {
|
||||
artist = nestedSearchString(itm, "performer", "name")
|
||||
artist = jsonutil.NestedString(itm, "performer", "name")
|
||||
}
|
||||
album := nestedSearchString(itm, "album", "title")
|
||||
trackCount := firstPositiveInt(
|
||||
searchInt(itm["trackCount"]),
|
||||
searchInt(itm["track_count"]),
|
||||
searchInt(itm["tracks_count"]),
|
||||
album := jsonutil.NestedString(itm, "album", "title")
|
||||
trackCount := jsonutil.FirstPositiveInt(
|
||||
jsonutil.IntFromAny(itm["trackCount"]),
|
||||
jsonutil.IntFromAny(itm["track_count"]),
|
||||
jsonutil.IntFromAny(itm["tracks_count"]),
|
||||
)
|
||||
explicit := searchBool(itm["explicit"])
|
||||
date := firstNonEmpty(
|
||||
asString(itm["release_date"]),
|
||||
asString(itm["releaseDate"]),
|
||||
nestedSearchString(itm, "album", "release_date"),
|
||||
nestedSearchString(itm, "album", "releaseDate"),
|
||||
date := jsonutil.FirstNonEmpty(
|
||||
jsonutil.StringFromAny(itm["release_date"]),
|
||||
jsonutil.StringFromAny(itm["releaseDate"]),
|
||||
jsonutil.NestedString(itm, "album", "release_date"),
|
||||
jsonutil.NestedString(itm, "album", "releaseDate"),
|
||||
)
|
||||
releases := 0
|
||||
if mediaType == "artist" {
|
||||
releases = firstPositiveInt(
|
||||
searchInt(itm["albums_count"]),
|
||||
searchInt(itm["numberOfAlbums"]),
|
||||
nestedSearchInt(itm, "albums", "total"),
|
||||
releases = jsonutil.FirstPositiveInt(
|
||||
jsonutil.IntFromAny(itm["albums_count"]),
|
||||
jsonutil.IntFromAny(itm["numberOfAlbums"]),
|
||||
jsonutil.IntFromAny(jsonutil.NestedAny(itm, "albums", "total")),
|
||||
)
|
||||
}
|
||||
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
|
||||
}
|
||||
case "beatport":
|
||||
items, ok := page["items"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, raw := range items {
|
||||
itm, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := jsonutil.StringFromAny(itm["id"])
|
||||
title := jsonutil.StringFromAny(itm["title"])
|
||||
if title == "" {
|
||||
title = jsonutil.StringFromAny(itm["name"])
|
||||
}
|
||||
if version := jsonutil.StringFromAny(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"))
|
||||
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, TrackCount: trackCount})
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
@@ -658,55 +608,10 @@ func formatSearchOptionLabel(index int, mediaType string, r searchResult) string
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 searchBool(v any) bool {
|
||||
b, ok := v.(bool)
|
||||
return ok && b
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
}
|
||||
+59
-19
@@ -10,8 +10,13 @@ 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
|
||||
# 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.
|
||||
requests_per_minute = 60
|
||||
# Verify SSL certificates for API connections
|
||||
# Set to false only if certificate verification fails (not recommended)
|
||||
@@ -21,9 +26,6 @@ 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.
|
||||
@@ -34,6 +36,11 @@ 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:
|
||||
@@ -53,6 +60,11 @@ 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:
|
||||
@@ -67,6 +79,13 @@ 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:
|
||||
@@ -76,21 +95,36 @@ 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:
|
||||
# 1 = medium (AAC 128), 2 = high (AAC 256), 3/4 = lossless (FLAC 16/44.1)
|
||||
# medium-hls is intentionally not supported.
|
||||
quality = 3
|
||||
# If a release lists more artists than this, use "Various Artists" as album artist.
|
||||
# Set to -1 to disable this collapse.
|
||||
various_artists_threshold = 3
|
||||
# Beatport Streaming account credentials
|
||||
username = ""
|
||||
password = ""
|
||||
# Session values are managed automatically. Do not modify manually.
|
||||
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
|
||||
@@ -138,7 +172,15 @@ saved_max_width = -1
|
||||
set_playlist_to_album = true
|
||||
# Use playlist position as tracknumber for playlist items
|
||||
renumber_playlist_tracks = true
|
||||
# Metadata fields to exclude from tagging
|
||||
# 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.
|
||||
exclude = []
|
||||
|
||||
[filepaths]
|
||||
@@ -170,5 +212,3 @@ 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
|
||||
+540
-193
File diff suppressed because it is too large.
Load diff
+669
-2
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"streamrip-go/internal/audio/tag"
|
||||
@@ -20,13 +22,13 @@ import (
|
||||
|
||||
type noopTagger struct{}
|
||||
|
||||
func (n noopTagger) TagFLAC(string, tag.Metadata, string) error { return nil }
|
||||
func (n noopTagger) TagFLAC(context.Context, string, tag.Metadata, string) error { return nil }
|
||||
|
||||
type failingTagger struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f failingTagger) TagFLAC(string, tag.Metadata, string) error { return f.err }
|
||||
func (f failingTagger) TagFLAC(context.Context, string, tag.Metadata, string) error { return f.err }
|
||||
|
||||
type fakeProvider struct {
|
||||
url string
|
||||
@@ -490,6 +492,22 @@ func TestBuildTagMetadataUsesAlbumArtistOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTagMetadataUsesAlbumContextTrackTotal(t *testing.T) {
|
||||
meta := map[string]any{
|
||||
"title": "ideal world (feat. higma)",
|
||||
"track_number": float64(2),
|
||||
"performer": map[string]any{"name": "Seren Azuma"},
|
||||
"album": map[string]any{
|
||||
"title": "YUKIHASU",
|
||||
"artist": map[string]any{"name": "Seren Azuma"},
|
||||
},
|
||||
}
|
||||
tags := buildTagMetadata(meta, "ideal world (feat. higma)", "qobuz", "295525879", ripTrackOptions{total: 11})
|
||||
if tags.TrackTotal != 11 {
|
||||
t.Fatalf("track total = %d, want 11", tags.TrackTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTagMetadataPlaylistOmitsDiscTags(t *testing.T) {
|
||||
meta := map[string]any{
|
||||
"title": "One Step Too Far",
|
||||
@@ -525,6 +543,9 @@ func TestBuildTagMetadataPlaylistOmitsDiscTags(t *testing.T) {
|
||||
if tags.DiscTotal != 0 {
|
||||
t.Fatalf("disc total = %d, want 0", tags.DiscTotal)
|
||||
}
|
||||
if !tags.OmitDiscTags {
|
||||
t.Fatalf("omit disc tags = %v, want true", tags.OmitDiscTags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackOutputPathFallsBackToDisc1(t *testing.T) {
|
||||
@@ -861,6 +882,71 @@ func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTagMetadataInitialKey(t *testing.T) {
|
||||
meta := map[string]any{
|
||||
"key": "E Minor",
|
||||
"album": map[string]any{"title": "Album"},
|
||||
"performer": map[string]any{"name": "Artist"},
|
||||
}
|
||||
|
||||
tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{})
|
||||
if tags.InitialKey != "Em" {
|
||||
t.Fatalf("InitialKey=%q", tags.InitialKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTagMetadataArtistList(t *testing.T) {
|
||||
meta := map[string]any{
|
||||
"artist_names": []string{"Lost Frequencies", "Calum Scott", "Kungs"},
|
||||
"performer": map[string]any{"name": "Lost Frequencies, Calum Scott & Kungs"},
|
||||
"album": map[string]any{"title": "Album"},
|
||||
}
|
||||
|
||||
tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{})
|
||||
if tags.Artist != "Lost Frequencies, Calum Scott & Kungs" {
|
||||
t.Fatalf("artist=%q", tags.Artist)
|
||||
}
|
||||
if got := strings.Join(tags.Artists, ";"); got != "Lost Frequencies;Calum Scott;Kungs" {
|
||||
t.Fatalf("artists=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleFromMetadataSkipsOriginalMix(t *testing.T) {
|
||||
meta := map[string]any{"title": "Dance Done", "version": "Original Mix"}
|
||||
if got := titleFromMetadata(meta, "42"); got != "Dance Done" {
|
||||
t.Fatalf("titleFromMetadata()=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleFromMetadataIncludesNonOriginalMix(t *testing.T) {
|
||||
meta := map[string]any{"title": "Dance Done", "version": "Extended Mix"}
|
||||
if got := titleFromMetadata(meta, "42"); got != "Dance Done (Extended Mix)" {
|
||||
t.Fatalf("titleFromMetadata()=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInitialKey(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"C Major": "C",
|
||||
"Db Major": "Db",
|
||||
"F# Major": "F#",
|
||||
"E Minor": "Em",
|
||||
"C# Minor": "C#m",
|
||||
"Bb Minor": "Bbm",
|
||||
"g# minor": "G#m",
|
||||
"F#m": "F#m",
|
||||
"Ab": "Ab",
|
||||
"Not A Key": "Not A Key",
|
||||
"C# Major": "C# Major",
|
||||
"Db Minor": "Db Minor",
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := normalizeInitialKey(input); got != want {
|
||||
t.Fatalf("normalizeInitialKey(%q)=%q want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTagMetadataReplayGainFallsBackToDeezerGain(t *testing.T) {
|
||||
meta := map[string]any{
|
||||
"gain": float64(-10),
|
||||
@@ -873,3 +959,584 @@ 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"])
|
||||
}
|
||||
}
|
||||
+56
-13
@@ -6,10 +6,12 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
@@ -31,6 +33,52 @@ 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 {
|
||||
@@ -51,30 +99,25 @@ 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) {
|
||||
result.SavedPath = savedPath
|
||||
} else if err := dl.FileNoProgress(ctx, largestURL, savedPath); err == nil {
|
||||
if cfg.SavedMaxWidth > 0 {
|
||||
_ = downscaleImage(savedPath, cfg.SavedMaxWidth)
|
||||
}
|
||||
if fileExists(savedPath) || fetchAtomic(ctx, dl, largestURL, savedPath, cfg.SavedMaxWidth) {
|
||||
result.SavedPath = savedPath
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Embed && embedURL != "" {
|
||||
embedDir := sessionEmbedDir(folder)
|
||||
if err := os.MkdirAll(embedDir, 0o755); err == nil {
|
||||
// 0o700: predictable path under the shared system temp dir.
|
||||
if err := os.MkdirAll(embedDir, 0o700); err == nil {
|
||||
registerTempDir(embedDir)
|
||||
embedPath := filepath.Join(embedDir, embedFilename(embedURL))
|
||||
if fileExists(embedPath) {
|
||||
result.EmbedPath = embedPath
|
||||
} else if err := dl.FileNoProgress(ctx, embedURL, embedPath); err == nil {
|
||||
if cfg.EmbedMaxWidth > 0 {
|
||||
_ = downscaleImage(embedPath, cfg.EmbedMaxWidth)
|
||||
}
|
||||
if fileExists(embedPath) || fetchAtomic(ctx, dl, embedURL, embedPath, cfg.EmbedMaxWidth) {
|
||||
result.EmbedPath = embedPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -27,7 +28,7 @@ var profiles = map[string]profile{
|
||||
"AAC": {codecLib: "aac", ext: "m4a", lossless: false},
|
||||
}
|
||||
|
||||
func Convert(path string, cfg config.ConversionConfig) (string, error) {
|
||||
func Convert(ctx context.Context, path string, cfg config.ConversionConfig) (string, error) {
|
||||
if !cfg.Enabled {
|
||||
return path, nil
|
||||
}
|
||||
@@ -45,7 +46,7 @@ func Convert(path string, cfg config.ConversionConfig) (string, error) {
|
||||
tmpPath := finalPath + ".tmp." + p.ext
|
||||
|
||||
args := buildFFmpegArgs(path, tmpPath, p, cfg)
|
||||
cmd := exec.Command("ffmpeg", args...)
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -86,7 +87,7 @@ func TestConvertKeepsSourceWhenRenameFails(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := config.ConversionConfig{Enabled: true, Codec: "ALAC"}
|
||||
out, err := Convert(in, cfg)
|
||||
out, err := Convert(context.Background(), in, cfg)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rename failure")
|
||||
}
|
||||
|
||||
+167
-10
@@ -1,6 +1,8 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -13,13 +15,16 @@ type Metadata struct {
|
||||
Title string
|
||||
Album string
|
||||
Artist string
|
||||
Artists []string
|
||||
AlbumArtist string
|
||||
OmitDiscTags bool
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
TrackTotal int
|
||||
DiscTotal int
|
||||
Date string
|
||||
Genre string
|
||||
InitialKey string
|
||||
Comment string
|
||||
Description string
|
||||
Lyrics string
|
||||
@@ -41,17 +46,17 @@ func New() *Tagger {
|
||||
return &Tagger{}
|
||||
}
|
||||
|
||||
func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
|
||||
func (t *Tagger) TagFLAC(ctx context.Context, 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(path, ext)
|
||||
forceMP4Muxer := shouldForceMP4Muxer(ctx, path, ext)
|
||||
tmpPath := taggedTempPath(path)
|
||||
runTag := func(cover string) ([]byte, error) {
|
||||
args := buildFFmpegArgs(path, tmpPath, meta, cover, ext, forceMP4Muxer)
|
||||
cmd := exec.Command("ffmpeg", args...)
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
@@ -69,6 +74,9 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err = applyMultiValueFLACTags(path, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -102,6 +110,14 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext
|
||||
}
|
||||
args = append(args, "-metadata", k+"="+v)
|
||||
}
|
||||
if meta.OmitDiscTags {
|
||||
args = append(args,
|
||||
"-metadata", "disc=",
|
||||
"-metadata", "disk=",
|
||||
"-metadata", "disctotal=",
|
||||
"-metadata", "totaldiscs=",
|
||||
)
|
||||
}
|
||||
if forceMP4Muxer {
|
||||
args = append(args, "-f", "mp4")
|
||||
}
|
||||
@@ -110,7 +126,7 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext
|
||||
return args
|
||||
}
|
||||
|
||||
func shouldForceMP4Muxer(path, ext string) bool {
|
||||
func shouldForceMP4Muxer(ctx context.Context, path, ext string) bool {
|
||||
switch strings.TrimPrefix(strings.ToLower(ext), ".") {
|
||||
case "m4a", "mp4":
|
||||
default:
|
||||
@@ -119,7 +135,7 @@ func shouldForceMP4Muxer(path, ext string) bool {
|
||||
if _, err := exec.LookPath("ffprobe"); err != nil {
|
||||
return false
|
||||
}
|
||||
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()
|
||||
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()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -151,6 +167,7 @@ func toTags(meta Metadata) map[string]string {
|
||||
"album_artist": meta.AlbumArtist,
|
||||
"date": meta.Date,
|
||||
"genre": meta.Genre,
|
||||
"INITIALKEY": meta.InitialKey,
|
||||
"comment": meta.Comment,
|
||||
"description": meta.Description,
|
||||
"lyrics": meta.Lyrics,
|
||||
@@ -166,11 +183,7 @@ func toTags(meta Metadata) map[string]string {
|
||||
"source_artist_id": meta.SourceArtistID,
|
||||
}
|
||||
if meta.TrackNumber > 0 {
|
||||
if meta.TrackTotal > 0 {
|
||||
tags["track"] = fmt.Sprintf("%02d/%02d", meta.TrackNumber, meta.TrackTotal)
|
||||
} else {
|
||||
tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
|
||||
}
|
||||
tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
|
||||
}
|
||||
if meta.TrackTotal > 0 {
|
||||
tags["tracktotal"] = strconv.Itoa(meta.TrackTotal)
|
||||
@@ -188,6 +201,150 @@ func toTags(meta Metadata) map[string]string {
|
||||
return tags
|
||||
}
|
||||
|
||||
func applyMultiValueFLACTags(path string, meta Metadata) error {
|
||||
if strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) != "flac" || len(meta.Artists) == 0 {
|
||||
return nil
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := replaceFLACVorbisComments(data, "ARTISTS", meta.Artists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated == nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, updated, st.Mode())
|
||||
}
|
||||
|
||||
type flacMetadataBlock struct {
|
||||
isLast bool
|
||||
blockType byte
|
||||
data []byte
|
||||
}
|
||||
|
||||
func replaceFLACVorbisComments(data []byte, key string, values []string) ([]byte, error) {
|
||||
if len(data) < 4 || string(data[:4]) != "fLaC" {
|
||||
return nil, fmt.Errorf("not a FLAC file")
|
||||
}
|
||||
|
||||
blocks := []flacMetadataBlock{}
|
||||
pos := 4
|
||||
vorbisIndex := -1
|
||||
for {
|
||||
if pos+4 > len(data) {
|
||||
return nil, fmt.Errorf("truncated FLAC metadata header")
|
||||
}
|
||||
header := data[pos]
|
||||
blockType := header & 0x7f
|
||||
length := int(data[pos+1])<<16 | int(data[pos+2])<<8 | int(data[pos+3])
|
||||
pos += 4
|
||||
if pos+length > len(data) {
|
||||
return nil, fmt.Errorf("truncated FLAC metadata block")
|
||||
}
|
||||
if blockType == 4 {
|
||||
vorbisIndex = len(blocks)
|
||||
}
|
||||
blocks = append(blocks, flacMetadataBlock{isLast: header&0x80 != 0, blockType: blockType, data: data[pos : pos+length]})
|
||||
pos += length
|
||||
if header&0x80 != 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if vorbisIndex < 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
commentBlock, err := replaceVorbisCommentValues(blocks[vorbisIndex].data, key, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks[vorbisIndex].data = commentBlock
|
||||
|
||||
out := make([]byte, 0, len(data)+len(commentBlock)-len(blocks[vorbisIndex].data))
|
||||
out = append(out, data[:4]...)
|
||||
for _, block := range blocks {
|
||||
if len(block.data) > 0xffffff {
|
||||
return nil, fmt.Errorf("FLAC metadata block too large")
|
||||
}
|
||||
header := block.blockType
|
||||
if block.isLast {
|
||||
header |= 0x80
|
||||
}
|
||||
out = append(out, header, byte(len(block.data)>>16), byte(len(block.data)>>8), byte(len(block.data)))
|
||||
out = append(out, block.data...)
|
||||
}
|
||||
out = append(out, data[pos:]...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func replaceVorbisCommentValues(data []byte, key string, values []string) ([]byte, error) {
|
||||
if len(data) < 8 {
|
||||
return nil, fmt.Errorf("truncated Vorbis comment block")
|
||||
}
|
||||
pos := 0
|
||||
vendorLength := int(binary.LittleEndian.Uint32(data[pos:]))
|
||||
pos += 4
|
||||
if pos+vendorLength+4 > len(data) {
|
||||
return nil, fmt.Errorf("truncated Vorbis vendor string")
|
||||
}
|
||||
vendor := data[pos : pos+vendorLength]
|
||||
pos += vendorLength
|
||||
commentCount := int(binary.LittleEndian.Uint32(data[pos:]))
|
||||
pos += 4
|
||||
|
||||
comments := make([][]byte, 0, commentCount+len(values))
|
||||
for i := 0; i < commentCount; i++ {
|
||||
if pos+4 > len(data) {
|
||||
return nil, fmt.Errorf("truncated Vorbis comment length")
|
||||
}
|
||||
commentLength := int(binary.LittleEndian.Uint32(data[pos:]))
|
||||
pos += 4
|
||||
if pos+commentLength > len(data) {
|
||||
return nil, fmt.Errorf("truncated Vorbis comment")
|
||||
}
|
||||
comment := data[pos : pos+commentLength]
|
||||
pos += commentLength
|
||||
if !vorbisCommentKeyEqual(comment, key) {
|
||||
comments = append(comments, comment)
|
||||
}
|
||||
}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
comments = append(comments, []byte(key+"="+value))
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]byte, 0, len(data))
|
||||
out = appendUint32LE(out, uint32(len(vendor)))
|
||||
out = append(out, vendor...)
|
||||
out = appendUint32LE(out, uint32(len(comments)))
|
||||
for _, comment := range comments {
|
||||
out = appendUint32LE(out, uint32(len(comment)))
|
||||
out = append(out, comment...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func vorbisCommentKeyEqual(comment []byte, key string) bool {
|
||||
idx := strings.IndexByte(string(comment), '=')
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(string(comment[:idx]), key)
|
||||
}
|
||||
|
||||
func appendUint32LE(out []byte, v uint32) []byte {
|
||||
return append(out, byte(v), byte(v>>8), byte(v>>16), byte(v>>24))
|
||||
}
|
||||
|
||||
func normalizeCopyright(in string) string {
|
||||
out := strings.ReplaceAll(in, "(c)", "©")
|
||||
out = strings.ReplaceAll(out, "(C)", "©")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -30,6 +31,7 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
||||
DiscNumber: 1,
|
||||
DiscTotal: 2,
|
||||
ISRC: "USABC1234567",
|
||||
InitialKey: "Em",
|
||||
ReplaygainTrackGain: "-7.25 dB",
|
||||
ReplaygainAlbumGain: "-8.1 dB",
|
||||
ReplaygainTrackPeak: "0.989",
|
||||
@@ -37,7 +39,7 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
||||
SourcePlatform: "qobuz",
|
||||
SourceTrackID: "t1",
|
||||
})
|
||||
if tags["track"] != "03/12" {
|
||||
if tags["track"] != "03" {
|
||||
t.Fatalf("track tag = %q", tags["track"])
|
||||
}
|
||||
if tags["disc"] != "1/2" {
|
||||
@@ -49,6 +51,9 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
||||
if tags["isrc"] != "USABC1234567" {
|
||||
t.Fatalf("isrc missing: %+v", tags)
|
||||
}
|
||||
if tags["INITIALKEY"] != "Em" {
|
||||
t.Fatalf("INITIALKEY missing: %+v", tags)
|
||||
}
|
||||
if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" {
|
||||
t.Fatalf("source tags missing: %+v", tags)
|
||||
}
|
||||
@@ -60,6 +65,102 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToTagsArtistList(t *testing.T) {
|
||||
tags := toTags(Metadata{Artist: "A, B & C", Artists: []string{"A", "B", "C"}})
|
||||
if tags["artist"] != "A, B & C" {
|
||||
t.Fatalf("artist tags = %+v", tags)
|
||||
}
|
||||
if _, ok := tags["ARTISTS"]; ok {
|
||||
t.Fatalf("ARTISTS should be written as repeated FLAC comments, got %+v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceVorbisCommentValuesWritesRepeatedTags(t *testing.T) {
|
||||
block := vorbisCommentBlock("vendor", []string{
|
||||
"ARTIST=A, B & C",
|
||||
"ARTISTS=A;B;C",
|
||||
"TITLE=Song",
|
||||
})
|
||||
|
||||
updated, err := replaceVorbisCommentValues(block, "ARTISTS", []string{"A", "B", "C"})
|
||||
if err != nil {
|
||||
t.Fatalf("replaceVorbisCommentValues() error = %v", err)
|
||||
}
|
||||
comments := readVorbisComments(t, updated)
|
||||
want := []string{"ARTIST=A, B & C", "TITLE=Song", "ARTISTS=A", "ARTISTS=B", "ARTISTS=C"}
|
||||
if len(comments) != len(want) {
|
||||
t.Fatalf("comments=%#v want %#v", comments, want)
|
||||
}
|
||||
for i := range want {
|
||||
if comments[i] != want[i] {
|
||||
t.Fatalf("comments=%#v want %#v", comments, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceFLACVorbisComments(t *testing.T) {
|
||||
vorbis := vorbisCommentBlock("vendor", []string{"ARTISTS=A;B", "TITLE=Song"})
|
||||
flac := append([]byte("fLaC"), flacBlockHeader(true, 4, len(vorbis))...)
|
||||
flac = append(flac, vorbis...)
|
||||
flac = append(flac, []byte("audio")...)
|
||||
|
||||
updated, err := replaceFLACVorbisComments(flac, "ARTISTS", []string{"A", "B"})
|
||||
if err != nil {
|
||||
t.Fatalf("replaceFLACVorbisComments() error = %v", err)
|
||||
}
|
||||
if string(updated[len(updated)-5:]) != "audio" {
|
||||
t.Fatalf("audio payload not preserved")
|
||||
}
|
||||
length := int(updated[5])<<16 | int(updated[6])<<8 | int(updated[7])
|
||||
comments := readVorbisComments(t, updated[8:8+length])
|
||||
want := []string{"TITLE=Song", "ARTISTS=A", "ARTISTS=B"}
|
||||
if len(comments) != len(want) {
|
||||
t.Fatalf("comments=%#v want %#v", comments, want)
|
||||
}
|
||||
for i := range want {
|
||||
if comments[i] != want[i] {
|
||||
t.Fatalf("comments=%#v want %#v", comments, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func vorbisCommentBlock(vendor string, comments []string) []byte {
|
||||
out := []byte{}
|
||||
out = appendUint32LE(out, uint32(len(vendor)))
|
||||
out = append(out, vendor...)
|
||||
out = appendUint32LE(out, uint32(len(comments)))
|
||||
for _, comment := range comments {
|
||||
out = appendUint32LE(out, uint32(len(comment)))
|
||||
out = append(out, comment...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flacBlockHeader(last bool, blockType byte, length int) []byte {
|
||||
header := blockType
|
||||
if last {
|
||||
header |= 0x80
|
||||
}
|
||||
return []byte{header, byte(length >> 16), byte(length >> 8), byte(length)}
|
||||
}
|
||||
|
||||
func readVorbisComments(t *testing.T, block []byte) []string {
|
||||
t.Helper()
|
||||
pos := 0
|
||||
vendorLength := int(binary.LittleEndian.Uint32(block[pos:]))
|
||||
pos += 4 + vendorLength
|
||||
count := int(binary.LittleEndian.Uint32(block[pos:]))
|
||||
pos += 4
|
||||
comments := make([]string, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
length := int(binary.LittleEndian.Uint32(block[pos:]))
|
||||
pos += 4
|
||||
comments = append(comments, string(block[pos:pos+length]))
|
||||
pos += length
|
||||
}
|
||||
return comments
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsWithCover(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cover := filepath.Join(tmp, "cover.jpg")
|
||||
@@ -96,6 +197,28 @@ func TestBuildFFmpegArgsSkipsCoverForUnsupportedContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsClearsDiscTagsWhenRequested(t *testing.T) {
|
||||
args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x", OmitDiscTags: true}, "", "flac", false)
|
||||
want := map[string]bool{
|
||||
"disc=": false,
|
||||
"disk=": false,
|
||||
"disctotal=": false,
|
||||
"totaldiscs=": false,
|
||||
}
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "-metadata" {
|
||||
if _, ok := want[args[i+1]]; ok {
|
||||
want[args[i+1]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for tag, found := range want {
|
||||
if !found {
|
||||
t.Fatalf("missing clear tag %q in args: %v", tag, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsForcesMP4Muxer(t *testing.T) {
|
||||
args := buildFFmpegArgs("in.m4a", "out.m4a", Metadata{Title: "x"}, "", "m4a", true)
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
|
||||
+279
-43
@@ -5,6 +5,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
@@ -13,8 +16,17 @@ 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
|
||||
}
|
||||
@@ -25,8 +37,8 @@ type ConfigData struct {
|
||||
Tidal TidalConfig `toml:"tidal"`
|
||||
Deezer DeezerConfig `toml:"deezer"`
|
||||
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"`
|
||||
@@ -49,13 +61,17 @@ type DownloadsConfig struct {
|
||||
}
|
||||
|
||||
type QobuzConfig struct {
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type TidalConfig struct {
|
||||
@@ -67,6 +83,10 @@ 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 {
|
||||
@@ -76,24 +96,40 @@ 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 {
|
||||
Quality int `toml:"quality"`
|
||||
VariousArtistsThreshold int `toml:"various_artists_threshold"`
|
||||
Username string `toml:"username"`
|
||||
Password string `toml:"password"`
|
||||
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"`
|
||||
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"`
|
||||
Quality int `toml:"quality"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -131,6 +167,7 @@ type ArtworkConfig struct {
|
||||
type MetadataConfig struct {
|
||||
SetPlaylistToAlbum bool `toml:"set_playlist_to_album"`
|
||||
RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"`
|
||||
ArtistSeparator string `toml:"artist_separator"`
|
||||
Exclude []string `toml:"exclude"`
|
||||
}
|
||||
|
||||
@@ -154,8 +191,7 @@ type CLIConfig struct {
|
||||
}
|
||||
|
||||
type MiscConfig struct {
|
||||
Version string `toml:"version"`
|
||||
CheckForUpdates bool `toml:"check_for_updates"`
|
||||
Version string `toml:"version"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
@@ -190,34 +226,219 @@ 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
|
||||
}
|
||||
|
||||
func UpgradeOutdated(path string) (string, error) {
|
||||
// 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) {
|
||||
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 = saveConfigData(resolvedPath, data); err != nil {
|
||||
return "", err
|
||||
if err = Validate(&data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return resolvedPath, nil
|
||||
if err = saveConfigData(resolvedPath, data); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return resolvedPath, backupPath, 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()
|
||||
@@ -234,9 +455,8 @@ func DefaultConfigData() ConfigData {
|
||||
VerifySSL: true,
|
||||
},
|
||||
Qobuz: QobuzConfig{
|
||||
Quality: 3,
|
||||
DownloadBooklets: true,
|
||||
UseAuthToken: false,
|
||||
Quality: 3,
|
||||
UseAuthToken: false,
|
||||
},
|
||||
Tidal: TidalConfig{
|
||||
Quality: 3,
|
||||
@@ -250,14 +470,13 @@ func DefaultConfigData() ConfigData {
|
||||
Yandex: YandexConfig{
|
||||
Quality: 2,
|
||||
},
|
||||
Beatport: BeatportConfig{
|
||||
Quality: 3,
|
||||
VariousArtistsThreshold: 3,
|
||||
},
|
||||
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"),
|
||||
@@ -282,6 +501,7 @@ func DefaultConfigData() ConfigData {
|
||||
Metadata: MetadataConfig{
|
||||
SetPlaylistToAlbum: true,
|
||||
RenumberPlaylistTracks: true,
|
||||
ArtistSeparator: "; ",
|
||||
Exclude: []string{},
|
||||
},
|
||||
Filepaths: FilepathsConfig{
|
||||
@@ -300,20 +520,25 @@ func DefaultConfigData() ConfigData {
|
||||
MaxSearchResults: 100,
|
||||
},
|
||||
Misc: MiscConfig{
|
||||
Version: CurrentConfigVersion,
|
||||
CheckForUpdates: true,
|
||||
Version: CurrentConfigVersion,
|
||||
},
|
||||
}
|
||||
|
||||
applyVendorDefaults(&data)
|
||||
return data
|
||||
}
|
||||
|
||||
func resolvePath(path string) (string, error) {
|
||||
// 0o700: this directory holds the credential-bearing config and the
|
||||
// download databases.
|
||||
if path != "" {
|
||||
return path, os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
appDir := defaultAppDir()
|
||||
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
||||
if err := os.MkdirAll(appDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(appDir, "config.toml"), nil
|
||||
@@ -328,17 +553,24 @@ func defaultAppDir() string {
|
||||
}
|
||||
|
||||
func saveConfigData(path string, data ConfigData) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
b, err := toml.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
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)
|
||||
}
|
||||
|
||||
func applyRuntimeDefaults(data *ConfigData) {
|
||||
applyVendorDefaults(data)
|
||||
home, _ := os.UserHomeDir()
|
||||
appDir := defaultAppDir()
|
||||
if data.Downloads.Folder == "" {
|
||||
@@ -350,9 +582,13 @@ func applyRuntimeDefaults(data *ConfigData) {
|
||||
if data.Database.FailedDownloadsPath == "" {
|
||||
data.Database.FailedDownloadsPath = filepath.Join(appDir, "failed_downloads.db")
|
||||
}
|
||||
if data.Youtube.VideoDownloadsFolder == "" {
|
||||
data.Youtube.VideoDownloadsFolder = filepath.Join(data.Downloads.Folder, "YouTubeVideos")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func cloneConfigData(in ConfigData) ConfigData {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
func TestDefaultConfigData(t *testing.T) {
|
||||
@@ -64,7 +70,7 @@ func TestUpgradeOutdatedConfig(t *testing.T) {
|
||||
t.Fatalf("saveConfigData() error = %v", err)
|
||||
}
|
||||
|
||||
resolved, err := UpgradeOutdated(path)
|
||||
resolved, backup, err := UpgradeOutdated(path)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeOutdated() error = %v", err)
|
||||
}
|
||||
@@ -72,6 +78,28 @@ 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)
|
||||
@@ -110,3 +138,304 @@ 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)
|
||||
}
|
||||
}
|
||||
+198
-253
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"github.com/vbauerster/mpb/v8/decor"
|
||||
"golang.org/x/term"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/verbose"
|
||||
|
||||
@@ -32,6 +34,10 @@ 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
|
||||
@@ -43,10 +49,6 @@ 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"))
|
||||
@@ -69,11 +71,24 @@ func (d *Downloader) FileVideo(ctx context.Context, sourceURL, outputPath string
|
||||
return d.file(ctx, sourceURL, outputPath, true, true)
|
||||
}
|
||||
|
||||
func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputPath, trackID string) error {
|
||||
// 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 {
|
||||
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
|
||||
@@ -86,6 +101,7 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -98,97 +114,72 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
|
||||
}
|
||||
}()
|
||||
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
bar, finishBar := d.newDownloadBar(outputPath, resp.ContentLength)
|
||||
defer func() { finishBar(success) }()
|
||||
|
||||
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
|
||||
written, err := decode(out, resp.Body, bar)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
if resp.ContentLength > 0 && written != resp.ContentLength {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if err = out.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
success = true
|
||||
return 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 {
|
||||
// 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 {
|
||||
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)
|
||||
@@ -200,102 +191,11 @@ 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)
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error {
|
||||
@@ -338,81 +238,22 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
success = true
|
||||
@@ -438,6 +279,92 @@ 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.
|
||||
@@ -835,10 +762,28 @@ const deezerBFChunkSize = 2048
|
||||
|
||||
var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7}
|
||||
|
||||
func deriveDeezerBlowfishKey(trackID string) []byte {
|
||||
// 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 {
|
||||
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]
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"errors"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -70,7 +72,7 @@ func TestManifestDetection(t *testing.T) {
|
||||
|
||||
func TestDeezerBlowfishKeyDerivation(t *testing.T) {
|
||||
trackID := "3135556"
|
||||
key := deriveDeezerBlowfishKey(trackID)
|
||||
key := deriveDeezerBlowfishKey(defaultDeezerBlowfishSecret, trackID)
|
||||
if len(key) != 16 {
|
||||
t.Fatalf("blowfish key len = %d, want 16", len(key))
|
||||
}
|
||||
@@ -85,7 +87,7 @@ func TestFileDeezerEncrypted(t *testing.T) {
|
||||
enc := make([]byte, len(plain))
|
||||
copy(enc, plain)
|
||||
|
||||
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
|
||||
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(defaultDeezerBlowfishSecret, trackID))
|
||||
if err != nil {
|
||||
t.Fatalf("cipher error: %v", err)
|
||||
}
|
||||
@@ -367,3 +369,144 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -129,3 +129,32 @@ 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
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -14,7 +15,7 @@ type Config struct {
|
||||
}
|
||||
|
||||
var tokenRe = regexp.MustCompile(`\{([a-z_]+)(?::0?(\d+))?\}`)
|
||||
var invalidPathRe = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`)
|
||||
var invalidPathRe = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F\x7F]`)
|
||||
|
||||
func FormatTemplate(template string, values map[string]string) string {
|
||||
return tokenRe.ReplaceAllStringFunc(template, func(m string) string {
|
||||
@@ -35,6 +36,28 @@ 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, "_")
|
||||
@@ -53,13 +76,45 @@ 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 == "" {
|
||||
|
||||
if s == "" || isAllDots(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]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatTemplate(t *testing.T) {
|
||||
got := FormatTemplate("{tracknumber:02}. {artist} - {title}{explicit}", map[string]string{
|
||||
@@ -15,8 +19,127 @@ func TestFormatTemplate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCleanName(t *testing.T) {
|
||||
got := CleanName(" Dreams/Live ", Config{RestrictCharacters: false, TruncateTo: 120})
|
||||
if got != "Dreams_Live" {
|
||||
t.Fatalf("got %q", got)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
package beatport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/jsonutil"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/ratelimit"
|
||||
"streamrip-go/internal/verbose"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://api.beatport.com/v4"
|
||||
tokenEndpoint = "/auth/o/token/"
|
||||
authEndpoint = "/auth/o/authorize/"
|
||||
loginEndpoint = "/auth/login/"
|
||||
)
|
||||
|
||||
var (
|
||||
errMissingCredentials = errors.New("missing beatport credentials")
|
||||
errNotLoggedIn = errors.New("beatport client not logged in")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
baseURL string
|
||||
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 {
|
||||
h := netutil.NewHTTPClient(40*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections)
|
||||
h.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: h,
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
|
||||
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) 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)
|
||||
if b.Username == "" || b.Password == "" {
|
||||
return errMissingCredentials
|
||||
}
|
||||
if strings.TrimSpace(b.AccessToken) != "" && time.Now().Unix()+300 < b.TokenExpiry {
|
||||
c.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(b.RefreshToken) != "" {
|
||||
if err := c.refresh(ctx); err == nil {
|
||||
c.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := c.loginPasswordFlow(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
c.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
|
||||
if !c.LoggedIn() {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
switch mediaType {
|
||||
case "track":
|
||||
track, err := c.getMap(ctx, "/catalog/tracks/"+url.PathEscape(strings.TrimSpace(item))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if relID := jsonutil.NestedString(track, "release", "id"); relID != "" {
|
||||
if rel, relErr := c.getRelease(ctx, relID); relErr == nil {
|
||||
track["release"] = rel
|
||||
}
|
||||
}
|
||||
return c.normalizeTrack(track), nil
|
||||
case "album":
|
||||
return c.getAlbumMetadata(ctx, item)
|
||||
case "playlist":
|
||||
return c.getPlaylistMetadata(ctx, item)
|
||||
case "chart":
|
||||
return c.getChartMetadata(ctx, strings.TrimPrefix(strings.TrimSpace(item), "chart:"))
|
||||
case "artist":
|
||||
artist, err := c.getMap(ctx, "/catalog/artists/"+url.PathEscape(strings.TrimSpace(item))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
releases, err := c.getPaginated(ctx, "/catalog/releases/", url.Values{"artist_id": []string{strings.TrimSpace(item)}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.releaseCollectionMetadata(artist, releases), nil
|
||||
case "label":
|
||||
label, err := c.getMap(ctx, "/catalog/labels/"+url.PathEscape(strings.TrimSpace(item))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks, err := c.getTrackCollection(ctx, url.Values{"label_id": []string{strings.TrimSpace(item)}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.collectionMetadata(label, tracks), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported beatport media type %q", mediaType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
|
||||
if !c.LoggedIn() {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
var key string
|
||||
switch mediaType {
|
||||
case "track":
|
||||
key = "tracks"
|
||||
case "album":
|
||||
key = "releases"
|
||||
case "label":
|
||||
key = "labels"
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported beatport search media type %q", mediaType)
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("order_by", "-publish_date")
|
||||
params.Set("is_available_for_streaming", "true")
|
||||
resp, err := c.getMap(ctx, "/catalog/search/", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := sliceAny(resp[key])
|
||||
if limit < len(items) {
|
||||
items = items[:limit]
|
||||
}
|
||||
return []map[string]any{{"items": c.normalizeSearchItems(mediaType, items)}}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) {
|
||||
if !c.LoggedIn() {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
q := beatportQuality(quality)
|
||||
params := url.Values{"quality": []string{q}}
|
||||
resp, err := c.getMap(ctx, "/catalog/tracks/"+url.PathEscape(strings.TrimSpace(item))+"/download/", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
location := strings.TrimSpace(jsonutil.StringFromAny(resp["location"]))
|
||||
if location == "" {
|
||||
return nil, errors.New("beatport download response missing location")
|
||||
}
|
||||
streamQuality := strings.TrimSpace(jsonutil.StringFromAny(resp["stream_quality"]))
|
||||
profile, ext := audioProfile(q, streamQuality)
|
||||
return &provider.Downloadable{URL: location, Extension: ext, Source: "beatport", TrackID: strings.TrimSpace(item), Audio: profile}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error { return nil }
|
||||
|
||||
func (c *Client) getAlbumMetadata(ctx context.Context, id string) (map[string]any, error) {
|
||||
release, err := c.getRelease(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks, err := c.getReleaseTracks(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.normalizeRelease(release, tracks), nil
|
||||
}
|
||||
|
||||
func (c *Client) getPlaylistMetadata(ctx context.Context, id string) (map[string]any, error) {
|
||||
if strings.HasPrefix(strings.TrimSpace(id), "chart:") {
|
||||
return c.getChartMetadata(ctx, strings.TrimPrefix(strings.TrimSpace(id), "chart:"))
|
||||
}
|
||||
playlist, err := c.getMap(ctx, "/catalog/playlists/"+url.PathEscape(strings.TrimSpace(id))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := c.getPaginated(ctx, "/catalog/playlists/"+url.PathEscape(strings.TrimSpace(id))+"/tracks/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks := make([]any, 0, len(items))
|
||||
for _, raw := range items {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if track, ok := m["track"].(map[string]any); ok {
|
||||
tracks = append(tracks, c.normalizeTrackListItem(track))
|
||||
}
|
||||
}
|
||||
return playlistMetadata(playlist, tracks), nil
|
||||
}
|
||||
|
||||
func (c *Client) getChartMetadata(ctx context.Context, id string) (map[string]any, error) {
|
||||
chart, err := c.getMap(ctx, "/catalog/charts/"+url.PathEscape(strings.TrimSpace(id))+"/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tracks, err := c.getPaginated(ctx, "/catalog/charts/"+url.PathEscape(strings.TrimSpace(id))+"/tracks/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := make([]any, 0, len(tracks))
|
||||
for _, raw := range tracks {
|
||||
if track, ok := raw.(map[string]any); ok {
|
||||
normalized = append(normalized, c.normalizeTrackListItem(track))
|
||||
}
|
||||
}
|
||||
return playlistMetadata(chart, normalized), nil
|
||||
}
|
||||
|
||||
func (c *Client) getRelease(ctx context.Context, id string) (map[string]any, error) {
|
||||
return c.getMap(ctx, "/catalog/releases/"+url.PathEscape(strings.TrimSpace(id))+"/", nil)
|
||||
}
|
||||
|
||||
func (c *Client) getReleaseTracks(ctx context.Context, id string) ([]any, error) {
|
||||
items, err := c.getPaginated(ctx, "/catalog/releases/"+url.PathEscape(strings.TrimSpace(id))+"/tracks/", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, raw := range items {
|
||||
if track, ok := raw.(map[string]any); ok {
|
||||
track["release"] = map[string]any{"id": id}
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (c *Client) getTrackCollection(ctx context.Context, params url.Values) ([]any, error) {
|
||||
return c.getPaginated(ctx, "/catalog/tracks/", params)
|
||||
}
|
||||
|
||||
func (c *Client) getPaginated(ctx context.Context, endpoint string, params url.Values) ([]any, error) {
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
} else {
|
||||
params = cloneValues(params)
|
||||
}
|
||||
out := make([]any, 0)
|
||||
for page := 1; ; page++ {
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
resp, err := c.getMap(ctx, endpoint, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sliceAny(resp["results"])...)
|
||||
if strings.TrimSpace(jsonutil.StringFromAny(resp["next"])) == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) getMap(ctx context.Context, endpoint string, params url.Values) (map[string]any, error) {
|
||||
resp, err := c.apiRequest(ctx, http.MethodGet, endpoint, params, nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) apiRequest(ctx context.Context, method, endpoint string, params url.Values, payload any, contentType string) (map[string]any, error) {
|
||||
if err := c.ensureToken(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err := c.rawRequest(ctx, method, endpoint, params, payload, contentType, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
if err = c.reauthorize(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, status, err = c.rawRequest(ctx, method, endpoint, params, payload, contentType, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, fmt.Errorf("beatport request failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if strings.TrimSpace(c.cfg.Session.Beatport.RefreshToken) != "" {
|
||||
if err := c.refresh(ctx); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
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) == "" {
|
||||
return errMissingCredentials
|
||||
}
|
||||
loginResp, status, err := c.rawRequest(ctx, http.MethodPost, loginEndpoint, nil, map[string]string{"username": b.Username, "password": b.Password}, "application/json", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("beatport login failed: status=%d body=%v", status, loginResp)
|
||||
}
|
||||
if c.sessionID == "" {
|
||||
return errors.New("beatport login response missing sessionid cookie")
|
||||
}
|
||||
code, err := c.authorize(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.issueToken(ctx, map[string]string{"client_id": c.appClientID(), "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"}}
|
||||
resp, status, err := c.rawRequest(ctx, http.MethodGet, authEndpoint, params, nil, "", false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if status != http.StatusFound {
|
||||
return "", fmt.Errorf("beatport authorize failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
code := strings.TrimSpace(jsonutil.StringFromAny(resp["code"]))
|
||||
if code == "" {
|
||||
return "", errors.New("beatport authorize redirect missing code")
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
func (c *Client) issueToken(ctx context.Context, payload map[string]string) error {
|
||||
resp, status, err := c.rawRequest(ctx, http.MethodPost, tokenEndpoint, nil, payload, "application/x-www-form-urlencoded", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("beatport token request failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
access := strings.TrimSpace(jsonutil.StringFromAny(resp["access_token"]))
|
||||
refresh := strings.TrimSpace(jsonutil.StringFromAny(resp["refresh_token"]))
|
||||
if access == "" {
|
||||
return errors.New("beatport token response missing access_token")
|
||||
}
|
||||
expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) rawRequest(ctx context.Context, method, endpoint string, params url.Values, payload any, contentType string, auth bool) (map[string]any, int, error) {
|
||||
if c.limiter != nil {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
switch contentType {
|
||||
case "application/json":
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
case "application/x-www-form-urlencoded":
|
||||
vals := url.Values{}
|
||||
for k, v := range payload.(map[string]string) {
|
||||
vals.Set(k, v)
|
||||
}
|
||||
body = strings.NewReader(vals.Encode())
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("unsupported beatport content type %q", contentType)
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(strings.TrimRight(c.baseURL, "/") + endpoint)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(params) > 0 {
|
||||
u.RawQuery = params.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36")
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if auth && strings.TrimSpace(c.cfg.Session.Beatport.AccessToken) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.cfg.Session.Beatport.AccessToken))
|
||||
}
|
||||
if endpoint == authEndpoint && c.sessionID != "" {
|
||||
req.Header.Set("Cookie", "sessionid="+c.sessionID)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if endpoint == loginEndpoint {
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == "sessionid" {
|
||||
c.sessionID = cookie.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if endpoint == authEndpoint && resp.StatusCode == http.StatusFound {
|
||||
loc := resp.Header.Get("Location")
|
||||
parsed, parseErr := url.Parse(loc)
|
||||
if parseErr == nil && strings.TrimSpace(parsed.Query().Get("code")) != "" {
|
||||
return map[string]any{"code": parsed.Query().Get("code")}, resp.StatusCode, nil
|
||||
}
|
||||
return map[string]any{}, resp.StatusCode, nil
|
||||
}
|
||||
var out map[string]any
|
||||
if err = json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return map[string]any{}, resp.StatusCode, nil
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return map[string]any{}, resp.StatusCode, nil
|
||||
}
|
||||
return out, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (c *Client) normalizeRelease(raw map[string]any, tracks []any) map[string]any {
|
||||
id := jsonutil.StringFromAny(raw["id"])
|
||||
artist := c.releaseArtistNameForTracks(raw["artists"], tracks)
|
||||
trackRelease := cloneMap(raw)
|
||||
trackRelease["album_artist_name"] = artist
|
||||
items := make([]any, 0, len(tracks))
|
||||
for _, entry := range tracks {
|
||||
track, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
track["release"] = trackRelease
|
||||
items = append(items, c.normalizeTrack(track))
|
||||
}
|
||||
return map[string]any{
|
||||
"id": id,
|
||||
"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)),
|
||||
"maximum_bit_depth": 16,
|
||||
"maximum_sampling_rate": "44.1",
|
||||
"image": imageMap(raw["image"]),
|
||||
"tracks": map[string]any{"items": items},
|
||||
"upc": jsonutil.StringFromAny(raw["upc"]),
|
||||
"label": raw["label"],
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) normalizeTrack(raw map[string]any) map[string]any {
|
||||
release := mapAny(raw["release"])
|
||||
trackArtistNames := artistNames(raw["artists"])
|
||||
artistName := jsonutil.DisplayArtistNames(trackArtistNames)
|
||||
artistID := firstArtistID(raw["artists"])
|
||||
albumArtist := strings.TrimSpace(jsonutil.StringFromAny(release["album_artist_name"]))
|
||||
if albumArtist == "" {
|
||||
albumArtist = c.releaseArtistName(release["artists"])
|
||||
}
|
||||
if albumArtist == "" {
|
||||
albumArtist = artistName
|
||||
}
|
||||
genreName := jsonutil.NestedString(raw, "genre", "name")
|
||||
if sub := jsonutil.NestedString(raw, "sub_genre", "name"); sub != "" {
|
||||
genreName = sub
|
||||
}
|
||||
date := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(release["new_release_date"]), jsonutil.StringFromAny(raw["publish_date"]))
|
||||
track := map[string]any{
|
||||
"id": jsonutil.StringFromAny(raw["id"]),
|
||||
"title": jsonutil.StringFromAny(raw["name"]),
|
||||
"version": jsonutil.StringFromAny(raw["mix_name"]),
|
||||
"artist": map[string]any{"id": artistID, "name": artistName},
|
||||
"performer": map[string]any{"id": artistID, "name": artistName},
|
||||
"artist_names": trackArtistNames,
|
||||
"album": map[string]any{"id": jsonutil.StringFromAny(release["id"]), "title": jsonutil.StringFromAny(release["name"]), "artist": map[string]any{"name": albumArtist}, "image": imageMap(release["image"]), "release_date_original": date},
|
||||
"track_number": jsonutil.IntFromAny(raw["number"]),
|
||||
"tracks_count": jsonutil.IntFromAny(release["track_count"]),
|
||||
"release_date_original": date,
|
||||
"genre": map[string]any{"name": genreName},
|
||||
"isrc": jsonutil.StringFromAny(raw["isrc"]),
|
||||
"bpm": jsonutil.IntFromAny(raw["bpm"]),
|
||||
"source_track_id": jsonutil.StringFromAny(raw["id"]),
|
||||
"source_album_id": jsonutil.StringFromAny(release["id"]),
|
||||
"source_artist_id": artistID,
|
||||
"maximum_bit_depth": 16,
|
||||
"maximum_sampling_rate": "44.1",
|
||||
}
|
||||
if keyName := jsonutil.NestedString(raw, "key", "name"); keyName != "" {
|
||||
track["key"] = keyName
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
func (c *Client) normalizeTrackListItem(raw map[string]any) map[string]any {
|
||||
names := artistNames(raw["artists"])
|
||||
return 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)},
|
||||
"album": map[string]any{"id": jsonutil.NestedString(raw, "release", "id"), "title": jsonutil.NestedString(raw, "release", "name")},
|
||||
"track_number": jsonutil.IntFromAny(raw["number"]),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) collectionMetadata(raw map[string]any, tracks []any) map[string]any {
|
||||
items := make([]any, 0, len(tracks))
|
||||
for _, entry := range tracks {
|
||||
track, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, c.normalizeTrackListItem(track))
|
||||
}
|
||||
return map[string]any{
|
||||
"id": jsonutil.StringFromAny(raw["id"]),
|
||||
"name": jsonutil.StringFromAny(raw["name"]),
|
||||
"title": jsonutil.StringFromAny(raw["name"]),
|
||||
"tracks_count": len(items),
|
||||
"tracks": map[string]any{"items": items},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) releaseCollectionMetadata(raw map[string]any, releases []any) map[string]any {
|
||||
items := make([]any, 0, len(releases))
|
||||
for _, entry := range releases {
|
||||
release, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(jsonutil.StringFromAny(release["id"]))
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id,
|
||||
"title": jsonutil.StringFromAny(release["name"]),
|
||||
"artist": map[string]any{"name": c.releaseArtistName(release["artists"])},
|
||||
"tracks_count": jsonutil.IntFromAny(release["track_count"]),
|
||||
"release_date_original": jsonutil.StringFromAny(release["new_release_date"]),
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"id": jsonutil.StringFromAny(raw["id"]),
|
||||
"name": jsonutil.StringFromAny(raw["name"]),
|
||||
"title": jsonutil.StringFromAny(raw["name"]),
|
||||
"albums": map[string]any{"items": items},
|
||||
}
|
||||
}
|
||||
|
||||
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},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) normalizeSearchItems(mediaType string, items []any) []any {
|
||||
out := make([]any, 0, len(items))
|
||||
for _, raw := range items {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch mediaType {
|
||||
case "track":
|
||||
out = append(out, c.normalizeTrackListItem(m))
|
||||
case "album":
|
||||
out = append(out, map[string]any{"id": jsonutil.StringFromAny(m["id"]), "title": jsonutil.StringFromAny(m["name"]), "artist": map[string]any{"name": c.releaseArtistName(m["artists"])}, "tracks_count": jsonutil.IntFromAny(m["track_count"]), "release_date_original": jsonutil.StringFromAny(m["new_release_date"])})
|
||||
case "label":
|
||||
out = append(out, map[string]any{"id": jsonutil.StringFromAny(m["id"]), "title": jsonutil.StringFromAny(m["name"]), "name": jsonutil.StringFromAny(m["name"])})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func beatportQuality(q int) string {
|
||||
switch q {
|
||||
case 1:
|
||||
return "medium"
|
||||
case 2:
|
||||
return "high"
|
||||
default:
|
||||
return "lossless"
|
||||
}
|
||||
}
|
||||
|
||||
func audioProfile(quality, streamQuality string) (provider.AudioProfile, string) {
|
||||
s := strings.ToLower(strings.TrimSpace(streamQuality))
|
||||
switch {
|
||||
case strings.Contains(s, "flac") || quality == "lossless":
|
||||
return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}, "flac"
|
||||
case strings.Contains(s, "256") || quality == "high":
|
||||
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 256}, "m4a"
|
||||
default:
|
||||
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 128}, "m4a"
|
||||
}
|
||||
}
|
||||
|
||||
func artistNames(v any) []string {
|
||||
items := sliceAny(v)
|
||||
names := make([]string, 0, len(items))
|
||||
for _, raw := range items {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name := strings.TrimSpace(jsonutil.StringFromAny(m["name"])); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func (c *Client) releaseArtistNameForTracks(releaseArtists any, tracks []any) string {
|
||||
common := commonReleaseArtists(releaseArtists, tracks)
|
||||
if len(common) > 0 {
|
||||
return jsonutil.DisplayArtistNames(artistNames(common))
|
||||
}
|
||||
return c.releaseArtistName(releaseArtists)
|
||||
}
|
||||
|
||||
func commonReleaseArtists(releaseArtists any, tracks []any) []any {
|
||||
items := sliceAny(releaseArtists)
|
||||
if len(items) == 0 || len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
counts := map[string]int{}
|
||||
trackCount := 0
|
||||
for _, rawTrack := range tracks {
|
||||
track, ok := rawTrack.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trackCount++
|
||||
seen := map[string]bool{}
|
||||
for _, rawArtist := range sliceAny(track["artists"]) {
|
||||
if key := artistIdentity(rawArtist); key != "" {
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
for key := range seen {
|
||||
counts[key]++
|
||||
}
|
||||
}
|
||||
if trackCount == 0 {
|
||||
return nil
|
||||
}
|
||||
common := make([]any, 0)
|
||||
for _, rawArtist := range items {
|
||||
if key := artistIdentity(rawArtist); key != "" && counts[key] == trackCount {
|
||||
common = append(common, rawArtist)
|
||||
}
|
||||
}
|
||||
return common
|
||||
}
|
||||
|
||||
func artistIdentity(raw any) string {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if id := strings.TrimSpace(jsonutil.StringFromAny(m["id"])); id != "" {
|
||||
return "id:" + id
|
||||
}
|
||||
if name := strings.TrimSpace(jsonutil.StringFromAny(m["name"])); name != "" {
|
||||
return "name:" + strings.ToLower(name)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstArtistID(v any) string {
|
||||
items := sliceAny(v)
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
m, _ := items[0].(map[string]any)
|
||||
return jsonutil.StringFromAny(m["id"])
|
||||
}
|
||||
|
||||
func imageMap(v any) map[string]any {
|
||||
m := mapAny(v)
|
||||
dynamic := strings.TrimSpace(jsonutil.StringFromAny(m["dynamic_uri"]))
|
||||
uri := strings.TrimSpace(jsonutil.StringFromAny(m["uri"]))
|
||||
if dynamic != "" {
|
||||
return map[string]any{
|
||||
"original": strings.ReplaceAll(dynamic, "{w}x{h}", "1400x1400"),
|
||||
"extralarge": strings.ReplaceAll(dynamic, "{w}x{h}", "1000x1000"),
|
||||
"large": strings.ReplaceAll(dynamic, "{w}x{h}", "500x500"),
|
||||
"small": strings.ReplaceAll(dynamic, "{w}x{h}", "250x250"),
|
||||
"thumbnail": strings.ReplaceAll(dynamic, "{w}x{h}", "100x100"),
|
||||
}
|
||||
}
|
||||
if uri != "" {
|
||||
return map[string]any{"original": uri, "large": uri}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapAny(v any) map[string]any {
|
||||
m, _ := v.(map[string]any)
|
||||
if m == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func sliceAny(v any) []any {
|
||||
s, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func cloneValues(in url.Values) url.Values {
|
||||
out := url.Values{}
|
||||
for k, vals := range in {
|
||||
out[k] = append([]string(nil), vals...)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package beatport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"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.Session.Beatport.Username = "user"
|
||||
cfg.Session.Beatport.Password = "pass"
|
||||
cfg.Session.Beatport.AccessToken = "token"
|
||||
cfg.Session.Beatport.TokenExpiry = time.Now().Add(time.Hour).Unix()
|
||||
c := New(cfg)
|
||||
c.baseURL = ts.URL
|
||||
c.http = ts.Client()
|
||||
c.loggedIn = true
|
||||
return c, ts.Close
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
t.Fatalf("encode json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDownloadableMapsQuality(t *testing.T) {
|
||||
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/catalog/tracks/42/download/" {
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("quality"); got != "high" {
|
||||
t.Fatalf("quality = %q, want high", got)
|
||||
}
|
||||
writeJSON(t, w, map[string]any{"location": "https://cdn.example/42.m4a", "stream_quality": ".256k.aac.mp4"})
|
||||
}))
|
||||
defer closeServer()
|
||||
|
||||
d, err := c.GetDownloadable(context.Background(), "42", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDownloadable() error = %v", err)
|
||||
}
|
||||
if d.URL != "https://cdn.example/42.m4a" || d.Extension != "m4a" || d.Audio.BitrateKbps != 256 || d.Audio.Quality != "HIGH" {
|
||||
t.Fatalf("unexpected downloadable: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumMetadataNormalizesReleaseTracks(t *testing.T) {
|
||||
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/catalog/releases/7/":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"id": 7,
|
||||
"name": "Release Name",
|
||||
"new_release_date": "2024-01-02",
|
||||
"track_count": 1,
|
||||
"artists": []any{map[string]any{"id": 10, "name": "Album Artist"}},
|
||||
"image": map[string]any{"dynamic_uri": "https://img.example/{w}x{h}.jpg"},
|
||||
})
|
||||
case "/catalog/releases/7/tracks/":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"next": nil,
|
||||
"results": []any{map[string]any{
|
||||
"id": 42,
|
||||
"name": "Track Name",
|
||||
"mix_name": "Original Mix",
|
||||
"number": 1,
|
||||
"artists": []any{map[string]any{"id": 11, "name": "Track Artist"}},
|
||||
"genre": map[string]any{"name": "House"},
|
||||
"isrc": "USABC1234567",
|
||||
}},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer closeServer()
|
||||
|
||||
meta, err := c.GetMetadata(context.Background(), "7", "album")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata() error = %v", err)
|
||||
}
|
||||
if meta["title"] != "Release Name" || meta["release_date_original"] != "2024-01-02" {
|
||||
t.Fatalf("unexpected album meta: %+v", meta)
|
||||
}
|
||||
tracks := meta["tracks"].(map[string]any)["items"].([]any)
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("tracks len = %d", len(tracks))
|
||||
}
|
||||
track := tracks[0].(map[string]any)
|
||||
if track["id"] != "42" || track["title"] != "Track Name" || track["version"] != "Original Mix" {
|
||||
t.Fatalf("unexpected track meta: %+v", track)
|
||||
}
|
||||
album := track["album"].(map[string]any)
|
||||
if album["title"] != "Release Name" {
|
||||
t.Fatalf("unexpected track album: %+v", album)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumMetadataCollapsesManyReleaseArtists(t *testing.T) {
|
||||
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/catalog/releases/7/":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"id": 7,
|
||||
"name": "Compilation",
|
||||
"track_count": 2,
|
||||
"artists": []any{
|
||||
map[string]any{"id": 1, "name": "A"},
|
||||
map[string]any{"id": 2, "name": "B"},
|
||||
map[string]any{"id": 3, "name": "C"},
|
||||
map[string]any{"id": 4, "name": "D"},
|
||||
},
|
||||
})
|
||||
case "/catalog/releases/7/tracks/":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"next": nil,
|
||||
"results": []any{
|
||||
map[string]any{"id": 42, "name": "Track A", "number": 1, "artists": []any{map[string]any{"id": 1, "name": "A"}}},
|
||||
map[string]any{"id": 43, "name": "Track B", "number": 2, "artists": []any{map[string]any{"id": 2, "name": "B"}}},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer closeServer()
|
||||
|
||||
meta, err := c.GetMetadata(context.Background(), "7", "album")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata() error = %v", err)
|
||||
}
|
||||
artist := meta["artist"].(map[string]any)
|
||||
if artist["name"] != "Various Artists" {
|
||||
t.Fatalf("album artist = %q, want Various Artists", artist["name"])
|
||||
}
|
||||
track := meta["tracks"].(map[string]any)["items"].([]any)[0].(map[string]any)
|
||||
album := track["album"].(map[string]any)
|
||||
albumArtist := album["artist"].(map[string]any)
|
||||
if albumArtist["name"] != "Various Artists" {
|
||||
t.Fatalf("track album artist = %q, want Various Artists", albumArtist["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumMetadataUsesArtistPresentOnEveryTrack(t *testing.T) {
|
||||
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/catalog/releases/7/":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"id": 7,
|
||||
"name": "Artist Album",
|
||||
"track_count": 3,
|
||||
"artists": []any{
|
||||
map[string]any{"id": 1, "name": "Main"},
|
||||
map[string]any{"id": 2, "name": "Guest A"},
|
||||
map[string]any{"id": 3, "name": "Guest B"},
|
||||
map[string]any{"id": 4, "name": "Guest C"},
|
||||
},
|
||||
})
|
||||
case "/catalog/releases/7/tracks/":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"next": nil,
|
||||
"results": []any{
|
||||
map[string]any{"id": 42, "name": "Track A", "number": 1, "artists": []any{map[string]any{"id": 1, "name": "Main"}, map[string]any{"id": 2, "name": "Guest A"}}},
|
||||
map[string]any{"id": 43, "name": "Track B", "number": 2, "artists": []any{map[string]any{"id": 1, "name": "Main"}, map[string]any{"id": 3, "name": "Guest B"}}},
|
||||
map[string]any{"id": 44, "name": "Track C", "number": 3, "artists": []any{map[string]any{"id": 1, "name": "Main"}, map[string]any{"id": 4, "name": "Guest C"}}},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer closeServer()
|
||||
|
||||
meta, err := c.GetMetadata(context.Background(), "7", "album")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata() error = %v", err)
|
||||
}
|
||||
artist := meta["artist"].(map[string]any)
|
||||
if artist["name"] != "Main" {
|
||||
t.Fatalf("album artist = %q, want Main", artist["name"])
|
||||
}
|
||||
track := meta["tracks"].(map[string]any)["items"].([]any)[0].(map[string]any)
|
||||
album := track["album"].(map[string]any)
|
||||
albumArtist := album["artist"].(map[string]any)
|
||||
if albumArtist["name"] != "Main" {
|
||||
t.Fatalf("track album artist = %q, want Main", albumArtist["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseArtistCollapseCanBeDisabled(t *testing.T) {
|
||||
cfg := &config.Config{Session: testConfigData(), File: testConfigData()}
|
||||
cfg.Session.Beatport.VariousArtistsThreshold = -1
|
||||
c := New(cfg)
|
||||
got := c.releaseArtistName([]any{
|
||||
map[string]any{"name": "A"},
|
||||
map[string]any{"name": "B"},
|
||||
map[string]any{"name": "C"},
|
||||
map[string]any{"name": "D"},
|
||||
})
|
||||
if got != "A, B, C & D" {
|
||||
t.Fatalf("releaseArtistName() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) {
|
||||
got := jsonutil.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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtistMetadataUsesReleases(t *testing.T) {
|
||||
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/catalog/artists/908468/":
|
||||
writeJSON(t, w, map[string]any{"id": 908468, "name": "D'ort"})
|
||||
case "/catalog/releases/":
|
||||
if got := r.URL.Query().Get("artist_id"); got != "908468" {
|
||||
t.Fatalf("artist_id = %q", got)
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"next": nil,
|
||||
"results": []any{map[string]any{
|
||||
"id": 123,
|
||||
"name": "An Artisan's Exhibition",
|
||||
"new_release_date": "2026-01-01",
|
||||
"track_count": 10,
|
||||
"artists": []any{map[string]any{"id": 908468, "name": "D'ort"}},
|
||||
}},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer closeServer()
|
||||
|
||||
meta, err := c.GetMetadata(context.Background(), "908468", "artist")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata() error = %v", err)
|
||||
}
|
||||
if meta["name"] != "D'ort" {
|
||||
t.Fatalf("artist name = %q", meta["name"])
|
||||
}
|
||||
albums := meta["albums"].(map[string]any)["items"].([]any)
|
||||
if len(albums) != 1 {
|
||||
t.Fatalf("albums len = %d", len(albums))
|
||||
}
|
||||
album := albums[0].(map[string]any)
|
||||
if album["id"] != "123" || album["title"] != "An Artisan's Exhibition" || album["tracks_count"] != 10 {
|
||||
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
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/ratelimit"
|
||||
"streamrip-go/internal/verbose"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -31,8 +33,6 @@ 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,9 +47,12 @@ var (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
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
|
||||
loggedIn bool
|
||||
ua string
|
||||
deviceID string
|
||||
@@ -69,7 +72,7 @@ func New(cfg *config.Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: httpClient,
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
|
||||
ua: randomDeezerUA(),
|
||||
deviceID: randomHexN(32),
|
||||
arl: strings.TrimSpace(cfg.Session.Deezer.ARL),
|
||||
@@ -82,6 +85,8 @@ 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 = ""
|
||||
@@ -114,6 +119,8 @@ func (c *Client) ensureLaunchSession(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
c.authMu.Lock()
|
||||
defer c.authMu.Unlock()
|
||||
return c.loggedIn
|
||||
}
|
||||
|
||||
@@ -122,7 +129,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 {
|
||||
@@ -151,7 +158,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")
|
||||
}
|
||||
|
||||
@@ -471,10 +478,11 @@ func (c *Client) persistRefreshToken() {
|
||||
if rt == "" {
|
||||
return
|
||||
}
|
||||
c.cfg.Session.Deezer.RefreshToken = rt
|
||||
c.cfg.File.Deezer.RefreshToken = rt
|
||||
if strings.TrimSpace(c.cfg.Path) != "" {
|
||||
_ = c.cfg.SaveFile()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +497,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authToken, err := deriveGatewayAuthToken(mobileToken)
|
||||
authToken, err := deriveGatewayAuthToken(c.gatewayKey(), mobileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -499,7 +507,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
|
||||
}
|
||||
c.sid = jsonutil.FirstNonEmpty(c.sid, sid)
|
||||
|
||||
encryptedPassword, err := encryptPassword(mobileToken, password)
|
||||
encryptedPassword, err := encryptPassword(c.gatewayKey(), mobileToken, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -518,7 +526,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
|
||||
"password": encryptedPassword,
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("api_key", apiKey)
|
||||
params.Set("api_key", c.apiKey())
|
||||
params.Set("sid", c.sid)
|
||||
params.Set("method", "mobile_userAuth")
|
||||
params.Set("output", "3")
|
||||
@@ -727,7 +735,7 @@ func (c *Client) bootstrapSID(ctx context.Context) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authToken, err := deriveGatewayAuthToken(mobileToken)
|
||||
authToken, err := deriveGatewayAuthToken(c.gatewayKey(), mobileToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -739,7 +747,7 @@ func (c *Client) mobileAuth(ctx context.Context) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("api_key", apiKey)
|
||||
params.Set("api_key", c.apiKey())
|
||||
params.Set("output", "3")
|
||||
params.Set("method", "mobile_auth")
|
||||
params.Set("network", randomHexN(32))
|
||||
@@ -792,7 +800,7 @@ func (c *Client) apiCheckToken(ctx context.Context, authToken string) (string, e
|
||||
return "", err
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("api_key", apiKey)
|
||||
params.Set("api_key", c.apiKey())
|
||||
params.Set("method", "api_checkToken")
|
||||
params.Set("auth_token", authToken)
|
||||
params.Set("output", "3")
|
||||
@@ -855,7 +863,7 @@ func (c *Client) mobileUserAutolog(ctx context.Context) error {
|
||||
payload["refresh_token"] = strings.TrimSpace(c.refresh)
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("api_key", apiKey)
|
||||
params.Set("api_key", c.apiKey())
|
||||
params.Set("sid", c.sid)
|
||||
params.Set("output", "3")
|
||||
params.Set("input", "3")
|
||||
@@ -1065,8 +1073,10 @@ func (c *Client) refreshLicenseFromPipe(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func deriveGatewayAuthToken(mobileToken string) (string, error) {
|
||||
dec, err := decryptMobileToken(mobileToken)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1082,19 +1092,19 @@ func deriveGatewayAuthToken(mobileToken string) (string, error) {
|
||||
return hex.EncodeToString(enc), nil
|
||||
}
|
||||
|
||||
func decryptMobileToken(mobileToken string) ([]byte, error) {
|
||||
func decryptMobileToken(gatewayKey, mobileToken string) ([]byte, error) {
|
||||
b, err := hex.DecodeString(strings.TrimSpace(mobileToken))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aesECBDecrypt([]byte(gatewayDec), b)
|
||||
return aesECBDecrypt([]byte(gatewayKey), b)
|
||||
}
|
||||
|
||||
func encryptPassword(mobileToken, password string) (string, error) {
|
||||
func encryptPassword(gatewayKey, mobileToken, password string) (string, error) {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return "", errors.New("missing deezer password")
|
||||
}
|
||||
dec, err := decryptMobileToken(mobileToken)
|
||||
dec, err := decryptMobileToken(gatewayKey, mobileToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1503,3 +1513,19 @@ 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
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func TestSearchTrack(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
c.loggedIn = true
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
c.loggedIn = true
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestGetMetadataAlbumPaginatesTracks(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
c.loggedIn = true
|
||||
|
||||
@@ -189,7 +189,7 @@ func TestGetMetadataPlaylistPaginatesTracks(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
c.loggedIn = true
|
||||
|
||||
@@ -241,7 +241,7 @@ func TestGetDownloadableNativeCipher(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
|
||||
origGateway := gatewayURL
|
||||
@@ -703,7 +703,7 @@ func TestMobileUserAutologIncludesRefreshToken(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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(gatewayDec), plain)
|
||||
enc, err := aesECBEncrypt([]byte(config.DefaultDeezerGatewayKey), plain)
|
||||
if err != nil {
|
||||
t.Fatalf("aesECBEncrypt() error = %v", err)
|
||||
}
|
||||
@@ -748,7 +748,7 @@ func TestLoginWithRefreshToken(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
c.jwt = "jwt-token"
|
||||
|
||||
@@ -863,3 +863,13 @@ 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
|
||||
}
|
||||
@@ -30,6 +30,7 @@ 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"
|
||||
@@ -39,8 +40,6 @@ 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"
|
||||
@@ -58,11 +57,15 @@ var (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
baseURL string
|
||||
fetchCfg func(ctx context.Context) (string, []string, error)
|
||||
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
|
||||
loggedIn bool
|
||||
secret string
|
||||
uat string
|
||||
@@ -87,11 +90,16 @@ 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),
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||
baseURL: baseURL,
|
||||
fetchCfg: nil,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +108,14 @@ 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)
|
||||
@@ -203,14 +215,18 @@ 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...)
|
||||
c.cfg.File.Qobuz.AppID = q.AppID
|
||||
c.cfg.File.Qobuz.Secrets = append([]string(nil), clean...)
|
||||
_ = c.cfg.SaveFile()
|
||||
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)
|
||||
}
|
||||
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" {
|
||||
@@ -251,16 +267,8 @@ 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 {
|
||||
@@ -283,7 +291,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 {
|
||||
@@ -364,40 +372,60 @@ func qobuzDownloadExtension(resp map[string]any, quality int, streamURL string)
|
||||
}
|
||||
|
||||
func qobuzAudioProfile(resp map[string]any, requestedQuality int, ext string) provider.AudioProfile {
|
||||
exactBitDepth, _ := intValue(firstNonNil(resp["bit_depth"], resp["bits_depth"], resp["maximum_bit_depth"]))
|
||||
exactSampling, _ := floatValue(firstNonNil(resp["sampling_rate"], resp["sample_rate"], resp["maximum_sampling_rate"]))
|
||||
if formatID, ok := intValue(resp["format_id"]); ok {
|
||||
switch formatID {
|
||||
case 5:
|
||||
if exactBitDepth == 0 {
|
||||
exactBitDepth = 16
|
||||
}
|
||||
if exactSampling == 0 {
|
||||
exactSampling = 44.1
|
||||
}
|
||||
return provider.AudioProfile{
|
||||
Container: "MP3",
|
||||
Codec: "MP3",
|
||||
Quality: "HIGH",
|
||||
BitDepth: 16,
|
||||
SamplingRate: "44.1",
|
||||
BitDepth: exactBitDepth,
|
||||
SamplingRate: formatSamplingRate(exactSampling),
|
||||
BitrateKbps: 320,
|
||||
}
|
||||
case 6:
|
||||
if exactBitDepth == 0 {
|
||||
exactBitDepth = 16
|
||||
}
|
||||
if exactSampling == 0 {
|
||||
exactSampling = 44.1
|
||||
}
|
||||
return provider.AudioProfile{
|
||||
Container: "FLAC",
|
||||
Codec: "FLAC",
|
||||
Quality: "LOSSLESS",
|
||||
BitDepth: 16,
|
||||
SamplingRate: "44.1",
|
||||
BitDepth: exactBitDepth,
|
||||
SamplingRate: formatSamplingRate(exactSampling),
|
||||
}
|
||||
case 7:
|
||||
if exactBitDepth == 0 {
|
||||
exactBitDepth = 24
|
||||
}
|
||||
return provider.AudioProfile{
|
||||
Container: "FLAC",
|
||||
Codec: "FLAC",
|
||||
Quality: "HI_RES",
|
||||
BitDepth: 24,
|
||||
SamplingRate: "96",
|
||||
BitDepth: exactBitDepth,
|
||||
SamplingRate: formatSamplingRate(exactSampling),
|
||||
}
|
||||
case 27:
|
||||
if exactBitDepth == 0 {
|
||||
exactBitDepth = 24
|
||||
}
|
||||
return provider.AudioProfile{
|
||||
Container: "FLAC",
|
||||
Codec: "FLAC",
|
||||
Quality: "HI_RES",
|
||||
BitDepth: 24,
|
||||
SamplingRate: "192",
|
||||
BitDepth: exactBitDepth,
|
||||
SamplingRate: formatSamplingRate(exactSampling),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,6 +466,22 @@ func qobuzAudioProfile(resp map[string]any, requestedQuality int, ext string) pr
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonNil(vals ...any) any {
|
||||
for _, v := range vals {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatSamplingRate(v float64) string {
|
||||
if v <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1000,11 +1044,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", mobileAppID)
|
||||
params.Set("app_id", c.mobileAppID())
|
||||
params.Set("username", username)
|
||||
params.Set("password", password)
|
||||
params.Set("request_ts", strconv.FormatInt(ts, 10))
|
||||
params.Set("request_sig", mobileSignRequest("oauth2/login", []kv{{"password", password}, {"username", username}}, ts))
|
||||
params.Set("request_sig", mobileSignRequest(c.mobileAppSecret(), "oauth2/login", []kv{{"password", password}, {"username", username}}, ts))
|
||||
|
||||
reqURL := baseURL + "/oauth2/login?" + params.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
@@ -1042,9 +1086,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", mobileAppID)
|
||||
params.Set("app_id", c.mobileAppID())
|
||||
params.Set("request_ts", strconv.FormatInt(ts, 10))
|
||||
params.Set("request_sig", mobileSignRequest("session/start", []kv{{"profile", mobileSessionProf}}, ts))
|
||||
params.Set("request_sig", mobileSignRequest(c.mobileAppSecret(), "session/start", []kv{{"profile", mobileSessionProf}}, ts))
|
||||
|
||||
reqURL := baseURL + "/session/start?" + params.Encode()
|
||||
form := url.Values{}
|
||||
@@ -1086,12 +1130,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", mobileAppID)
|
||||
params.Set("app_id", c.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("file/url", []kv{{"format_id", strconv.Itoa(formatID)}, {"intent", "stream"}, {"track_id", trackID}}, ts))
|
||||
params.Set("request_sig", mobileSignRequest(c.mobileAppSecret(), "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 {
|
||||
@@ -1123,7 +1167,11 @@ func (c *Client) mobileCopyURLToWriter(ctx context.Context, sourceURL string, ou
|
||||
return err
|
||||
}
|
||||
c.setMobileHeaders(req)
|
||||
resp, err := c.http.Do(req)
|
||||
client := c.streamHTTP
|
||||
if client == nil {
|
||||
client = c.http
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1209,7 +1257,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", mobileAppID)
|
||||
req.Header.Set("X-App-Id", c.mobileAppID())
|
||||
req.Header.Set("X-App-Version", mobileAppVersion)
|
||||
req.Header.Set("X-Device-Platform", "android")
|
||||
req.Header.Set("X-Device-Model", "Nexus 6P")
|
||||
@@ -1222,7 +1270,9 @@ func (c *Client) setMobileHeaders(req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func mobileSignRequest(endpoint string, params []kv, ts int64) string {
|
||||
// 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 {
|
||||
method := strings.ReplaceAll(endpoint, "/", "")
|
||||
sortKVs(params)
|
||||
var sb strings.Builder
|
||||
@@ -1232,7 +1282,7 @@ func mobileSignRequest(endpoint string, params []kv, ts int64) string {
|
||||
sb.WriteString(p.Value)
|
||||
}
|
||||
sb.WriteString(strconv.FormatInt(ts, 10))
|
||||
sb.WriteString(mobileAppSecret)
|
||||
sb.WriteString(appSecret)
|
||||
h := md5.Sum([]byte(sb.String()))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -1268,7 +1318,17 @@ func (c *Client) mobileDeriveTrackKey(encryptedKey string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode mobile info: %w", err)
|
||||
}
|
||||
reader := hkdf.New(func() hash.Hash { return sha256.New() }, hexDecodeOrNil(mobileAppSecret), salt, info)
|
||||
// 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)
|
||||
kek := make([]byte, 16)
|
||||
if _, err = io.ReadFull(reader, kek); err != nil {
|
||||
return nil, fmt.Errorf("mobile hkdf derive failed: %w", err)
|
||||
@@ -1277,11 +1337,6 @@ 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 {
|
||||
@@ -1436,3 +1491,198 @@ 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")
|
||||
}
|
||||
@@ -208,7 +208,7 @@ func TestGetArtistPagination(t *testing.T) {
|
||||
|
||||
func newTestClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
d := config.DefaultConfigData()
|
||||
d := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
d := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
d := testConfigData()
|
||||
d.Qobuz.EmailOrUserID = "user@example.com"
|
||||
d.Qobuz.PasswordOrToken = "hash"
|
||||
d.Qobuz.AppID = "old-app"
|
||||
@@ -371,6 +371,24 @@ func TestGetDownloadableUsesReturnedURLExtension(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzAudioProfileUsesExactReturnedQuality(t *testing.T) {
|
||||
profile := qobuzAudioProfile(map[string]any{
|
||||
"format_id": float64(7),
|
||||
"bit_depth": float64(24),
|
||||
"sampling_rate": float64(44.1),
|
||||
}, 4, "flac")
|
||||
if profile.BitDepth != 24 || profile.SamplingRate != "44.1" || profile.Quality != "HI_RES" {
|
||||
t.Fatalf("unexpected profile: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzAudioProfileAvoidsGuessingHiResSampleRate(t *testing.T) {
|
||||
profile := qobuzAudioProfile(map[string]any{"format_id": float64(7)}, 4, "flac")
|
||||
if profile.BitDepth != 24 || profile.SamplingRate != "" || profile.Quality != "HI_RES" {
|
||||
t.Fatalf("unexpected profile: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func qobuzSecretSig(requestTS, secret string) string {
|
||||
raw := "trackgetFileUrlformat_id27intentstreamtrack_id19512574" + requestTS + secret
|
||||
hash := md5.Sum([]byte(raw))
|
||||
@@ -378,7 +396,7 @@ func qobuzSecretSig(requestTS, secret string) string {
|
||||
}
|
||||
|
||||
func TestRefreshAppCredentialsRejectsEmptyData(t *testing.T) {
|
||||
d := config.DefaultConfigData()
|
||||
d := testConfigData()
|
||||
c := New(&config.Config{File: d, Session: d})
|
||||
c.fetchCfg = func(context.Context) (string, []string, error) {
|
||||
return "", []string{" "}, nil
|
||||
@@ -388,3 +406,228 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ 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")
|
||||
@@ -26,22 +28,32 @@ var soundcloudSearchBaseURL = "https://soundcloud.com"
|
||||
type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
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
|
||||
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,
|
||||
http: &http.Client{Timeout: 20 * time.Second},
|
||||
cache: map[string]map[string]any{},
|
||||
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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +62,8 @@ func (c *Client) Source() string {
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
c.authMu.Lock()
|
||||
defer c.authMu.Unlock()
|
||||
return c.loggedIn
|
||||
}
|
||||
|
||||
@@ -57,12 +71,14 @@ 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 {
|
||||
@@ -133,6 +149,11 @@ 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
|
||||
@@ -201,7 +222,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")
|
||||
}
|
||||
|
||||
@@ -273,7 +294,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)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGetTrackMetadataAndDownloadable(t *testing.T) {
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
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 := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
c.bin = "definitely-not-a-real-yt-dlp-bin"
|
||||
err := c.Login(context.Background())
|
||||
@@ -254,3 +254,13 @@ 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
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/ratelimit"
|
||||
"streamrip-go/internal/verbose"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,8 +28,6 @@ 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,14 +58,17 @@ type Client struct {
|
||||
baseURL string
|
||||
lyricsAPI string
|
||||
openAPI string
|
||||
loggedIn bool
|
||||
// authMu guards loggedIn and the login/refresh path, which download
|
||||
// workers can reach concurrently.
|
||||
authMu sync.Mutex
|
||||
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),
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
|
||||
baseURL: baseURL,
|
||||
lyricsAPI: lyricsAPIv1,
|
||||
openAPI: openAPIV2,
|
||||
@@ -77,10 +80,14 @@ 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
|
||||
}
|
||||
@@ -111,10 +118,10 @@ func (c *Client) Login(ctx context.Context) error {
|
||||
return fmt.Errorf("tidal login failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
if v := stringify(resp["countryCode"]); v != "" {
|
||||
if v := jsonutil.StringFromAny(resp["countryCode"]); v != "" {
|
||||
c.cfg.Session.Tidal.CountryCode = v
|
||||
}
|
||||
if v := stringify(resp["userId"]); v != "" {
|
||||
if v := jsonutil.StringFromAny(resp["userId"]); v != "" {
|
||||
c.cfg.Session.Tidal.UserID = v
|
||||
}
|
||||
|
||||
@@ -137,7 +144,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error {
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", clientID)
|
||||
form.Set("client_id", c.appClientID())
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("scope", "r_usr+w_usr+w_sub")
|
||||
@@ -150,33 +157,36 @@ func (c *Client) refreshAccessToken(ctx context.Context) error {
|
||||
return fmt.Errorf("tidal token refresh failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
newToken := stringify(resp["access_token"])
|
||||
newToken := jsonutil.StringFromAny(resp["access_token"])
|
||||
if newToken == "" {
|
||||
return errors.New("tidal token refresh missing access_token")
|
||||
}
|
||||
|
||||
newRefresh := stringify(resp["refresh_token"])
|
||||
newRefresh := jsonutil.StringFromAny(resp["refresh_token"])
|
||||
expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 7 * 24 * 3600
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -251,13 +261,13 @@ func (c *Client) fetchTrackLyrics(ctx context.Context, trackID string) (string,
|
||||
return "", ""
|
||||
}
|
||||
|
||||
lyrics := strings.TrimSpace(stringify(resp["lyrics"]))
|
||||
lrc := strings.TrimSpace(stringify(resp["subtitles"]))
|
||||
lyrics := strings.TrimSpace(jsonutil.StringFromAny(resp["lyrics"]))
|
||||
lrc := strings.TrimSpace(jsonutil.StringFromAny(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 {
|
||||
@@ -284,7 +294,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 {
|
||||
@@ -327,30 +337,6 @@ 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 {
|
||||
@@ -395,18 +381,18 @@ func (c *Client) getAtmosDownloadable(ctx context.Context, trackID string) (*pro
|
||||
}
|
||||
|
||||
func playbackLooksAtmos(resp map[string]any) bool {
|
||||
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
|
||||
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(resp["audioMode"])), "ATMOS") {
|
||||
return true
|
||||
}
|
||||
if modes, ok := resp["audioModes"].([]any); ok {
|
||||
for _, raw := range modes {
|
||||
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
|
||||
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(raw)), "ATMOS") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
manifestB64 := stringify(resp["manifest"])
|
||||
manifestB64 := jsonutil.StringFromAny(resp["manifest"])
|
||||
if manifestB64 == "" {
|
||||
return false
|
||||
}
|
||||
@@ -418,17 +404,17 @@ func playbackLooksAtmos(resp map[string]any) bool {
|
||||
if err = json.Unmarshal(b, &manifest); err != nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(stringify(manifest["audioMode"])), "ATMOS") {
|
||||
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(manifest["audioMode"])), "ATMOS") {
|
||||
return true
|
||||
}
|
||||
if modes, ok := manifest["audioModes"].([]any); ok {
|
||||
for _, raw := range modes {
|
||||
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
|
||||
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(raw)), "ATMOS") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
codec := strings.ToLower(stringify(manifest["codecs"]))
|
||||
codec := strings.ToLower(jsonutil.StringFromAny(manifest["codecs"]))
|
||||
return strings.Contains(codec, "ec-3") || strings.Contains(codec, "eac3") || strings.Contains(codec, "joc") || strings.Contains(codec, "atmos")
|
||||
}
|
||||
|
||||
@@ -512,7 +498,7 @@ func (c *Client) fetchArtistAlbums(ctx context.Context, artistID string) ([]map[
|
||||
if wrapped, ok := itm["item"].(map[string]any); ok {
|
||||
itm = wrapped
|
||||
}
|
||||
id := stringify(itm["id"])
|
||||
id := jsonutil.StringFromAny(itm["id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
@@ -564,14 +550,14 @@ func (c *Client) getDownloadableFromTrackManifestForFormats(ctx context.Context,
|
||||
if !ok {
|
||||
return nil, errors.New("tidal trackManifests missing attributes")
|
||||
}
|
||||
uri := stringify(attrs["uri"])
|
||||
uri := jsonutil.StringFromAny(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(stringify(f))
|
||||
fv := strings.ToUpper(jsonutil.StringFromAny(f))
|
||||
if strings.Contains(fv, "FLAC") {
|
||||
ext = "flac"
|
||||
break
|
||||
@@ -601,7 +587,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")
|
||||
}
|
||||
|
||||
@@ -618,7 +604,7 @@ func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*pro
|
||||
return nil, fmt.Errorf("tidal video playbackinfo failed: status=%d", status)
|
||||
}
|
||||
|
||||
manifestB64 := stringify(resp["manifest"])
|
||||
manifestB64 := jsonutil.StringFromAny(resp["manifest"])
|
||||
if manifestB64 == "" {
|
||||
return nil, errors.New("tidal video manifest missing")
|
||||
}
|
||||
@@ -634,7 +620,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 := stringify(urls[0])
|
||||
masterURL := jsonutil.StringFromAny(urls[0])
|
||||
if masterURL == "" {
|
||||
return nil, errors.New("tidal video master url missing")
|
||||
}
|
||||
@@ -665,7 +651,7 @@ func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*pro
|
||||
}
|
||||
|
||||
func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadable {
|
||||
manifestB64 := stringify(resp["manifest"])
|
||||
manifestB64 := jsonutil.StringFromAny(resp["manifest"])
|
||||
if manifestB64 == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -681,11 +667,11 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
|
||||
if !ok || len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
streamURL := stringify(urls[0])
|
||||
streamURL := jsonutil.StringFromAny(urls[0])
|
||||
if streamURL == "" {
|
||||
return nil
|
||||
}
|
||||
codec := strings.ToLower(stringify(manifest["codecs"]))
|
||||
codec := strings.ToLower(jsonutil.StringFromAny(manifest["codecs"]))
|
||||
ext := "m4a"
|
||||
if strings.Contains(codec, "flac") {
|
||||
ext = "flac"
|
||||
@@ -696,14 +682,14 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
|
||||
if profile.Container == "" {
|
||||
profile = tidalAudioProfileFromExtension(ext)
|
||||
}
|
||||
audioQuality := strings.ToUpper(strings.TrimSpace(stringify(resp["audioQuality"])))
|
||||
audioQuality := strings.ToUpper(strings.TrimSpace(jsonutil.StringFromAny(resp["audioQuality"])))
|
||||
if audioQuality == "" {
|
||||
audioQuality = strings.ToUpper(strings.TrimSpace(stringify(manifest["audioQuality"])))
|
||||
audioQuality = strings.ToUpper(strings.TrimSpace(jsonutil.StringFromAny(manifest["audioQuality"])))
|
||||
}
|
||||
if audioQuality != "" {
|
||||
profile = applyTidalAudioQuality(profile, audioQuality)
|
||||
}
|
||||
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
|
||||
if strings.Contains(strings.ToUpper(jsonutil.StringFromAny(resp["audioMode"])), "ATMOS") {
|
||||
profile = tidalAtmosAudioProfile()
|
||||
}
|
||||
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal", Audio: profile}
|
||||
@@ -712,7 +698,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(stringify(raw)))
|
||||
f := strings.ToUpper(strings.TrimSpace(jsonutil.StringFromAny(raw)))
|
||||
switch {
|
||||
case strings.Contains(f, "EAC3") || strings.Contains(f, "JOC") || strings.Contains(f, "ATMOS"):
|
||||
return tidalAtmosAudioProfile()
|
||||
@@ -984,35 +970,20 @@ 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(clientID + ":" + clientSec))
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(c.appClientID() + ":" + c.appClientSecret()))
|
||||
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 := stringify(meta["cover"])
|
||||
cover := jsonutil.StringFromAny(meta["cover"])
|
||||
if cover == "" {
|
||||
cover = stringify(meta["squareImage"])
|
||||
cover = jsonutil.StringFromAny(meta["squareImage"])
|
||||
}
|
||||
if cover == "" {
|
||||
return
|
||||
@@ -1031,3 +1002,20 @@ 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
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import (
|
||||
"testing"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/jsonutil"
|
||||
)
|
||||
|
||||
func TestLoginMissingToken(t *testing.T) {
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Tidal.AccessToken = ""
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
err := c.Login(context.Background())
|
||||
@@ -37,7 +38,7 @@ func TestSearch(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
@@ -73,7 +74,7 @@ func TestGetVideoDownloadable(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
@@ -143,7 +144,7 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
@@ -185,7 +186,7 @@ func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "MY"
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
@@ -197,10 +198,10 @@ func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata() err = %v", err)
|
||||
}
|
||||
if got := stringify(meta["lyrics"]); got != "plain lyrics line" {
|
||||
if got := jsonutil.StringFromAny(meta["lyrics"]); got != "plain lyrics line" {
|
||||
t.Fatalf("lyrics = %q, want plain lyrics line", got)
|
||||
}
|
||||
if got := stringify(meta["lyrics_synced"]); got != "[00:00.00]plain lyrics line" {
|
||||
if got := jsonutil.StringFromAny(meta["lyrics_synced"]); got != "[00:00.00]plain lyrics line" {
|
||||
t.Fatalf("lyrics_synced = %q, want synced lrc", got)
|
||||
}
|
||||
}
|
||||
@@ -219,7 +220,7 @@ func TestGetMetadataTrackIgnoresLyricsEndpointFailure(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
@@ -257,7 +258,7 @@ func TestAPIRequestRetriesTooManyRequests(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Downloads.RequestsPerMinute = 0
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
@@ -274,8 +275,8 @@ func TestAPIRequestRetriesTooManyRequests(t *testing.T) {
|
||||
if calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2", calls)
|
||||
}
|
||||
if stringify(resp["title"]) != "Song" {
|
||||
t.Fatalf("title = %q, want Song", stringify(resp["title"]))
|
||||
if jsonutil.StringFromAny(resp["title"]) != "Song" {
|
||||
t.Fatalf("title = %q, want Song", jsonutil.StringFromAny(resp["title"]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +298,7 @@ func TestAPIPostRetriesTooManyRequests(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Downloads.RequestsPerMinute = 0
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
|
||||
@@ -311,8 +312,8 @@ func TestAPIPostRetriesTooManyRequests(t *testing.T) {
|
||||
if calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2", calls)
|
||||
}
|
||||
if stringify(resp["access_token"]) != "fresh-token" {
|
||||
t.Fatalf("access_token = %q, want fresh-token", stringify(resp["access_token"]))
|
||||
if jsonutil.StringFromAny(resp["access_token"]) != "fresh-token" {
|
||||
t.Fatalf("access_token = %q, want fresh-token", jsonutil.StringFromAny(resp["access_token"]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +343,7 @@ func TestGetDownloadablePrefersAtmosWhenEnabled(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
cfgData.Tidal.PreferAtmos = true
|
||||
@@ -377,28 +378,6 @@ 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
|
||||
@@ -448,7 +427,7 @@ func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData := testConfigData()
|
||||
cfgData.Tidal.AccessToken = "token"
|
||||
cfgData.Tidal.CountryCode = "US"
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
@@ -473,3 +452,13 @@ 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
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
@@ -25,25 +26,26 @@ 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
|
||||
desktopWindowsSignKey = "kzqU4XhfCaY6B6JTHODeq5"
|
||||
legacyMP3SignSalt = "XGRlBW9FXlekgbPrRHuSiA"
|
||||
defaultEstimatedKbps = 50000
|
||||
baseURL = "https://api.music.yandex.net"
|
||||
desktopClientHeader = "YandexMusicDesktopAppWindows/5.13.2"
|
||||
desktopOrigin = "music-application://desktop"
|
||||
requestAttempts = 3
|
||||
)
|
||||
|
||||
var ErrMissingYandexToken = errors.New("missing yandex access_token")
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
baseURL string
|
||||
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
|
||||
loggedIn bool
|
||||
userID string
|
||||
}
|
||||
@@ -52,7 +54,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),
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute, cfg.Session.Downloads.MaxConnections),
|
||||
baseURL: baseURL,
|
||||
userID: strings.TrimSpace(cfg.Session.Yandex.UserID),
|
||||
}
|
||||
@@ -63,10 +65,14 @@ 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
|
||||
}
|
||||
@@ -80,16 +86,19 @@ 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
|
||||
c.cfg.Session.Yandex.UserID = uid
|
||||
c.cfg.File.Yandex.UserID = uid
|
||||
_ = c.cfg.SaveFile()
|
||||
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.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 {
|
||||
@@ -107,7 +116,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 {
|
||||
@@ -137,7 +146,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)
|
||||
@@ -168,7 +177,7 @@ func (c *Client) Close() error {
|
||||
}
|
||||
|
||||
func (c *Client) getTrackMetadata(ctx context.Context, item string) (map[string]any, error) {
|
||||
trackRef := canonicalTrackRequestID(item)
|
||||
trackRef := strings.TrimSpace(item)
|
||||
form := url.Values{}
|
||||
form.Set("trackIds", trackRef)
|
||||
form.Set("withMixData", "true")
|
||||
@@ -183,7 +192,7 @@ func (c *Client) getTrackMetadata(ctx context.Context, item string) (map[string]
|
||||
if len(items) == 0 {
|
||||
return nil, errors.New("yandex track metadata missing result")
|
||||
}
|
||||
return normalizeTrack(items[0], trackRef), nil
|
||||
return normalizeTrack(items[0], trackRef, c.artistSeparator()), nil
|
||||
}
|
||||
|
||||
func (c *Client) getAlbumMetadata(ctx context.Context, item string) (map[string]any, error) {
|
||||
@@ -198,7 +207,7 @@ func (c *Client) getAlbumMetadata(ctx context.Context, item string) (map[string]
|
||||
if len(result) == 0 {
|
||||
return nil, errors.New("yandex album metadata missing result")
|
||||
}
|
||||
return normalizeAlbum(result), nil
|
||||
return normalizeAlbum(result, c.artistSeparator()), nil
|
||||
}
|
||||
|
||||
func (c *Client) getArtistMetadata(ctx context.Context, item string) (map[string]any, error) {
|
||||
@@ -273,7 +282,7 @@ func (c *Client) normalizeSearchItems(mediaType string, result map[string]any) [
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
appendItem(normalizeTrack(itm, canonicalTrackRefFromRaw(itm, "")))
|
||||
appendItem(normalizeTrack(itm, canonicalTrackRefFromRaw(itm, ""), c.artistSeparator()))
|
||||
}
|
||||
case "album":
|
||||
for _, raw := range getResults("albums") {
|
||||
@@ -281,7 +290,7 @@ func (c *Client) normalizeSearchItems(mediaType string, result map[string]any) [
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
appendItem(normalizeAlbumSearchItem(itm))
|
||||
appendItem(normalizeAlbumSearchItem(itm, c.artistSeparator()))
|
||||
}
|
||||
case "artist":
|
||||
for _, raw := range getResults("artists") {
|
||||
@@ -334,7 +343,7 @@ func (c *Client) getLegacyDownloadable(ctx context.Context, trackID string) (*pr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
directURL, err := legacyDirectURL(xmlInfo)
|
||||
directURL, err := legacyDirectURL(c.legacyMP3Salt(), xmlInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -355,7 +364,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(trackID, qualityParam, codecs, transport)
|
||||
sign, ts := yandexDownloadSign(c.signKey(), trackID, qualityParam, codecs, transport)
|
||||
params := url.Values{}
|
||||
params.Set("trackId", trackID)
|
||||
params.Set("codecs", strings.Join(codecs, ","))
|
||||
@@ -421,11 +430,11 @@ func (c *Client) fetchLegacyXML(ctx context.Context, xmlURL string) (*legacyDown
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func legacyDirectURL(info *legacyDownloadInfoXML) (string, error) {
|
||||
func legacyDirectURL(salt string, 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(legacyMP3SignSalt + strings.TrimPrefix(info.Path, "/") + info.S))
|
||||
sum := md5.Sum([]byte(salt + strings.TrimPrefix(info.Path, "/") + info.S))
|
||||
return "https://" + info.Host + "/get-mp3/" + hex.EncodeToString(sum[:]) + "/" + info.TS + info.Path, nil
|
||||
}
|
||||
|
||||
@@ -561,17 +570,18 @@ func resultMap(resp map[string]any) map[string]any {
|
||||
return resp
|
||||
}
|
||||
|
||||
func normalizeTrack(raw map[string]any, fallbackID string) map[string]any {
|
||||
func normalizeTrack(raw map[string]any, fallbackID string, artistSeparator string) map[string]any {
|
||||
trackID := canonicalTrackRefFromRaw(raw, fallbackID)
|
||||
albumRaw := firstAlbum(raw)
|
||||
artistName := joinArtists(raw)
|
||||
albumArtist := joinAlbumArtists(albumRaw)
|
||||
artistNames := artistNames(raw)
|
||||
artistName := jsonutil.DisplayArtistNames(artistNames)
|
||||
albumArtist := joinAlbumArtists(albumRaw, artistSeparator)
|
||||
if albumArtist == "" {
|
||||
albumArtist = artistName
|
||||
}
|
||||
trackNumber, discNumber := trackNumbers(albumRaw)
|
||||
trackTotal := jsonutil.IntFromAny(albumRaw["trackCount"])
|
||||
discTotal := firstPositiveInt(
|
||||
discTotal := jsonutil.FirstPositiveInt(
|
||||
jsonutil.IntFromAny(raw["volumesCount"]),
|
||||
jsonutil.IntFromAny(albumRaw["volumesCount"]),
|
||||
)
|
||||
@@ -583,12 +593,13 @@ func normalizeTrack(raw map[string]any, fallbackID string) map[string]any {
|
||||
"tracks_count": trackTotal,
|
||||
"media_number": discNumber,
|
||||
"numberOfVolumes": discTotal,
|
||||
"release_date": firstNonEmpty(jsonutil.StringFromAny(raw["releaseDate"]), jsonutil.StringFromAny(albumRaw["releaseDate"])),
|
||||
"release_date": jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["releaseDate"]), jsonutil.StringFromAny(albumRaw["releaseDate"])),
|
||||
"parental_warning": isExplicit(raw),
|
||||
"explicit": isExplicit(raw),
|
||||
"source_track_id": jsonutil.StringFromAny(raw["realId"]),
|
||||
"performer": map[string]any{"name": artistName},
|
||||
"artist": map[string]any{"name": artistName, "id": firstArtistID(raw)},
|
||||
"artist_names": artistNames,
|
||||
"album": normalizeAlbumSummary(albumRaw, albumArtist),
|
||||
"image": imageMapFromTrack(raw, albumRaw),
|
||||
"cover": imageMapFromTrack(raw, albumRaw),
|
||||
@@ -599,10 +610,10 @@ func normalizeTrack(raw map[string]any, fallbackID string) map[string]any {
|
||||
return meta
|
||||
}
|
||||
|
||||
func normalizeAlbum(raw map[string]any) map[string]any {
|
||||
artistName := joinArtists(raw)
|
||||
func normalizeAlbum(raw map[string]any, artistSeparator string) map[string]any {
|
||||
artistName := joinArtists(raw, artistSeparator)
|
||||
if artistName == "" {
|
||||
artistName = joinAlbumArtists(raw)
|
||||
artistName = joinAlbumArtists(raw, artistSeparator)
|
||||
}
|
||||
volumes := albumVolumes(raw)
|
||||
items := make([]any, 0)
|
||||
@@ -670,9 +681,9 @@ func normalizePlaylist(raw map[string]any) map[string]any {
|
||||
}
|
||||
}
|
||||
owner, _ := raw["owner"].(map[string]any)
|
||||
ownerName := firstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
|
||||
ownerName := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
|
||||
return map[string]any{
|
||||
"id": firstNonEmpty(jsonutil.StringFromAny(raw["playlistUuid"]), jsonutil.StringFromAny(raw["kind"])),
|
||||
"id": jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["playlistUuid"]), jsonutil.StringFromAny(raw["kind"])),
|
||||
"name": jsonutil.StringFromAny(raw["title"]),
|
||||
"title": jsonutil.StringFromAny(raw["title"]),
|
||||
"description": jsonutil.StringFromAny(raw["description"]),
|
||||
@@ -698,8 +709,8 @@ func normalizeAlbumSummary(albumRaw map[string]any, artistName string) map[strin
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAlbumSearchItem(raw map[string]any) map[string]any {
|
||||
artistName := joinArtists(raw)
|
||||
func normalizeAlbumSearchItem(raw map[string]any, artistSeparator string) map[string]any {
|
||||
artistName := joinArtists(raw, artistSeparator)
|
||||
return map[string]any{
|
||||
"id": jsonutil.StringFromAny(raw["id"]),
|
||||
"title": jsonutil.StringFromAny(raw["title"]),
|
||||
@@ -715,7 +726,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": firstPositiveInt(
|
||||
"albums_count": jsonutil.FirstPositiveInt(
|
||||
jsonutil.IntFromAny(jsonutil.NestedAny(raw, "counts", "directAlbums")),
|
||||
jsonutil.IntFromAny(jsonutil.NestedAny(raw, "counts", "tracks")),
|
||||
),
|
||||
@@ -724,8 +735,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 := firstNonEmpty(jsonutil.StringFromAny(owner["uid"]), jsonutil.StringFromAny(raw["uid"]))
|
||||
ownerName := firstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
|
||||
ownerID := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(owner["uid"]), jsonutil.StringFromAny(raw["uid"]))
|
||||
ownerName := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(owner["name"]), jsonutil.StringFromAny(owner["login"]))
|
||||
return map[string]any{
|
||||
"id": ownerID + ":" + jsonutil.StringFromAny(raw["kind"]),
|
||||
"title": jsonutil.StringFromAny(raw["title"]),
|
||||
@@ -768,7 +779,11 @@ func firstArtistID(raw map[string]any) string {
|
||||
return jsonutil.StringFromAny(artist["id"])
|
||||
}
|
||||
|
||||
func joinArtists(raw map[string]any) string {
|
||||
func joinArtists(raw map[string]any, artistSeparator string) string {
|
||||
return jsonutil.DisplayArtistNames(artistNames(raw))
|
||||
}
|
||||
|
||||
func artistNames(raw map[string]any) []string {
|
||||
artists, _ := raw["artists"].([]any)
|
||||
parts := make([]string, 0, len(artists))
|
||||
for _, entry := range artists {
|
||||
@@ -780,13 +795,17 @@ func joinArtists(raw map[string]any) string {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
return parts
|
||||
}
|
||||
|
||||
func joinAlbumArtists(albumRaw map[string]any) string {
|
||||
func joinAlbumArtists(albumRaw map[string]any, artistSeparator string) string {
|
||||
if len(albumRaw) == 0 {
|
||||
return ""
|
||||
}
|
||||
return jsonutil.DisplayArtistNames(albumArtistNames(albumRaw))
|
||||
}
|
||||
|
||||
func albumArtistNames(albumRaw map[string]any) []string {
|
||||
artists, _ := albumRaw["artists"].([]any)
|
||||
parts := make([]string, 0, len(artists))
|
||||
for _, entry := range artists {
|
||||
@@ -798,7 +817,14 @@ func joinAlbumArtists(albumRaw map[string]any) string {
|
||||
parts = append(parts, name)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
return parts
|
||||
}
|
||||
|
||||
func (c *Client) artistSeparator() string {
|
||||
if c.cfg.Session.Metadata.ArtistSeparator == "" {
|
||||
return "; "
|
||||
}
|
||||
return c.cfg.Session.Metadata.ArtistSeparator
|
||||
}
|
||||
|
||||
func trackNumbers(albumRaw map[string]any) (int, int) {
|
||||
@@ -807,22 +833,14 @@ func trackNumbers(albumRaw map[string]any) (int, int) {
|
||||
if trackNumber > 0 {
|
||||
trackNumber++
|
||||
}
|
||||
return trackNumber, firstPositiveInt(jsonutil.IntFromAny(position["volume"]), 1)
|
||||
}
|
||||
|
||||
func canonicalTrackRequestID(item string) string {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return item
|
||||
}
|
||||
return item
|
||||
return trackNumber, jsonutil.FirstPositiveInt(jsonutil.IntFromAny(position["volume"]), 1)
|
||||
}
|
||||
|
||||
func canonicalTrackRefFromRaw(raw map[string]any, fallback string) string {
|
||||
if strings.Contains(strings.TrimSpace(fallback), ":") {
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
trackID := firstNonEmpty(jsonutil.StringFromAny(raw["realId"]), jsonutil.StringFromAny(raw["id"]))
|
||||
trackID := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(raw["realId"]), jsonutil.StringFromAny(raw["id"]))
|
||||
albumID := jsonutil.StringFromAny(firstAlbum(raw)["id"])
|
||||
if trackID == "" {
|
||||
trackID = strings.TrimSpace(fallback)
|
||||
@@ -889,7 +907,7 @@ func imageMapFromTrack(raw map[string]any, albumRaw map[string]any) map[string]a
|
||||
}
|
||||
|
||||
func imageMapFromURI(raw map[string]any) map[string]any {
|
||||
uri := firstNonEmpty(jsonutil.StringFromAny(raw["ogImage"]), jsonutil.StringFromAny(raw["coverUri"]))
|
||||
uri := jsonutil.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"])
|
||||
@@ -924,9 +942,11 @@ func isExplicit(raw map[string]any) bool {
|
||||
return jsonutil.BoolFromAny(raw["explicit"])
|
||||
}
|
||||
|
||||
func yandexDownloadSign(trackID, quality string, codecs []string, transport string) (string, int64) {
|
||||
// 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) {
|
||||
ts := time.Now().Unix()
|
||||
mac := hmac.New(sha256.New, []byte(desktopWindowsSignKey))
|
||||
mac := hmac.New(sha256.New, []byte(signKey))
|
||||
_, _ = mac.Write([]byte(strconv.FormatInt(ts, 10) + trackID + quality + strings.Join(codecs, "") + transport))
|
||||
return strings.TrimRight(base64.StdEncoding.EncodeToString(mac.Sum(nil)), "="), ts
|
||||
}
|
||||
@@ -999,20 +1019,18 @@ func waitRetry(ctx context.Context, delay time.Duration) error {
|
||||
}
|
||||
}
|
||||
|
||||
func firstPositiveInt(vals ...int) int {
|
||||
for _, v := range vals {
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
// 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
|
||||
}
|
||||
return 0
|
||||
return config.DefaultYandexSignKey
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
func (c *Client) legacyMP3Salt() string {
|
||||
if v := strings.TrimSpace(c.cfg.Session.Yandex.LegacyMP3SignSalt); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
return config.DefaultYandexLegacyMP3Salt
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
func TestYandexDownloadSignMatchesCapturedFormat(t *testing.T) {
|
||||
sign, ts := yandexDownloadSign("32038184", "lossless", []string{"flac", "aac", "he-aac", "mp3", "flac-mp4", "aac-mp4", "he-aac-mp4"}, "raw")
|
||||
sign, ts := yandexDownloadSign(config.DefaultYandexSignKey, "32038184", "lossless", []string{"flac", "aac", "he-aac", "mp3", "flac-mp4", "aac-mp4", "he-aac-mp4"}, "raw")
|
||||
if ts <= 0 {
|
||||
t.Fatalf("timestamp = %d", ts)
|
||||
}
|
||||
@@ -47,20 +47,20 @@ func TestGetDownloadableUsesModernGetFileInfo(t *testing.T) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"result": map[string]any{
|
||||
"downloadInfo": map[string]any{
|
||||
"trackId": "32038184",
|
||||
"quality": "lossless",
|
||||
"codec": "flac-mp4",
|
||||
"trackId": "32038184",
|
||||
"quality": "lossless",
|
||||
"codec": "flac-mp4",
|
||||
"transport": "encraw",
|
||||
"key": "00112233445566778899aabbccddeeff",
|
||||
"bitrate": 0,
|
||||
"url": "https://strm.example/music-v2/crypt/x/flac-mp4",
|
||||
"key": "00112233445566778899aabbccddeeff",
|
||||
"bitrate": 0,
|
||||
"url": "https://strm.example/music-v2/crypt/x/flac-mp4",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
d := config.DefaultConfigData()
|
||||
d := testConfigData()
|
||||
d.Downloads.RequestsPerMinute = 0
|
||||
d.Yandex.AccessToken = "token"
|
||||
c := New(&config.Config{File: d, Session: d})
|
||||
@@ -115,13 +115,13 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
|
||||
"id": "9442712",
|
||||
"realId": "9442712",
|
||||
"title": "Nightcall",
|
||||
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}},
|
||||
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}},
|
||||
"albums": []map[string]any{{
|
||||
"id": "1000856",
|
||||
"title": "OutRun",
|
||||
"releaseDate": "2013-02-25T00:00:00+04:00",
|
||||
"trackCount": 13,
|
||||
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}},
|
||||
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}},
|
||||
"trackPosition": map[string]any{
|
||||
"index": 0,
|
||||
"volume": 1,
|
||||
@@ -132,7 +132,7 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
d := config.DefaultConfigData()
|
||||
d := testConfigData()
|
||||
d.Downloads.RequestsPerMinute = 0
|
||||
d.Yandex.AccessToken = "token"
|
||||
c := New(&config.Config{File: d, Session: d})
|
||||
@@ -155,10 +155,20 @@ func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
|
||||
if album, _ := meta["album"].(map[string]any); jsonutil.StringFromAny(album["title"]) != "OutRun" {
|
||||
t.Fatalf("unexpected album: %+v", album)
|
||||
}
|
||||
if artist := jsonutil.NestedString(meta, "artist", "name"); artist != "Kavinsky & Lovefoxxx" {
|
||||
t.Fatalf("artist = %q", artist)
|
||||
}
|
||||
if albumArtist := jsonutil.NestedString(meta, "album", "artist", "name"); albumArtist != "Kavinsky & Lovefoxxx" {
|
||||
t.Fatalf("album artist = %q", albumArtist)
|
||||
}
|
||||
artists, _ := meta["artist_names"].([]string)
|
||||
if strings.Join(artists, ";") != "Kavinsky;Lovefoxxx" {
|
||||
t.Fatalf("artist_names = %#v", artists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {
|
||||
url, err := legacyDirectURL(&legacyDownloadInfoXML{
|
||||
url, err := legacyDirectURL(config.DefaultYandexLegacyMP3Salt, &legacyDownloadInfoXML{
|
||||
Host: "example.test",
|
||||
Path: "/abc123",
|
||||
TS: "1234567890",
|
||||
@@ -172,3 +182,13 @@ 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
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package ratelimit provides the API request throttle shared by the provider
|
||||
// clients.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
@@ -6,20 +8,54 @@ 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
|
||||
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.
|
||||
next time.Time
|
||||
}
|
||||
|
||||
func New(requestsPerMinute int) *Limiter {
|
||||
// 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 {
|
||||
if requestsPerMinute <= 0 {
|
||||
return &Limiter{}
|
||||
}
|
||||
return &Limiter{interval: time.Minute / time.Duration(requestsPerMinute)}
|
||||
if burst < 1 {
|
||||
burst = 1
|
||||
}
|
||||
if burst > requestsPerMinute {
|
||||
burst = requestsPerMinute
|
||||
}
|
||||
return &Limiter{
|
||||
interval: time.Minute / time.Duration(requestsPerMinute),
|
||||
burst: burst,
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -27,22 +63,21 @@ func (l *Limiter) Wait(ctx context.Context) error {
|
||||
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
if l.next.IsZero() {
|
||||
l.next = now.Add(l.interval)
|
||||
l.mu.Unlock()
|
||||
return nil
|
||||
// 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
|
||||
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))
|
||||
delay := time.Until(wake)
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
|
||||
@@ -2,19 +2,25 @@ package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLimiterDisabled(t *testing.T) {
|
||||
l := New(-1)
|
||||
if err := l.Wait(context.Background()); err != nil {
|
||||
t.Fatalf("Wait() error = %v", err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterContextCancel(t *testing.T) {
|
||||
l := New(1)
|
||||
// burst 1 means strict spacing, so the second request must block.
|
||||
l := New(1, 1)
|
||||
if err := l.Wait(context.Background()); err != nil {
|
||||
t.Fatalf("first Wait() error = %v", err)
|
||||
}
|
||||
@@ -26,3 +32,85 @@ 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)
|
||||
}
|
||||
}
|
||||
+164
-7
@@ -3,23 +3,40 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type SQLite struct {
|
||||
db *sql.DB
|
||||
mu sync.Mutex
|
||||
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
|
||||
}
|
||||
|
||||
func NewSQLite(path string) (*SQLite, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
db, err := sql.Open("sqlite", sqliteDSN(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}
|
||||
s := &SQLite{db: db, path: path}
|
||||
if err = s.init(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
@@ -27,6 +44,70 @@ 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
|
||||
@@ -47,6 +128,20 @@ 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
|
||||
}
|
||||
|
||||
@@ -94,6 +189,14 @@ 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
|
||||
@@ -120,12 +223,27 @@ 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
|
||||
}
|
||||
if _, err = tx.Exec(`DROP TABLE downloads_legacy`); err != nil {
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
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
|
||||
}
|
||||
|
||||
func (s *SQLite) IsDownloaded(ctx context.Context, source, id string) (bool, error) {
|
||||
@@ -137,6 +255,26 @@ 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
|
||||
}
|
||||
|
||||
@@ -162,6 +300,25 @@ 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
|
||||
|
||||
@@ -2,7 +2,12 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -48,3 +53,193 @@ func TestSQLiteStore(t *testing.T) {
|
||||
t.Fatalf("MarkFailed() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// makeLegacyDB creates a database with the pre-source-column schema and the
|
||||
// given ids, as a pre-migration install would have on disk.
|
||||
func makeLegacyDB(t *testing.T, path string, ids ...string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if _, err = db.Exec(`CREATE TABLE downloads (id TEXT NOT NULL PRIMARY KEY)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err = db.Exec(`INSERT INTO downloads(id) VALUES (?)`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyMigrationPreservesHistory is the regression test for the migration
|
||||
// that silently wiped the dedupe state: rows were carried over with source=empty
|
||||
// while IsDownloaded matched source exactly, so every previously downloaded
|
||||
// track looked new and the whole library was re-downloaded.
|
||||
func TestLegacyMigrationPreservesHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "downloads.db")
|
||||
makeLegacyDB(t, path, "old-1", "old-2")
|
||||
|
||||
s, err := NewSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLite() error = %v", err)
|
||||
}
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
for _, id := range []string{"old-1", "old-2"} {
|
||||
ok, err := s.IsDownloaded(ctx, "qobuz", id)
|
||||
if err != nil {
|
||||
t.Fatalf("IsDownloaded(%q) error = %v", id, err)
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("pre-migration download %q reported as not downloaded", id)
|
||||
}
|
||||
}
|
||||
|
||||
ok, err := s.IsDownloaded(ctx, "qobuz", "never-seen")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("unknown id reported as downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyMigrationKeepsBackupAndLegacyTable asserts the migration is
|
||||
// recoverable: the original file is copied aside and downloads_legacy survives.
|
||||
func TestLegacyMigrationKeepsBackupAndLegacyTable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "downloads.db")
|
||||
makeLegacyDB(t, path, "old-1")
|
||||
|
||||
s, err := NewSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLite() error = %v", err)
|
||||
}
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backups := 0
|
||||
for _, e := range entries {
|
||||
if strings.Contains(e.Name(), ".bak-") {
|
||||
backups++
|
||||
}
|
||||
}
|
||||
if backups != 1 {
|
||||
t.Errorf("expected exactly 1 backup file, found %d in %v", backups, entries)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err = s.db.QueryRow(`SELECT COUNT(1) FROM downloads_legacy`).Scan(&n); err != nil {
|
||||
t.Fatalf("downloads_legacy was dropped: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("downloads_legacy row count = %d, want 1", n)
|
||||
}
|
||||
|
||||
if v, err := s.userVersion(); err != nil || v != schemaVersion {
|
||||
t.Errorf("user_version = %d (err %v), want %d", v, err, schemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyRowsDetectedOnReopen covers a database already migrated by the
|
||||
// earlier, lossy code path: the sourceless rows are on disk but nothing in
|
||||
// this process migrated them.
|
||||
func TestLegacyRowsDetectedOnReopen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "downloads.db")
|
||||
makeLegacyDB(t, path, "old-1")
|
||||
|
||||
s, err := NewSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = s.Close()
|
||||
|
||||
reopened, err := NewSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = reopened.Close() }()
|
||||
|
||||
if !reopened.hasLegacyRows {
|
||||
t.Fatal("legacy rows not detected on reopen")
|
||||
}
|
||||
ok, err := reopened.IsDownloaded(ctx, "tidal", "old-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("legacy row not honored after reopen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentAccess exercises the mutex and connection handling under load.
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "downloads.db")
|
||||
s, err := NewSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
id := fmt.Sprintf("t%d", n)
|
||||
if err := s.MarkDownloaded(ctx, "qobuz", id); err != nil {
|
||||
t.Errorf("MarkDownloaded: %v", err)
|
||||
return
|
||||
}
|
||||
if _, err := s.IsDownloaded(ctx, "qobuz", id); err != nil {
|
||||
t.Errorf("IsDownloaded: %v", err)
|
||||
}
|
||||
if err := s.MarkFailed(ctx, "qobuz", "track", id); err != nil {
|
||||
t.Errorf("MarkFailed: %v", err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSQLiteDSNPragmasApplied(t *testing.T) {
|
||||
// A path containing '?' must not be parsed as query parameters.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "weird?name#x.db")
|
||||
|
||||
s, err := NewSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLite(%q) = %v", path, err)
|
||||
}
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
var mode string
|
||||
if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Errorf("journal_mode = %q, want wal", mode)
|
||||
}
|
||||
|
||||
var timeout int
|
||||
if err := s.db.QueryRow(`PRAGMA busy_timeout`).Scan(&timeout); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if timeout != 10000 {
|
||||
t.Errorf("busy_timeout = %d, want 10000", timeout)
|
||||
}
|
||||
|
||||
if err := s.MarkDownloaded(context.Background(), "qobuz", "a"); err != nil {
|
||||
t.Fatalf("write to odd path failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ func Parse(raw string) *ParsedURL {
|
||||
return parseQobuz(raw, parts)
|
||||
case isYandexHost(host):
|
||||
return parseYandex(raw, parts)
|
||||
case isBeatportHost(host):
|
||||
return parseBeatport(raw, parts)
|
||||
case isTidalHost(host):
|
||||
return parseTidal(raw, parts)
|
||||
case isDeezerHost(host):
|
||||
@@ -58,6 +60,60 @@ func Parse(raw string) *ParsedURL {
|
||||
}
|
||||
}
|
||||
|
||||
func parseBeatport(raw string, parts []string) *ParsedURL {
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
if len(parts[0]) == 2 {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) > 0 && parts[0] == "catalog" {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaType := ""
|
||||
idIndex := 1
|
||||
switch parts[0] {
|
||||
case "track", "tracks":
|
||||
mediaType = "track"
|
||||
if parts[0] == "track" {
|
||||
idIndex = 2
|
||||
}
|
||||
case "release", "releases":
|
||||
mediaType = "album"
|
||||
if parts[0] == "release" {
|
||||
idIndex = 2
|
||||
}
|
||||
case "library":
|
||||
if len(parts) < 3 || (parts[1] != "playlists" && parts[1] != "playlist") {
|
||||
return nil
|
||||
}
|
||||
mediaType = "playlist"
|
||||
idIndex = 2
|
||||
case "playlists":
|
||||
mediaType = "playlist"
|
||||
idIndex = 2
|
||||
case "chart", "playlist":
|
||||
mediaType = "chart"
|
||||
idIndex = 2
|
||||
case "artist":
|
||||
mediaType = "artist"
|
||||
idIndex = 2
|
||||
case "label":
|
||||
mediaType = "label"
|
||||
idIndex = 2
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if idIndex >= len(parts) || strings.TrimSpace(parts[idIndex]) == "" {
|
||||
return nil
|
||||
}
|
||||
return &ParsedURL{OriginalURL: raw, Source: "beatport", MediaType: mediaType, ID: parts[idIndex], Kind: KindGeneric}
|
||||
}
|
||||
|
||||
func parseQobuz(raw string, parts []string) *ParsedURL {
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
@@ -71,6 +127,9 @@ func parseQobuz(raw string, parts []string) *ParsedURL {
|
||||
}
|
||||
|
||||
mediaType := parts[0]
|
||||
if mediaType == "interpreter" {
|
||||
mediaType = "artist"
|
||||
}
|
||||
if !isSupportedMedia(mediaType) {
|
||||
return nil
|
||||
}
|
||||
@@ -219,6 +278,10 @@ func isYandexHost(host string) bool {
|
||||
return host == "music.yandex.ru" || host == "music.yandex.com" || host == "music.yandex.kz" || host == "music.yandex.by"
|
||||
}
|
||||
|
||||
func isBeatportHost(host string) bool {
|
||||
return host == "beatport.com" || host == "api.beatport.com"
|
||||
}
|
||||
|
||||
func isTidalHost(host string) bool {
|
||||
return host == "tidal.com" || host == "open.tidal.com" || host == "listen.tidal.com"
|
||||
}
|
||||
@@ -233,7 +296,7 @@ func isSoundcloudHost(host string) bool {
|
||||
|
||||
func isSupportedMedia(mediaType string) bool {
|
||||
switch mediaType {
|
||||
case "album", "track", "playlist", "artist", "label", "video":
|
||||
case "album", "track", "playlist", "artist", "label", "video", "chart":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -27,6 +27,22 @@ func TestQobuzAlbumURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzInterpreterURLParsesAsArtist(t *testing.T) {
|
||||
inputs := []string{
|
||||
"https://www.qobuz.com/us-en/interpreter/odezenne/739874",
|
||||
"https://play.qobuz.com/artist/739874",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
result := Parse(input)
|
||||
if result == nil {
|
||||
t.Fatalf("expected parsed url for %q", input)
|
||||
}
|
||||
if result.Source != "qobuz" || result.MediaType != "artist" || result.ID != "739874" {
|
||||
t.Fatalf("unexpected parse result for %q: %+v", input, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestYandexURLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
@@ -51,6 +67,31 @@ func TestYandexURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeatportURLs(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
mediaType string
|
||||
id string
|
||||
}{
|
||||
{url: "https://www.beatport.com/track/strobe/1696999", mediaType: "track", id: "1696999"},
|
||||
{url: "https://www.beatport.com/release/random-album/12345", mediaType: "album", id: "12345"},
|
||||
{url: "https://www.beatport.com/library/playlists/67890", mediaType: "playlist", id: "67890"},
|
||||
{url: "https://www.beatport.com/chart/some-chart/111", mediaType: "chart", id: "111"},
|
||||
{url: "https://www.beatport.com/artist/deadmau5/24078", mediaType: "artist", id: "24078"},
|
||||
{url: "https://www.beatport.com/label/mau5trap/1234", mediaType: "label", id: "1234"},
|
||||
{url: "https://api.beatport.com/v4/catalog/tracks/1696999/", mediaType: "track", id: "1696999"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
result := Parse(tc.url)
|
||||
if result == nil {
|
||||
t.Fatalf("expected parse for %q", tc.url)
|
||||
}
|
||||
if result.Source != "beatport" || result.MediaType != tc.mediaType || result.ID != tc.id {
|
||||
t.Fatalf("unexpected parse result for %q: %+v", tc.url, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalTrackURL(t *testing.T) {
|
||||
inputs := []string{
|
||||
"https://tidal.com/browse/track/3083287",
|
||||
|
||||
Reference in new issue
Block a user