96 Commits
Author SHA1 Message Date
arnef ba6bf2f34c feat: allow overriding the output directory via GODEEZ_OUTPUT_DIR
The output directory used to be fixed at ~/Music/GoDeez. config.Load
now honours GODEEZ_OUTPUT_DIR when set, with a leading ~ expanded by
hand since the shell only does that for unquoted arguments, not for
values read out of the environment. Falls back to the previous default
when unset.
2026-08-14 21:00:15 +02:00
arnef b1c15341fd feat: write an m3u playlist to preserve playlist track order
Playlist file names carry no ordering information, so the sequence a
user curated on Deezer was lost once the tracks landed on disk (unlike
albums, which get a zero padded track number prefix). downloadTrack
now reports the output path on success as well as on skip, and
downloadAllTracks collects the per-track paths for playlist downloads
and writes them, in order, to an extended m3u file next to the audio.
Failed tracks are left out since there is nothing on disk to point at;
a write failure is reported as a warning rather than failing the run.
2026-08-14 21:00:08 +02:00
arnef 765595133f docs: add copilot instructions for future sessions
Document build/test/lint commands, the cmd -> download -> deezer
pipeline architecture, and codebase conventions so future Copilot
sessions can work effectively in this repository.
2026-08-14 20:58:51 +02:00
Mathis Maquenne b1ad9b4904 docs: document packages, exported API and non-obvious logic 2026-08-06 13:04:10 +02:00
Mathis Maquenne 5dfd832d0d docs: rewrite headline and clarify quality wording 2026-08-06 11:11:04 +02:00
Mathis Maquenne 3b51426251 fix: load handwritten release notes into the release body 2026-08-05 23:35:37 +02:00
Mathis Maquenne 770efdb752 docs: update changelog for v1.5.0 2026-08-05 23:18:57 +02:00
Mathis Maquenne d3a536f744 docs: document update force and artist limit flags 2026-08-05 23:10:13 +02:00
Mathis Maquenne dddfad4984 Merge branch 'feat/wav-output' into dev 2026-08-05 23:02:29 +02:00
Mathis Maquenne bcb3558a73 docs: document wav download quality 2026-08-05 22:59:26 +02:00
Mathis Maquenne 84dd371fdd feat: tag wav files with id3 and info chunks 2026-08-05 22:51:43 +02:00
Mathis Maquenne b804c5e288 feat: add wav download quality 2026-08-05 22:51:43 +02:00
Mathis Maquenne b3777bf8eb feat: add lossless flac to wav converter 2026-08-05 22:51:43 +02:00
Mathis Maquenne eea049aae9 refactor: unexport internal identifiers and fix naming nits 2026-08-05 21:55:59 +02:00
Mathis Maquenne c1775e5c04 style: prefer stdlib idioms for errors and http methods 2026-08-05 21:43:59 +02:00
Mathis Maquenne a695fb4de3 fix: resolve bugs found in pre-release review 2026-08-05 21:33:08 +02:00
Mathis Maquenne 5bb1448650 refactor: validate limit only for artist downloads 2026-08-05 12:42:51 +02:00
Mathis Maquenne e90a56ec53 fix: use filepath helpers for filesystem paths 2026-08-05 12:41:55 +02:00
Mathis Maquenne b616a58d7b ops: run go test in ci 2026-08-05 11:51:20 +02:00
Mathis Maquenne 66fda7424a test: add update version and checksum tests 2026-08-05 11:50:58 +02:00
Mathis Maquenne 3fe050ea15 test: add download, store, and scraper tests 2026-08-05 11:50:30 +02:00
Mathis Maquenne 563efe78bd test: add deezer crypto and filename tests 2026-08-05 11:49:21 +02:00
Mathis Maquenne f8b301e24c style: rename accessors to idiomatic names 2026-08-05 11:47:53 +02:00
Mathis Maquenne 88319ebb3c refactor: rename updater package to update 2026-08-05 11:46:34 +02:00
Mathis Maquenne 5c95554855 refactor: consolidate download domain into download package 2026-08-05 11:46:10 +02:00
Mathis Maquenne dbb808f371 refactor: replace fileutil with fsutil and local hashing 2026-08-05 11:42:35 +02:00
Mathis Maquenne 12dbbb5e10 refactor: move resource info rendering into downloader 2026-08-05 11:41:24 +02:00
Mathis Maquenne dbd7837b17 refactor: fold auth and crypto into deezer domain 2026-08-05 11:40:14 +02:00
Mathis Maquenne 16c71c8688 refactor: decouple tag writers from deezer models 2026-08-05 11:37:59 +02:00
Mathis Maquenne 3c9da5f24a refactor: build cobra commands with constructors 2026-08-05 11:35:52 +02:00
Mathis Maquenne 0ab25addd5 refactor: open track store explicitly and remove global state 2026-08-05 11:34:27 +02:00
Mathis Maquenne 714d567603 refactor: introduce typed resource kind 2026-08-05 11:33:13 +02:00
Mathis Maquenne 3ec258b876 refactor: remove dead logger package and unused fields 2026-08-05 11:31:17 +02:00
Mathis Maquenne 97153c1688 docs: document credential login and update CLI output 2026-08-05 10:47:49 +02:00
Mathis Maquenne 1fbbd60106 refactor: use track terminology instead of song 2026-07-30 12:53:03 +02:00
Mathis Maquenne 90b7bb25f0 Merge branch 'feat/auto-update' into dev 2026-07-30 11:32:12 +02:00
Mathis Maquenne 88c7b57648 ops: run ci on every branch push 2026-07-30 11:30:08 +02:00
Mathis Maquenne 4286ccd626 docs: document self-update and new release asset names 2026-07-30 11:30:08 +02:00
Mathis Maquenne 887fc682fe ops: add ci workflow 2026-07-30 11:30:08 +02:00
Mathis Maquenne 3acd1aeb4f ops: add goreleaser configuration and release workflow 2026-07-30 11:30:08 +02:00
Mathis Maquenne 544fd09199 feat: notify when a new version is available 2026-07-30 11:30:08 +02:00
Mathis Maquenne b734ad802a feat: add update command 2026-07-30 11:30:08 +02:00
Mathis Maquenne eb93d01c19 feat: add updater package for in-place self-update 2026-07-30 11:30:08 +02:00
Mathis Maquenne 0e0cbdad5e feat: add version command 2026-07-30 11:30:08 +02:00
Mathis Maquenne 785bb58cb5 feat: add buildinfo package for version information 2026-07-30 11:30:08 +02:00
Mathis Maquenne cab49d2818 build: add golang.org/x/mod dependency 2026-07-30 11:30:08 +02:00
Mathis Maquenne c544c9406c fix: exit with a non-zero status when a command fails 2026-07-30 11:29:06 +02:00
Mathis Maquenne 0287cfba9c Merge branch 'feat/credential-login' into dev 2026-07-30 11:24:49 +02:00
Mathis Maquenne c2f60be18e refactor: use ARL initialism casing 2026-07-30 11:13:13 +02:00
Mathis Maquenne 44a57d5f3a feat: add login and logout commands 2026-07-29 11:33:50 +02:00
Mathis Maquenne 09fa93f1e4 refactor: no longer require DEEZER_ARL at startup 2026-07-29 11:33:50 +02:00
Mathis Maquenne f45ae6aee6 feat: retry with credential-based login when the ARL is invalid 2026-07-29 11:33:50 +02:00
Mathis Maquenne 56b0f404d6 feat: add internal/auth package for credential-based login 2026-07-29 11:33:50 +02:00
Mathis Maquenne 3ce5c4d365 build: add go-keyring dependency 2026-07-29 11:33:42 +02:00
Mathis Maquenne 465f54e466 refactor: disambiguate blowfish crypto helper names 2026-07-29 11:28:39 +02:00
Mathis Maquenne 163fa18845 feat: add AES-ECB crypto helpers for email/password login 2026-07-29 11:28:39 +02:00
Mathis Maquenne 020f8c1162 chore: update dependencies 2026-07-29 11:28:33 +02:00
Mathis Maquenne 9c7580d256 docs: update README and CHANGELOG for v1.4.0 2026-03-01 22:55:26 +01:00
Mathis Maquenne d50945a7ef fix: zero-pad track numbers and show warning count in summary 2026-03-01 22:29:38 +01:00
Mathis Maquenne 08160b0290 refactor: drop config.toml and Viper, use DEEZER_ARL env var 2026-03-01 22:19:14 +01:00
Mathis Maquenne cf699834f6 refactor: hardcode secret key in crypto package 2026-03-01 21:57:38 +01:00
Mathis Maquenne 650d578b80 refactor: remove disabled watcher feature 2026-03-01 21:56:54 +01:00
Mathis Maquenne acf468eddd chore: update all dependencies 2026-03-01 21:55:42 +01:00
Mathis Maquenne 08b608ec80 refactor: simplify codebase (-209 lines) 2026-03-01 21:55:42 +01:00
Mathis Maquenne cd45c3f40a perf(downloader): fetch metadata concurrently with stream download 2026-03-01 21:29:02 +01:00
Mathis Maquenne 0a8da3d0e7 feat(tags): filter genres to avoid unusual tags 2025-09-12 14:41:29 +02:00
Mathis Maquenne 2ef1ba42a8 docs: update for v1.3.0 2025-09-11 01:18:14 +02:00
Mathis Maquenne e4c421ff00 feat: add CTA after download and mention it in README 2025-09-11 00:32:42 +02:00
Mathis Maquenne 9d87f635d0 refactor(downloader): improve structure and readability 2025-09-11 00:05:17 +02:00
Mathis Maquenne 8c9b327b2c feat(tags): fetch genre and add it to file tags with --genre 2025-09-10 16:05:53 +02:00
Mathis Maquenne 0493866141 refactor: move BPM fetching logic into provider package 2025-09-10 15:09:24 +02:00
Mathis Maquenne 523b562ed0 Merge branch 'feature/download-track' into dev 2025-09-10 11:11:40 +02:00
Mathis Maquenne c2b4fcfa11 feat(cli): simplify output for individual songs 2025-09-10 11:10:52 +02:00
Felipe Marinho 9b23e3a8d9 feat: download single tracks (#5)
Introduces the `godeez download track <ID>` command, allowing users to download individual songs directly.

Single tracks are saved into a "Singles" folder by default to keep them organized. The README has been updated with documentation and examples for this new functionality.
2025-09-10 10:26:58 +02:00
Mathis Maquenne cf7f76948b chore(tags): add the version of a track to the title tag 2025-09-09 23:47:33 +02:00
Mathis Maquenne 4b2c9c92b8 chore(tags): add copyright tag 2025-09-09 10:37:56 +02:00
Mathis Maquenne 22ddb2408b fix(media): handle empty media 2025-09-09 10:05:42 +02:00
Mathis Maquenne 3022e226ce fix(build): comment out unfinished autostart implementation 2025-08-18 11:29:02 +02:00
Mathis Maquenne c4329431b5 docs: update for v1.2.0 2025-08-18 11:20:29 +02:00
Mathis Maquenne be40910480 chore(deps): update all dependencies 2025-08-18 10:49:42 +02:00
Mathis Maquenne 1813a16bb7 feat(cli): change default download quality from flac to mp3_320 2025-08-09 19:40:06 +02:00
Mathis Maquenne 283c5d76e9 feat(cli): add --strict flag to fail track download if quality is unavailable 2025-08-09 19:17:29 +02:00
Mathis Maquenne 7154c15cc3 refactor(cli): remove --quality=best flag since fallback is now automatic 2025-08-06 22:28:58 +02:00
Mathis Maquenne 50fde9dfa9 feat(media): fallback to lower quality if requested format is unavailable 2025-08-06 22:09:06 +02:00
Mathis Maquenne b4f69396af fix(song): handle empty SNG_CONTRIBUTORS array during unmarshal 2025-08-06 20:45:27 +02:00
Mathis Maquenne fe3491e16d chore(watcher): disable feature due to known DB concurrency issues 2025-08-06 18:18:17 +02:00
Mathis Maquenne ef018b38ad fix(logger): initialize downloader with nil logger to avoid error 2025-06-23 21:03:27 -04:00
Mathis Maquenne 33e774d078 refactor: simplify homeDir and appConfig usage 2025-06-23 20:58:08 -04:00
Mathis Maquenne a619b37922 feat(watcher): add macOS autostart support via launchd 2025-06-23 20:03:56 -04:00
Mathis Maquenne 31d8a3b04f feat(watch): add command to remove a playlist from the watch list 2025-06-22 17:51:17 -04:00
Mathis Maquenne 95c81a2a67 feat(watch): add command to list watched playlists 2025-06-22 17:47:54 -04:00
Mathis Maquenne 498f7c8d3f feat(watch): add command to register a playlist to the watch list 2025-06-22 17:47:05 -04:00
Mathis Maquenne b19c92f227 feat: watch playlists and download newly added tracks 2025-06-22 17:35:16 -04:00
Mathis Maquenne 99bdc22a25 fix(cli): restrict --limit option to download artist command 2025-06-21 16:22:16 -04:00
Mathis Maquenne 0680d8c8e7 chore(deps): update golang.org/x/crypto and golang.org/x/net to patch security vulnerabilities 2025-06-20 20:07:51 -04:00
Mathis Maquenne 67b4fe404e feat: download artist's top tracks with --limit flag 2025-06-20 20:00:59 -04:00
83 changed files with 6455 additions and 1573 deletions
+87
View File
@@ -0,0 +1,87 @@
# GoDeez
GoDeez is a Go CLI that downloads music (tracks, albums, playlists, artist top
tracks) from Deezer in MP3 or lossless FLAC/WAV, embeds metadata tags, and
tracks what has already been downloaded so repeat runs can skip it.
## Build, test, lint
Mirror what CI (`.github/workflows/ci.yml`) runs:
```bash
gofmt -l . # must print nothing; CI fails on unformatted files
go vet ./...
go test ./...
go build ./...
```
Run a single test:
```bash
go test ./internal/download/ -run TestHashFile
go test ./internal/download/ -run TestHashFile -v
```
Release packaging is validated with `goreleaser check` (config in
`.goreleaser.yaml`); releases are cut by pushing a `v*` tag, which requires a
matching section in `CHANGELOG.md` (`.github/scripts/release-notes.sh`
extracts it and fails the release otherwise).
## Architecture
The flow is `cmd``download.Downloader``deezer` client → `store`/`tag`/`audio`:
- **`cmd`** is a thin cobra layer (`download`, `login`, `logout`, `update`,
`version`). It parses flags and delegates; it does not contain download
logic. `cmd.Execute` also kicks off an async update check
(`internal/update.StartCheck`) before running the command and prints the
result after, so the network round trip overlaps with the command instead
of adding to startup time.
- **`internal/deezer`** is the API client. `Kind` (`album`/`playlist`/
`artist`/`track`) is the single source of truth mapping a resource type to
its gw-light page method, request id field, and `Resource` implementation
(`Album`/`Playlist`/`Artist`/`Single`). Adding a new resource kind means
extending every switch in `kind.go`. `Resource`'s `decode` method is
unexported to seal the interface to this package.
- **`internal/download`** (package `download`) drives the per-track pipeline
via `Downloader.Run`: fetch resource → for each track, resolve a media
source → decide skip/hash-dedupe → stream and decrypt → optionally convert
FLAC to WAV → write tags → record in the store. Tracks are processed
sequentially, one at a time. Most per-track failures (bad cover, failed BPM
lookup, etc.) are collected as warnings rather than aborting the whole run;
only `context.Canceled` stops the loop early.
- **`internal/store`** is a bbolt-backed ledger (`.tracks.db`) written as a
hidden file inside the output directory, so it travels with the music
library it describes. Losing it is harmless — the only cost is
re-downloading.
- **`internal/tag`** writes metadata per container format (ID3v2 for MP3,
Vorbis comments for FLAC, ID3 chunk + RIFF LIST/INFO for WAV), dispatching
on file extension. Taggers write through a temp file so a mid-write failure
can't corrupt existing audio.
- **`internal/config`** has no config file — the output directory
(`~/Music/GoDeez`) is fixed, and the only setting is the `DEEZER_ARL`
env var, which is optional (falls back to keyring-stored credentials from
`godeez login`).
- **`internal/buildinfo`** holds the version/commit/date injected by
goreleaser via `-ldflags`; unreleased builds report `dev`, which disables
update-related behavior (`IsDev()`).
- **`internal/update`** implements self-update: checks GitHub releases,
verifies the downloaded binary's SHA256 against the published
`checksums.txt`, and replaces the running binary in place.
## Conventions
- Package- and function-level doc comments consistently explain *why*, not
just what — e.g. why a check happens where it does, or why an error is
handled a particular way. Preserve this style when touching existing code
or adding new exported functions.
- Most per-track errors during a download are non-fatal and surfaced as
warnings in the run summary; only cancellation (`context.Canceled`) should
abort the loop. Keep new failure modes inside this pattern unless they are
truly unrecoverable for the whole run.
- `Kind` in `internal/deezer/kind.go` is deliberately exhaustive with no
default case that silently returns a zero value in the id/method switches —
follow the same pattern (explicit cases, empty/error fallback) if extending
it.
- Credentials: `DEEZER_ARL` always takes precedence over keyring-stored
login credentials when both are present.
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# Extracts the CHANGELOG.md section for a version and formats it as the GitHub
# release body. Usage: release-notes.sh v1.5.0
#
# Exits non-zero when the version has no section, so a tag can never be
# published with empty or stale release notes.
set -euo pipefail
if [ $# -ne 1 ]; then
echo "usage: $0 <version>" >&2
exit 2
fi
version="${1#v}"
changelog="${CHANGELOG_FILE:-CHANGELOG.md}"
if [ ! -f "$changelog" ]; then
echo "$changelog not found" >&2
exit 1
fi
notes=$(awk -v ver="$version" '
BEGIN { heading = "## [" ver "]" }
index($0, heading) == 1 { found = 1; next }
found && index($0, "## [") == 1 { exit }
found { print }
' "$changelog")
if [ -z "${notes//[[:space:]]/}" ]; then
echo "no $changelog section found for version $version" >&2
exit 1
fi
printf "## What's new in v%s\n%s\n" "$version" "$notes"
+40
View File
@@ -0,0 +1,40 @@
name: ci
on:
push:
branches: ['**']
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Not gofmt'd:"
echo "$unformatted"
exit 1
fi
- run: go vet ./...
- run: go test ./...
- run: go build ./...
# Catches a broken release config before a tag is pushed.
- uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: check
+33
View File
@@ -0,0 +1,33 @@
name: release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Fails the release if CHANGELOG.md has no section for this tag.
- name: Build release notes from CHANGELOG.md
run: .github/scripts/release-notes.sh "${{ github.ref_name }}" > "${RUNNER_TEMP}/release-notes.md"
- uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: release --clean --release-notes=${{ runner.temp }}/release-notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+43
View File
@@ -0,0 +1,43 @@
version: 2
project_name: godeez
before:
hooks:
- go mod tidy
builds:
- id: godeez
main: .
binary: godeez
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w
- -X github.com/mathismqn/godeez/internal/buildinfo.version={{ .Version }}
- -X github.com/mathismqn/godeez/internal/buildinfo.commit={{ .FullCommit }}
- -X github.com/mathismqn/godeez/internal/buildinfo.date={{ .CommitDate }}
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
archives:
- formats:
- binary
name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}'
checksum:
name_template: checksums.txt
algorithm: sha256
snapshot:
version_template: '{{ incpatch .Version }}-snapshot'
release:
prerelease: auto
+89 -10
View File
@@ -5,25 +5,104 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.0] - 2026-08-05
### Added
- Add new `login` and `logout` commands to authenticate with your Deezer email and password. Credentials are stored in the system keyring. Requires `DEEZER_MOBILE_API_KEY` and `DEEZER_MOBILE_GW_KEY` to be set.
- Add new `update` command to replace the binary in place with the latest release, with `--check` to only report availability and `--force` to reinstall.
- Add new `version` command to print the version, commit, build date, and platform.
- Notify when a newer version is available after a download completes.
- Add WAV download quality (`--quality=wav`): the FLAC stream is converted locally to lossless WAV.
### Changed
- `DEEZER_ARL` is now optional. When it is unset, the credentials stored by `godeez login` are used instead, and expired sessions are renewed automatically.
- Release binaries are now named `godeez_<version>_<os>_<arch>` (previously `godeez-<version>-<os>-<arch>`) and are published alongside a `checksums.txt` file.
### Fixed
- Interrupted downloads no longer leave partial files behind.
- Avoid overwriting an existing file when another track resolves to the same name.
- Report a clear error when the database is already in use by another process.
- Migrate the legacy database when `~/.godeez` and `~/Music/GoDeez` are on different filesystems.
- Write metadata tags even when the cover art or track duration is missing.
## [1.4.0] - 2026-03-01
### Added
- Automatic database migration from `~/.godeez/tracks.db` to `~/Music/GoDeez/.tracks.db`.
### Changed
- Configuration now uses `DEEZER_ARL` environment variable (replaces `config.toml`).
- Database moved from `~/.godeez/tracks.db` to `~/Music/GoDeez/.tracks.db`.
- Show warning count in download summary.
### Removed
- `config.toml` configuration file and `~/.godeez` directory.
- `--config` flag from CLI.
- `secret_key` and `output_dir` configuration options.
- Watcher feature (`watch` subcommands).
### Fixed
- Track number zero-padding for correct file sorting.
## [1.3.0] - 2025-09-11
### Added
- Add new `track` command to download individual tracks.
- Add `--genre` flag to fetch and embed genre information into file metadata tags.
### Fixed
- Handle empty media resources gracefully to prevent crashes.
## [1.2.0] - 2025-08-18
### Added
- Add new `artist` command to download an artists top tracks.
- Add `--limit` flag for the `artist` command to restrict the number of tracks.
- Add `--strict` flag for downloads: fail if the requested quality is unavailable.
### Changed
- Set default download quality to MP3 320 kbps.
### Removed
- Remove `--quality=best` option. Fallback to lower quality is now the default behavior; use the `--strict` flag to prevent fallback.
### Fixed
- Handle error when `SNG_CONTRIBUTORS` metadata is empty.
## [1.1.1] - 2025-06-16 ## [1.1.1] - 2025-06-16
### Fixed ### Fixed
- Restored ability to download tracks **without a Deezer Premium account** (limited to **MP3 128kbps** for free accounts).
- Restore ability to download tracks without a Deezer Premium account (limited to MP3 128 kbps for free accounts).
## [1.1.0] - 2025-05-19 ## [1.1.0] - 2025-05-19
### Added ### Added
- Support for downloading full albums and playlists with more than 40 tracks (previous limit removed).
- Option to fetch and embed **BPM** and **musical key** into metadata tags. - Support downloading full albums and playlists with more than 40 tracks (previous limit removed).
- New local **database system** (`tracks.db`) to track downloaded files and avoid re-downloading, even if files are renamed or moved. - Fetch and embed BPM and musical key into metadata tags.
- Improved CLI **output formatting** for a cleaner and more informative user experience. - Add local database system (`tracks.db`) to track downloaded files and avoid re-downloading, even if files are renamed or moved.
- Improve CLI output formatting for a cleaner and more informative user experience.
### Changed ### Changed
- The `.godeez` file in the users home directory has been replaced by a `.godeez/` directory.
It now stores both `config.toml` and the internal `tracks.db`. - Replace the `godeez` file in the users home directory with a `.godeez/` directory, which now stores both `config.toml` and `tracks.db`.
If you're upgrading from an older version, move your existing config into `.godeez/config.toml`. 👉 If upgrading, move your existing config into `.godeez/config.toml`.
- Simplified `config.toml`: `iv` and `license_token` are no longer required. - Simplify `config.toml`: remove the need for `iv` and `license_token`.
- Cleanup logic: corrupted or incomplete files are now automatically deleted on download failure. - Automatically delete corrupted or incomplete files on download failure.
## [1.0.0] - 2024-10-15 ## [1.0.0] - 2024-10-15
+180 -76
View File
@@ -6,93 +6,164 @@
[![License](https://img.shields.io/github/license/mathismqn/godeez)](https://github.com/mathismqn/godeez/blob/main/LICENSE) [![License](https://img.shields.io/github/license/mathismqn/godeez)](https://github.com/mathismqn/godeez/blob/main/LICENSE)
[![Last commit](https://img.shields.io/github/last-commit/mathismqn/godeez)](https://github.com/mathismqn/godeez/commits/main) [![Last commit](https://img.shields.io/github/last-commit/mathismqn/godeez)](https://github.com/mathismqn/godeez/commits/main)
A simple Go tool for downloading music from [Deezer](https://www.deezer.com). Download music from [Deezer](https://www.deezer.com) in MP3 or lossless FLAC/WAV.
[Features](#features) • [Features](#features) •
[Installation](#installation) • [Installation](#installation) •
[Updating](#updating) •
[Configuration](#configuration) • [Configuration](#configuration) •
[Usage](#usage) [Usage](#usage)
[Contributing](#contributing) •
[License](#license)
</div> </div>
## Features ## Features
* Download playlists and albums from Deezer - Download playlists, albums, artists' top tracks, and individual tracks
* Select audio quality: MP3 128kbps, MP3 320kbps, or FLAC (⚠️ non-premium accounts are limited to 128kbps) - Choose audio quality: MP3 128 kbps, MP3 320 kbps (default), or lossless FLAC/WAV (⚠️ non-premium accounts are limited to MP3 128 kbps)
* Automatically adds metadata tags to downloaded files - Authenticate with an ARL cookie or with your Deezer email and password
* Fetch and tag songs with BPM and musical key - Automatically embed metadata tags (artist, album, title, artwork, etc.)
* Smart skip system: avoids re-downloading already existing files using hashes and metadata - Fetch and tag tracks with BPM, musical key, and genre
* Cross-platform support (works on Windows, macOS, and Linux) - Works on Windows, macOS, and Linux
* Simple and easy-to-use CLI
## Installation ## Installation
To install **GoDeez**, simply download the latest binary for your platform from the Releases page. Prebuilt binaries are available for every release.
1. Go to the [Releases](https://github.com/mathismqn/godeez/releases) page. 1. Go to the [Releases](https://github.com/mathismqn/godeez/releases) page.
2. Download the appropriate binary for your operating system (Windows, macOS, or Linux). 2. Download the appropriate binary for your operating system and architecture, named `godeez_<version>_<os>_<arch>`.
3. Move the binary to a directory included in $PATH for easy access (optional but recommended). 3. (Optional) Move the binary to a directory on your `$PATH` for easier access.
Example (Linux/macOS): Example (Linux/macOS):
```bash ```bash
# Move the downloaded binary to /usr/local/bin for easy access from anywhere # Make it executable and move it to /usr/local/bin for access from anywhere
mv godeez-1.1.0-linux-amd64 /usr/local/bin/godeez chmod +x godeez_1.5.0_linux_amd64
mv godeez_1.5.0_linux_amd64 /usr/local/bin/godeez
```
Each release also includes a `checksums.txt`, so you can verify your download:
```bash
sha256sum -c checksums.txt --ignore-missing
```
### macOS
The macOS binaries are not signed with an Apple Developer certificate, so
Gatekeeper blocks them on first run. You only need to clear the quarantine flag
once:
```bash
xattr -d com.apple.quarantine /usr/local/bin/godeez
```
## Updating
**GoDeez** can update itself to the latest release:
```bash
# Check for a new version
godeez update --check
# Download, verify, and install it
godeez update
# Reinstall even if already up to date
godeez update --force
```
The new binary is verified against the release's published SHA256 checksum
before it replaces the current one. If **GoDeez** lives in a directory you do
not own (such as `/usr/local/bin` on some systems), run `sudo godeez update`.
To disable new-version notifications:
```bash
export GODEEZ_NO_UPDATE_CHECK=1
```
To check which version you are running:
```bash
godeez version
``` ```
## Configuration ## Configuration
The first time you run **GoDeez**, a configuration directory named `.godeez` will be automatically created in your home directory (`$HOME` on Linux/macOS, `%USERPROFILE%` on Windows). **GoDeez** authenticates to Deezer in one of two ways: with an **ARL cookie** copied from your browser, or with your **email and password**. The ARL cookie works out of the box and is the recommended option; email/password login requires two extra keys that **GoDeez** does not ship (see below).
Inside this directory: ### ARL cookie
- `config.toml`: main configuration file which contains several important variables that you need to fill out manually
- `tracks.db`: internal database used to track downloaded files and prevent duplicates
### Steps to configure Set your ARL cookie as an environment variable:
1. Run the application for the first time: this creates the `.godeez` directory and the `config.toml` file inside it. ```bash
2. Edit the `config.toml` file with a text editor to set the required values. export DEEZER_ARL="your_arl_cookie_here"
### Variables to configure
Here are the key variables you need to set in `config.toml`:
1. `arl_cookie`
* **What is it?**: The `arl_cookie` is a session cookie used for authentication with Deezer. Without this cookie, the downloader cannot access your account to retrieve playlists, albums, or songs.
* **How to retrieve it**:
1. Open your browser and log in to your Deezer account.
2. Open the Developer Tools (right-click on the page and select “Inspect” or press F12).
3. Navigate to the Application tab (in Chrome/Edge) or Storage tab (in Firefox).
4. In the left panel, look for Cookies and select `https://www.deezer.com`.
5. Find the arl cookie and copy its value.
2. `secret_key`
* **What is it?**: The `secret_key` is a cryptographic value used to decrypt Deezers media files.
* **How to retrieve it?**: While we cannot provide the specific secret_key in this documentation, it can be found online through various sources or developer communities that focus on Deezer.
3. `output_dir` (optional)
* **What is it?**: The `output_dir` is the path where downloaded music files will be saved.
* **Default**: If left empty, it defaults to `~/Music/GoDeez`.
* **Note**: Once set, it's recommended not to change it, as this may interfere with the skip system that relies on consistent file paths and hash indexing to detect already downloaded songs.
### Example
Here's an example of a minimal `config.toml` you can customize:
```toml
# ~/.godeez/config.toml
arl_cookie = 'your_arl_cookie_here'
secret_key = 'your_secret_key_here'
output_dir = '' # optional
``` ```
To make it persistent, add the line above to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.).
#### How to retrieve your ARL cookie
1. Open your browser and log in to your [Deezer](https://www.deezer.com) account.
2. Open the **Developer Tools** (right-click on the page and select **Inspect**, or press <kbd>F12</kbd>).
3. Navigate to the **Application** tab (Chrome/Edge) or **Storage** tab (Firefox).
4. In the left panel, look for **Cookies** and select **https://www.deezer.com**.
5. Find the `arl` cookie and copy its value.
> **Note:** The ARL cookie may expire after some time. If you get authentication errors, retrieve a fresh cookie using the steps above.
### Email and password
Instead of copying a cookie, you can log in once with your Deezer account:
```bash
godeez login
```
You will be prompted for your email and password. On success, **GoDeez** stores your credentials in your system keyring under the service name `godeez`. From then on, **GoDeez** authenticates on its own and renews the session when it expires.
To remove the stored credentials:
```bash
godeez logout
```
#### Gateway keys
Email/password login goes through Deezer's mobile gateway, which requires two keys:
```bash
export DEEZER_MOBILE_API_KEY="your_api_key_here"
export DEEZER_MOBILE_GW_KEY="your_gateway_key" # exactly 16 characters
```
**GoDeez** does not bundle these keys, so you have to supply your own. For background on what they are and where they live in Deezer's clients, see [this write-up](https://gist.github.com/svbnet/b79b705a4c19d74896670c1ac7ad627e). If either variable is missing, `godeez login` exits with an error.
> **Note:** `DEEZER_ARL` takes precedence over stored credentials. If it is set, **GoDeez** always uses the cookie and never falls back to your login, so unset it (and remove it from your shell profile) before running `godeez login`.
> **Note:** The keyring entry holds your password alongside the ARL because the password is reused to renew expired sessions. On Linux, the keyring requires a running secret service; without one, `godeez login` fails with `system keyring is unavailable`.
### Output directory
Downloaded files are saved to `~/Music/GoDeez`. The download database (`.tracks.db`) is stored in the same directory as your music.
To use a different location, set `GODEEZ_OUTPUT_DIR`:
```bash
export GODEEZ_OUTPUT_DIR="/path/to/your/music"
```
A leading `~` is expanded to your home directory.
> **Upgrading from v1.3.0?** The `~/.godeez` directory and `config.toml` are no longer used. Set the `DEEZER_ARL` environment variable instead. Your existing database will be migrated automatically on first run.
## Usage ## Usage
### CLI Overview ### CLI overview
When you run `godeez` without any additional commands, youll see a general help menu: Running `godeez` without arguments shows the help menu:
```bash
```text
GoDeez is a tool to download music from Deezer GoDeez is a tool to download music from Deezer
Usage: Usage:
@@ -100,47 +171,80 @@ Usage:
Available Commands: Available Commands:
completion Generate the autocompletion script for the specified shell completion Generate the autocompletion script for the specified shell
download Download songs from Deezer download Download tracks from Deezer
help Help about any command help Help about any command
login Log in to Deezer with your email and password
logout Remove stored Deezer credentials
update Update GoDeez to the latest version
version Print the current version of GoDeez
Flags: Flags:
--config string config file (default ~/.godeez/config.toml) -h, --help help for godeez
-h, --help help for godeez
Use "godeez [command] --help" for more information about a command. Use "godeez [command] --help" for more information about a command.
``` ```
This provides an overview of the available commands and flags.
To download music, you need to use the download command. Heres how the CLI looks when you run `godeez download`: ### Download commands
```bash
Download songs from Deezer ```text
Download tracks from Deezer
Usage: Usage:
godeez download [command] godeez download [command]
Available Commands: Available Commands:
album Download songs from album album Download tracks from an album
playlist Download songs from playlist artist Download an artist's top tracks
playlist Download tracks from a playlist
track Download a single track
Flags: Flags:
--bpm fetch BPM/key and add to file tags --bpm fetch BPM/key and add to file tags
--genre fetch genre and add to file tags
-h, --help help for download -h, --help help for download
-q, --quality string download quality [mp3_128, mp3_320, flac, best] (default "best") -q, --quality string download quality [mp3_128, mp3_320, flac, wav] (default "mp3_320")
--strict fail the download if the requested quality is unavailable
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s) -t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
Global Flags:
--config string config file (default ~/.godeez/config.toml)
Use "godeez download [command] --help" for more information about a command. Use "godeez download [command] --help" for more information about a command.
``` ```
> **Note:** The `artist` command takes an extra `-l, --limit` flag to choose how many top tracks to download (default 10, maximum 100).
### Examples
```bash
# Download an album
godeez download album 12345678
# Download a playlist
godeez download playlist 87654321
# Download an artist's top tracks (limit to 5 tracks)
godeez download artist 11223344 --limit 5
# Download a single track
godeez download track 98765432
# Download with specific quality, BPM, and genre data
godeez download track 98765432 --quality flac --bpm --genre
```
## Contributing ## Contributing
Contributions help make **GoDeez** a better tool for everyone, and any help is greatly appreciated. Contributions make **GoDeez** better for everyone, and any help is greatly appreciated — whether it's a bug fix, a new feature, or a documentation improvement.
Whether its a bug fix, a new feature, or improving documentation, your input is valuable.
If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion. To contribute, fork the repository and open a pull request. To report a bug or suggest a feature, open an issue instead.
Every bit of support counts, so dont forget to give the project a star if you enjoy using it. Thank you for helping make this project better!
## Support the project
If **GoDeez** helps you enjoy your music collection, please consider giving it a star ⭐
**Why star the project?**
- Helps more music lovers discover it
- Shows appreciation for the work and keeps me motivated
- Takes one click, and it means a lot
## License ## License
@@ -148,4 +252,4 @@ This project is licensed under the MIT License. See the [LICENSE](https://github
--- ---
> ⚠️ This tool is provided for educational and personal use only. Please ensure your usage complies with Deezers Terms of Service. > ⚠️ This tool is provided for educational and personal use only. Please ensure your usage complies with Deezer's Terms of Service.
+75 -34
View File
@@ -4,55 +4,96 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/mathismqn/godeez/internal/downloader" "github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/download"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var opts downloader.Options func newDownloadCmd() *cobra.Command {
var downloadCmd = &cobra.Command{
Use: "download",
Short: "Download songs from Deezer",
}
func init() {
RootCmd.AddCommand(downloadCmd)
downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "best", "download quality [mp3_128, mp3_320, flac, best]")
downloadCmd.PersistentFlags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
downloadCmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
downloadCmd.AddCommand(
newDownloadCmd("album"),
newDownloadCmd("playlist"),
)
}
func newDownloadCmd(resourceType string) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType), Use: "download",
Short: fmt.Sprintf("Download songs from %s", resourceType), Short: "Download tracks from Deezer",
Annotations: map[string]string{updateNoticeAnnotation: "true"},
}
opts := &download.Options{}
cmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac, wav]")
cmd.PersistentFlags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
cmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
cmd.PersistentFlags().BoolVar(&opts.Genre, "genre", false, "fetch genre and add to file tags")
cmd.PersistentFlags().BoolVar(&opts.Strict, "strict", false, "fail the download if the requested quality is unavailable")
cmd.AddCommand(
newDownloadSubCmd(deezer.KindAlbum, opts),
newDownloadSubCmd(deezer.KindPlaylist, opts),
newDownloadSubCmd(deezer.KindArtist, opts),
newDownloadSubCmd(deezer.KindTrack, opts),
)
return cmd
}
// newDownloadSubCmd builds one download subcommand from a deezer.Kind. The
// four kinds differ only in wording and in whether they take a track limit,
// so they share this constructor rather than being written out four times.
//
// All four share one Options value through the parent's persistent flags,
// which is safe because exactly one subcommand ever runs.
//
// A cancelled download is reported as success: the user pressed Ctrl-C and
// has already seen the progress output, so an error on top of it would be
// noise, and a non-zero exit would misreport a deliberate stop as a failure.
func newDownloadSubCmd(kind deezer.Kind, opts *download.Options) *cobra.Command {
cmd := &cobra.Command{
Use: fmt.Sprintf("%s <%s_id>", kind, kind),
Short: downloadShort(kind),
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error { PreRunE: func(cmd *cobra.Command, args []string) error {
return opts.Validate() opts.Quality = strings.ToLower(opts.Quality)
return opts.Validate(kind)
}, },
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context() cfg, err := config.Load()
dl := downloader.New(appConfig, resourceType) if err != nil {
if err := dl.Run(ctx, opts, args[0]); err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
return err return err
} }
config.MigrateLegacy(cfg.OutputDir)
return nil st, err := store.Open(cfg.OutputDir)
if err != nil {
return err
}
defer st.Close()
err = download.New(cfg, st, kind).Run(cmd.Context(), *opts, args[0])
if errors.Is(err, context.Canceled) {
return nil
}
return err
}, },
} }
if kind == deezer.KindArtist {
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of tracks to download")
}
return cmd return cmd
} }
func downloadShort(kind deezer.Kind) string {
switch kind {
case deezer.KindArtist:
return "Download an artist's top tracks"
case deezer.KindTrack:
return "Download a single track"
case deezer.KindAlbum:
return "Download tracks from an album"
default:
return fmt.Sprintf("Download tracks from a %s", kind)
}
}
+108
View File
@@ -0,0 +1,108 @@
package cmd
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/spf13/cobra"
"golang.org/x/term"
)
func newLoginCmd() *cobra.Command {
return &cobra.Command{
Use: "login",
Short: "Log in to Deezer with your email and password",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := deezer.CheckGatewayEnv(); err != nil {
return err
}
err := runLogin(cmd.Context())
if errors.Is(err, context.Canceled) {
return nil
}
return err
},
}
}
func runLogin(ctx context.Context) error {
email, password, err := promptCredentials(ctx)
if err != nil {
return err
}
_, username, err := deezer.Login(ctx, email, password)
if err != nil {
return err
}
fmt.Printf("Successfully logged in as %s.\n", username)
return nil
}
// promptCredentials reads the email and password, giving up if ctx is
// cancelled.
//
// Reading stdin cannot itself be cancelled, so the read runs in a goroutine
// and this selects on whichever finishes first. That goroutine outlives a
// cancelled prompt, which is why the channel is buffered.
//
// Terminal state is captured up front and restored on cancellation: Ctrl-C
// during the password prompt would otherwise leave echo disabled and the
// user's shell silently typing blind.
func promptCredentials(ctx context.Context) (string, string, error) {
oldState, stateErr := term.GetState(int(os.Stdin.Fd()))
type credentials struct {
email string
password string
err error
}
resultChan := make(chan credentials, 1)
go func() {
var c credentials
c.email, c.password, c.err = readCredentials()
resultChan <- c
}()
select {
case c := <-resultChan:
return c.email, c.password, c.err
case <-ctx.Done():
if stateErr == nil {
term.Restore(int(os.Stdin.Fd()), oldState)
}
fmt.Println()
return "", "", ctx.Err()
}
}
// readCredentials prompts on the terminal. The password is read with echo
// off so it neither appears on screen nor reaches the shell history.
func readCredentials() (string, string, error) {
fmt.Print("Email: ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", "", err
}
email := strings.TrimSpace(line)
fmt.Print("Password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", "", err
}
return email, string(passwordBytes), nil
}
+25
View File
@@ -0,0 +1,25 @@
package cmd
import (
"fmt"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/spf13/cobra"
)
func newLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored Deezer credentials",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := deezer.ClearCredentials(); err != nil {
return err
}
fmt.Println("Successfully logged out.")
return nil
},
}
}
+102 -16
View File
@@ -1,27 +1,113 @@
// Package cmd defines the godeez command line: the root command and its
// download, login, logout, update and version subcommands. It is a thin layer
// that parses flags and delegates to the internal packages.
package cmd package cmd
import ( import (
"github.com/mathismqn/godeez/internal/config" "context"
"fmt"
"os"
"slices"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/mathismqn/godeez/internal/update"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term"
) )
var ( // updateNoticeAnnotation marks the commands that may print an update notice.
cfgPath string // It is an annotation rather than a field because it is inherited: marking
appConfig *config.Config // the download command opts in all of its subcommands.
) const updateNoticeAnnotation = "godeez:update-notice"
var RootCmd = &cobra.Command{ // Execute runs the CLI.
Use: "godeez", //
Short: "GoDeez is a tool to download music from Deezer", // The update check is started before the command and collected after it, so
SilenceUsage: true, // the network round trip overlaps with work the user actually asked for
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // instead of adding to the startup time.
var err error func Execute(ctx context.Context) error {
appConfig, err = config.New(cfgPath) root := newRootCmd()
return err var notice <-chan string
}, if wantsUpdateNotice(root) {
notice = update.StartCheck(ctx)
}
err := root.ExecuteContext(ctx)
printUpdateNotice(notice)
return err
} }
func init() { func newRootCmd() *cobra.Command {
RootCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "config file (default ~/.godeez/config.toml)") root := &cobra.Command{
Use: "godeez",
Short: "GoDeez is a tool to download music from Deezer",
SilenceUsage: true,
}
root.AddCommand(
newDownloadCmd(),
newLoginCmd(),
newLogoutCmd(),
newUpdateCmd(),
newVersionCmd(),
)
return root
}
// wantsUpdateNotice decides whether this invocation should check for updates.
//
// The aim is to nag only during real interactive use. Notices are suppressed
// when stderr is not a terminal, so they cannot corrupt piped or scripted
// output; on help output, where they are noise; on commands that only print
// their usage; and on anything not explicitly opted in via the annotation,
// which notably keeps `godeez version` and `godeez update` quiet.
func wantsUpdateNotice(root *cobra.Command) bool {
if !term.IsTerminal(int(os.Stderr.Fd())) {
return false
}
args := os.Args[1:]
if slices.Contains(args, "-h") || slices.Contains(args, "--help") {
return false
}
target, _, err := root.Find(args)
if err != nil || target == nil {
return false
}
if target.Run == nil && target.RunE == nil {
return false
}
for cmd := target; cmd != nil; cmd = cmd.Parent() {
if cmd.Annotations[updateNoticeAnnotation] == "true" {
return true
}
}
return false
}
// printUpdateNotice prints the notice only if the check has already finished.
//
// The non-blocking receive is the point: the command is done and the user
// should get their prompt back, so a check that is still in flight is
// dropped rather than waited on. A nil channel, meaning no check was started,
// takes the same path.
func printUpdateNotice(notice <-chan string) {
select {
case latest := <-notice:
if latest == "" {
return
}
fmt.Fprintf(os.Stderr, "\n ┌ Update available: %s → %s\n └ Run `godeez update` to install\n",
buildinfo.Version(), latest)
default:
}
} }
+84
View File
@@ -0,0 +1,84 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/mathismqn/godeez/internal/update"
"github.com/spf13/cobra"
)
type updateOptions struct {
checkOnly bool
force bool
}
func newUpdateCmd() *cobra.Command {
opts := &updateOptions{}
cmd := &cobra.Command{
Use: "update",
Short: "Update GoDeez to the latest version",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
err := runUpdate(cmd.Context(), opts)
if errors.Is(err, context.Canceled) {
return nil
}
return err
},
}
cmd.Flags().BoolVar(&opts.checkOnly, "check", false, "only report whether an update is available")
cmd.Flags().BoolVar(&opts.force, "force", false, "reinstall even if already up to date")
return cmd
}
// runUpdate reports the current and latest versions and installs the update.
//
// Whether the binary can be replaced at all is checked before the network
// call, so a package-managed install is told so straight away instead of
// after a pointless round trip.
//
// The force check comes before the check-only one so that `--check --force`
// still just reports rather than installing.
func runUpdate(ctx context.Context, opts *updateOptions) error {
if err := update.CheckUpdatable(); err != nil {
return err
}
u := update.New()
u.Out = os.Stdout
current := buildinfo.Version()
release, err := u.Latest(ctx)
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}
latest := release.Version()
fmt.Printf("Current: %s\nLatest: %s\n", current, latest)
if !update.IsNewer(current, latest) && !opts.force {
fmt.Println("Already up to date.")
return nil
}
if opts.checkOnly {
fmt.Printf("Run `godeez update` to install %s.\n", latest)
return nil
}
if err := u.Apply(ctx, release); err != nil {
return err
}
fmt.Printf("Updated to %s.\n", latest)
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package cmd
import (
"fmt"
"runtime"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/spf13/cobra"
)
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the current version of GoDeez",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("godeez %s\n", buildinfo.Version())
if commit := buildinfo.Commit(); commit != "" {
fmt.Printf(" commit: %s\n", commit)
}
if date := buildinfo.Date(); date != "" {
fmt.Printf(" built: %s\n", date)
}
fmt.Printf(" go: %s\n", runtime.Version())
fmt.Printf(" platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}
}
+23 -32
View File
@@ -1,49 +1,40 @@
module github.com/mathismqn/godeez module github.com/mathismqn/godeez
go 1.23.1 go 1.25.0
require ( require (
github.com/spf13/cobra v1.8.1 github.com/mewkiz/flac v1.0.13
github.com/spf13/viper v1.19.0 github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/mod v0.38.0
golang.org/x/term v0.45.0
) )
require ( require (
github.com/andybalholm/cascadia v1.3.2 // indirect github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/fatih/color v1.14.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/icza/bitio v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect golang.org/x/net v0.57.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect golang.org/x/sys v0.47.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect golang.org/x/text v0.40.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
require ( require (
github.com/PuerkitoBio/goquery v1.10.0 github.com/PuerkitoBio/goquery v1.12.0
github.com/bogem/id3v2/v2 v2.1.4 github.com/bogem/id3v2/v2 v2.1.4
github.com/briandowns/spinner v1.23.2 github.com/briandowns/spinner v1.23.2
github.com/flytam/filenamify v1.2.0 github.com/flytam/filenamify v1.2.0
github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.1 github.com/go-flac/go-flac/v2 v2.0.4
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.10 // indirect
go.etcd.io/bbolt v1.4.0 go.etcd.io/bbolt v1.5.0
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.54.0
) )
+56 -105
View File
@@ -1,148 +1,99 @@
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI= github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8= github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac/v2 v2.0.1 h1:1zilNkbmmpK9DLsz2NbjLHG8avOmthYqUfVc9YKB/Ps= github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac/v2 v2.0.1/go.mod h1:hvgeR2hElLbwk0Q1/vMazIDmIc2LAFSd9Bx/Fk6ViKo= github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+237
View File
@@ -0,0 +1,237 @@
// Package audio converts downloaded audio between formats.
//
// Deezer does not serve wav, so a wav download is really a flac download
// followed by FLACToWAV. The conversion is lossless in both directions: flac
// decodes to exactly the PCM samples it was encoded from, so nothing is lost
// by going through it.
package audio
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"github.com/mathismqn/godeez/internal/fsutil"
"github.com/mewkiz/flac"
)
const (
// headerSize is the canonical PCM wav header: a 12 byte RIFF/WAVE header,
// a 24 byte fmt chunk, and an 8 byte data chunk header.
headerSize = 44
formatPCM = 1
// ctxCheckInterval is how often, in flac frames, cancellation is polled.
// A flac frame is a few thousand samples, so checking every frame would
// add a select to the innermost decode loop for no practical gain in
// responsiveness.
ctxCheckInterval = 64
// maxDataSize is the largest audio payload that still fits. RIFF stores
// its sizes as uint32, and the RIFF size field covers the header after
// its own first 8 bytes as well as the data, so the audio itself has to
// stay that much below the limit. This works out to roughly 6 hours of
// CD quality stereo, which no single track will reach, but silently
// producing a file with a wrapped size field would be worse than an
// error.
maxDataSize = math.MaxUint32 - (headerSize - 8)
)
// FLACToWAV decodes the flac at srcPath and writes it as a PCM wav to
// dstPath.
//
// The size is checked twice, once from the flac header before doing any work
// and once against the bytes actually written, because NSamples is zero in
// flac streams that were encoded without a known length.
//
// Output goes to a temporary file in the destination directory and is renamed
// into place at the end, so a cancelled or failed conversion never leaves a
// half decoded file where a playable one is expected.
func FLACToWAV(ctx context.Context, srcPath, dstPath string) error {
stream, err := flac.Open(srcPath)
if err != nil {
return err
}
defer stream.Close()
info := stream.Info
bytesPerSample, err := bytesPerSample(info.BitsPerSample)
if err != nil {
return err
}
if info.NChannels < 1 || info.NChannels > 2 {
return fmt.Errorf("unsupported channel count: %d", info.NChannels)
}
if size := int64(info.NSamples) * int64(info.NChannels) * int64(bytesPerSample); size > maxDataSize {
return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", size)
}
file, err := os.CreateTemp(filepath.Dir(dstPath), fsutil.PartPattern)
if err != nil {
return err
}
tmpPath := file.Name()
done := false
defer func() {
if !done {
file.Close()
os.Remove(tmpPath)
}
}()
// The header goes down with a zero data size and is patched afterwards:
// the real length is only known once every frame has been decoded, and
// buffering the whole stream in memory to find out first is not worth it.
w := bufio.NewWriter(file)
if err := writeHeader(w, info.SampleRate, info.NChannels, info.BitsPerSample, 0); err != nil {
return err
}
dataSize, err := writeSamples(ctx, w, stream, int(info.NChannels), bytesPerSample)
if err != nil {
return err
}
if dataSize > maxDataSize {
return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", dataSize)
}
// RIFF chunks must end on an even offset. Only reachable with 8 or 24 bit
// mono, where a sample is an odd number of bytes.
if dataSize%2 != 0 {
if err := w.WriteByte(0); err != nil {
return err
}
}
if err := w.Flush(); err != nil {
return err
}
if err := patchSizes(file, dataSize); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, dstPath); err != nil {
return err
}
done = true
return nil
}
func bytesPerSample(bitsPerSample uint8) (int, error) {
switch bitsPerSample {
case 8, 16, 24:
return int(bitsPerSample) / 8, nil
default:
return 0, fmt.Errorf("unsupported bit depth: %d", bitsPerSample)
}
}
func writeHeader(w io.Writer, sampleRate uint32, nChannels, bitsPerSample uint8, dataSize uint32) error {
blockAlign := uint32(nChannels) * uint32(bitsPerSample) / 8
header := make([]byte, 0, headerSize)
header = append(header, "RIFF"...)
header = binary.LittleEndian.AppendUint32(header, uint32(headerSize-8)+dataSize)
header = append(header, "WAVE"...)
header = append(header, "fmt "...)
header = binary.LittleEndian.AppendUint32(header, 16)
header = binary.LittleEndian.AppendUint16(header, formatPCM)
header = binary.LittleEndian.AppendUint16(header, uint16(nChannels))
header = binary.LittleEndian.AppendUint32(header, sampleRate)
header = binary.LittleEndian.AppendUint32(header, sampleRate*blockAlign)
header = binary.LittleEndian.AppendUint16(header, uint16(blockAlign))
header = binary.LittleEndian.AppendUint16(header, uint16(bitsPerSample))
header = append(header, "data"...)
header = binary.LittleEndian.AppendUint32(header, dataSize)
_, err := w.Write(header)
return err
}
func writeSamples(ctx context.Context, w io.Writer, stream *flac.Stream, nChannels, bytesPerSample int) (int64, error) {
var dataSize int64
buf := make([]byte, 4)
for i := 0; ; i++ {
if i%ctxCheckInterval == 0 {
select {
case <-ctx.Done():
return dataSize, ctx.Err()
default:
}
}
frame, err := stream.ParseNext()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return dataSize, err
}
if len(frame.Subframes) != nChannels {
return dataSize, fmt.Errorf("frame %d has %d channels, want %d", frame.Num, len(frame.Subframes), nChannels)
}
for i := range frame.Subframes[0].Samples {
for _, subframe := range frame.Subframes {
putSample(buf, subframe.Samples[i], bytesPerSample)
if _, err := w.Write(buf[:bytesPerSample]); err != nil {
return dataSize, err
}
dataSize += int64(bytesPerSample)
}
}
}
return dataSize, nil
}
// putSample encodes one sample little endian into buf.
//
// 8 bit wav is the odd one out: it stores unsigned samples biased by 128,
// while every wider depth is signed two's complement. Writing an 8 bit sample
// signed produces audio that sounds like loud static, so the bias is not
// optional.
func putSample(buf []byte, sample int32, bytesPerSample int) {
if bytesPerSample == 1 {
buf[0] = byte(sample + 128)
return
}
value := uint32(sample)
for i := range bytesPerSample {
buf[i] = byte(value >> (8 * i))
}
}
// patchSizes rewrites the two length fields once the real data size is known:
// the RIFF size at offset 4 and the data chunk size just before the samples
// begin.
//
// The pad byte counts towards the RIFF size but not towards the data chunk
// size, which is why only the first of the two includes it.
func patchSizes(file *os.File, dataSize int64) error {
buf := make([]byte, 4)
binary.LittleEndian.PutUint32(buf, uint32(headerSize-8+dataSize+dataSize%2))
if _, err := file.WriteAt(buf, 4); err != nil {
return err
}
binary.LittleEndian.PutUint32(buf, uint32(dataSize))
_, err := file.WriteAt(buf, headerSize-4)
return err
}
+291
View File
@@ -0,0 +1,291 @@
package audio
import (
"bytes"
"context"
"encoding/binary"
"os"
"path/filepath"
"testing"
"github.com/mewkiz/flac"
"github.com/mewkiz/flac/frame"
"github.com/mewkiz/flac/meta"
)
func testSamples(nChannels, bitsPerSample, nSamples int) [][]int32 {
max := int32(1)<<(bitsPerSample-1) - 1
min := -int32(1) << (bitsPerSample - 1)
channels := make([][]int32, nChannels)
for c := range channels {
samples := make([]int32, nSamples)
for i := range samples {
switch i {
case 0:
samples[i] = min
case 1:
samples[i] = max
case 2:
samples[i] = 0
default:
samples[i] = int32(i*(c+1)) % max
if i%3 == 0 {
samples[i] = -samples[i]
}
}
}
channels[c] = samples
}
return channels
}
func writeTestFLAC(t *testing.T, path string, sampleRate uint32, bitsPerSample uint8, channels [][]int32) {
t.Helper()
nSamples := len(channels[0])
info := &meta.StreamInfo{
BlockSizeMin: uint16(nSamples),
BlockSizeMax: uint16(nSamples),
SampleRate: sampleRate,
NChannels: uint8(len(channels)),
BitsPerSample: bitsPerSample,
NSamples: uint64(nSamples),
}
file, err := os.Create(path)
if err != nil {
t.Fatalf("create flac: %v", err)
}
defer file.Close()
enc, err := flac.NewEncoder(file, info)
if err != nil {
t.Fatalf("new encoder: %v", err)
}
subframes := make([]*frame.Subframe, len(channels))
for c, samples := range channels {
subframes[c] = &frame.Subframe{
SubHeader: frame.SubHeader{Pred: frame.PredVerbatim},
Samples: samples,
NSamples: nSamples,
}
}
channelsLayout := frame.ChannelsMono
if len(channels) == 2 {
channelsLayout = frame.ChannelsLR
}
f := &frame.Frame{
Header: frame.Header{
HasFixedBlockSize: true,
BlockSize: uint16(nSamples),
SampleRate: sampleRate,
Channels: channelsLayout,
BitsPerSample: bitsPerSample,
},
Subframes: subframes,
}
if err := enc.WriteFrame(f); err != nil {
t.Fatalf("write frame: %v", err)
}
if err := enc.Close(); err != nil {
t.Fatalf("close encoder: %v", err)
}
}
func expectedPCM(channels [][]int32, bytesPerSample int) []byte {
var buf bytes.Buffer
for i := range channels[0] {
for _, samples := range channels {
sample := samples[i]
switch bytesPerSample {
case 1:
buf.WriteByte(byte(sample + 128))
case 2:
buf.Write([]byte{byte(sample), byte(sample >> 8)})
case 3:
buf.Write([]byte{byte(sample), byte(sample >> 8), byte(sample >> 16)})
}
}
}
return buf.Bytes()
}
func TestFLACToWAV(t *testing.T) {
tests := []struct {
name string
sampleRate uint32
bitsPerSample uint8
nChannels int
nSamples int
}{
{"16 bit stereo", 44100, 16, 2, 512},
{"16 bit mono", 44100, 16, 1, 512},
{"24 bit stereo", 48000, 24, 2, 333},
{"8 bit mono", 22050, 8, 1, 128},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "in.flac")
dst := filepath.Join(dir, "out.wav")
channels := testSamples(tt.nChannels, int(tt.bitsPerSample), tt.nSamples)
writeTestFLAC(t, src, tt.sampleRate, tt.bitsPerSample, channels)
if err := FLACToWAV(context.Background(), src, dst); err != nil {
t.Fatalf("FLACToWAV() error = %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read wav: %v", err)
}
bytesPerSample := int(tt.bitsPerSample) / 8
blockAlign := uint16(tt.nChannels * bytesPerSample)
want := expectedPCM(channels, bytesPerSample)
dataSize := uint32(len(want))
pad := dataSize % 2
if len(got) != headerSize+len(want)+int(pad) {
t.Fatalf("file size = %d, want %d", len(got), headerSize+len(want)+int(pad))
}
if string(got[0:4]) != "RIFF" || string(got[8:12]) != "WAVE" {
t.Errorf("magic = %q %q, want \"RIFF\" \"WAVE\"", got[0:4], got[8:12])
}
if size := binary.LittleEndian.Uint32(got[4:8]); size != headerSize-8+dataSize+pad {
t.Errorf("riff size = %d, want %d", size, headerSize-8+dataSize+pad)
}
if string(got[12:16]) != "fmt " {
t.Errorf("fmt chunk id = %q, want \"fmt \"", got[12:16])
}
if size := binary.LittleEndian.Uint32(got[16:20]); size != 16 {
t.Errorf("fmt chunk size = %d, want 16", size)
}
if format := binary.LittleEndian.Uint16(got[20:22]); format != formatPCM {
t.Errorf("format = %d, want %d", format, formatPCM)
}
if n := binary.LittleEndian.Uint16(got[22:24]); n != uint16(tt.nChannels) {
t.Errorf("channels = %d, want %d", n, tt.nChannels)
}
if rate := binary.LittleEndian.Uint32(got[24:28]); rate != tt.sampleRate {
t.Errorf("sample rate = %d, want %d", rate, tt.sampleRate)
}
if rate := binary.LittleEndian.Uint32(got[28:32]); rate != tt.sampleRate*uint32(blockAlign) {
t.Errorf("byte rate = %d, want %d", rate, tt.sampleRate*uint32(blockAlign))
}
if align := binary.LittleEndian.Uint16(got[32:34]); align != blockAlign {
t.Errorf("block align = %d, want %d", align, blockAlign)
}
if bits := binary.LittleEndian.Uint16(got[34:36]); bits != uint16(tt.bitsPerSample) {
t.Errorf("bits per sample = %d, want %d", bits, tt.bitsPerSample)
}
if string(got[36:40]) != "data" {
t.Errorf("data chunk id = %q, want \"data\"", got[36:40])
}
if size := binary.LittleEndian.Uint32(got[40:44]); size != dataSize {
t.Errorf("data chunk size = %d, want %d", size, dataSize)
}
if !bytes.Equal(got[headerSize:headerSize+len(want)], want) {
t.Error("pcm payload does not match the source samples")
}
})
}
}
func TestFLACToWAVErrors(t *testing.T) {
dir := t.TempDir()
notFLAC := filepath.Join(dir, "not.flac")
if err := os.WriteFile(notFLAC, []byte("this is not a flac file"), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
valid := filepath.Join(dir, "valid.flac")
writeTestFLAC(t, valid, 44100, 16, testSamples(2, 16, 64))
data, err := os.ReadFile(valid)
if err != nil {
t.Fatalf("read flac: %v", err)
}
truncated := filepath.Join(dir, "truncated.flac")
if err := os.WriteFile(truncated, data[:len(data)/2], 0644); err != nil {
t.Fatalf("write file: %v", err)
}
tests := []struct {
name string
path string
}{
{"not a flac file", notFLAC},
{"missing file", filepath.Join(dir, "missing.flac")},
{"truncated file", truncated},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dst := filepath.Join(t.TempDir(), "out.wav")
if err := FLACToWAV(context.Background(), tt.path, dst); err == nil {
t.Error("FLACToWAV() error = nil, want error")
}
if _, err := os.Stat(dst); err == nil {
t.Error("FLACToWAV() left an output file behind")
}
})
}
}
func TestFLACToWAVCanceled(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "in.flac")
dst := filepath.Join(dir, "out.wav")
writeTestFLAC(t, src, 44100, 16, testSamples(2, 16, 512))
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := FLACToWAV(ctx, src, dst); err == nil {
t.Error("FLACToWAV() error = nil, want context.Canceled")
}
if _, err := os.Stat(dst); err == nil {
t.Error("FLACToWAV() left an output file behind")
}
matches, _ := filepath.Glob(filepath.Join(dir, ".godeez-*.part"))
if len(matches) > 0 {
t.Errorf("FLACToWAV() left %d part files behind", len(matches))
}
}
func TestBytesPerSample(t *testing.T) {
tests := []struct {
bitsPerSample uint8
want int
wantErr bool
}{
{8, 1, false},
{16, 2, false},
{24, 3, false},
{4, 0, true},
{12, 0, true},
{20, 0, true},
{32, 0, true},
}
for _, tt := range tests {
got, err := bytesPerSample(tt.bitsPerSample)
if (err != nil) != tt.wantErr {
t.Errorf("bytesPerSample(%d) error = %v, wantErr %v", tt.bitsPerSample, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("bytesPerSample(%d) = %d, want %d", tt.bitsPerSample, got, tt.want)
}
}
}
-176
View File
@@ -1,176 +0,0 @@
package bpm
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
)
type Metrics struct {
BPM string
Key string
}
func FetchMetrics(ctx context.Context, httpClient *http.Client, artist, title, duration string) (*Metrics, error) {
url, err := findSongURL(ctx, httpClient, artist, title, duration)
if err != nil {
return nil, err
}
html, err := fetchPage(ctx, httpClient, url)
if err != nil {
return nil, err
}
return parseMetrics(html)
}
func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
rootUrl := "https://songbpm.com"
reqUrl := rootUrl + "/searches"
values := url.Values{}
values.Add("query", fmt.Sprintf("%s %s", artist, title))
req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBufferString(values.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", "https://songbpm.com")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return "", err
}
var (
found bool
url string
)
doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, selection *goquery.Selection) bool {
lowerSelection := strings.ToLower(selection.Text())
lowerTitle := strings.ToLower(title)
lowerArtist := strings.ToLower(artist)
if !strings.Contains(lowerSelection, lowerTitle) || !strings.Contains(lowerSelection, lowerArtist) {
return true
}
durationStr := strings.TrimSpace(selection.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text())
parts := strings.Split(durationStr, ":")
if len(parts) != 2 {
return true
}
minutes, err := strconv.Atoi(parts[0])
if err != nil {
return true
}
seconds, err := strconv.Atoi(parts[1])
if err != nil {
return true
}
foundDuration := minutes*60 + seconds
duration, err := strconv.Atoi(duration)
if err != nil {
return true
}
if foundDuration <= (duration-2) || foundDuration >= (duration+2) {
return true
}
url = selection.AttrOr("href", "")
found = true
return false
})
if !found {
return "", fmt.Errorf("no data found")
}
return rootUrl + url, nil
}
func fetchPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func parseMetrics(html string) (*Metrics, error) {
bpmRegex := regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
bpmMatch := bpmRegex.FindStringSubmatch(html)
keyRegex := regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
keyMatch := keyRegex.FindStringSubmatch(html)
modeRegex := regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
modeMatch := modeRegex.FindStringSubmatch(html)
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
return nil, fmt.Errorf("no data found")
}
isMinor := false
bpm := bpmMatch[1]
key := keyMatch[1]
if modeMatch[1] == "minor" {
isMinor = true
}
if strings.Contains(key, "/") {
parts := strings.Split(key, "/")
key = parts[0]
}
key = strings.ReplaceAll(key, "♯", "#")
key = strings.ReplaceAll(key, "♭", "b")
if isMinor && !strings.HasSuffix(key, "m") {
key += "m"
}
return &Metrics{
BPM: bpm,
Key: key,
}, nil
}
+97
View File
@@ -0,0 +1,97 @@
// Package buildinfo reports the version, commit and build date of the running
// binary.
//
// Release builds have these stamped in by goreleaser through -ldflags. When
// that has not happened, as with `go build` or `go install`, the values are
// recovered from the module metadata the toolchain embeds. Anything that
// cannot be established as a real release is reported as a development build,
// which is what disables the update machinery.
package buildinfo
import (
"fmt"
"runtime"
"runtime/debug"
"strings"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)
// devVersion is the version reported by builds that were not produced by a
// release. Both the update check and `godeez update` refuse to run on them.
const devVersion = "dev"
// Injected at link time by goreleaser. They are unexported and read through
// the accessors below so nothing can depend on their zero values directly.
var (
version = devVersion
commit = ""
date = ""
)
// Version returns the release version without a leading "v", or devVersion
// for anything that is not a release build. Binaries built with `go install`
// carry no ldflags but do record the module version, so that is consulted
// before giving up.
func Version() string {
if version != devVersion {
return version
}
if info, ok := debug.ReadBuildInfo(); ok {
if v := releaseVersion(info.Main.Version); v != "" {
return v
}
}
return devVersion
}
// releaseVersion accepts v only if it names a published release, returning ""
// otherwise.
//
// Pseudo-versions describe a commit that was never tagged, and a build suffix
// marks a local or modified build. Treating either as a release would offer
// the user an update path from a version that does not exist.
func releaseVersion(v string) string {
if !semver.IsValid(v) {
return ""
}
if semver.Build(v) != "" || module.IsPseudoVersion(v) {
return ""
}
return strings.TrimPrefix(v, "v")
}
func IsDev() bool {
return Version() == devVersion
}
// Commit returns the revision the binary was built from, falling back to the
// VCS stamp the Go toolchain records when building inside a repository. It
// returns "" when neither is available.
func Commit() string {
if commit != "" {
return commit
}
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
if s.Key == "vcs.revision" {
return s.Value
}
}
}
return ""
}
func Date() string {
return date
}
func UserAgent() string {
return fmt.Sprintf("godeez/%s (%s/%s)", Version(), runtime.GOOS, runtime.GOARCH)
}
+52 -63
View File
@@ -1,82 +1,71 @@
// Package config resolves where godeez reads its session from and writes its
// downloads to. There is no config file: the output directory defaults to
// ~/Music/GoDeez but can be overridden with GODEEZ_OUTPUT_DIR, and the only
// other setting is the DEEZER_ARL environment variable, which exists as an
// escape hatch for users who would rather not store credentials in the system
// keyring.
package config package config
import ( import (
"fmt" "fmt"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings"
"github.com/mathismqn/godeez/internal/fileutil" "github.com/mathismqn/godeez/internal/fsutil"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/viper"
) )
type Config struct { type Config struct {
ArlCookie string `mapstructure:"arl_cookie"` ARLCookie string
SecretKey string `mapstructure:"secret_key"` OutputDir string
OutputDir string `mapstructure:"output_dir"`
} }
func New(cfgPath string) (*Config, error) { // Load resolves the configuration and creates the output directory.
homeDir, err := os.UserHomeDir() //
// An empty ARLCookie is normal and not an error: it means fall back to the
// stored credentials, which is the usual path. Creating the directory here
// rather than at first write means a bad path fails immediately instead of
// after the first track has been fetched.
func Load() (*Config, error) {
arl := os.Getenv("DEEZER_ARL")
outputDir, err := resolveOutputDir()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
cfgDir := filepath.Join(homeDir, ".godeez")
if err := fileutil.EnsureDir(cfgDir); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
}
if cfgPath == "" {
cfgPath = path.Join(cfgDir, "config.toml")
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
fmt.Printf("Config file not found, creating one at %s\n", cfgPath)
content := []byte("arl_cookie = ''\nsecret_key = ''\noutput_dir = ''\n")
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
return nil, fmt.Errorf("failed to create config file: %w", err)
}
os.Exit(0)
}
}
viper.SetConfigFile(cfgPath)
viper.SetConfigType("toml")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := cfg.Validate(homeDir); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
if err := store.OpenDB(cfgDir); err != nil {
return nil, err return nil, err
} }
return &cfg, nil if err := fsutil.EnsureDir(outputDir); err != nil {
} return nil, fmt.Errorf("failed to create output directory: %w", err)
func (c *Config) Validate(homeDir string) error {
if c.ArlCookie == "" {
return fmt.Errorf("arl_cookie is not set")
}
if c.SecretKey == "" {
return fmt.Errorf("secret_key is not set")
}
if len(c.SecretKey) != 16 {
return fmt.Errorf("secret_key must be 16 bytes long")
}
if c.OutputDir == "" {
c.OutputDir = filepath.Join(homeDir, "Music", "GoDeez")
} }
return nil return &Config{
ARLCookie: arl,
OutputDir: outputDir,
}, nil
}
// resolveOutputDir honours GODEEZ_OUTPUT_DIR when set, falling back to
// ~/Music/GoDeez otherwise.
//
// A leading "~" is expanded by hand because the shell only does that for
// unquoted arguments, not for values read out of the environment, so users
// setting this in a profile file would otherwise end up with a literal "~"
// directory.
func resolveOutputDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
if dir := strings.TrimSpace(os.Getenv("GODEEZ_OUTPUT_DIR")); dir != "" {
if dir == "~" {
return homeDir, nil
}
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
return filepath.Join(homeDir, rest), nil
}
return dir, nil
}
return filepath.Join(homeDir, "Music", "GoDeez"), nil
} }
+76
View File
@@ -0,0 +1,76 @@
package config
import (
"io"
"os"
"path/filepath"
)
// MigrateLegacy moves the download ledger from the old ~/.godeez directory
// next to the user's music, where it now lives.
//
// It is silent and best effort throughout. A failed migration costs the user
// their skip history, which the next download simply rebuilds, so there is
// nothing worth interrupting them about. An existing database at the new
// location always wins, which makes this safe to run on every download rather
// than needing a flag to say whether it has happened yet.
//
// The rename is attempted first and falls back to a copy, because the old and
// new locations are often on different filesystems.
func MigrateLegacy(outputDir string) {
homeDir, err := os.UserHomeDir()
if err != nil {
return
}
oldDir := filepath.Join(homeDir, ".godeez")
oldDB := filepath.Join(oldDir, "tracks.db")
newDB := filepath.Join(outputDir, ".tracks.db")
if _, err := os.Stat(oldDB); err == nil {
if _, err := os.Stat(newDB); os.IsNotExist(err) {
if err := os.Rename(oldDB, newDB); err != nil {
if err := copyFile(oldDB, newDB); err != nil {
return
}
os.Remove(oldDB)
}
}
}
os.Remove(oldDir)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.CreateTemp(filepath.Dir(dst), ".tracks.db-*.tmp")
if err != nil {
return err
}
tmp := out.Name()
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Sync(); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return err
}
return nil
}
-36
View File
@@ -1,36 +0,0 @@
package crypto
import (
"crypto/cipher"
"crypto/md5"
"fmt"
"golang.org/x/crypto/blowfish"
)
var iv = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
func GetKey(secretKey, songID string) []byte {
hash := md5.Sum([]byte(songID))
hashHex := fmt.Sprintf("%x", hash)
key := []byte(secretKey)
for i := 0; i < len(hash); i++ {
key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16]
}
return key
}
func Decrypt(data, key []byte) ([]byte, error) {
block, err := blowfish.NewCipher(key)
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data)
return decrypted, nil
}
+14 -37
View File
@@ -3,9 +3,7 @@ package deezer
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path" "path/filepath"
"strconv"
"time"
"github.com/flytam/filenamify" "github.com/flytam/filenamify"
) )
@@ -19,54 +17,33 @@ type Album struct {
PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"` PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"`
Label string `json:"LABEL_NAME"` Label string `json:"LABEL_NAME"`
ProducerLine string `json:"PRODUCER_LINE"` ProducerLine string `json:"PRODUCER_LINE"`
Copyright string `json:"COPYRIGHT"`
Duration string `json:"DURATION"` Duration string `json:"DURATION"`
} `json:"DATA"` } `json:"DATA"`
Songs struct { Tracks struct {
Data []*Song `json:"data"` Data []*Track `json:"data"`
} `json:"SONGS"` } `json:"SONGS"`
} `json:"results"` } `json:"results"`
} }
func (a *Album) String() string { func (a *Album) Title() string {
duration, err := strconv.Atoi(a.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Album Info ] =================
Title: %s
Artist: %s
Tracks: %d
Duration: %s
==================================================`,
a.Results.Data.Title,
a.Results.Data.Artist,
len(a.Results.Songs.Data),
time.Duration(duration)*time.Second,
)
}
func (a *Album) GetType() string {
return "Album"
}
func (a *Album) GetTitle() string {
return a.Results.Data.Title return a.Results.Data.Title
} }
func (a *Album) GetSongs() []*Song { func (a *Album) Tracks() []*Track {
return a.Results.Songs.Data return a.Results.Tracks.Data
} }
func (a *Album) GetOutputDir(outputDir string) string { func (a *Album) SetTracks(t []*Track) {
a.Results.Tracks.Data = t
}
func (a *Album) OutputDir(outputDir string) string {
base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title) base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{}) base, _ = filenamify.Filenamify(base, filenamify.Options{})
outputDir = path.Join(outputDir, base) return filepath.Join(outputDir, base)
return outputDir
} }
func (a *Album) Unmarshal(data []byte) error { func (a *Album) decode(data []byte) error {
return json.Unmarshal(data, a) return json.Unmarshal(data, a)
} }
+40
View File
@@ -0,0 +1,40 @@
package deezer
import (
"encoding/json"
"path/filepath"
"github.com/flytam/filenamify"
)
type Artist struct {
Results struct {
Data struct {
Name string `json:"ART_NAME"`
} `json:"DATA"`
Tracks struct {
Data []*Track `json:"data"`
} `json:"TOP"`
} `json:"results"`
}
func (a *Artist) Title() string {
return a.Results.Data.Name
}
func (a *Artist) Tracks() []*Track {
return a.Results.Tracks.Data
}
func (a *Artist) SetTracks(t []*Track) {
a.Results.Tracks.Data = t
}
func (a *Artist) OutputDir(outputDir string) string {
base, _ := filenamify.Filenamify(a.Results.Data.Name, filenamify.Options{})
return filepath.Join(outputDir, base)
}
func (a *Artist) decode(data []byte) error {
return json.Unmarshal(data, a)
}
+57
View File
@@ -0,0 +1,57 @@
package deezer
import (
"crypto/cipher"
"crypto/md5"
"encoding/hex"
"golang.org/x/crypto/blowfish"
)
// blowfishIV and blowfishSecretKey are Deezer's own constants, not values
// chosen by this project. They are the same for every user and every track,
// and are widely published; the per-track key derived from them in
// BlowfishKey is what actually varies. Changing either one simply produces
// audio that will not decode.
var (
blowfishIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
blowfishSecretKey = []byte("g4el58wc0zvf9na1")
)
// BlowfishKey derives the per-track decryption key for trackID.
//
// Deezer takes the MD5 of the track ID as a 32 character hex string and folds
// its two halves back into the 16 byte secret, XORing byte i of the secret
// with hex digits i and i+16. The loop therefore runs over the 16 bytes of
// the raw digest, not the 32 characters of its hex encoding, and the result
// is the same 16 byte length as the secret.
func BlowfishKey(trackID string) []byte {
hash := md5.Sum([]byte(trackID))
hashHex := hex.EncodeToString(hash[:])
key := make([]byte, len(blowfishSecretKey))
copy(key, blowfishSecretKey)
for i := range len(hash) {
key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16]
}
return key
}
// DecryptBlowfish decrypts a single stream chunk with key and returns the
// plaintext.
//
// This is not a whole-file operation. Only every third chunk of a Deezer
// stream is encrypted, so callers are responsible for applying that stripe
// pattern; see streamToTempFile in the download package.
func DecryptBlowfish(data, key []byte) ([]byte, error) {
block, err := blowfish.NewCipher(key)
if err != nil {
return nil, err
}
decrypted := make([]byte, len(data))
cipher.NewCBCDecrypter(block, blowfishIV).CryptBlocks(decrypted, data)
return decrypted, nil
}
+56
View File
@@ -0,0 +1,56 @@
package deezer
import (
"bytes"
"crypto/cipher"
"encoding/hex"
"testing"
"golang.org/x/crypto/blowfish"
)
func TestBlowfishKey(t *testing.T) {
tests := []struct {
trackID string
want string
}{
{"3135556", "6c6c666b39662c37652575603c643439"},
{"123456789", "6d34656061377f31322a7336393f626b"},
{"1", "3464656e343a7d3a672c236a33696061"},
}
for _, tt := range tests {
got := hex.EncodeToString(BlowfishKey(tt.trackID))
if got != tt.want {
t.Errorf("BlowfishKey(%q) = %s, want %s", tt.trackID, got, tt.want)
}
}
}
func TestDecryptBlowfishRoundTrip(t *testing.T) {
key := BlowfishKey("3135556")
plaintext := bytes.Repeat([]byte("01234567"), 16)
block, err := blowfish.NewCipher(key)
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
encrypted := make([]byte, len(plaintext))
cipher.NewCBCEncrypter(block, blowfishIV).CryptBlocks(encrypted, plaintext)
decrypted, err := DecryptBlowfish(encrypted, key)
if err != nil {
t.Fatalf("DecryptBlowfish: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Errorf("round trip mismatch: got %x, want %x", decrypted, plaintext)
}
}
func TestDecryptBlowfishInvalidKey(t *testing.T) {
if _, err := DecryptBlowfish(make([]byte, 8), nil); err == nil {
t.Error("expected error for nil key")
}
}
+174 -90
View File
@@ -1,105 +1,185 @@
// Package deezer talks to Deezer's private endpoints: the gw-light web API,
// the Android mobile gateway used for email and password login, and the
// media servers that hand out encrypted audio streams. None of it is
// documented or supported by Deezer, so the request shapes, the error
// markers matched in response bodies and the crypto constants in this
// package were all derived from the official clients and can break without
// warning.
//
// A Client wraps an authenticated Session and fetches a Resource, which is
// one of Album, Playlist, Artist or Single. Audio is served Blowfish
// encrypted; see blowfish.go for the key derivation and the download
// package for the stripe pattern that undoes it.
package deezer package deezer
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"github.com/mathismqn/godeez/internal/config"
) )
type Client struct { type Client struct {
AppConfig *config.Config Session *Session
Session *Session
} }
func NewClient(ctx context.Context, appConfig *config.Config) (*Client, error) { // NewClient authenticates with Deezer and returns a client bound to the
session, err := Authenticate(ctx, appConfig.ArlCookie) // resulting session. An empty arlCookie falls back to the credentials held
// in the system keyring.
func NewClient(ctx context.Context, arlCookie string) (*Client, error) {
session, err := resolveSession(ctx, arlCookie)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err) return nil, fmt.Errorf("failed to authenticate: %w", err)
} }
return &Client{ return &Client{
AppConfig: appConfig, Session: session,
Session: session,
}, nil }, nil
} }
func (c *Client) FetchResource(ctx context.Context, ressource Resource, id string) error { // resolveSession authenticates with arlCookie when one is supplied, and
payload := map[string]interface{}{ // otherwise falls back to the stored credentials.
"nb": 10000, //
"start": 0, // The validate callback handed to resolveARL is the real authentication, not
"playlist_id": id, // a separate probe, so a stored ARL that still works is not sent twice.
"alb_id": id, // session is therefore only nil here when resolveARL had to log in again to
"lang": "en", // mint a fresh ARL.
"tab": 0, func resolveSession(ctx context.Context, arlCookie string) (*Session, error) {
"tags": true, if arlCookie != "" {
"header": true, return authenticate(ctx, arlCookie)
}
jsonData, err := json.Marshal(payload)
if err != nil {
return err
} }
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", ressource.GetType(), c.Session.APIToken) var session *Session
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) validate := func(ctx context.Context, arl string) error {
if err != nil { s, err := authenticate(ctx, arl)
return err if err != nil {
return err
}
session = s
return nil
} }
resp, err := c.Session.HttpClient.Do(req) arl, err := resolveARL(ctx, validate)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`) {
return fmt.Errorf("invalid playlist ID")
}
if strings.Contains(string(body), `"DATA_ERROR":"album::getData"`) {
return fmt.Errorf("invalid album ID")
}
if strings.Contains(string(body), `"results":{}`) {
return fmt.Errorf("unexpected response")
}
return ressource.Unmarshal(body)
}
func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*Media, error) {
var formats string
switch quality {
case "mp3_128":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "mp3_320":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"}]`
case "flac":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"}]`
case "best":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
}
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, formats, song.TrackToken)
req, err := http.NewRequestWithContext(ctx, "POST", "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := c.Session.HttpClient.Do(req) if session == nil {
session, err = authenticate(ctx, arl)
if err != nil {
return nil, err
}
}
return session, nil
}
// FetchResource fetches the page for the given kind and id and decodes it
// into the matching Resource implementation.
//
// gw-light answers 200 even for an unknown id and reports the failure inside
// the JSON, so bad ids have to be detected by matching markers in the body
// rather than by reading the status code. The nb parameter is set far above
// any real tracklist length to pull an entire resource in one request and
// avoid paging.
func (c *Client) FetchResource(ctx context.Context, kind Kind, id string) (Resource, error) {
resource, err := kind.newResource()
if err != nil {
return nil, err
}
payload := map[string]any{
"nb": 10000,
"start": 0,
"lang": "en",
"tab": 0,
"tags": true,
"header": true,
}
payload[kind.idKey()] = id
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, err
}
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", kind.pageMethod(), c.Session.apiToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
resp, err := c.Session.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
bodyStr := string(body)
for _, check := range []struct {
marker string
errMsg string
}{
{`"DATA_ERROR":"playlist::getData"`, "invalid playlist ID"},
{`"DATA_ERROR":"album::getData"`, "invalid album ID"},
{`"DATA_ERROR":"artist::getData"`, "invalid artist ID"},
{`"DATA_ERROR":"song::getData"`, "invalid track ID"},
} {
if strings.Contains(bodyStr, check.marker) {
return nil, errors.New(check.errMsg)
}
}
if strings.Contains(bodyStr, `"results":{}`) {
return nil, errors.New("unexpected response")
}
if err := resource.decode(body); err != nil {
return nil, err
}
return resource, nil
}
// FetchMedia resolves a playable source URL for track at the requested
// quality.
//
// Each quality maps to an ordered fallback chain, so asking for flac on a
// track that has none yields mp3_320 instead of an error; callers compare
// Media.Format against what they asked for to detect a downgrade. There is
// no wav entry because Deezer does not serve wav: the download package
// requests flac and converts locally.
//
// A 400 is accepted alongside 200 because the gateway uses it to return a
// structured error payload that is more useful than the status code.
func (c *Client) FetchMedia(ctx context.Context, track *Track, quality string) (*Media, error) {
qualityFormats := map[string]string{
"mp3_128": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
"mp3_320": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
"flac": `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
}
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.licenseToken, qualityFormats[quality], track.TrackToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
if err != nil {
return nil, err
}
resp, err := c.Session.HTTPClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -115,37 +195,39 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
} }
var media Media var media Media
err = json.Unmarshal(body, &media) if err := json.Unmarshal(body, &media); err != nil {
if err != nil {
return nil, err return nil, err
} }
if len(media.Errors) > 0 { if len(media.Errors) > 0 {
if media.Errors[0].Code == 1000 { if media.Errors[0].Code == 1000 {
return nil, fmt.Errorf("invalid license token") return nil, errors.New("invalid license token")
} }
return nil, errors.New(media.Errors[0].Message)
return nil, fmt.Errorf("%s", media.Errors[0].Message)
} }
if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 { if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 {
if media.Data[0].Errors[0].Code == 2002 { if media.Data[0].Errors[0].Code == 2002 {
return nil, fmt.Errorf("invalid track token") return nil, errors.New("invalid track token")
} }
return nil, errors.New(media.Data[0].Errors[0].Message)
}
return nil, fmt.Errorf("%s", media.Data[0].Errors[0].Message) if len(media.Data) == 0 || len(media.Data[0].Media) == 0 || len(media.Data[0].Media[0].Sources) == 0 {
return nil, errors.New("no sources found")
} }
return &media, nil return &media, nil
} }
func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error) { func (c *Client) FetchCoverImage(ctx context.Context, track *Track) ([]byte, error) {
url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", song.Cover) url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", track.Cover)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := c.Session.HttpClient.Do(req) resp, err := c.Session.HTTPClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -158,18 +240,19 @@ func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error
return io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
} }
func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) { // MediaStream opens the audio stream for media. The caller owns the returned
url, err := media.GetURL() // body and must close it.
//
// The session client is copied so its timeout can be cleared for this
// request: the session timeout is sized for short API calls and would abort
// a long track transfer. Cancellation is left to ctx.
func (c *Client) MediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, media.URL(), nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) streamingClient := *c.Session.HTTPClient
if err != nil {
return nil, err
}
streamingClient := *c.Session.HttpClient
streamingClient.Timeout = 0 streamingClient.Timeout = 0
resp, err := streamingClient.Do(req) resp, err := streamingClient.Do(req)
@@ -178,6 +261,7 @@ func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
} }
+71
View File
@@ -0,0 +1,71 @@
package deezer
import (
"encoding/json"
"errors"
"fmt"
"github.com/zalando/go-keyring"
)
const (
keyringService = "godeez"
keyringUser = "default"
)
// Credentials is the JSON blob stored as a single system keyring secret. The
// password is kept alongside the ARL so an expired session can be renewed
// without prompting; see Login. Nothing here is ever written to disk by
// godeez itself.
type Credentials struct {
Email string `json:"email"`
Password string `json:"password"`
ARL string `json:"arl,omitempty"`
}
// loadCredentials returns the stored credentials, or nil with no error when
// the user has simply never logged in. That case is distinguished from a
// genuine keyring failure so callers can fall back to DEEZER_ARL instead of
// aborting.
func loadCredentials() (*Credentials, error) {
secret, err := keyring.Get(keyringService, keyringUser)
if err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return nil, nil
}
return nil, fmt.Errorf("system keyring is unavailable: %v", err)
}
var creds Credentials
if err := json.Unmarshal([]byte(secret), &creds); err != nil {
return nil, err
}
return &creds, nil
}
func saveCredentials(creds *Credentials) error {
data, err := json.Marshal(creds)
if err != nil {
return err
}
if err := keyring.Set(keyringService, keyringUser, string(data)); err != nil {
return fmt.Errorf("system keyring is unavailable: %v", err)
}
return nil
}
// ClearCredentials removes the stored credentials. Logging out when nothing
// is stored is not an error, so a missing entry is reported as success.
func ClearCredentials() error {
if err := keyring.Delete(keyringService, keyringUser); err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return nil
}
return fmt.Errorf("system keyring is unavailable: %v", err)
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package deezer
import (
"crypto/aes"
"crypto/cipher"
"fmt"
)
// zeroPad right pads data with zero bytes to a whole number of AES blocks.
// This is not PKCS#7 and is not unambiguously reversible, but it is what the
// mobile gateway expects for the password field.
func zeroPad(data []byte) []byte {
bs := aes.BlockSize
padded := make([]byte, len(data)+(bs-len(data)%bs)%bs)
copy(padded, data)
return padded
}
func ecbEncrypt(key, data []byte) ([]byte, error) {
return ecbTransform(key, data, (cipher.Block).Encrypt)
}
func ecbDecrypt(key, data []byte) ([]byte, error) {
return ecbTransform(key, data, (cipher.Block).Decrypt)
}
// ecbTransform applies op block by block in ECB mode.
//
// ECB leaks equality between identical plaintext blocks and would be the
// wrong choice for anything designed today, but it is the mode Deezer's
// mobile gateway uses, so interoperating requires it. The standard library
// deliberately ships no ECB mode, which is why this exists. Do not reuse it
// for anything outside the gateway handshake.
func ecbTransform(key, data []byte, op func(cipher.Block, []byte, []byte)) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
bs := block.BlockSize()
if len(data)%bs != 0 {
return nil, fmt.Errorf("data length %d is not a multiple of the AES block size", len(data))
}
out := make([]byte, len(data))
for i := 0; i < len(data); i += bs {
op(block, out[i:i+bs], data[i:i+bs])
}
return out, nil
}
+72
View File
@@ -0,0 +1,72 @@
package deezer
import (
"bytes"
"testing"
)
func TestZeroPad(t *testing.T) {
tests := []struct {
length int
want int
}{
{0, 0},
{1, 16},
{15, 16},
{16, 16},
{17, 32},
}
for _, tt := range tests {
padded := zeroPad(make([]byte, tt.length))
if len(padded) != tt.want {
t.Errorf("zeroPad(len %d) = len %d, want %d", tt.length, len(padded), tt.want)
}
}
}
func TestZeroPadPreservesData(t *testing.T) {
data := []byte("secret")
padded := zeroPad(data)
if !bytes.Equal(padded[:len(data)], data) {
t.Errorf("zeroPad changed data: got %q", padded[:len(data)])
}
for _, b := range padded[len(data):] {
if b != 0 {
t.Errorf("padding is not zero: %v", padded)
}
}
}
func TestECBRoundTrip(t *testing.T) {
key := []byte("0123456789abcdef")
plaintext := zeroPad([]byte("some secret data"))
encrypted, err := ecbEncrypt(key, plaintext)
if err != nil {
t.Fatalf("ecbEncrypt: %v", err)
}
if bytes.Equal(encrypted, plaintext) {
t.Fatal("encrypted data equals plaintext")
}
decrypted, err := ecbDecrypt(key, encrypted)
if err != nil {
t.Fatalf("ecbDecrypt: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Errorf("round trip mismatch: got %q, want %q", decrypted, plaintext)
}
}
func TestECBRejectsPartialBlock(t *testing.T) {
key := []byte("0123456789abcdef")
if _, err := ecbEncrypt(key, make([]byte, 15)); err == nil {
t.Error("expected error for data not a multiple of the block size")
}
if _, err := ecbDecrypt(key, make([]byte, 17)); err == nil {
t.Error("expected error for data not a multiple of the block size")
}
}
+319
View File
@@ -0,0 +1,319 @@
package deezer
import (
"bytes"
"context"
"crypto/aes"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
gatewayBaseURL = "https://api.deezer.com/1.0/gateway.php"
gatewayUserAgent = "Deezer/6.1.22.49 (Android; 9; Tablet; us) innotek GmbH VirtualBox"
// nonceAlphabet omits '7' and 'c' on purpose. It mirrors the alphabet the
// Android client uses to build its uniq_id, and the gateway is picky about
// the shape of that value, so this must not be "completed" into a full hex
// alphabet.
nonceAlphabet = "012345689abdef"
)
// The gateway only answers requests that look like they come from the Android
// app, so these describe a plausible device. They are deliberately generic
// rather than derived from the user's real machine: nothing here should
// identify the person running godeez.
const (
deviceOS = "Android"
deviceName = "VirtualBox"
deviceType = "tablet"
deviceModel = "VirtualBox"
devicePlatform = "innotek GmbH_x86_64_9"
deviceSerial = ""
)
type mobileClient struct {
httpClient *http.Client
apiKey string
gwKey []byte
sid string
}
// CheckGatewayEnv reports whether the mobile gateway keys are present and
// well formed. It exists so the login command can fail immediately with a
// clear message instead of prompting for a password it cannot use.
func CheckGatewayEnv() error {
_, _, err := gatewayEnv()
return err
}
// gatewayEnv reads the two mobile gateway keys from the environment.
//
// They are not shipped with godeez: they are Deezer's, and baking them into a
// public repository would be both a licensing problem and a fast route to
// having them revoked. Users who want email and password login supply their
// own, which is why this is the one feature gated behind environment
// variables. The gateway key doubles as an AES key, hence the exact length
// requirement.
func gatewayEnv() (string, string, error) {
apiKey := os.Getenv("DEEZER_MOBILE_API_KEY")
gwKey := os.Getenv("DEEZER_MOBILE_GW_KEY")
if apiKey == "" || gwKey == "" {
return "", "", errors.New("DEEZER_MOBILE_API_KEY and DEEZER_MOBILE_GW_KEY must be set to use email/password login")
}
if len(gwKey) != aes.BlockSize {
return "", "", fmt.Errorf("DEEZER_MOBILE_GW_KEY must be exactly %d bytes long", aes.BlockSize)
}
return apiKey, gwKey, nil
}
func newMobileClient() (*mobileClient, error) {
apiKey, gwKey, err := gatewayEnv()
if err != nil {
return nil, err
}
return &mobileClient{
httpClient: &http.Client{Timeout: 20 * time.Second},
apiKey: apiKey,
gwKey: []byte(gwKey),
}, nil
}
// login runs the three step mobile handshake and returns the resulting
// credentials plus the account's display name.
//
// The steps are ordered and stateful, so none of them can be skipped or
// reordered: authenticate yields a token and two one-shot AES keys,
// checkToken trades the token for a session id that gatewayRequest then
// attaches to every later call, and only then will userAuth accept the
// encrypted password and return an ARL.
func (m *mobileClient) login(ctx context.Context, email, password string) (*Credentials, string, error) {
token, tokenKey, userKey, err := m.authenticate(ctx)
if err != nil {
return nil, "", err
}
if err := m.checkToken(ctx, token, tokenKey); err != nil {
return nil, "", err
}
arl, username, err := m.userAuth(ctx, email, password, userKey)
if err != nil {
return nil, "", err
}
return &Credentials{Email: email, Password: password, ARL: arl}, username, nil
}
// authenticate performs the first handshake step and returns the session
// token, the key used to sign it back in checkToken, and the key used to
// encrypt the password in userAuth.
//
// The gateway packs all three into one hex blob encrypted under the gateway
// key, at fixed offsets: 64 bytes of token, then two 16 byte keys. The length
// check guards against a short or error response being sliced blindly.
//
// Errors arrive with a 200 status and are only visible as markers in the
// body, so they are matched as strings.
func (m *mobileClient) authenticate(ctx context.Context) (string, string, string, error) {
body, err := m.gatewayRequest(ctx, "mobile_auth", http.MethodGet, "uniq_id", genUniqID(), nil)
if err != nil {
return "", "", "", err
}
var res struct {
Results struct {
Token string `json:"TOKEN"`
} `json:"results"`
}
if err := json.Unmarshal(body, &res); err != nil {
return "", "", "", err
}
if strings.Contains(string(body), "Undefined or invalid API key") {
return "", "", "", errors.New("DEEZER_MOBILE_API_KEY is invalid")
}
if strings.Contains(string(body), "GATEWAY_ERROR") || res.Results.Token == "" {
return "", "", "", errors.New("unexpected response from gateway")
}
encrypted, err := hex.DecodeString(res.Results.Token)
if err != nil {
return "", "", "", err
}
decrypted, err := ecbDecrypt(m.gwKey, encrypted)
if err != nil {
return "", "", "", err
}
if len(decrypted) < 96 {
return "", "", "", errors.New("unexpected response from gateway")
}
token := string(decrypted[0:64])
tokenKey := string(decrypted[64:80])
userKey := string(decrypted[80:96])
return token, tokenKey, userKey, nil
}
// checkToken proves possession of the token by returning it encrypted under
// tokenKey, and stores the session id the gateway hands back. Every
// subsequent request carries that id, so userAuth fails without this step.
func (m *mobileClient) checkToken(ctx context.Context, token, tokenKey string) error {
encrypted, err := ecbEncrypt([]byte(tokenKey), []byte(token))
if err != nil {
return err
}
authToken := hex.EncodeToString(encrypted)
body, err := m.gatewayRequest(ctx, "api_checkToken", http.MethodGet, "auth_token", authToken, nil)
if err != nil {
return err
}
var res struct {
Results string `json:"results"`
}
if err := json.Unmarshal(body, &res); err != nil {
return err
}
if res.Results == "" {
return errors.New("unexpected response from gateway")
}
m.sid = res.Results
return nil
}
// userAuth exchanges the user's credentials for an ARL cookie and returns it
// along with the account's display name.
//
// The password is sent AES-ECB encrypted under userKey rather than in the
// clear. The empty and constant fields in the payload are not padding: the
// gateway rejects the request outright if any of them are missing.
func (m *mobileClient) userAuth(ctx context.Context, email, password, userKey string) (string, string, error) {
encryptedPassword, err := ecbEncrypt([]byte(userKey), zeroPad([]byte(password)))
if err != nil {
return "", "", err
}
payload := map[string]string{
"mail": email,
"password": hex.EncodeToString(encryptedPassword),
"device_serial": deviceSerial,
"platform": devicePlatform,
"custo_version_id": "",
"custo_partner": "",
"model": deviceModel,
"device_name": deviceName,
"device_os": deviceOS,
"device_type": deviceType,
"google_play_services_availability": "1",
"consent_string": "",
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return "", "", err
}
body, err := m.gatewayRequest(ctx, "mobile_userAuth", http.MethodPost, "", "", jsonBody)
if err != nil {
return "", "", err
}
if strings.Contains(string(body), "USER_AUTH_ERROR") {
return "", "", errors.New("invalid email or password")
}
var res struct {
Results struct {
ARL string `json:"ARL"`
UserID int `json:"USER_ID"`
BlogName string `json:"BLOG_NAME"`
} `json:"results"`
}
if err := json.Unmarshal(body, &res); err != nil {
return "", "", err
}
if res.Results.ARL == "" || res.Results.UserID == 0 {
return "", "", errors.New("unexpected response from gateway")
}
return res.Results.ARL, res.Results.BlogName, nil
}
func (m *mobileClient) gatewayRequest(ctx context.Context, method, httpMethod, paramKey, paramValue string, jsonBody []byte) ([]byte, error) {
u, err := url.Parse(gatewayBaseURL)
if err != nil {
return nil, err
}
q := u.Query()
q.Set("method", method)
q.Set("api_key", m.apiKey)
q.Set("output", "3")
if httpMethod == http.MethodPost {
q.Set("input", "3")
}
if m.sid != "" {
q.Set("sid", m.sid)
}
if paramKey != "" {
q.Set(paramKey, paramValue)
}
u.RawQuery = q.Encode()
var reqBody io.Reader
if jsonBody != nil {
reqBody = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, httpMethod, u.String(), reqBody)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", gatewayUserAgent)
if jsonBody != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := m.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// genUniqID builds the 32 character device identifier sent with the first
// handshake request. It is regenerated per login on purpose, so that repeated
// logins are not linkable to one another by a stable device id.
func genUniqID() string {
b := make([]byte, 32)
for i := range b {
b[i] = nonceAlphabet[rand.IntN(len(nonceAlphabet))]
}
return string(b)
}
+68
View File
@@ -0,0 +1,68 @@
package deezer
import "fmt"
// Kind is the type of Deezer resource being downloaded. It is the single
// source of truth for the four supported resources: the cmd package derives
// its download subcommands from these constants, and each kind maps to a
// gw-light page method, the request field naming its id, and a Resource
// implementation. Adding a kind means extending all three switches below.
type Kind string
const (
KindAlbum Kind = "album"
KindPlaylist Kind = "playlist"
KindArtist Kind = "artist"
KindTrack Kind = "track"
)
func (k Kind) pageMethod() string {
switch k {
case KindAlbum:
return "Album"
case KindPlaylist:
return "Playlist"
case KindArtist:
return "Artist"
case KindTrack:
return "Track"
}
return ""
}
// idKey returns the request field that carries the resource id. The names are
// Deezer's own internal abbreviations and do not follow from the kind, so they
// have to be spelled out. A track is a "song" on the wire.
func (k Kind) idKey() string {
switch k {
case KindAlbum:
return "alb_id"
case KindPlaylist:
return "playlist_id"
case KindArtist:
return "art_id"
case KindTrack:
return "sng_id"
}
return ""
}
// newResource returns an empty Resource for the kind. KindTrack maps to
// Single because a single track page has its own response shape rather than
// being an album with one entry.
func (k Kind) newResource() (Resource, error) {
switch k {
case KindAlbum:
return &Album{}, nil
case KindPlaylist:
return &Playlist{}, nil
case KindArtist:
return &Artist{}, nil
case KindTrack:
return &Single{}, nil
}
return nil, fmt.Errorf("unsupported resource type: %s", k)
}
+68
View File
@@ -0,0 +1,68 @@
package deezer
import (
"context"
"errors"
"fmt"
)
// resolveARL returns a usable ARL cookie from the stored credentials, logging
// in again if the stored one has expired.
//
// validate is supplied by the caller so the check can be the real
// authentication rather than a throwaway probe. Only ErrInvalidARL triggers a
// re-login: any other validation failure is most likely the network or Deezer
// being down, and silently re-sending the password in that case would turn a
// transient outage into a spurious login attempt.
func resolveARL(ctx context.Context, validate func(ctx context.Context, arl string) error) (string, error) {
creds, err := loadCredentials()
if err != nil {
return "", err
}
if creds != nil && creds.ARL != "" {
verr := validate(ctx, creds.ARL)
if verr == nil {
return creds.ARL, nil
}
if !errors.Is(verr, ErrInvalidARL) {
return "", fmt.Errorf("stored session could not be validated: %w", verr)
}
}
if creds != nil && creds.Email != "" && creds.Password != "" {
arl, _, err := Login(ctx, creds.Email, creds.Password)
if err != nil {
return "", fmt.Errorf("stored session expired and could not be renewed automatically: %w", err)
}
return arl, nil
}
return "", errors.New("run 'godeez login' or export DEEZER_ARL environment variable")
}
// Login authenticates with email and password through the mobile gateway,
// persists the credentials to the system keyring, and returns the resulting
// ARL cookie and the account's display name.
//
// The password is stored, not just the ARL, because ARLs expire and renewing
// one without prompting the user again requires replaying the login.
func Login(ctx context.Context, email, password string) (string, string, error) {
client, err := newMobileClient()
if err != nil {
return "", "", err
}
creds, username, err := client.login(ctx, email, password)
if err != nil {
return "", "", err
}
if err := saveCredentials(creds); err != nil {
return "", "", err
}
return creds.ARL, username, nil
}
+16 -40
View File
@@ -1,56 +1,32 @@
package deezer package deezer
import (
"fmt"
)
type Media struct { type Media struct {
Errors []MediaError `json:"errors"` Errors []mediaError `json:"errors"`
Data []struct { Data []struct {
Media []struct { Media []struct {
Type string `json:"media_type"` Format string `json:"format"`
Cipher Cipher `json:"cipher"` Sources []struct {
Format string `json:"format"` URL string `json:"url"`
Sources []Source `json:"sources"` } `json:"sources"`
} }
Errors []MediaError `json:"errors"` Errors []mediaError `json:"errors"`
} `json:"data"` } `json:"data"`
} }
type MediaError struct { type mediaError struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} }
type Cipher struct { // URL and Format read the first source Deezer offered, which is the best one
Type string `json:"type"` // available for the requested quality. Both index without checking because
// FetchMedia has already rejected empty and error responses; do not call them
// on a Media obtained any other way.
func (m *Media) URL() string {
return m.Data[0].Media[0].Sources[0].URL
} }
type Source struct { func (m *Media) Format() string {
URL string `json:"url"` return m.Data[0].Media[0].Format
Provider string `json:"provider"`
}
func (m *Media) GetURL() (string, error) {
if len(m.Data) == 0 || len(m.Data[0].Media) == 0 || len(m.Data[0].Media[0].Sources) == 0 {
return "", fmt.Errorf("no media sources found")
}
url := m.Data[0].Media[0].Sources[0].URL
for _, source := range m.Data[0].Media[0].Sources {
if source.Provider == "ak" {
url = source.URL
break
}
}
return url, nil
}
func (m *Media) GetFormat() (string, error) {
if len(m.Data) == 0 || len(m.Data[0].Media) == 0 {
return "", fmt.Errorf("no media format found")
}
return m.Data[0].Media[0].Format, nil
} }
+14 -34
View File
@@ -2,9 +2,7 @@ package deezer
import ( import (
"encoding/json" "encoding/json"
"fmt" "path/filepath"
"path"
"time"
"github.com/flytam/filenamify" "github.com/flytam/filenamify"
) )
@@ -13,50 +11,32 @@ type Playlist struct {
Results struct { Results struct {
Data struct { Data struct {
Title string `json:"TITLE"` Title string `json:"TITLE"`
Status int `json:"STATUS"`
Creator string `json:"PARENT_USERNAME"` Creator string `json:"PARENT_USERNAME"`
Duration int `json:"DURATION"` Duration int `json:"DURATION"`
} `json:"DATA"` } `json:"DATA"`
Songs struct { Tracks struct {
Data []*Song `json:"data"` Data []*Track `json:"data"`
} `json:"SONGS"` } `json:"SONGS"`
} `json:"results"` } `json:"results"`
} }
func (p *Playlist) String() string { func (p *Playlist) Title() string {
return fmt.Sprintf(
`=============== [ Playlist Info ] ===============
Title: %s
Creator: %s
Tracks: %d
Duration: %s
=================================================`,
p.Results.Data.Title,
p.Results.Data.Creator,
len(p.Results.Songs.Data),
time.Duration(p.Results.Data.Duration)*time.Second,
)
}
func (p *Playlist) GetType() string {
return "Playlist"
}
func (p *Playlist) GetTitle() string {
return p.Results.Data.Title return p.Results.Data.Title
} }
func (p *Playlist) GetSongs() []*Song { func (p *Playlist) Tracks() []*Track {
return p.Results.Songs.Data return p.Results.Tracks.Data
} }
func (p *Playlist) GetOutputDir(outputDir string) string { func (p *Playlist) SetTracks(t []*Track) {
p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{}) p.Results.Tracks.Data = t
outputDir = path.Join(outputDir, p.Results.Data.Title)
return outputDir
} }
func (p *Playlist) Unmarshal(data []byte) error { func (p *Playlist) OutputDir(outputDir string) string {
base, _ := filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
return filepath.Join(outputDir, base)
}
func (p *Playlist) decode(data []byte) error {
return json.Unmarshal(data, p) return json.Unmarshal(data, p)
} }
+14 -5
View File
@@ -1,9 +1,18 @@
package deezer package deezer
// Resource is a downloadable Deezer page: an Album, Playlist, Artist or
// Single. Implementations differ only in how they decode the gw-light
// response and where they place their output, so the accessors are
// intentionally thin and are not documented individually.
//
// The unexported decode method seals the interface. Only the four types in
// this package can satisfy it, which lets Kind.newResource stay an exhaustive
// switch and guarantees FetchResource never receives an implementation whose
// wire format it does not know.
type Resource interface { type Resource interface {
GetTitle() string Title() string
GetType() string Tracks() []*Track
GetSongs() []*Song SetTracks(tracks []*Track)
GetOutputDir(outputDir string) string OutputDir(outputDir string) string
Unmarshal(data []byte) error decode(data []byte) error
} }
+41 -29
View File
@@ -3,6 +3,7 @@ package deezer
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,29 +11,30 @@ import (
"time" "time"
) )
type UserDataResponse struct { // ErrInvalidARL reports that an ARL cookie was rejected. Callers should treat
Results struct { // it as recoverable and re-login rather than as a hard failure; resolveARL
APIToken string `json:"checkForm"` // relies on that distinction to decide whether to renew a stored session.
User struct { var ErrInvalidARL = errors.New("invalid or expired ARL cookie")
Id int `json:"USER_ID"`
Options struct {
LicenseToken string `json:"license_token"`
MobileOffline bool `json:"mobile_offline"`
WebOffline bool `json:"web_offline"`
} `json:"OPTIONS"`
} `json:"USER"`
} `json:"results"`
}
type Session struct { type Session struct {
ArlCookie string apiToken string
APIToken string licenseToken string
LicenseToken string HTTPClient *http.Client
HttpClient *http.Client
Premium bool Premium bool
} }
func Authenticate(ctx context.Context, arlCookie string) (*Session, error) { // authenticate exchanges an ARL cookie for a Session. It returns
// ErrInvalidARL if the cookie is rejected.
//
// The endpoint answers 200 with an empty user for a bad cookie rather than an
// error status, so a zero user id is the only reliable signal that the ARL is
// no longer valid. A cookie jar is required because gw-light sets session
// cookies that later calls depend on.
//
// Premium is inferred from the offline listening options, which are the
// closest thing the payload carries to a subscription flag; it gates the
// higher quality formats.
func authenticate(ctx context.Context, arlCookie string) (*Session, error) {
jar, err := cookiejar.New(nil) jar, err := cookiejar.New(nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -43,7 +45,7 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
} }
url := "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=" url := "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token="
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -68,22 +70,32 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
return nil, err return nil, err
} }
var res UserDataResponse var res struct {
Results struct {
APIToken string `json:"checkForm"`
User struct {
ID int `json:"USER_ID"`
Options struct {
LicenseToken string `json:"license_token"`
MobileOffline bool `json:"mobile_offline"`
WebOffline bool `json:"web_offline"`
} `json:"OPTIONS"`
} `json:"USER"`
} `json:"results"`
}
if err := json.Unmarshal(body, &res); err != nil { if err := json.Unmarshal(body, &res); err != nil {
return nil, err return nil, err
} }
if res.Results.User.Id == 0 { if res.Results.User.ID == 0 {
return nil, fmt.Errorf("invalid arl cookie") return nil, ErrInvalidARL
} }
isPremium := res.Results.User.Options.MobileOffline || res.Results.User.Options.WebOffline opts := res.Results.User.Options
return &Session{ return &Session{
ArlCookie: arlCookie, apiToken: res.Results.APIToken,
APIToken: res.Results.APIToken, licenseToken: opts.LicenseToken,
LicenseToken: res.Results.User.Options.LicenseToken, HTTPClient: client,
HttpClient: client, Premium: opts.MobileOffline || opts.WebOffline,
Premium: isPremium,
}, nil }, nil
} }
+36
View File
@@ -0,0 +1,36 @@
package deezer
import (
"encoding/json"
"path/filepath"
)
type Single struct {
Results struct {
Data *Track `json:"DATA"`
} `json:"results"`
}
func (s *Single) Title() string {
if s.Results.Data == nil {
return ""
}
return s.Results.Data.FullTitle()
}
func (s *Single) Tracks() []*Track {
if s.Results.Data == nil {
return nil
}
return []*Track{s.Results.Data}
}
func (s *Single) SetTracks(tracks []*Track) {}
func (s *Single) OutputDir(outputDir string) string {
return filepath.Join(outputDir, "Singles")
}
func (s *Single) decode(data []byte) error {
return json.Unmarshal(data, s)
}
-50
View File
@@ -1,50 +0,0 @@
package deezer
import (
"fmt"
"github.com/flytam/filenamify"
)
type Song struct {
ID string `json:"SNG_ID"`
Artist string `json:"ART_NAME"`
Title string `json:"SNG_TITLE"`
Version string `json:"VERSION"`
Cover string `json:"ALB_PICTURE"`
Contributors struct {
MainArtists []string `json:"main_artist"`
Composers []string `json:"composer"`
Authors []string `json:"author"`
} `json:"SNG_CONTRIBUTORS"`
Duration string `json:"DURATION"`
Gain string `json:"GAIN"`
ISRC string `json:"ISRC"`
TrackNumber string `json:"TRACK_NUMBER"`
TrackToken string `json:"TRACK_TOKEN"`
}
func (s *Song) GetTitle() string {
songTitle := s.Title
if s.Version != "" {
songTitle = fmt.Sprintf("%s %s", s.Title, s.Version)
}
return songTitle
}
func (s *Song) GetFileName(resourceType string, song *Song, media *Media) string {
ext := "mp3"
if media.Data[0].Media[0].Format == "FLAC" {
ext = "flac"
}
trackNumber := ""
if resourceType == "album" {
trackNumber = song.TrackNumber + ". "
}
fileName := fmt.Sprintf("%s%s - %s.%s", trackNumber, s.Artist, s.GetTitle(), ext)
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
return fileName
}
+107
View File
@@ -0,0 +1,107 @@
package deezer
import (
"encoding/json"
"fmt"
"strconv"
"github.com/flytam/filenamify"
)
type Contributors struct {
MainArtists []string `json:"main_artist"`
Composers []string `json:"composer"`
Authors []string `json:"author"`
}
// UnmarshalJSON tolerates the empty array Deezer sends when a track has no
// contributors. The field is an object in every other case, so decoding it
// straight into the struct fails on those tracks.
func (c *Contributors) UnmarshalJSON(data []byte) error {
if string(data) == "[]" {
*c = Contributors{}
return nil
}
// Alias drops the method set, so this Unmarshal does not recurse.
type Alias Contributors
aux := (*Alias)(c)
return json.Unmarshal(data, aux)
}
type Track struct {
ID string `json:"SNG_ID"`
Artist string `json:"ART_NAME"`
Title string `json:"SNG_TITLE"`
Version string `json:"VERSION"`
Cover string `json:"ALB_PICTURE"`
Contributors Contributors `json:"SNG_CONTRIBUTORS"`
Duration string `json:"DURATION"`
Gain string `json:"GAIN"`
ISRC string `json:"ISRC"`
TrackNumber string `json:"TRACK_NUMBER"`
TrackToken string `json:"TRACK_TOKEN"`
}
func (t *Track) FullTitle() string {
if t.Version != "" {
return t.Title + " " + t.Version
}
return t.Title
}
// Filename builds the on-disk name for the track, sanitised for the current
// filesystem. Album downloads get a zero padded track number prefix so the
// directory sorts in playing order; the other kinds have no meaningful
// ordering to preserve.
func (t *Track) Filename(kind Kind, format string) string {
ext := "mp3"
switch format {
case "FLAC":
ext = "flac"
case "WAV":
ext = "wav"
}
prefix := ""
if kind == KindAlbum {
if n, err := strconv.Atoi(t.TrackNumber); err == nil {
prefix = fmt.Sprintf("%02d. ", n)
} else {
prefix = t.TrackNumber + ". "
}
}
base := fmt.Sprintf("%s%s - %s", prefix, t.Artist, t.FullTitle())
base, _ = filenamify.Filenamify(base, filenamify.Options{MaxLength: 255})
// 255 bytes is the per-component limit on ext4 and APFS. The budget also
// has to cover the extension, its dot, and the "-id3v2" suffix the tagging
// library appends to its temporary file: without that headroom, tagging a
// long title fails after the download has already succeeded.
base = truncateBytes(base, 255-len(ext)-1-len("-id3v2"))
return base + "." + ext
}
// truncateBytes shortens s to at most maxLen bytes without splitting a rune.
// The limit is in bytes because that is what filesystems enforce, but cutting
// mid-rune would leave an invalid UTF-8 name, so it backs up to the last rune
// boundary that fits.
func truncateBytes(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
if len(s) <= maxLen {
return s
}
last := 0
for i := range s {
if i > maxLen {
return s[:last]
}
last = i
}
return s[:last]
}
+116
View File
@@ -0,0 +1,116 @@
package deezer
import (
"encoding/json"
"strings"
"testing"
)
func TestFullTitle(t *testing.T) {
tests := []struct {
title string
version string
want string
}{
{"Song", "", "Song"},
{"Song", "(Remix)", "Song (Remix)"},
}
for _, tt := range tests {
track := &Track{Title: tt.title, Version: tt.version}
if got := track.FullTitle(); got != tt.want {
t.Errorf("FullTitle() = %q, want %q", got, tt.want)
}
}
}
func TestFilename(t *testing.T) {
tests := []struct {
name string
track Track
kind Kind
mediaFormat string
want string
}{
{
name: "album with numeric track number",
track: Track{Artist: "Artist", Title: "Song", TrackNumber: "1"},
kind: KindAlbum,
mediaFormat: "MP3_320",
want: "01. Artist - Song.mp3",
},
{
name: "album with non-numeric track number",
track: Track{Artist: "Artist", Title: "Song", TrackNumber: "A"},
kind: KindAlbum,
mediaFormat: "MP3_320",
want: "A. Artist - Song.mp3",
},
{
name: "playlist has no prefix",
track: Track{Artist: "Artist", Title: "Song", TrackNumber: "1"},
kind: KindPlaylist,
mediaFormat: "MP3_128",
want: "Artist - Song.mp3",
},
{
name: "flac extension",
track: Track{Artist: "Artist", Title: "Song"},
kind: KindTrack,
mediaFormat: "FLAC",
want: "Artist - Song.flac",
},
{
name: "wav extension",
track: Track{Artist: "Artist", Title: "Song"},
kind: KindTrack,
mediaFormat: "WAV",
want: "Artist - Song.wav",
},
{
name: "version appended",
track: Track{Artist: "Artist", Title: "Song", Version: "(Live)"},
kind: KindTrack,
mediaFormat: "MP3_320",
want: "Artist - Song (Live).mp3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.track.Filename(tt.kind, tt.mediaFormat); got != tt.want {
t.Errorf("Filename() = %q, want %q", got, tt.want)
}
})
}
}
func TestFilenameSanitizesSeparators(t *testing.T) {
track := Track{Artist: "AC/DC", Title: "Song"}
got := track.Filename(KindTrack, "MP3_320")
if strings.ContainsRune(got, '/') {
t.Errorf("Filename() contains a path separator: %q", got)
}
if !strings.HasSuffix(got, ".mp3") {
t.Errorf("Filename() = %q, want .mp3 suffix", got)
}
}
func TestContributorsUnmarshalJSON(t *testing.T) {
var c Contributors
if err := json.Unmarshal([]byte("[]"), &c); err != nil {
t.Fatalf("unmarshal empty array: %v", err)
}
if len(c.MainArtists) != 0 || len(c.Composers) != 0 || len(c.Authors) != 0 {
t.Errorf("expected empty contributors, got %+v", c)
}
data := `{"main_artist":["A","B"],"composer":["C"],"author":["D"]}`
if err := json.Unmarshal([]byte(data), &c); err != nil {
t.Fatalf("unmarshal object: %v", err)
}
if len(c.MainArtists) != 2 || c.MainArtists[0] != "A" || len(c.Composers) != 1 || len(c.Authors) != 1 {
t.Errorf("unexpected contributors: %+v", c)
}
}
+179
View File
@@ -0,0 +1,179 @@
package download
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
)
// songbpm.com publishes the tempo and key as prose rather than as structured
// data, so these match the surrounding sentence instead of a CSS selector.
// The attribute wildcards absorb the utility classes the site regenerates on
// every deploy, but the wording itself is load bearing: if the sentence
// changes, the lookup starts returning no data. The double space in modeRegex
// is present in the real markup and is not a typo.
//
// The key pattern accepts both the typographic accidentals the page renders
// and their ASCII equivalents, since which one appears varies by track.
var (
bpmRegex = regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
keyRegex = regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
modeRegex = regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
)
func fetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (bpmKey, error) {
trackURL, err := findTrackURL(ctx, httpClient, artist, title, duration)
if err != nil {
return bpmKey{}, err
}
html, err := fetchBPMPage(ctx, httpClient, trackURL)
if err != nil {
return bpmKey{}, err
}
return parseBPM(html)
}
// findTrackURL searches songbpm.com and returns the page for the track.
//
// Artist and title alone are not enough to identify a track, since the search
// happily returns remixes, live versions and covers under the same names.
// Duration is used as the tiebreaker, with a couple of seconds of tolerance
// to absorb the disagreement between Deezer's rounding and songbpm's. No
// match within tolerance is treated as not found rather than guessed at,
// because a wrong BPM is worse than a missing one.
func findTrackURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
const rootURL = "https://songbpm.com"
values := url.Values{}
values.Add("query", fmt.Sprintf("%s %s", artist, title))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rootURL+"/searches", bytes.NewBufferString(values.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", rootURL)
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return "", err
}
wantDuration, err := strconv.Atoi(duration)
if err != nil {
return "", fmt.Errorf("invalid duration: %w", err)
}
lowerTitle := strings.ToLower(title)
lowerArtist := strings.ToLower(artist)
var matchURL string
doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
text := strings.ToLower(sel.Text())
if !strings.Contains(text, lowerTitle) || !strings.Contains(text, lowerArtist) {
return true
}
durationStr := strings.TrimSpace(sel.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text())
parts := strings.Split(durationStr, ":")
if len(parts) != 2 {
return true
}
minutes, err := strconv.Atoi(parts[0])
if err != nil {
return true
}
seconds, err := strconv.Atoi(parts[1])
if err != nil {
return true
}
const toleranceSec = 2
foundDuration := minutes*60 + seconds
if foundDuration < wantDuration-toleranceSec || foundDuration > wantDuration+toleranceSec {
return true
}
matchURL = sel.AttrOr("href", "")
return false
})
if matchURL == "" {
return "", errors.New("no data found")
}
return rootURL + matchURL, nil
}
func fetchBPMPage(ctx context.Context, httpClient *http.Client, pageURL string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
if err != nil {
return "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
// parseBPM extracts the tempo and musical key from a track page.
//
// All three patterns must match: a page with a tempo but no key is treated as
// no data, since a half filled tag is not worth writing. Enharmonic keys are
// published as pairs like "C#/Db" and only the first spelling is kept, the
// accidentals are folded to ASCII for tag compatibility, and a minor mode is
// encoded with a trailing "m" to match the convention DJ software expects.
func parseBPM(html string) (bpmKey, error) {
bpmMatch := bpmRegex.FindStringSubmatch(html)
keyMatch := keyRegex.FindStringSubmatch(html)
modeMatch := modeRegex.FindStringSubmatch(html)
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
return bpmKey{}, errors.New("no data found")
}
bpm := bpmMatch[1]
key := strings.SplitN(keyMatch[1], "/", 2)[0]
key = strings.ReplaceAll(key, "\u266f", "#")
key = strings.ReplaceAll(key, "\u266d", "b")
if modeMatch[1] == "minor" {
key += "m"
}
return bpmKey{BPM: bpm, Key: key}, nil
}
+42
View File
@@ -0,0 +1,42 @@
package download
import "testing"
func bpmHTML(bpm, key, mode string) string {
return `tempo of <span class="x">` + bpm + ` BPM</span>` +
` with a <span class="x">` + key + `</span> key` +
` and a <span class="x">` + mode + `</span> mode`
}
func TestParseBPM(t *testing.T) {
tests := []struct {
name string
html string
wantBPM string
wantKey string
}{
{"major key", bpmHTML("128", "A", "major"), "128", "A"},
{"minor key gets m suffix", bpmHTML("90", "F", "minor"), "90", "Fm"},
{"unicode sharp normalized", bpmHTML("124", "C♯", "major"), "124", "C#"},
{"unicode flat normalized", bpmHTML("100", "B♭", "minor"), "100", "Bbm"},
{"enharmonic pair keeps first", bpmHTML("110", "A♯/B♭", "major"), "110", "A#"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseBPM(tt.html)
if err != nil {
t.Fatalf("parseBPM: %v", err)
}
if got.BPM != tt.wantBPM || got.Key != tt.wantKey {
t.Errorf("parseBPM() = %+v, want BPM %q Key %q", got, tt.wantBPM, tt.wantKey)
}
})
}
}
func TestParseBPMNoData(t *testing.T) {
if _, err := parseBPM("<html>nothing here</html>"); err == nil {
t.Error("expected error for page without BPM data")
}
}
+171
View File
@@ -0,0 +1,171 @@
// Package download drives the end to end download of a Deezer resource.
//
// Run fetches the resource, then walks its tracks in order: resolve a media
// source, decide whether the track can be skipped, stream and decrypt it,
// optionally convert to wav, write tags, and record the result in the store
// so a later run can skip it. Tracks are processed one at a time.
//
// Most per-track failures are collected as warnings rather than aborting the
// run, so an unavailable cover or a failed BPM lookup does not cost the user
// the rest of an album. Only context cancellation stops the loop early.
package download
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fsutil"
"github.com/mathismqn/godeez/internal/store"
)
type Downloader struct {
appConfig *config.Config
store *store.Store
kind deezer.Kind
deezerClient *deezer.Client
hashIndexOnce sync.Once
hashIndex *hashIndex
hashIndexErr error
}
func New(appConfig *config.Config, st *store.Store, kind deezer.Kind) *Downloader {
return &Downloader{
appConfig: appConfig,
store: st,
kind: kind,
}
}
// Run downloads every track of the resource identified by id. opts is
// expected to have passed Validate already, which the cmd package does while
// parsing flags.
func (d *Downloader) Run(ctx context.Context, opts Options, id string) error {
if err := d.initDeezerClient(ctx, opts); err != nil {
return err
}
resource, outputDir, err := d.prepareResource(ctx, id, opts)
if err != nil {
return err
}
return d.downloadAllTracks(ctx, resource, opts, outputDir)
}
// initDeezerClient authenticates and rejects quality settings the account
// cannot serve.
//
// The check runs against sourceQuality rather than the raw option because wav
// is produced locally from a flac source, so it carries the same premium
// requirement as flac. mp3_128 is the only format available without a
// subscription. Failing here keeps the user from watching a whole album
// download at a silently downgraded quality.
func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error {
var err error
d.deezerClient, err = deezer.NewClient(ctx, d.appConfig.ARLCookie)
if err != nil {
return err
}
if !d.deezerClient.Session.Premium && opts.sourceQuality() != "mp3_128" {
return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
}
return nil
}
// prepareResource fetches the resource, applies the artist track limit, and
// makes sure the output directory exists.
//
// The limit only applies to artists because that is the one kind whose track
// list is unbounded: it is the artist's top tracks, not a finite album or
// playlist.
//
// Sweeping the part files last clears leftovers from a previous run that was
// killed mid-write. They are ignorable on their own, but they accumulate and
// would otherwise be mistaken for real downloads.
func (d *Downloader) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) {
resource, err := d.deezerClient.FetchResource(ctx, d.kind, id)
if err != nil {
return nil, "", fmt.Errorf("failed to fetch resource: %w", err)
}
tracks := resource.Tracks()
if len(tracks) == 0 {
if d.kind == deezer.KindTrack {
return nil, "", fmt.Errorf("track with ID %s not found", id)
}
return nil, "", fmt.Errorf("%s has no tracks", d.kind)
}
if d.kind == deezer.KindArtist && len(tracks) > opts.Limit {
resource.SetTracks(tracks[:opts.Limit])
}
outputDir := resource.OutputDir(d.appConfig.OutputDir)
if err := fsutil.EnsureDir(outputDir); err != nil {
return nil, "", fmt.Errorf("failed to create output directory: %w", err)
}
sweepPartFiles(outputDir)
return resource, outputDir, nil
}
// downloadAllTracks runs the per-track pipeline over the whole resource and
// prints the summary.
//
// Cancellation is checked both before each track and against the result,
// because a track cancelled mid-stream surfaces the error through the result
// rather than through ctx. Any other per-track error is recorded and the loop
// continues.
func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error {
tracks := resource.Tracks()
startTime := time.Now()
if d.kind != deezer.KindTrack {
fmt.Printf("%s\n\nStarting download...\n\n", resourceInfo(resource))
}
progress := newProgressTracker(len(tracks), d.kind)
var m3uEntries []m3uEntry
for i, track := range tracks {
if ctx.Err() != nil {
return ctx.Err()
}
sp := progress.startDownload(i, track)
result := d.downloadTrack(ctx, resource, track, opts, outputDir)
sp.Stop()
if result.err != nil && errors.Is(result.err, context.Canceled) {
return result.err
}
progress.handleResult(i, track, result)
if d.kind == deezer.KindPlaylist && result.err == nil {
m3uEntries = append(m3uEntries, m3uEntry{track: track, path: result.path})
}
}
// Written after the loop, not per track, so a run that fails partway
// through still produces a playlist covering whatever did make it to
// disk instead of no playlist at all.
if d.kind == deezer.KindPlaylist {
if err := writeM3U(outputDir, m3uEntries); err != nil {
fmt.Printf("\nWarning: failed to write m3u playlist: %v\n", err)
}
}
progress.printSummary(outputDir, time.Since(startTime))
return nil
}
+152
View File
@@ -0,0 +1,152 @@
package download
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"unicode"
"unicode/utf8"
"github.com/PuerkitoBio/goquery"
)
// Genres come from last.fm's community tags, which are free text and range
// from real genres to things like "seen live". These two lists are the filter
// that keeps only the useful ones. They are matched as substrings, so "deep
// house" is caught by "house".
//
// The split into two lists drives the ordering in filterTags, which prefers
// the electronic tag as the primary genre.
var electronicKeywords = toLower([]string{
"Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo",
"Drum And Bass", "Dub", "Dubstep", "EDM", "Electro", "Electronic", "Electronica",
"Eurodance", "Gabber", "Garage", "Hardcore", "Hardstyle", "House", "Industrial",
"Jungle", "Moombahton", "Synthpop", "Synthwave", "Techno", "Trance", "Trap",
"Trip Hop", "Vaporwave",
})
var nonElectronicKeywords = toLower([]string{
"Blues", "Chillout", "Classical", "Country", "Folk", "Funk", "Hip Hop", "Jazz",
"Latin", "Metal", "Pop", "R&B", "Rap", "Reggae", "Rock", "Soul",
})
func toLower(ss []string) []string {
out := make([]string, len(ss))
for i, s := range ss {
out[i] = strings.ToLower(s)
}
return out
}
func fetchGenre(ctx context.Context, httpClient *http.Client, artist, title string) (string, error) {
// Escape the path segments: names containing '/', '?', or '#' would
// otherwise change the URL structure and fetch the wrong page.
reqURL := fmt.Sprintf("https://www.last.fm/music/%s/%s/+tags", url.PathEscape(artist), url.PathEscape(title))
doc, err := fetchGenrePage(ctx, httpClient, reqURL)
if err != nil {
return "", err
}
// last.fm orders tags by popularity, so the first two are the consensus
// view. Taking more starts pulling in mood and era tags that make a poor
// genre field.
tags := parseGenreTags(doc)
if len(tags) > 2 {
tags = tags[:2]
}
filtered := filterTags(tags)
if len(filtered) == 0 {
return "", errors.New("no data found")
}
return formatTags(filtered), nil
}
func fetchGenrePage(ctx context.Context, httpClient *http.Client, pageURL string) (*goquery.Document, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return goquery.NewDocumentFromReader(resp.Body)
}
// parseGenreTags reads the tag list out of a last.fm page. The selector
// tracks last.fm's current markup and is the first thing to break if they
// redesign; a failure here is non-fatal and simply leaves the genre unset.
func parseGenreTags(doc *goquery.Document) []string {
var tags []string
doc.Find("ol.big-tags .big-tags-item-name a").Each(func(_ int, s *goquery.Selection) {
if tag := strings.TrimSpace(s.Text()); tag != "" {
tags = append(tags, tag)
}
})
return tags
}
func matchesKeyword(tag string, keywords []string) bool {
tagLower := strings.ToLower(tag)
for _, kw := range keywords {
if strings.Contains(tagLower, kw) {
return true
}
}
return false
}
// filterTags keeps only recognised genre tags, electronic ones first.
//
// It returns nothing at all unless at least one electronic tag matched, so a
// purely non-electronic track ends up with no genre rather than a partial
// one. Tags matching neither list are dropped.
func filterTags(tags []string) []string {
var electronic, nonElectronic []string
for _, tag := range tags {
if matchesKeyword(tag, electronicKeywords) {
electronic = append(electronic, tag)
} else if matchesKeyword(tag, nonElectronicKeywords) {
nonElectronic = append(nonElectronic, tag)
}
}
if len(electronic) > 0 {
return append(electronic, nonElectronic...)
}
return electronic
}
// formatTags title cases the tags and joins them for the genre field.
// last.fm tags arrive in whatever case the tagger typed, so they are
// normalised rather than written through as is.
func formatTags(tags []string) string {
formatted := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
words := strings.Fields(tag)
for i, w := range words {
r, size := utf8.DecodeRuneInString(w)
words[i] = string(unicode.ToUpper(r)) + strings.ToLower(w[size:])
}
formatted = append(formatted, strings.Join(words, " "))
}
return strings.Join(formatted, " / ")
}
+51
View File
@@ -0,0 +1,51 @@
package download
import (
"slices"
"testing"
)
func TestMatchesKeyword(t *testing.T) {
if !matchesKeyword("Deep House", electronicKeywords) {
t.Error("expected Deep House to match electronic keywords")
}
if !matchesKeyword("classic rock", nonElectronicKeywords) {
t.Error("expected classic rock to match non-electronic keywords")
}
if matchesKeyword("Spoken Word", electronicKeywords) {
t.Error("did not expect Spoken Word to match electronic keywords")
}
}
func TestFilterTags(t *testing.T) {
got := filterTags([]string{"Deep House", "Rock", "Spoken Word"})
want := []string{"Deep House", "Rock"}
if !slices.Equal(got, want) {
t.Errorf("filterTags() = %v, want %v", got, want)
}
if got := filterTags([]string{"Rock", "Jazz"}); len(got) != 0 {
t.Errorf("filterTags() = %v, want empty", got)
}
if got := filterTags(nil); len(got) != 0 {
t.Errorf("filterTags(nil) = %v, want empty", got)
}
}
func TestFormatTags(t *testing.T) {
tests := []struct {
tags []string
want string
}{
{[]string{"deep house"}, "Deep House"},
{[]string{"TECHNO", "trance"}, "Techno / Trance"},
{[]string{" house ", ""}, "House"},
}
for _, tt := range tests {
if got := formatTags(tt.tags); got != tt.want {
t.Errorf("formatTags(%v) = %q, want %q", tt.tags, got, tt.want)
}
}
}
+84
View File
@@ -0,0 +1,84 @@
package download
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"io/fs"
"os"
"path/filepath"
)
func hashFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// hashIndex maps content hash to path for everything under the output
// directory. It is what lets a moved or renamed file still be recognised as
// an existing download.
type hashIndex struct {
files map[string]string
}
// newHashIndex hashes every file under root.
//
// Unreadable files and directories are skipped rather than failing the walk,
// since a permission error somewhere in a music library should not break the
// skip check. Only cancellation aborts it. Duplicated content collapses to
// whichever path is walked last, which is fine: any copy is a valid answer.
func newHashIndex(ctx context.Context, root string) (*hashIndex, error) {
index := &hashIndex{files: make(map[string]string)}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil || d.IsDir() {
return nil
}
hash, err := hashFile(path)
if err != nil {
return nil
}
index.files[hash] = path
return nil
})
if err != nil {
return nil, err
}
return index, nil
}
func (h *hashIndex) find(hash string) (string, bool) {
path, ok := h.files[hash]
return path, ok
}
// initHashIndex builds the index on first use and reuses it afterwards.
//
// Building it means hashing an entire music library, so it is deferred until
// something actually needs it: a run where every recorded path is still valid
// never pays that cost. The error is cached alongside the index so a failed
// build is not retried once per track.
func (d *Downloader) initHashIndex(ctx context.Context) error {
d.hashIndexOnce.Do(func() {
d.hashIndex, d.hashIndexErr = newHashIndex(ctx, d.appConfig.OutputDir)
})
return d.hashIndexErr
}
+71
View File
@@ -0,0 +1,71 @@
package download
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestHashFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(path, []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
got, err := hashFile(path)
if err != nil {
t.Fatalf("hashFile: %v", err)
}
want := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
if got != want {
t.Errorf("hashFile() = %s, want %s", got, want)
}
}
func TestHashFileMissing(t *testing.T) {
if _, err := hashFile(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("expected error for missing file")
}
}
func TestHashIndexFind(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sub", "track.mp3")
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
index, err := newHashIndex(context.Background(), dir)
if err != nil {
t.Fatalf("newHashIndex: %v", err)
}
hash := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
found, ok := index.find(hash)
if !ok || found != path {
t.Errorf("find(%s) = %q, %v; want %q, true", hash, found, ok, path)
}
if _, ok := index.find("deadbeef"); ok {
t.Error("find() reported a match for an unknown hash")
}
}
func TestHashIndexCanceledContext(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := newHashIndex(ctx, dir); err == nil {
t.Error("expected error for canceled context")
}
}
+111
View File
@@ -0,0 +1,111 @@
package download
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/mathismqn/godeez/internal/deezer"
)
func resourceInfo(resource deezer.Resource) string {
switch r := resource.(type) {
case *deezer.Album:
return albumInfo(r)
case *deezer.Playlist:
return playlistInfo(r)
case *deezer.Artist:
return artistInfo(r)
case *deezer.Single:
return singleInfo(r)
}
return ""
}
func albumInfo(a *deezer.Album) string {
duration, err := strconv.Atoi(a.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Album Info ] =================
Title: %s
Artist: %s
Tracks: %d
Duration: %s
==================================================`,
a.Results.Data.Title,
a.Results.Data.Artist,
len(a.Results.Tracks.Data),
time.Duration(duration)*time.Second,
)
}
func playlistInfo(p *deezer.Playlist) string {
return fmt.Sprintf(
`=============== [ Playlist Info ] ===============
Title: %s
Creator: %s
Tracks: %d
Duration: %s
=================================================`,
p.Results.Data.Title,
p.Results.Data.Creator,
len(p.Results.Tracks.Data),
time.Duration(p.Results.Data.Duration)*time.Second,
)
}
func artistInfo(a *deezer.Artist) string {
tracks := a.Results.Tracks.Data
count := len(tracks)
totalSec := 0
for _, t := range tracks {
if d, err := strconv.Atoi(t.Duration); err == nil {
totalSec += d
}
}
limit := min(3, count)
var b strings.Builder
fmt.Fprintf(&b, "============= [ Artist Info ] =============\n")
fmt.Fprintf(&b, "Artist: %s\n", a.Results.Data.Name)
fmt.Fprintf(&b, "Tracks: %d\n", count)
fmt.Fprintf(&b, "Playtime: %s\n", time.Duration(totalSec)*time.Second)
fmt.Fprintf(&b, "-------------------------------------------\n")
fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit)
for i := 0; i < limit; i++ {
t := tracks[i]
fmt.Fprintf(&b, " %2d. %s %s\n", i+1, t.Artist, t.FullTitle())
}
fmt.Fprintf(&b, "===========================================\n")
return b.String()
}
func singleInfo(s *deezer.Single) string {
if s.Results.Data == nil {
return "Track: No data available"
}
duration, err := strconv.Atoi(s.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Track Info ] =================
Title: %s
Artist: %s
Duration: %s
==================================================`,
s.Results.Data.FullTitle(),
s.Results.Data.Artist,
time.Duration(duration)*time.Second,
)
}
+60
View File
@@ -0,0 +1,60 @@
package download
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/mathismqn/godeez/internal/deezer"
)
// m3uEntry pairs a playlist track with the file it ended up at, so the m3u
// can be written after the fact without re-deriving the path.
type m3uEntry struct {
track *deezer.Track
path string
}
// writeM3U records a playlist's tracks in their original order as an
// extended M3U file next to the downloaded audio.
//
// This exists because the on-disk file names carry no ordering for
// playlists (unlike albums, which get a zero padded track number prefix):
// without an m3u, the sequence the user curated on Deezer is lost the moment
// the tracks land in a directory that any player will list alphabetically.
//
// Skipped tracks are included: they are already on disk from a previous run
// and belong at their playlist position just as much as one downloaded this
// run. Failed tracks are left out since there is nothing on disk to point
// at, and a broken entry would only confuse the player.
//
// Paths are written relative to outputDir so the m3u keeps working if the
// whole playlist folder is moved or copied elsewhere.
func writeM3U(outputDir string, entries []m3uEntry) error {
if len(entries) == 0 {
return nil
}
m3uPath := filepath.Join(outputDir, filepath.Base(outputDir)+".m3u")
var b strings.Builder
b.WriteString("#EXTM3U\n")
for _, e := range entries {
duration, err := strconv.Atoi(e.track.Duration)
if err != nil {
duration = 0
}
rel, err := filepath.Rel(outputDir, e.path)
if err != nil {
rel = e.path
}
fmt.Fprintf(&b, "#EXTINF:%d,%s - %s\n%s\n", duration, e.track.Artist, e.track.FullTitle(), rel)
}
return os.WriteFile(m3uPath, []byte(b.String()), 0644)
}
+87
View File
@@ -0,0 +1,87 @@
package download
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/mathismqn/godeez/internal/deezer"
)
type bpmKey struct {
BPM string
Key string
}
type metadataResult struct {
bpmKey bpmKey
genre string
warnings []string
}
// fetchMetadata looks up BPM, key and genre from third party sites, running
// the two lookups concurrently since neither depends on the other.
//
// Both channels are buffered so a goroutine whose result is never collected
// still exits instead of blocking forever. Failures become warnings rather
// than errors: these are nice to have tags, and a site being down should not
// cost the user the track. Cancellation is silent, because the run is already
// being torn down and a warning per track would just be noise.
func fetchMetadata(ctx context.Context, httpClient *http.Client, track *deezer.Track, opts Options) metadataResult {
if !opts.BPM && !opts.Genre {
return metadataResult{}
}
type bpmResult struct {
value bpmKey
err error
}
type genreResult struct {
value string
err error
}
bpmChan := make(chan bpmResult, 1)
genreChan := make(chan genreResult, 1)
if opts.BPM {
go func() {
result, err := fetchBPM(ctx, httpClient, track.Artist, track.Title, track.Duration)
bpmChan <- bpmResult{value: result, err: err}
}()
}
if opts.Genre {
go func() {
genre, err := fetchGenre(ctx, httpClient, track.Artist, track.FullTitle())
genreChan <- genreResult{value: genre, err: err}
}()
}
var result metadataResult
if opts.BPM {
r := <-bpmChan
if r.err != nil {
if !errors.Is(r.err, context.Canceled) {
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", r.err))
}
} else {
result.bpmKey = r.value
}
}
if opts.Genre {
r := <-genreChan
if r.err != nil {
if !errors.Is(r.err, context.Canceled) {
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", r.err))
}
} else {
result.genre = r.value
}
}
return result
}
+61
View File
@@ -0,0 +1,61 @@
package download
import (
"errors"
"fmt"
"time"
"github.com/mathismqn/godeez/internal/deezer"
)
var validQualities = map[string]bool{
"mp3_128": true,
"mp3_320": true,
"flac": true,
"wav": true,
}
type Options struct {
Quality string
Timeout time.Duration
Limit int
BPM bool
Genre bool
Strict bool
}
// sourceQuality is the quality to request from Deezer, which is not always
// the quality the user asked for. Deezer does not serve wav, so a wav
// download pulls flac and converts it locally.
func (o *Options) sourceQuality() string {
if o.Quality == "wav" {
return "flac"
}
return o.Quality
}
func (o *Options) convertsToWAV() bool {
return o.Quality == "wav"
}
// Validate checks the options against the resource kind. The limit is only
// meaningful for artists, whose top track list is open ended, and is capped
// at 100 because that is as many as Deezer returns.
func (o *Options) Validate(kind deezer.Kind) error {
if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality)
}
if o.Timeout <= 0 {
return errors.New("timeout must be a positive duration")
}
if kind == deezer.KindArtist {
if o.Limit <= 0 {
return errors.New("limit must be a positive integer")
}
if o.Limit > 100 {
return errors.New("limit must not exceed 100")
}
}
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package download
import (
"testing"
"time"
"github.com/mathismqn/godeez/internal/deezer"
)
func TestOptionsSourceQuality(t *testing.T) {
tests := []struct {
quality string
want string
wantWAV bool
}{
{"mp3_128", "mp3_128", false},
{"mp3_320", "mp3_320", false},
{"flac", "flac", false},
{"wav", "flac", true},
}
for _, tt := range tests {
t.Run(tt.quality, func(t *testing.T) {
opts := Options{Quality: tt.quality}
if got := opts.sourceQuality(); got != tt.want {
t.Errorf("sourceQuality() = %q, want %q", got, tt.want)
}
if got := opts.convertsToWAV(); got != tt.wantWAV {
t.Errorf("convertsToWAV() = %v, want %v", got, tt.wantWAV)
}
})
}
}
func TestOptionsValidate(t *testing.T) {
valid := Options{Quality: "mp3_320", Timeout: time.Minute, Limit: 10}
tests := []struct {
name string
mutate func(o *Options)
kind deezer.Kind
wantErr bool
}{
{"valid", func(o *Options) {}, deezer.KindAlbum, false},
{"mp3_128", func(o *Options) { o.Quality = "mp3_128" }, deezer.KindAlbum, false},
{"flac", func(o *Options) { o.Quality = "flac" }, deezer.KindAlbum, false},
{"wav", func(o *Options) { o.Quality = "wav" }, deezer.KindAlbum, false},
{"invalid quality", func(o *Options) { o.Quality = "ogg" }, deezer.KindAlbum, true},
{"uppercase quality", func(o *Options) { o.Quality = "MP3_320" }, deezer.KindAlbum, true},
{"zero timeout", func(o *Options) { o.Timeout = 0 }, deezer.KindAlbum, true},
{"negative timeout", func(o *Options) { o.Timeout = -time.Second }, deezer.KindAlbum, true},
{"artist zero limit", func(o *Options) { o.Limit = 0 }, deezer.KindArtist, true},
{"artist limit too high", func(o *Options) { o.Limit = 101 }, deezer.KindArtist, true},
{"artist limit at max", func(o *Options) { o.Limit = 100 }, deezer.KindArtist, false},
{"album ignores zero limit", func(o *Options) { o.Limit = 0 }, deezer.KindAlbum, false},
{"track ignores zero limit", func(o *Options) { o.Limit = 0 }, deezer.KindTrack, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := valid
tt.mutate(&opts)
err := opts.Validate(tt.kind)
if (err != nil) != tt.wantErr {
t.Errorf("Validate(%s) error = %v, wantErr %v", tt.kind, err, tt.wantErr)
}
})
}
}
+121
View File
@@ -0,0 +1,121 @@
package download
import (
"fmt"
"math/rand/v2"
"os"
"time"
"github.com/briandowns/spinner"
"github.com/mathismqn/godeez/internal/deezer"
)
type downloadResult struct {
skipped bool
path string
warnings []string
err error
}
type downloadStats struct {
downloaded int
skipped int
failed int
warnings int
}
type progressTracker struct {
stats downloadStats
totalTracks int
kind deezer.Kind
}
func newProgressTracker(totalTracks int, kind deezer.Kind) *progressTracker {
return &progressTracker{
totalTracks: totalTracks,
kind: kind,
}
}
func (pt *progressTracker) startDownload(index int, track *deezer.Track) *spinner.Spinner {
trackProgress := fmt.Sprintf("[%d/%d]", index+1, pt.totalTracks)
sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
sp.Writer = os.Stdout
sp.Prefix = trackProgress + " "
sp.Suffix = fmt.Sprintf(" Downloading: %s - %s", track.Artist, track.FullTitle())
sp.Start()
return sp
}
func (pt *progressTracker) handleResult(index int, track *deezer.Track, result downloadResult) {
trackProgress := fmt.Sprintf("[%d/%d]", index+1, pt.totalTracks)
trackTitle := track.FullTitle()
if result.skipped {
pt.stats.skipped++
fmt.Printf("%s ↷ Skipped: %s - %s\n Already exists at: %s\n",
trackProgress, track.Artist, trackTitle, result.path)
return
}
if result.err != nil {
pt.stats.failed++
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n",
trackProgress, track.Artist, trackTitle, result.err)
return
}
pt.stats.downloaded++
if len(result.warnings) > 0 {
pt.stats.warnings++
}
symbol := "✔"
if len(result.warnings) > 0 {
symbol = "⚠"
}
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, track.Artist, trackTitle)
for _, w := range result.warnings {
fmt.Printf(" Warning: %s\n", w)
}
}
func (pt *progressTracker) printSummary(outputDir string, elapsed time.Duration) {
if pt.kind != deezer.KindTrack {
warningsLine := ""
if pt.stats.warnings > 0 {
warningsLine = fmt.Sprintf("\nWarnings: %d", pt.stats.warnings)
}
fmt.Printf(`
================== [ Summary ] ==================
Downloaded: %d
Skipped: %d
Failed: %d%s
Elapsed time: %s
Files saved to: %s
=================================================
`,
pt.stats.downloaded,
pt.stats.skipped,
pt.stats.failed,
warningsLine,
elapsed.Round(time.Second),
outputDir,
)
if pt.stats.downloaded > 0 {
pt.showSupportMessage()
}
}
}
// showSupportMessage nudges the user to star the repository, but only on
// roughly one run in ten.
func (*progressTracker) showSupportMessage() {
if rand.Float64() < 0.1 {
fmt.Println("\n⭐ Enjoying GoDeez? Star it on GitHub: https://github.com/mathismqn/godeez")
}
}
+48
View File
@@ -0,0 +1,48 @@
package download
import (
"context"
"github.com/mathismqn/godeez/internal/fsutil"
)
// shouldSkipDownload reports whether trackID has already been downloaded at
// mediaFormat, returning the path of the existing file.
//
// A recorded download at a different quality is not a skip: asking for flac
// after previously fetching mp3_128 should download again.
//
// When the recorded path is gone the file may simply have been moved or
// renamed by the user, so the content hash is used to look for it elsewhere
// under the output directory before giving up. A match repairs the stored
// path, which keeps the ledger useful across library reorganisations. That
// lookup is best effort throughout: every failure falls through to
// downloading again, which is always safe.
func (d *Downloader) shouldSkipDownload(ctx context.Context, trackID, mediaFormat string) (string, bool) {
existing, err := d.store.DownloadInfo(trackID)
if err != nil || existing.Quality != mediaFormat {
return "", false
}
if fsutil.Exists(existing.Path) {
return existing.Path, true
}
if existing.Hash == "" {
return "", false
}
if err := d.initHashIndex(ctx); err != nil {
return "", false
}
foundPath, ok := d.hashIndex.find(existing.Hash)
if !ok {
return "", false
}
existing.Path = foundPath
_ = d.store.PutDownloadInfo(existing)
return foundPath, true
}
+133
View File
@@ -0,0 +1,133 @@
package download
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fsutil"
)
// chunkSize is the stripe width Deezer encrypts with. It is fixed by the
// BF_CBC_STRIPE cipher named in the media request and is not tunable: reading
// in any other unit would misalign the stripe pattern and corrupt the output.
const chunkSize = 2048
// sweepPartFiles deletes leftover .part files in dir. Failures are ignored
// because this is opportunistic cleanup, and refusing to download because a
// stale temp file could not be removed would be worse than leaving it.
func sweepPartFiles(dir string) {
matches, err := filepath.Glob(filepath.Join(dir, fsutil.PartPattern))
if err != nil {
return
}
for _, match := range matches {
os.Remove(match)
}
}
// streamToFile writes the decrypted stream to outputPath.
//
// The download lands in a temporary file first and is only renamed into place
// once it is complete, so an interrupted run never leaves a truncated file
// sitting at the real path where it would look like a finished download.
func (d *Downloader) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
tmpPath, err := d.streamToTempFile(ctx, stream, filepath.Dir(outputPath), key)
if err != nil {
return err
}
if err := os.Rename(tmpPath, outputPath); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// streamToTempFile decrypts the stream into a .part file in dir and returns
// its path. The caller owns the file from that point on. It closes stream.
//
// The temp file is created in the destination directory rather than the
// system temp dir so the caller's rename stays on one filesystem and is
// therefore atomic.
func (d *Downloader) streamToTempFile(ctx context.Context, stream io.ReadCloser, dir string, key []byte) (string, error) {
defer stream.Close()
file, err := os.CreateTemp(dir, fsutil.PartPattern)
if err != nil {
return "", err
}
tmpPath := file.Name()
// done stays false until the file is fully written and closed, so every
// early return below removes the partial file instead of orphaning it.
done := false
defer func() {
if !done {
file.Close()
os.Remove(tmpPath)
}
}()
buffer := make([]byte, chunkSize)
for chunk := 0; ; chunk++ {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
// Read until the chunk is full rather than trusting a single Read.
// A short read is legal and common on a network stream, and treating
// one as a chunk boundary would shift every following chunk out of
// step with the stripe pattern.
totalRead := 0
for totalRead < chunkSize {
n, err := stream.Read(buffer[totalRead:])
totalRead += n
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return "", err
}
}
if totalRead == 0 {
break
}
// Deezer encrypts only every third chunk and leaves the other two in
// the clear, which is what BF_CBC_STRIPE means. A trailing partial
// chunk is never encrypted even when its index is a multiple of three,
// hence the length check: decrypting it would corrupt the end of the
// file.
if chunk%3 == 0 && totalRead == chunkSize {
buffer, err = deezer.DecryptBlowfish(buffer, key)
if err != nil {
return "", err
}
}
if _, err = file.Write(buffer[:totalRead]); err != nil {
return "", err
}
if totalRead < chunkSize {
break
}
}
if err := file.Sync(); err != nil {
return "", err
}
if err := file.Close(); err != nil {
return "", err
}
done = true
return tmpPath, nil
}
+40
View File
@@ -0,0 +1,40 @@
package download
import (
"strings"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/tag"
)
func buildTagMetadata(resource deezer.Resource, track *deezer.Track, cover []byte, bpm bpmKey, genre string) tag.Metadata {
m := tag.Metadata{
Title: track.FullTitle(),
Artists: strings.Join(track.Contributors.MainArtists, ", "),
Composers: strings.Join(track.Contributors.Composers, ", "),
Lyricists: strings.Join(track.Contributors.Authors, ", "),
Genre: genre,
BPM: bpm.BPM,
Key: bpm.Key,
TrackNumber: track.TrackNumber,
Duration: track.Duration,
Gain: track.Gain,
ISRC: track.ISRC,
Cover: cover,
}
if album, ok := resource.(*deezer.Album); ok {
data := album.Results.Data
m.Album = &tag.AlbumMetadata{
Artist: data.Artist,
Title: data.Title,
Label: data.Label,
OriginalReleaseDate: data.OriginalReleaseDate,
ReleaseDate: data.PhysicalReleaseDate,
ProducerLine: data.ProducerLine,
Copyright: data.Copyright,
}
}
return m
}
+167
View File
@@ -0,0 +1,167 @@
package download
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/mathismqn/godeez/internal/audio"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fsutil"
"github.com/mathismqn/godeez/internal/store"
"github.com/mathismqn/godeez/internal/tag"
)
// downloadTrack runs the whole pipeline for one track and reports the outcome
// rather than returning an error, so the caller can keep going.
//
// Ordering matters here. The format is resolved before the skip check,
// because whether a track counts as already downloaded depends on the format
// that will actually be written, which is not always the one requested. The
// external metadata lookup is started concurrently and collected late, since
// it hits third party sites and is the slowest part of the pipeline while
// also being the least important. Tagging and the store write happen last, in
// finalizeDownload, once the file is known to be complete.
//
// Only cancellation and a failure to produce the audio itself are fatal.
// Everything else, including a missing cover or a quality downgrade, is
// reported as a warning.
func (d *Downloader) downloadTrack(ctx context.Context, resource deezer.Resource, track *deezer.Track, opts Options, outputDir string) downloadResult {
media, err := d.deezerClient.FetchMedia(ctx, track, opts.sourceQuality())
if err != nil {
return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)}
}
mediaFormat := media.Format()
outputFormat := mediaFormat
if opts.convertsToWAV() {
if mediaFormat != "FLAC" {
return downloadResult{err: fmt.Errorf("wav requires a flac source, but only '%s' is available", strings.ToLower(mediaFormat))}
}
outputFormat = "WAV"
}
if opts.Strict && strings.ToLower(outputFormat) != opts.Quality {
return downloadResult{err: fmt.Errorf("requested quality '%s' not available", opts.Quality)}
}
if skipPath, skip := d.shouldSkipDownload(ctx, track.ID, outputFormat); skip {
return downloadResult{skipped: true, path: skipPath}
}
metadataChan := make(chan metadataResult, 1)
go func() {
metadataChan <- fetchMetadata(ctx, d.deezerClient.Session.HTTPClient, track, opts)
}()
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
stream, err := d.deezerClient.MediaStream(dlCtx, media)
if err != nil {
return downloadResult{err: fmt.Errorf("failed to get media stream: %w", err)}
}
fileName := track.Filename(d.kind, outputFormat)
outputPath := d.uniqueOutputPath(track.ID, filepath.Join(outputDir, fileName))
key := deezer.BlowfishKey(track.ID)
if opts.convertsToWAV() {
tmpPath, err := d.streamToTempFile(dlCtx, stream, outputDir, key)
if err != nil {
return downloadResult{err: fmt.Errorf("failed to stream to file: %w", err)}
}
defer fsutil.Remove(tmpPath)
if err := audio.FLACToWAV(ctx, tmpPath, outputPath); err != nil {
return downloadResult{err: fmt.Errorf("failed to convert to wav: %w", err)}
}
} else if err := d.streamToFile(dlCtx, stream, outputPath, key); err != nil {
return downloadResult{err: fmt.Errorf("failed to stream to file: %w", err)}
}
var warnings []string
if opts.Quality != strings.ToLower(outputFormat) {
warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(outputFormat)))
}
cover, err := d.deezerClient.FetchCoverImage(ctx, track)
if err != nil && !errors.Is(err, context.Canceled) {
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
}
metadata := <-metadataChan
// Cancellation between the write and the tagging leaves a complete but
// untagged file. Removing it keeps a cancelled run from being mistaken
// for a finished one, and nothing has been recorded in the store yet.
if err := ctx.Err(); err != nil {
fsutil.Remove(outputPath)
return downloadResult{err: err}
}
warnings = append(warnings, metadata.warnings...)
warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, outputFormat, metadata.genre, cover, metadata.bpmKey)...)
return downloadResult{path: outputPath, warnings: warnings}
}
// uniqueOutputPath avoids clobbering an unrelated file by appending " (2)",
// " (3)" and so on until the name is free.
//
// The file this track already owns according to the store is exempt: a
// re-download of the same track should overwrite its own output rather than
// pile up numbered copies next to it.
func (d *Downloader) uniqueOutputPath(trackID, path string) string {
owned := ""
if info, err := d.store.DownloadInfo(trackID); err == nil {
owned = info.Path
}
ext := filepath.Ext(path)
stem := strings.TrimSuffix(path, ext)
candidate := path
for i := 2; candidate != owned && fsutil.Exists(candidate); i++ {
candidate = fmt.Sprintf("%s (%d)%s", stem, i, ext)
}
return candidate
}
// finalizeDownload tags the finished file and records it in the store,
// returning any non-fatal problems as warnings.
//
// The hash is taken after tagging so it matches the bytes actually on disk,
// which is what the skip check later compares against. The download is
// recorded even when tagging or hashing failed: the audio is there, and
// refusing to record it would mean downloading it all over again next time.
func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, outputFormat, genre string, cover []byte, bpmKey bpmKey) []string {
var warnings []string
if err := tag.Write(outputPath, buildTagMetadata(resource, track, cover, bpmKey, genre)); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err))
}
hash, err := hashFile(outputPath)
if err != nil {
warnings = append(warnings, fmt.Sprintf("failed to get file hash: %v", err))
}
info := &store.DownloadInfo{
TrackID: track.ID,
Quality: outputFormat,
Path: outputPath,
Hash: hash,
Downloaded: time.Now(),
}
if err := d.store.PutDownloadInfo(info); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to save download info: %v", err))
}
return warnings
}
-311
View File
@@ -1,311 +0,0 @@
package downloader
import (
"context"
"errors"
"fmt"
"io"
"os"
"path"
"sync"
"time"
"github.com/briandowns/spinner"
"github.com/mathismqn/godeez/internal/bpm"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/crypto"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/store"
"github.com/mathismqn/godeez/internal/tags"
)
const chunkSize = 2048
type Client struct {
appConfig *config.Config
resourceType string
deezerClient *deezer.Client
hashIndexOnce sync.Once
hashIndex *fileutil.HashIndex
hashIndexErr error
}
func New(appConfig *config.Config, resourceType string) *Client {
return &Client{
appConfig: appConfig,
resourceType: resourceType,
deezerClient: nil,
}
}
func (c *Client) Run(ctx context.Context, opts Options, id string) error {
var err error
c.deezerClient, err = deezer.NewClient(ctx, c.appConfig)
if err != nil {
return err
}
if !c.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") {
return fmt.Errorf("premium account required for %s quality", opts.Quality)
}
var resource deezer.Resource
switch c.resourceType {
case "album":
resource = &deezer.Album{}
case "playlist":
resource = &deezer.Playlist{}
default:
return fmt.Errorf("unsupported resource type: %s", c.resourceType)
}
if err := c.deezerClient.FetchResource(ctx, resource, id); err != nil {
return fmt.Errorf("failed to fetch resource: %w", err)
}
songs := resource.GetSongs()
if len(songs) == 0 {
return fmt.Errorf("%s has no songs", c.resourceType)
}
rootOutputDir := c.appConfig.OutputDir
resourceOutputDir := resource.GetOutputDir(rootOutputDir)
if err := fileutil.EnsureDir(resourceOutputDir); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
startTime := time.Now()
fmt.Printf("%s\n\nStarting download...\n\n", resource)
downloaded := 0
skipped := 0
failed := 0
for i, song := range songs {
if ctx.Err() != nil {
return ctx.Err()
}
trackProgress := fmt.Sprintf("[%d/%d]", i+1, len(songs))
sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
sp.Writer = os.Stdout
sp.Prefix = trackProgress + " "
sp.Suffix = fmt.Sprintf(" Downloading: %s - %s", song.Artist, song.Title)
sp.Start()
warnings, err := c.downloadSong(ctx, resource, song, opts, resourceOutputDir)
sp.Stop()
if err != nil {
if errors.Is(err, context.Canceled) {
return err
}
if path, ok := IsSkipError(err); ok {
skipped++
fmt.Printf("%s ↷ Skipped: %s - %s\n Already exists at: %s\n", trackProgress, song.Artist, song.Title, path)
continue
}
failed++
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n", trackProgress, song.Artist, song.Title, err)
continue
}
symbol := "✔"
if len(warnings) > 0 {
symbol = "⚠"
}
downloaded++
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, song.Title)
for _, w := range warnings {
fmt.Printf(" Warning: %s\n", w)
}
}
fmt.Printf(`
================== [ Summary ] ==================
Downloaded: %d
Skipped: %d
Failed: %d
Elapsed time: %s
Files saved to: %s
=================================================
`,
downloaded,
skipped,
failed,
time.Since(startTime).Round(time.Second),
resourceOutputDir,
)
return nil
}
func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, song *deezer.Song, opts Options, outputDir string) ([]string, error) {
var warnings []string
media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality)
if err != nil {
return warnings, fmt.Errorf("failed to fetch media: %w", err)
}
fileName := song.GetFileName(c.resourceType, song, media)
outputPath := path.Join(outputDir, fileName)
mediaFormat, err := media.GetFormat()
if err != nil {
return warnings, fmt.Errorf("failed to get media format: %w", err)
}
if path, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
return warnings, SkipError{Path: path}
}
var metricsChan chan *bpm.Metrics
var errChan chan error
if opts.BPM {
metricsChan = make(chan *bpm.Metrics, 1)
errChan = make(chan error, 1)
go func() {
metrics, err := bpm.FetchMetrics(ctx, c.deezerClient.Session.HttpClient, song.Artist, song.Title, song.Duration)
if err != nil {
errChan <- err
return
}
metricsChan <- metrics
}()
}
stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID)
if err != nil {
return warnings, fmt.Errorf("failed to get media stream: %w", err)
}
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
key := crypto.GetKey(c.appConfig.SecretKey, song.ID)
if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil {
fileutil.DeleteFile(outputPath)
return warnings, fmt.Errorf("failed to stream to file: %w", err)
}
metrics := &bpm.Metrics{}
if opts.BPM {
select {
case metrics = <-metricsChan:
case err := <-errChan:
if !errors.Is(err, context.Canceled) {
warnings = append(warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err))
}
}
}
cover, err := c.deezerClient.FetchCoverImage(ctx, song)
if err != nil && !errors.Is(err, context.Canceled) {
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
}
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, cover, metrics)...)
return warnings, nil
}
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
defer stream.Close()
file, err := os.Create(outputPath)
if err != nil {
return err
}
defer file.Close()
buffer := make([]byte, chunkSize)
for chunk := 0; ; chunk++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
// continue
}
totalRead := 0
for totalRead < chunkSize {
n, err := stream.Read(buffer[totalRead:])
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return err
}
if n > 0 {
totalRead += n
}
}
if totalRead == 0 {
break
}
if chunk%3 == 0 && totalRead == chunkSize {
buffer, err = crypto.Decrypt(buffer, key)
if err != nil {
return err
}
}
_, err = file.Write(buffer[:totalRead])
if err != nil {
return err
}
if totalRead < chunkSize {
break
}
}
return nil
}
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat string, cover []byte, metrics *bpm.Metrics) []string {
var warnings []string
if err := tags.AddTags(resource, song, cover, outputPath, metrics.BPM, metrics.Key); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err))
}
hash, err := fileutil.GetFileHash(outputPath)
if err != nil {
warnings = append(warnings, fmt.Sprintf("failed to get file hash: %v", err))
}
info := &store.DownloadInfo{
SongID: song.ID,
Quality: mediaFormat,
Path: outputPath,
Hash: hash,
Downloaded: time.Now(),
}
if err := info.Save(); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to save download info: %v", err))
}
return warnings
}
func (c *Client) initHashIndex(ctx context.Context) error {
c.hashIndexOnce.Do(func() {
c.hashIndex, c.hashIndexErr = fileutil.NewHashIndex(ctx, c.appConfig.OutputDir)
})
return c.hashIndexErr
}
-27
View File
@@ -1,27 +0,0 @@
package downloader
import (
"fmt"
"time"
)
var validQualities = map[string]bool{
"mp3_128": true,
"mp3_320": true,
"flac": true,
"best": true,
}
type Options struct {
Quality string
Timeout time.Duration
BPM bool
}
func (o *Options) Validate() error {
if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality)
}
return nil
}
-43
View File
@@ -1,43 +0,0 @@
package downloader
import (
"context"
"github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/store"
)
type SkipError struct {
Path string
}
func (e SkipError) Error() string {
return e.Path
}
func IsSkipError(err error) (string, bool) {
if skipErr, ok := err.(SkipError); ok {
return skipErr.Path, true
}
return "", false
}
func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) {
if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat {
if fileutil.FileExists(existing.Path) {
return existing.Path, true
}
if existing.Hash != "" {
if err := c.initHashIndex(ctx); err == nil {
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok {
existing.Path = foundPath
_ = existing.Save()
return foundPath, true
}
}
}
}
return "", false
}
-52
View File
@@ -1,52 +0,0 @@
package fileutil
import (
"crypto/sha256"
"fmt"
"io"
"os"
)
func EnsureDir(path string) error {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return os.MkdirAll(path, 0755)
}
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("file already exists at %s", path)
}
return nil
}
func FileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func DeleteFile(path string) error {
if !FileExists(path) {
return nil
}
return os.Remove(path)
}
func GetFileHash(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
-56
View File
@@ -1,56 +0,0 @@
package fileutil
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
)
type HashIndex struct {
files map[string]string
}
func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
index := &HashIndex{files: make(map[string]string)}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil || info.IsDir() {
return nil
}
file, err := os.Open(path)
if err != nil {
return nil
}
defer file.Close()
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return nil
}
sum := hex.EncodeToString(h.Sum(nil))
index.files[sum] = path
return nil
})
if err != nil {
return nil, err
}
return index, nil
}
func (h *HashIndex) Find(hash string) (string, bool) {
path, ok := h.files[hash]
return path, ok
}
+44
View File
@@ -0,0 +1,44 @@
// Package fsutil holds the few filesystem helpers shared across godeez.
package fsutil
import (
"fmt"
"os"
)
// PartPattern names in-progress downloads. It is both an os.CreateTemp
// pattern and a glob, which is what lets the downloader sweep away leftovers
// from an interrupted run. The leading dot hides them from file managers, and
// the suffix keeps them from being mistaken for finished audio.
const PartPattern = ".godeez-*.part"
// EnsureDir creates path if it does not exist. An existing non-directory at
// that path is an error rather than something to overwrite.
func EnsureDir(path string) error {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return os.MkdirAll(path, 0755)
}
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("file already exists at %s", path)
}
return nil
}
// Exists reports whether path is an existing regular file. A directory is
// deliberately not "exists" here: every caller is asking about a file it
// intends to read, write or delete.
func Exists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func Remove(path string) error {
if !Exists(path) {
return nil
}
return os.Remove(path)
}
+22 -17
View File
@@ -2,32 +2,42 @@ package store
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"time" "time"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
// DownloadInfo records one completed download. Quality is stored so that
// re-requesting the same track at a higher quality is not mistaken for a
// duplicate, and Hash lets a file that has since been moved or renamed still
// be recognised.
type DownloadInfo struct { type DownloadInfo struct {
SongID string `json:"song_id"` TrackID string `json:"song_id"`
Quality string `json:"quality"` Quality string `json:"quality"`
Path string `json:"path"` Path string `json:"path"`
Hash string `json:"hash"` Hash string `json:"hash"`
Downloaded time.Time `json:"downloaded_at"` Downloaded time.Time `json:"downloaded_at"`
} }
func GetDownloadInfo(songID string) (*DownloadInfo, error) { var trackBucket = []byte("tracks")
// DownloadInfo returns the record for trackID. A track that has never been
// downloaded is reported as an error rather than a nil result, and callers
// treat any error the same way: download it.
func (s *Store) DownloadInfo(trackID string) (*DownloadInfo, error) {
var info DownloadInfo var info DownloadInfo
if err := db.View(func(tx *bbolt.Tx) error { if err := s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(trackBucket) b := tx.Bucket(trackBucket)
if b == nil { if b == nil {
return fmt.Errorf("bucket not found") return errors.New("bucket not found")
} }
data := b.Get([]byte(songID)) data := b.Get([]byte(trackID))
if data == nil { if data == nil {
return fmt.Errorf("not found") return errors.New("not found")
} }
return json.Unmarshal(data, &info) return json.Unmarshal(data, &info)
}); err != nil { }); err != nil {
@@ -37,11 +47,11 @@ func GetDownloadInfo(songID string) (*DownloadInfo, error) {
return &info, nil return &info, nil
} }
func (d *DownloadInfo) Save() error { func (s *Store) PutDownloadInfo(d *DownloadInfo) error {
return db.Update(func(tx *bbolt.Tx) error { return s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(trackBucket) b, err := tx.CreateBucketIfNotExists(trackBucket)
if b == nil { if err != nil {
return fmt.Errorf("bucket not found") return fmt.Errorf("failed to create bucket: %w", err)
} }
data, err := json.Marshal(d) data, err := json.Marshal(d)
@@ -49,11 +59,6 @@ func (d *DownloadInfo) Save() error {
return err return err
} }
err = b.Put([]byte(d.SongID), data) return b.Put([]byte(d.TrackID), data)
if err != nil {
return err
}
return nil
}) })
} }
+29 -19
View File
@@ -1,32 +1,42 @@
// Package store keeps the ledger of what has already been downloaded, so a
// repeated run can skip tracks instead of fetching them again.
//
// It is a bbolt database written as a hidden file inside the output
// directory, which keeps it travelling with the music library it describes.
// Losing it is harmless: the worst outcome is re-downloading.
package store package store
import ( import (
"errors"
"fmt" "fmt"
"path" "path/filepath"
"time"
bolt "go.etcd.io/bbolt" "go.etcd.io/bbolt"
bolterrors "go.etcd.io/bbolt/errors"
) )
var ( type Store struct {
db *bolt.DB db *bbolt.DB
trackBucket = []byte("tracks") }
)
func OpenDB(cfgDir string) error { // Open opens the ledger in dir, creating it if needed.
var err error //
// bbolt takes an exclusive file lock, so a second godeez running against the
dbPath := path.Join(cfgDir, "tracks.db") // same output directory blocks here. The timeout turns that into a clear
db, err = bolt.Open(dbPath, 0600, nil) // message rather than an apparent hang.
func Open(dir string) (*Store, error) {
db, err := bbolt.Open(filepath.Join(dir, ".tracks.db"), 0600, &bbolt.Options{Timeout: 5 * time.Second})
if err != nil { if err != nil {
return fmt.Errorf("failed to open database: %w", err) if errors.Is(err, bolterrors.ErrTimeout) {
return nil, errors.New("database is already in use by another process")
}
return nil, fmt.Errorf("failed to open database: %w", err)
} }
if err := db.Update(func(tx *bolt.Tx) error { return &Store{db: db}, nil
_, err := tx.CreateBucketIfNotExists(trackBucket) }
return err
}); err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
return nil func (s *Store) Close() error {
return s.db.Close()
} }
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestOpenPutGet(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
if _, err := os.Stat(filepath.Join(dir, ".tracks.db")); err != nil {
t.Errorf("expected .tracks.db to exist: %v", err)
}
info := &DownloadInfo{
TrackID: "123",
Quality: "MP3_320",
Path: "/music/track.mp3",
Hash: "abc",
Downloaded: time.Now().Truncate(time.Second),
}
if err := s.PutDownloadInfo(info); err != nil {
t.Fatalf("PutDownloadInfo: %v", err)
}
got, err := s.DownloadInfo("123")
if err != nil {
t.Fatalf("DownloadInfo: %v", err)
}
if got.TrackID != info.TrackID || got.Quality != info.Quality || got.Path != info.Path || got.Hash != info.Hash {
t.Errorf("DownloadInfo() = %+v, want %+v", got, info)
}
}
func TestDownloadInfoNotFound(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
if _, err := s.DownloadInfo("missing"); err == nil {
t.Error("expected error for unknown track ID")
}
}
func TestClose(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := s.Close(); err != nil {
t.Errorf("Close: %v", err)
}
}
+95
View File
@@ -0,0 +1,95 @@
package tag
import (
"os"
"strings"
"github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac/v2"
)
type flacTagger struct {
file *flac.File
cmts *flacvorbis.MetaDataBlockVorbisComment
index int
path string
}
func (t *flacTagger) write(m Metadata) error {
if m.Album != nil {
date := m.Album.ReleaseDate
if parts := strings.Split(date, "-"); len(parts) == 3 {
date = parts[0]
}
t.addTag("TRACKNUMBER", m.TrackNumber)
t.addTag("ALBUMARTIST", m.Album.Artist)
t.addTag("ALBUM", m.Album.Title)
t.addTag("PUBLISHER", m.Album.Label)
t.addTag("ORIGINALDATE", m.Album.OriginalReleaseDate)
t.addTag("DATE", date)
t.addTag("COMMENT", m.Album.ProducerLine)
t.addTag("COPYRIGHT", m.Album.Copyright)
}
t.addTag("ARTIST", m.Artists)
t.addTag("TITLE", m.Title)
t.addTag("COMPOSER", m.Composers)
t.addTag("LYRICIST", m.Lyricists)
t.addTag("GENRE", m.Genre)
t.addTag("REPLAYGAIN_TRACK_GAIN", m.Gain)
t.addTag("ISRC", m.ISRC)
t.addTag("BPM", m.BPM)
t.addTag("KEY", m.Key)
t.addTag("INITIALKEY", m.Key)
cmtsMeta := t.cmts.Marshal()
// index 0 means no comment block was found: a valid flac always starts
// with STREAMINFO, so a real Vorbis comment can never be the first block.
// Anything else is the index of the block being replaced.
if t.index > 0 {
t.file.Meta[t.index] = &cmtsMeta
} else {
t.file.Meta = append(t.file.Meta, &cmtsMeta)
}
if len(m.Cover) > 0 {
if picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", m.Cover, "image/jpeg"); err == nil {
pictureMeta := picture.Marshal()
t.file.Meta = append(t.file.Meta, &pictureMeta)
}
}
tmpPath := t.path + ".tmp"
if err := t.file.Save(tmpPath); err != nil {
os.Remove(tmpPath)
return err
}
return os.Rename(tmpPath, t.path)
}
// addTag appends a Vorbis comment. Vorbis allows repeated keys, so this adds
// to whatever the file already had rather than replacing it; re-tagging a
// file that was already tagged would therefore duplicate entries. That does
// not arise in practice because godeez only tags files it just downloaded.
//
// The key is written twice for the musical key: KEY is the common spelling
// and INITIALKEY is what several DJ applications look for.
func (t *flacTagger) addTag(name, value string) {
if value != "" {
t.cmts.Add(name, value)
}
}
func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int) {
for idx, meta := range file.Meta {
if meta.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err == nil {
return cmt, idx
}
}
}
return nil, 0
}
+87
View File
@@ -0,0 +1,87 @@
package tag
import (
"strconv"
"strings"
"github.com/bogem/id3v2/v2"
)
type id3v2Tagger struct {
tag *id3v2.Tag
}
func (t *id3v2Tagger) write(m Metadata) error {
defer t.tag.Close()
applyID3Frames(t.tag, m)
return t.tag.Save()
}
func applyID3Frames(tag *id3v2.Tag, m Metadata) {
if m.Album != nil {
year := m.Album.ReleaseDate
if parts := strings.Split(year, "-"); len(parts) == 3 {
year = parts[0]
}
addID3Text(tag, "TRCK", m.TrackNumber)
addID3Text(tag, "TPE2", m.Album.Artist)
addID3Text(tag, "TALB", m.Album.Title)
addID3Text(tag, "TPUB", m.Album.Label)
addID3Text(tag, "TDOR", m.Album.OriginalReleaseDate)
addID3Text(tag, "TYER", year)
addID3Comment(tag, m.Album.ProducerLine)
addID3Text(tag, "TCOP", m.Album.Copyright)
}
addID3Text(tag, "TPE1", m.Artists)
addID3Text(tag, "TIT2", m.Title)
addID3Text(tag, "TCOM", m.Composers)
addID3Text(tag, "TEXT", m.Lyricists)
addID3Text(tag, "TCON", m.Genre)
if duration, err := strconv.Atoi(m.Duration); err == nil {
addID3Text(tag, "TLEN", strconv.Itoa(duration*1000))
}
addID3Text(tag, "TBPM", m.BPM)
addID3Text(tag, "TKEY", m.Key)
addID3TXXX(tag, "GAIN", m.Gain)
addID3TXXX(tag, "ISRC", m.ISRC)
if len(m.Cover) > 0 {
tag.AddAttachedPicture(id3v2.PictureFrame{
Encoding: tag.DefaultEncoding(),
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Cover",
Picture: m.Cover,
})
}
}
func addID3Text(tag *id3v2.Tag, name, value string) {
if value != "" {
tag.AddTextFrame(name, tag.DefaultEncoding(), value)
}
}
func addID3Comment(tag *id3v2.Tag, value string) {
if value != "" {
tag.AddCommentFrame(id3v2.CommentFrame{
Encoding: tag.DefaultEncoding(),
Language: "eng",
Text: value,
})
}
}
func addID3TXXX(tag *id3v2.Tag, description, value string) {
if value != "" {
tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
Encoding: tag.DefaultEncoding(),
Description: description,
Value: value,
})
}
}
+91
View File
@@ -0,0 +1,91 @@
// Package tag writes track metadata into finished audio files.
//
// Every container stores metadata differently: mp3 uses ID3v2 frames, flac
// uses Vorbis comments, and wav carries an ID3 chunk plus a RIFF LIST/INFO
// chunk for players that read only one of the two. Write hides that behind a
// single Metadata struct and dispatches on the file extension.
//
// The taggers are written for freshly downloaded files. Empty fields are
// skipped rather than written as blanks, and each tagger writes through a
// temporary file so a failure part way cannot corrupt the audio.
package tag
import (
"path/filepath"
"github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac/v2"
)
// AlbumMetadata is the subset of tags that only make sense for a track that
// belongs to an album. It is nil on a standalone single.
type AlbumMetadata struct {
Artist string
Title string
Label string
OriginalReleaseDate string
ReleaseDate string
ProducerLine string
Copyright string
}
// Metadata is the container-independent tag set. Every field is a string
// because the underlying formats store them as text; conversions such as
// Duration to milliseconds happen inside the individual taggers.
type Metadata struct {
Title string
Artists string
Composers string
Lyricists string
Genre string
BPM string
Key string
TrackNumber string
Duration string
Gain string
ISRC string
Cover []byte
Album *AlbumMetadata
}
type tagger interface {
write(m Metadata) error
}
// newTagger picks an implementation from the file extension. Anything that is
// not mp3 or wav is attempted as flac rather than rejected, so an unexpected
// extension fails with a parse error from the flac library instead of a
// generic unsupported-format message.
func newTagger(filePath string) (tagger, error) {
switch filepath.Ext(filePath) {
case ".mp3":
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return nil, err
}
return &id3v2Tagger{tag: tag}, nil
case ".wav":
return &wavTagger{path: filePath}, nil
}
file, err := flac.ParseFile(filePath)
if err != nil {
return nil, err
}
cmts, idx := extractFLACComment(file)
if cmts == nil {
cmts = flacvorbis.New()
}
return &flacTagger{file: file, cmts: cmts, index: idx, path: filePath}, nil
}
func Write(filePath string, m Metadata) error {
t, err := newTagger(filePath)
if err != nil {
return err
}
return t.write(m)
}
+307
View File
@@ -0,0 +1,307 @@
package tag
import (
"bytes"
"encoding/binary"
"errors"
"io"
"os"
"strings"
"github.com/bogem/id3v2/v2"
)
type wavTagger struct {
path string
}
type wavChunk struct {
id string
payload []byte
}
type infoField struct {
id string
value string
}
// write replaces the metadata chunks in a wav file.
//
// Both a LIST/INFO chunk and an id3 chunk are written because wav has no
// single agreed metadata convention: older players and file managers read
// LIST/INFO, while music libraries and DJ software expect ID3. Writing only
// one leaves the tags invisible to half the tools people use.
func (t *wavTagger) write(m Metadata) error {
id3Chunk, err := buildID3Chunk(m)
if err != nil {
return err
}
var chunks []wavChunk
if info := buildInfoChunk(m); info != nil {
chunks = append(chunks, wavChunk{id: "LIST", payload: info})
}
if id3Chunk != nil {
chunks = append(chunks, wavChunk{id: "id3 ", payload: id3Chunk})
}
return rewriteWAV(t.path, chunks)
}
func buildID3Chunk(m Metadata) ([]byte, error) {
tag := id3v2.NewEmptyTag()
applyID3Frames(tag, m)
if !tag.HasFrames() {
return nil, nil
}
var buf bytes.Buffer
if _, err := tag.WriteTo(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildInfoChunk assembles the LIST/INFO payload, or nil when there is
// nothing worth writing.
//
// The four character ids are the RIFF INFO registry's, not arbitrary names.
// INFO only has a year field, so a full release date is reduced to its year.
// Values are NUL terminated because RIFF INFO strings are C strings.
//
// A payload of exactly 4 bytes is just the "INFO" marker with no fields
// after it, which is why that length means empty.
func buildInfoChunk(m Metadata) []byte {
fields := []infoField{
{"INAM", m.Title},
{"IART", m.Artists},
{"IGNR", m.Genre},
{"ITRK", m.TrackNumber},
}
if m.Album != nil {
date := m.Album.ReleaseDate
if parts := strings.Split(date, "-"); len(parts) == 3 {
date = parts[0]
}
fields = append(fields,
infoField{"IPRD", m.Album.Title},
infoField{"ICRD", date},
infoField{"ICMT", m.Album.ProducerLine},
infoField{"ICOP", m.Album.Copyright},
)
}
var buf bytes.Buffer
buf.WriteString("INFO")
for _, field := range fields {
if field.value == "" {
continue
}
writeChunk(&buf, field.id, append([]byte(field.value), 0))
}
if buf.Len() == 4 {
return nil
}
return buf.Bytes()
}
// writeChunk writes one RIFF chunk: a four character id, the payload length
// as a little endian uint32, then the payload.
//
// RIFF requires chunks to start on even offsets, so an odd length is followed
// by a pad byte. That byte is not counted in the declared size, which is the
// detail that makes chunk walking fiddly; see skipPad for the reading side.
func writeChunk(w io.Writer, id string, payload []byte) {
header := make([]byte, 0, 8)
header = append(header, id...)
header = binary.LittleEndian.AppendUint32(header, uint32(len(payload)))
w.Write(header)
w.Write(payload)
if len(payload)%2 != 0 {
w.Write([]byte{0})
}
}
// rewriteWAV copies path into a new file, dropping any existing metadata
// chunks, appending the given ones, and swapping the result into place.
//
// A wav file cannot be edited in place: chunk sizes and the RIFF size in the
// header would all have to shift. Rewriting is simpler and, combined with the
// rename at the end, means an interrupted tag write leaves the original
// untouched.
//
// The RIFF size field is patched at offset 4 only after everything is written,
// since the final size is not known until then.
func rewriteWAV(path string, chunks []wavChunk) error {
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
header := make([]byte, 12)
if _, err := io.ReadFull(src, header); err != nil {
return err
}
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return errors.New("not a wav file")
}
tmpPath := path + ".tmp"
dst, err := os.Create(tmpPath)
if err != nil {
return err
}
done := false
defer func() {
if !done {
dst.Close()
os.Remove(tmpPath)
}
}()
if _, err := dst.Write(header); err != nil {
return err
}
size, err := copyChunks(dst, src)
if err != nil {
return err
}
for _, chunk := range chunks {
var buf bytes.Buffer
writeChunk(&buf, chunk.id, chunk.payload)
if _, err := dst.Write(buf.Bytes()); err != nil {
return err
}
size += int64(buf.Len())
}
riffSize := make([]byte, 4)
binary.LittleEndian.PutUint32(riffSize, uint32(size))
if _, err := dst.WriteAt(riffSize, 4); err != nil {
return err
}
if err := dst.Sync(); err != nil {
return err
}
if err := dst.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
done = true
return nil
}
// copyChunks streams every chunk from src to dst except the metadata ones,
// and returns the byte count that belongs in the RIFF size field.
//
// Dropping the existing id3 and LIST/INFO chunks here is what makes tagging
// repeatable: the caller appends fresh ones, so tags are replaced rather than
// accumulated. A LIST chunk that is not an INFO list is something else
// entirely, such as an adtl annotation list, and is preserved.
//
// A truncated final chunk is treated as the end of the file rather than an
// error, because trailing garbage after the audio data is common and should
// not make the file untaggable.
func copyChunks(dst io.Writer, src io.Reader) (int64, error) {
// The count starts at 4 for the "WAVE" id, which sits inside the RIFF
// chunk and so counts towards its size, while the 8 byte RIFF header
// itself does not.
size := int64(4)
head := make([]byte, 8)
for {
if _, err := io.ReadFull(src, head); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return size, nil
}
return size, err
}
id := string(head[0:4])
payloadSize := int64(binary.LittleEndian.Uint32(head[4:8]))
if id == "id3 " || id == "ID3 " {
if err := skipPayload(src, payloadSize); err != nil {
return size, err
}
continue
}
if id == "LIST" {
payload := make([]byte, payloadSize)
if _, err := io.ReadFull(src, payload); err != nil {
return size, err
}
if err := skipPad(src, payloadSize); err != nil {
return size, err
}
if bytes.HasPrefix(payload, []byte("INFO")) {
continue
}
var buf bytes.Buffer
writeChunk(&buf, id, payload)
if _, err := dst.Write(buf.Bytes()); err != nil {
return size, err
}
size += int64(buf.Len())
continue
}
if _, err := dst.Write(head); err != nil {
return size, err
}
if _, err := io.CopyN(dst, src, payloadSize); err != nil {
return size, err
}
size += 8 + payloadSize
if payloadSize%2 != 0 {
if _, err := dst.Write([]byte{0}); err != nil {
return size, err
}
size++
if err := skipPad(src, payloadSize); err != nil {
return size, err
}
}
}
}
func skipPayload(src io.Reader, payloadSize int64) error {
if _, err := io.CopyN(io.Discard, src, payloadSize); err != nil {
return err
}
return skipPad(src, payloadSize)
}
// skipPad consumes the pad byte that follows an odd length chunk. It is not
// included in the chunk's declared size, so skipping it is what keeps the
// reader aligned on the next chunk header.
//
// A missing pad byte at the very end of the file is tolerated: some encoders
// omit it on the last chunk.
func skipPad(src io.Reader, payloadSize int64) error {
if payloadSize%2 == 0 {
return nil
}
if _, err := io.CopyN(io.Discard, src, 1); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
+246
View File
@@ -0,0 +1,246 @@
package tag
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func minimalWAV(audio []byte) []byte {
var body bytes.Buffer
body.WriteString("WAVE")
fmtPayload := make([]byte, 0, 16)
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 1)
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 2)
fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 44100)
fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 176400)
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 4)
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 16)
writeChunk(&body, "fmt ", fmtPayload)
writeChunk(&body, "data", audio)
var out bytes.Buffer
out.WriteString("RIFF")
binary.Write(&out, binary.LittleEndian, uint32(body.Len()))
out.Write(body.Bytes())
return out.Bytes()
}
func parseChunks(t *testing.T, data []byte) map[string][]byte {
t.Helper()
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
t.Fatalf("bad riff header: %q %q", data[0:4], data[8:12])
}
if size := binary.LittleEndian.Uint32(data[4:8]); int(size) != len(data)-8 {
t.Errorf("riff size = %d, want %d", size, len(data)-8)
}
chunks := make(map[string][]byte)
for offset := 12; offset < len(data); {
if offset%2 != 0 {
t.Errorf("chunk at offset %d is not word aligned", offset)
}
if offset+8 > len(data) {
t.Fatalf("truncated chunk header at offset %d", offset)
}
id := string(data[offset : offset+4])
size := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
if offset+8+size > len(data) {
t.Fatalf("chunk %q at offset %d overruns the file", id, offset)
}
if _, ok := chunks[id]; ok {
t.Errorf("duplicate %q chunk", id)
}
chunks[id] = data[offset+8 : offset+8+size]
offset += 8 + size + size%2
}
return chunks
}
func testMetadata() Metadata {
return Metadata{
Title: "Song",
Artists: "Artist",
Genre: "Rock",
BPM: "120",
Key: "Am",
TrackNumber: "3",
Duration: "215",
Gain: "-7.5",
ISRC: "FR1234567890",
Cover: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00},
Album: &AlbumMetadata{
Artist: "Album Artist",
Title: "Album",
Label: "Label",
ReleaseDate: "2024-05-01",
ProducerLine: "Producer line",
Copyright: "Copyright",
},
}
}
func writeTestWAV(t *testing.T, audio []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "track.wav")
if err := os.WriteFile(path, minimalWAV(audio), 0644); err != nil {
t.Fatalf("write wav: %v", err)
}
return path
}
func TestWriteWAV(t *testing.T) {
audio := bytes.Repeat([]byte{0x11, 0x22, 0x33, 0x44}, 16)
path := writeTestWAV(t, audio)
original := parseChunks(t, minimalWAV(audio))
if err := Write(path, testMetadata()); err != nil {
t.Fatalf("Write() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read wav: %v", err)
}
chunks := parseChunks(t, data)
if !bytes.Equal(chunks["data"], original["data"]) {
t.Error("data chunk was modified")
}
if !bytes.Equal(chunks["fmt "], original["fmt "]) {
t.Error("fmt chunk was modified")
}
id3, ok := chunks["id3 "]
if !ok {
t.Fatal("missing id3 chunk")
}
if string(id3[0:3]) != "ID3" {
t.Errorf("id3 chunk does not start with an ID3 header: %q", id3[0:3])
}
for _, want := range []string{"Song", "Artist", "Album", "120", "Am", "FR1234567890", "Rock"} {
if !bytes.Contains(id3, []byte(want)) {
t.Errorf("id3 chunk is missing %q", want)
}
}
if !bytes.Contains(id3, []byte{0xff, 0xd8, 0xff, 0xe0}) {
t.Error("id3 chunk is missing the cover art")
}
list, ok := chunks["LIST"]
if !ok {
t.Fatal("missing LIST chunk")
}
if string(list[0:4]) != "INFO" {
t.Errorf("LIST form = %q, want \"INFO\"", list[0:4])
}
for _, want := range []struct{ id, value string }{
{"INAM", "Song"},
{"IART", "Artist"},
{"IGNR", "Rock"},
{"ITRK", "3"},
{"IPRD", "Album"},
{"ICRD", "2024"},
{"ICMT", "Producer line"},
{"ICOP", "Copyright"},
} {
if !bytes.Contains(list, append([]byte(want.id), append([]byte{byte(len(want.value) + 1), 0, 0, 0}, want.value...)...)) {
t.Errorf("LIST chunk is missing %s = %q", want.id, want.value)
}
}
}
func TestWriteWAVIsIdempotent(t *testing.T) {
audio := bytes.Repeat([]byte{0x01, 0x02}, 32)
path := writeTestWAV(t, audio)
if err := Write(path, testMetadata()); err != nil {
t.Fatalf("first Write() error = %v", err)
}
first, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read wav: %v", err)
}
firstChunks := parseChunks(t, first)
if err := Write(path, testMetadata()); err != nil {
t.Fatalf("second Write() error = %v", err)
}
second, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read wav: %v", err)
}
secondChunks := parseChunks(t, second)
if len(first) != len(second) {
t.Errorf("re-tagging changed the file size: %d bytes then %d bytes", len(first), len(second))
}
if len(firstChunks) != len(secondChunks) {
t.Errorf("chunk count = %d, want %d", len(secondChunks), len(firstChunks))
}
for id, payload := range firstChunks {
got, ok := secondChunks[id]
if !ok {
t.Errorf("re-tagging dropped the %q chunk", id)
continue
}
if len(got) != len(payload) {
t.Errorf("%q chunk size = %d, want %d", id, len(got), len(payload))
}
}
if !bytes.Equal(secondChunks["data"], audio) {
t.Error("data chunk was modified")
}
if !bytes.Equal(secondChunks["fmt "], firstChunks["fmt "]) {
t.Error("fmt chunk was modified")
}
if !bytes.Equal(secondChunks["LIST"], firstChunks["LIST"]) {
t.Error("LIST chunk was modified")
}
}
func TestWriteWAVOddSizedChunks(t *testing.T) {
audio := bytes.Repeat([]byte{0x07}, 33)
path := writeTestWAV(t, audio)
if err := Write(path, testMetadata()); err != nil {
t.Fatalf("Write() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read wav: %v", err)
}
chunks := parseChunks(t, data)
if !bytes.Equal(chunks["data"], audio) {
t.Error("data chunk was modified")
}
if _, ok := chunks["id3 "]; !ok {
t.Error("missing id3 chunk")
}
}
func TestWriteWAVRejectsNonWAV(t *testing.T) {
path := filepath.Join(t.TempDir(), "track.wav")
if err := os.WriteFile(path, []byte("this is not a wav file at all"), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
if err := Write(path, testMetadata()); err == nil {
t.Error("Write() error = nil, want error")
}
if _, err := os.Stat(path + ".tmp"); err == nil {
t.Error("Write() left a temp file behind")
}
}
-91
View File
@@ -1,91 +0,0 @@
package tags
import (
"os"
"strings"
"github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac/v2"
"github.com/mathismqn/godeez/internal/deezer"
)
type flacTagger struct {
file *flac.File
cmts *flacvorbis.MetaDataBlockVorbisComment
index int
}
func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error {
if album, ok := resource.(*deezer.Album); ok {
dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-")
if len(dateParts) == 3 {
album.Results.Data.PhysicalReleaseDate = dateParts[0]
}
t.addTag("ALBUM", album.Results.Data.Title)
t.addTag("ALBUMARTIST", album.Results.Data.Artist)
t.addTag("PUBLISHER", album.Results.Data.Label)
t.addTag("ORIGINALDATE", album.Results.Data.OriginalReleaseDate)
t.addTag("DATE", album.Results.Data.PhysicalReleaseDate)
t.addTag("COMMENT", album.Results.Data.ProducerLine)
t.addTag("TRACKNUMBER", song.TrackNumber)
}
t.addTag("TITLE", song.Title)
t.addTag("ARTIST", strings.Join(song.Contributors.MainArtists, ", "))
t.addTag("COMPOSER", strings.Join(song.Contributors.Composers, ", "))
t.addTag("LYRICIST", strings.Join(song.Contributors.Authors, ", "))
t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain)
t.addTag("ISRC", song.ISRC)
t.addTag("BPM", tempo)
t.addTag("KEY", key)
t.addTag("INITIALKEY", key)
cmtsmeta := t.cmts.Marshal()
if t.index > 0 {
t.file.Meta[t.index] = &cmtsmeta
} else {
t.file.Meta = append(t.file.Meta, &cmtsmeta)
}
picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg")
if err != nil {
return err
}
picturemeta := picture.Marshal()
t.file.Meta = append(t.file.Meta, &picturemeta)
return t.saveTags(path)
}
func (t *flacTagger) addTag(name, value string) {
if value != "" {
t.cmts.Add(name, value)
}
}
func (t *flacTagger) saveTags(path string) error {
tempPath := path + ".tmp"
t.file.Save(tempPath)
return os.Rename(tempPath, path)
}
func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int, error) {
var cmt *flacvorbis.MetaDataBlockVorbisComment
var cmtIdx int
var err error
for idx, meta := range file.Meta {
if meta.Type == flac.VorbisComment {
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
cmtIdx = idx
if err != nil {
return nil, 0, err
}
}
}
return cmt, cmtIdx, nil
}
-73
View File
@@ -1,73 +0,0 @@
package tags
import (
"fmt"
"strconv"
"strings"
"github.com/bogem/id3v2/v2"
"github.com/mathismqn/godeez/internal/deezer"
)
type id3v2Tagger struct {
tag *id3v2.Tag
}
func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error {
defer t.tag.Close()
duration, err := strconv.Atoi(song.Duration)
if err != nil {
return err
}
song.Duration = fmt.Sprintf("%d", duration*1000)
if album, ok := resource.(*deezer.Album); ok {
t.addTag("TALB", album.Results.Data.Title)
t.addTag("TPE2", album.Results.Data.Artist)
t.addTag("TPUB", album.Results.Data.Label)
t.addTag("TDOR", album.Results.Data.OriginalReleaseDate)
t.addTag("TYER", album.Results.Data.PhysicalReleaseDate)
t.addTag("COMM", album.Results.Data.ProducerLine)
t.addTag("TRCK", song.TrackNumber)
}
t.addTag("TIT2", song.Title)
t.addTag("TPE1", strings.Join(song.Contributors.MainArtists, ", "))
t.addTag("TCOM", strings.Join(song.Contributors.Composers, ", "))
t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", "))
t.addTag("TLEN", song.Duration)
t.addTXXXTag("GAIN", song.Gain)
t.addTXXXTag("ISRC", song.ISRC)
t.addTag("TBPM", tempo)
t.addTag("TKEY", key)
frame := id3v2.PictureFrame{
Encoding: t.tag.DefaultEncoding(),
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Cover",
Picture: cover,
}
t.tag.AddAttachedPicture(frame)
return t.tag.Save()
}
func (t *id3v2Tagger) addTag(name, value string) {
if value != "" {
t.tag.AddTextFrame(name, t.tag.DefaultEncoding(), value)
}
}
func (t *id3v2Tagger) addTXXXTag(description, value string) {
if value != "" {
udf := id3v2.UserDefinedTextFrame{
Encoding: t.tag.DefaultEncoding(),
Description: description,
Value: value,
}
t.tag.AddUserDefinedTextFrame(udf)
}
}
-49
View File
@@ -1,49 +0,0 @@
package tags
import (
"path"
"github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac/v2"
"github.com/mathismqn/godeez/internal/deezer"
)
type tagger interface {
addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error
}
func newTagger(filePath string) (tagger, error) {
ext := path.Ext(filePath)
if ext == ".mp3" {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return nil, err
}
return &id3v2Tagger{tag: tag}, nil
}
file, err := flac.ParseFile(filePath)
if err != nil {
return nil, err
}
cmts, idx, err := extractFLACComment(file)
if err != nil {
return nil, err
}
if cmts == nil && idx > 0 {
cmts = flacvorbis.New()
}
return &flacTagger{file: file, cmts: cmts, index: idx}, nil
}
func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key string) error {
tagger, err := newTagger(filePath)
if err != nil {
return err
}
return tagger.addTags(resource, song, cover, filePath, tempo, key)
}
+251
View File
@@ -0,0 +1,251 @@
package update
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/mathismqn/godeez/internal/fsutil"
)
// managedPrefixes are install roots owned by a package manager. Overwriting a
// binary there would leave the package manager's database describing a file
// that no longer matches, and its next upgrade would silently revert the
// self-update. Users on these installs are pointed back at their package
// manager instead.
var managedPrefixes = []string{
"/nix/store",
"/opt/homebrew",
"/usr/local/Cellar",
"/home/linuxbrew",
"/snap",
"/var/lib/flatpak",
}
// resolveTarget returns the binary that should be replaced, or an error
// explaining why self-updating is not appropriate here.
//
// Symlinks are resolved first so the real file is replaced rather than the
// link: package managers commonly expose a binary through a symlink, and
// following it is what makes the managed prefix check meaningful. A build
// that was not produced by a release is refused outright, since there is no
// version to compare against.
func resolveTarget() (string, error) {
if buildinfo.IsDev() {
return "", fmt.Errorf("development build cannot self-update; install a release from https://github.com/%s/%s/releases",
repoOwner, repoName)
}
exe, err := os.Executable()
if err != nil {
return "", fmt.Errorf("failed to locate the running binary: %w", err)
}
target := exe
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
target = resolved
}
for _, prefix := range managedPrefixes {
if target == prefix || strings.HasPrefix(target, prefix+"/") {
return "", fmt.Errorf("%s was installed by a package manager; update it with that instead", target)
}
}
return target, nil
}
func CheckUpdatable() error {
_, err := resolveTarget()
return err
}
// checkWritable proves the install directory is writable by actually creating
// and removing a file there. Inspecting permission bits would not account for
// read-only mounts or the platform's own rules, and finding out only after
// the download has finished wastes the user's time.
func checkWritable(dir string) error {
f, err := os.CreateTemp(dir, tmpPattern)
if err != nil {
hint := "re-run with sudo"
if runtime.GOOS == "windows" {
hint = "re-run from an elevated prompt"
}
return fmt.Errorf("cannot write to %s: %w; %s", dir, err, hint)
}
name := f.Name()
f.Close()
os.Remove(name)
return nil
}
// Apply downloads release and replaces the running binary with it.
//
// The order of these steps is the safety property. The expected checksum is
// fetched before the asset, so a release that does not publish one fails
// before anything is downloaded. The download lands in a temporary file in
// the install directory, which keeps the final rename on the same filesystem
// and therefore atomic. The binary is only replaced after the checksum
// matches, so a corrupted or tampered download can never be executed.
func (u *Updater) Apply(ctx context.Context, release *Release) error {
target, err := resolveTarget()
if err != nil {
return err
}
dir := filepath.Dir(target)
if err := checkWritable(dir); err != nil {
return err
}
asset, err := release.assetForRuntime()
if err != nil {
return err
}
want, err := u.fetchChecksum(ctx, release, asset.Name)
if err != nil {
return err
}
u.step("Downloading %s", asset.Name)
tmp, sum, err := u.download(ctx, dir, asset)
if err != nil {
return err
}
defer fsutil.Remove(tmp)
u.step("Verifying checksum")
if sum != want {
return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", asset.Name, want, sum)
}
if err := os.Chmod(tmp, 0755); err != nil {
return err
}
u.step("Replacing %s", target)
return u.replaceBinary(target, tmp)
}
func (u *Updater) fetchChecksum(ctx context.Context, release *Release, assetName string) (string, error) {
asset, ok := release.asset(checksumsAsset)
if !ok {
return "", fmt.Errorf("release %s does not publish %s", release.TagName, checksumsAsset)
}
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
body, err := u.get(ctx, asset.URL, nil)
if err != nil {
return "", err
}
defer body.Close()
return parseChecksums(io.LimitReader(body, maxResponseSize), assetName)
}
// parseChecksums finds the digest for name in a sha256sum style file.
//
// The optional "*" before the filename is the marker sha256sum uses for
// binary mode and is not part of the name.
func parseChecksums(r io.Reader, name string) (string, error) {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) != 2 {
continue
}
if strings.TrimPrefix(fields[1], "*") == name {
return strings.ToLower(fields[0]), nil
}
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("no checksum listed for %s", name)
}
// download writes asset to a temporary file in dir and returns its path and
// sha256. The hash is computed while streaming, so the file is never read a
// second time and never has to be held in memory.
func (u *Updater) download(ctx context.Context, dir string, asset Asset) (string, string, error) {
ctx, cancel := context.WithTimeout(ctx, downloadTimeout)
defer cancel()
body, err := u.get(ctx, asset.URL, nil)
if err != nil {
return "", "", err
}
defer body.Close()
f, err := os.CreateTemp(dir, tmpPattern)
if err != nil {
return "", "", err
}
tmp := f.Name()
hash := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, hash), body); err != nil {
f.Close()
fsutil.Remove(tmp)
return "", "", fmt.Errorf("failed to download %s: %w", asset.Name, err)
}
if err := f.Close(); err != nil {
fsutil.Remove(tmp)
return "", "", err
}
return tmp, hex.EncodeToString(hash.Sum(nil)), nil
}
// replaceBinary swaps the new binary into place.
//
// Unix lets a running executable be renamed over, so a single atomic rename
// is enough. Windows locks the file of a running process, so the current
// binary has to be moved aside first, which leaves a window where the target
// does not exist; if installing the replacement then fails, the old one is
// moved back. The .old file is removed on the next update rather than
// immediately, since it is still locked while this process runs.
func (u *Updater) replaceBinary(target, tmp string) error {
if runtime.GOOS != "windows" {
return os.Rename(tmp, target)
}
old := target + ".old"
os.Remove(old)
if err := os.Rename(target, old); err != nil {
return fmt.Errorf("failed to move the current binary aside: %w", err)
}
if err := os.Rename(tmp, target); err != nil {
if rollbackErr := os.Rename(old, target); rollbackErr != nil {
return fmt.Errorf("failed to install the new binary: %w; the previous one could not be restored from %s: %v",
err, old, rollbackErr)
}
return fmt.Errorf("failed to install the new binary: %w", err)
}
os.Remove(old)
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package update
import (
"strings"
"testing"
)
func TestParseChecksums(t *testing.T) {
checksums := `ABCDEF0123 godeez_1.0.0_darwin_arm64
deadbeef *godeez_1.0.0_linux_amd64
malformed-line
one two three
cafebabe godeez_1.0.0_windows_amd64.exe
`
tests := []struct {
name string
asset string
want string
}{
{"plain name lowercased", "godeez_1.0.0_darwin_arm64", "abcdef0123"},
{"star-prefixed name", "godeez_1.0.0_linux_amd64", "deadbeef"},
{"windows asset", "godeez_1.0.0_windows_amd64.exe", "cafebabe"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseChecksums(strings.NewReader(checksums), tt.asset)
if err != nil {
t.Fatalf("parseChecksums: %v", err)
}
if got != tt.want {
t.Errorf("parseChecksums() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseChecksumsMissing(t *testing.T) {
if _, err := parseChecksums(strings.NewReader("abc other_asset\n"), "godeez_1.0.0_darwin_arm64"); err == nil {
t.Error("expected error for missing asset name")
}
}
+143
View File
@@ -0,0 +1,143 @@
package update
import (
"context"
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/mathismqn/godeez/internal/fsutil"
)
const noCheckEnv = "GODEEZ_NO_UPDATE_CHECK"
const (
// cacheTTL keeps the check to roughly once a day, which is often enough
// to notice a release without hitting the GitHub API on every command.
cacheTTL = 24 * time.Hour
// checkTimeout is deliberately short. The check is a courtesy running
// alongside a download, so it gives up quickly rather than delaying
// anything the user actually asked for.
checkTimeout = 3 * time.Second
)
type cacheEntry struct {
CheckedAt time.Time `json:"checked_at"`
LatestVersion string `json:"latest_version"`
}
func cachePath() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "godeez", "update.json"), nil
}
// readCache returns the cached result, or false if there is nothing usable.
// Every failure, including a corrupt or unreadable file, is reported the same
// way: the caller simply checks again, so there is nothing to distinguish.
func readCache() (cacheEntry, bool) {
path, err := cachePath()
if err != nil {
return cacheEntry{}, false
}
data, err := os.ReadFile(path)
if err != nil {
return cacheEntry{}, false
}
var entry cacheEntry
if err := json.Unmarshal(data, &entry); err != nil {
return cacheEntry{}, false
}
if entry.LatestVersion == "" || time.Since(entry.CheckedAt) > cacheTTL {
return cacheEntry{}, false
}
return entry, true
}
func writeCache(version string) error {
path, err := cachePath()
if err != nil {
return err
}
if err := fsutil.EnsureDir(filepath.Dir(path)); err != nil {
return err
}
data, err := json.Marshal(cacheEntry{CheckedAt: time.Now(), LatestVersion: version})
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
// check returns the latest version if it is newer than the running one, or
// "" if it is not. The cache is written even when the release turns out not
// to be newer, since the point is to record that GitHub was asked recently,
// and a failure to write it is ignored: an uncacheable check still works, it
// just repeats.
func check(ctx context.Context) (string, error) {
if entry, ok := readCache(); ok {
return latestIfNewer(entry.LatestVersion), nil
}
release, err := New().Latest(ctx)
if err != nil {
return "", err
}
latest := release.Version()
_ = writeCache(latest)
return latestIfNewer(latest), nil
}
func latestIfNewer(latest string) string {
if IsNewer(buildinfo.Version(), latest) {
return latest
}
return ""
}
// StartCheck begins a background update check and returns a channel that
// yields the newer version, if there is one, and is closed either way.
//
// It runs concurrently so the check never delays the command the user ran,
// and the channel is buffered so the goroutine exits even if nobody reads the
// result. Errors are swallowed: a failed check is not something to report.
//
// The check is skipped entirely for development builds, which have no version
// to compare, and whenever GODEEZ_NO_UPDATE_CHECK is set, which is the escape
// hatch for packagers and offline use. Both cases close the channel
// immediately so callers need no special handling.
func StartCheck(ctx context.Context) <-chan string {
ch := make(chan string, 1)
if os.Getenv(noCheckEnv) != "" || buildinfo.IsDev() {
close(ch)
return ch
}
go func() {
defer close(ch)
ctx, cancel := context.WithTimeout(ctx, checkTimeout)
defer cancel()
if latest, err := check(ctx); err == nil && latest != "" {
ch <- latest
}
}()
return ch
}
+93
View File
@@ -0,0 +1,93 @@
package update
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"runtime"
)
const (
repoOwner = "mathismqn"
repoName = "godeez"
latestReleaseURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest"
checksumsAsset = "checksums.txt"
// maxResponseSize caps what is read from GitHub, so a malformed or
// hostile response cannot exhaust memory. Release JSON and the checksums
// file are both a few kilobytes.
maxResponseSize = 1 << 20
)
var githubAPIHeaders = map[string]string{
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
type Release struct {
TagName string `json:"tag_name"`
Assets []Asset `json:"assets"`
}
type Asset struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
}
func (u *Updater) Latest(ctx context.Context) (*Release, error) {
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
defer cancel()
body, err := u.get(ctx, latestReleaseURL, githubAPIHeaders)
if err != nil {
return nil, err
}
defer body.Close()
var release Release
if err := json.NewDecoder(io.LimitReader(body, maxResponseSize)).Decode(&release); err != nil {
return nil, fmt.Errorf("failed to decode release: %w", err)
}
if release.TagName == "" {
return nil, errors.New("release has no tag name")
}
return &release, nil
}
func (r *Release) Version() string {
return trimV(r.TagName)
}
func (r *Release) asset(name string) (Asset, bool) {
for _, a := range r.Assets {
if a.Name == name {
return a, true
}
}
return Asset{}, false
}
// assetForRuntime finds the release asset for the current platform.
//
// The name is reconstructed from the goreleaser naming template rather than
// discovered, so this has to stay in step with the name_template in
// .goreleaser.yaml: a change there breaks self-update for everyone already
// running an older build.
func (r *Release) assetForRuntime() (Asset, error) {
name := fmt.Sprintf("%s_%s_%s_%s", repoName, r.Version(), runtime.GOOS, runtime.GOARCH)
if runtime.GOOS == "windows" {
name += ".exe"
}
asset, ok := r.asset(name)
if !ok {
return Asset{}, fmt.Errorf("release %s has no binary for %s/%s (expected %s)",
r.TagName, runtime.GOOS, runtime.GOARCH, name)
}
return asset, nil
}
+71
View File
@@ -0,0 +1,71 @@
// Package update handles both halves of keeping godeez current: the passive
// background check that tells the user a newer release exists, and the
// `godeez update` command that installs it.
//
// Releases come from the GitHub releases API. Downloads are verified against
// the published checksums file before anything replaces the running binary,
// and installs owned by a package manager are refused rather than overwritten.
package update
import (
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/mathismqn/godeez/internal/buildinfo"
)
const (
// Timeouts are per request rather than for the whole operation, so a slow
// but progressing download is not killed part way. The generous download
// timeout covers a binary of a few tens of megabytes on a poor connection.
apiTimeout = 30 * time.Second
downloadTimeout = 5 * time.Minute
tmpPattern = ".godeez-update-*"
)
type Updater struct {
client *http.Client
Out io.Writer
}
// New returns an Updater that reports nothing. Callers that want the step by
// step progress, such as the update command, set Out themselves; the
// background check leaves it discarding so it cannot write over the download
// output.
func New() *Updater {
return &Updater{
client: &http.Client{},
Out: io.Discard,
}
}
func (u *Updater) step(format string, args ...any) {
fmt.Fprintf(u.Out, format+"...\n", args...)
}
func (u *Updater) get(ctx context.Context, url string, headers map[string]string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", buildinfo.UserAgent())
for name, value := range headers {
req.Header.Set(name, value)
}
resp, err := u.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, url)
}
return resp.Body, nil
}
+44
View File
@@ -0,0 +1,44 @@
package update
import (
"strings"
"golang.org/x/mod/semver"
)
func trimV(v string) string {
return strings.TrimPrefix(strings.TrimSpace(v), "v")
}
// canonical normalises a version for comparison, returning "" if it is not
// valid semver. Tags carry a leading "v" and buildinfo reports versions
// without one, so the prefix is added back before validating rather than
// requiring callers to agree on a spelling.
func canonical(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if !strings.HasPrefix(v, "v") {
v = "v" + v
}
if !semver.IsValid(v) {
return ""
}
return v
}
// IsNewer reports whether latest is a strictly newer release than current.
//
// An unparseable version on either side yields false rather than an error or
// a guess: this decides whether to nag the user about an update, and staying
// quiet is the right failure mode when the comparison is meaningless.
func IsNewer(current, latest string) bool {
c, l := canonical(current), canonical(latest)
if c == "" || l == "" {
return false
}
return semver.Compare(l, c) > 0
}
+65
View File
@@ -0,0 +1,65 @@
package update
import "testing"
func TestIsNewer(t *testing.T) {
tests := []struct {
current string
latest string
want bool
}{
{"1.0.0", "1.0.1", true},
{"v1.0.0", "v1.1.0", true},
{"1.0.0", "v2.0.0", true},
{"1.0.0", "1.0.0", false},
{"1.1.0", "1.0.0", false},
{" 1.0.0 ", "1.0.1", true},
{"1.0.0", "1.0.1-rc.1", true},
{"1.0.0-rc.1", "1.0.0", true},
{"dev", "1.0.0", false},
{"1.0.0", "not-a-version", false},
{"", "1.0.0", false},
{"1.0.0", "", false},
}
for _, tt := range tests {
if got := IsNewer(tt.current, tt.latest); got != tt.want {
t.Errorf("IsNewer(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want)
}
}
}
func TestTrimV(t *testing.T) {
tests := []struct {
in string
want string
}{
{"v1.2.3", "1.2.3"},
{"1.2.3", "1.2.3"},
{" v1.2.3 ", "1.2.3"},
}
for _, tt := range tests {
if got := trimV(tt.in); got != tt.want {
t.Errorf("trimV(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestCanonical(t *testing.T) {
tests := []struct {
in string
want string
}{
{"1.2.3", "v1.2.3"},
{"v1.2.3", "v1.2.3"},
{"", ""},
{"garbage", ""},
}
for _, tt := range tests {
if got := canonical(tt.in); got != tt.want {
t.Errorf("canonical(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
+12 -1
View File
@@ -1,3 +1,5 @@
// Command godeez downloads music from Deezer. See the cmd package for the
// command line surface and the internal packages for the download pipeline.
package main package main
import ( import (
@@ -9,8 +11,17 @@ import (
) )
func main() { func main() {
// The interrupt-cancelled context is threaded through every network call
// and file write, so Ctrl-C unwinds the download cleanly and leaves no
// partial files behind rather than killing the process mid-write.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
cmd.RootCmd.ExecuteContext(ctx) // Nothing is printed here because cobra has already reported the error.
// stop is called explicitly since the deferred call would not run before
// os.Exit.
if err := cmd.Execute(ctx); err != nil {
stop()
os.Exit(1)
}
} }