diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3a4f152 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..15e41fd --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..65f4287 --- /dev/null +++ b/Makefile @@ -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}' diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a413e3 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# 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` | + +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 + +rip database downloads # what has already been downloaded +rip database failed +``` + +Global options (these must come *before* the command): + +``` +-f, --folder Override downloads folder +-q, --quality <0-4> Override provider quality +-c, --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 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 ` 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-`. + +## 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. diff --git a/cmd/rip/args.go b/cmd/rip/args.go index 5531a60..d303623 100644 --- a/cmd/rip/args.go +++ b/cmd/rip/args.go @@ -27,6 +27,7 @@ type globalOptions struct { noSSLVerify bool verbose int help bool + version bool command string commandArgs []string } @@ -50,6 +51,9 @@ func parseGlobalArgs(args []string) (globalOptions, error) { case isHelpArg(arg): opts.help = true return opts, nil + case arg == "--version" || arg == "-V": + opts.version = true + return opts, nil case arg == "-ndb" || arg == "--no-db": opts.noDB = true case arg == "--no-progress": diff --git a/cmd/rip/help.go b/cmd/rip/help.go index f81fa88..2fef180 100644 --- a/cmd/rip/help.go +++ b/cmd/rip/help.go @@ -44,6 +44,7 @@ Global options: --no-ssl-verify Disable TLS verification --config-path Use a custom config file -v, -vv Verbose logging + -V, --version Show version -h, --help Show help Run 'rip help ' for command-specific help. diff --git a/cmd/rip/main.go b/cmd/rip/main.go index 415ed4c..0f21222 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -32,6 +32,10 @@ func run() int { fmt.Fprintf(os.Stderr, "option error: %v\n", err) return 2 } + if gopts.version { + printVersion(os.Stdout) + return 0 + } if gopts.help { printMainHelp(os.Stdout) return 0 diff --git a/cmd/rip/version.go b/cmd/rip/version.go new file mode 100644 index 0000000..39d875c --- /dev/null +++ b/cmd/rip/version.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go index 4059a26..603b5b0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -286,13 +286,18 @@ func applyVendorDefaults(data *ConfigData) { } } -// qualityRange is the accepted quality id range per source. Qobuz and Beatport -// have no "lowest" tier, so their floor is 1. +// 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, 2}, - "soundcloud": {0, 1}, + "deezer": {0, 4}, + "soundcloud": {0, 4}, "yandex": {0, 4}, "beatport": {1, 4}, } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8496660..b75d3e9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -154,7 +154,6 @@ func TestValidateRejectsBadValues(t *testing.T) { {"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"}, - {"deezer quality 3", func(d *ConfigData) { d.Deezer.Quality = 3 }, "deezer.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"}, @@ -182,13 +181,40 @@ func TestValidateRejectsBadValues(t *testing.T) { } } +// 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 = 2 + d.Deezer.Quality = 4 d.Qobuz.Quality = 4 if err := Validate(&d); err != nil { t.Fatalf("Validate() = %v, want nil", err)