Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ad9b4904 | ||
|
|
5dfd832d0d | ||
|
|
3b51426251 | ||
|
|
770efdb752 | ||
|
|
d3a536f744 | ||
|
|
dddfad4984 | ||
|
|
bcb3558a73 | ||
|
|
84dd371fdd | ||
|
|
b804c5e288 | ||
|
|
b3777bf8eb | ||
|
|
eea049aae9 | ||
|
|
c1775e5c04 | ||
|
|
a695fb4de3 | ||
|
|
5bb1448650 | ||
|
|
e90a56ec53 | ||
|
|
b616a58d7b | ||
|
|
66fda7424a | ||
|
|
3fe050ea15 | ||
|
|
563efe78bd | ||
|
|
f8b301e24c | ||
|
|
88319ebb3c | ||
|
|
5c95554855 | ||
|
|
dbb808f371 | ||
|
|
12dbbb5e10 | ||
|
|
dbd7837b17 | ||
|
|
16c71c8688 | ||
|
|
3c9da5f24a | ||
|
|
0ab25addd5 | ||
|
|
714d567603 | ||
|
|
3ec258b876 | ||
|
|
97153c1688 | ||
|
|
1fbbd60106 | ||
|
|
90b7bb25f0 | ||
|
|
88c7b57648 | ||
|
|
4286ccd626 | ||
|
|
887fc682fe | ||
|
|
3acd1aeb4f | ||
|
|
544fd09199 | ||
|
|
b734ad802a | ||
|
|
eb93d01c19 | ||
|
|
0e0cbdad5e | ||
|
|
785bb58cb5 | ||
|
|
cab49d2818 | ||
|
|
c544c9406c | ||
|
|
0287cfba9c | ||
|
|
c2f60be18e | ||
|
|
44a57d5f3a | ||
|
|
09fa93f1e4 | ||
|
|
f45ae6aee6 | ||
|
|
56b0f404d6 | ||
|
|
3ce5c4d365 | ||
|
|
465f54e466 | ||
|
|
163fa18845 | ||
|
|
020f8c1162 | ||
|
|
9c7580d256 | ||
|
|
d50945a7ef | ||
|
|
08160b0290 | ||
|
|
cf699834f6 | ||
|
|
650d578b80 | ||
|
|
acf468eddd | ||
|
|
08b608ec80 | ||
|
|
cd45c3f40a | ||
|
|
0a8da3d0e7 | ||
|
|
2ef1ba42a8 | ||
|
|
e4c421ff00 | ||
|
|
9d87f635d0 | ||
|
|
8c9b327b2c | ||
|
|
0493866141 | ||
|
|
523b562ed0 | ||
|
|
c2b4fcfa11 | ||
|
|
9b23e3a8d9 | ||
|
|
cf7f76948b | ||
|
|
4b2c9c92b8 | ||
|
|
22ddb2408b | ||
|
|
3022e226ce | ||
|
|
c4329431b5 | ||
|
|
be40910480 | ||
|
|
1813a16bb7 | ||
|
|
283c5d76e9 | ||
|
|
7154c15cc3 | ||
|
|
50fde9dfa9 | ||
|
|
b4f69396af | ||
|
|
fe3491e16d | ||
|
|
ef018b38ad | ||
|
|
33e774d078 | ||
|
|
a619b37922 | ||
|
|
31d8a3b04f | ||
|
|
95c81a2a67 | ||
|
|
498f7c8d3f | ||
|
|
b19c92f227 | ||
|
|
99bdc22a25 | ||
|
|
0680d8c8e7 | ||
|
|
67b4fe404e |
Executable
+36
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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
@@ -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 artist’s 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 user’s 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 user’s 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
|
||||||
|
|
||||||
|
|||||||
@@ -6,93 +6,156 @@
|
|||||||
[](https://github.com/mathismqn/godeez/blob/main/LICENSE)
|
[](https://github.com/mathismqn/godeez/blob/main/LICENSE)
|
||||||
[](https://github.com/mathismqn/godeez/commits/main)
|
[](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 Deezer’s 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.
|
||||||
|
|
||||||
|
> **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, you’ll 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 +163,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. Here’s 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 it’s 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 don’t 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 +244,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 Deezer’s Terms of Service.
|
> ⚠️ This tool is provided for educational and personal use only. Please ensure your usage complies with Deezer's Terms of Service.
|
||||||
|
|||||||
+72
-31
@@ -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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config.MigrateLegacy(cfg.OutputDir)
|
||||||
|
|
||||||
if err := dl.Run(ctx, opts, args[0]); err != 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) {
|
if errors.Is(err, context.Canceled) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-13
@@ -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.
|
||||||
|
//
|
||||||
|
// The update check is started before the command and collected after it, so
|
||||||
|
// the network round trip overlaps with work the user actually asked for
|
||||||
|
// instead of adding to the startup time.
|
||||||
|
func Execute(ctx context.Context) error {
|
||||||
|
root := newRootCmd()
|
||||||
|
|
||||||
|
var notice <-chan string
|
||||||
|
if wantsUpdateNotice(root) {
|
||||||
|
notice = update.StartCheck(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := root.ExecuteContext(ctx)
|
||||||
|
|
||||||
|
printUpdateNotice(notice)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRootCmd() *cobra.Command {
|
||||||
|
root := &cobra.Command{
|
||||||
Use: "godeez",
|
Use: "godeez",
|
||||||
Short: "GoDeez is a tool to download music from Deezer",
|
Short: "GoDeez is a tool to download music from Deezer",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
}
|
||||||
var err error
|
|
||||||
appConfig, err = config.New(cfgPath)
|
|
||||||
|
|
||||||
return err
|
root.AddCommand(
|
||||||
},
|
newDownloadCmd(),
|
||||||
|
newLoginCmd(),
|
||||||
|
newLogoutCmd(),
|
||||||
|
newUpdateCmd(),
|
||||||
|
newVersionCmd(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
// wantsUpdateNotice decides whether this invocation should check for updates.
|
||||||
RootCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "config file (default ~/.godeez/config.toml)")
|
//
|
||||||
|
// 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:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+24
-62
@@ -1,82 +1,44 @@
|
|||||||
|
// Package config resolves where godeez reads its session from and writes its
|
||||||
|
// downloads to. There is no config file: the output directory is fixed and
|
||||||
|
// the only 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"
|
||||||
|
|
||||||
"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.
|
||||||
|
//
|
||||||
|
// 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")
|
||||||
|
|
||||||
homeDir, err := os.UserHomeDir()
|
homeDir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfgDir := filepath.Join(homeDir, ".godeez")
|
outputDir := filepath.Join(homeDir, "Music", "GoDeez")
|
||||||
if err := fileutil.EnsureDir(cfgDir); err != nil {
|
if err := fsutil.EnsureDir(outputDir); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfgPath == "" {
|
return &Config{
|
||||||
cfgPath = path.Join(cfgDir, "config.toml")
|
ARLCookie: arl,
|
||||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
OutputDir: outputDir,
|
||||||
fmt.Printf("Config file not found, creating one at %s\n", cfgPath)
|
}, nil
|
||||||
|
|
||||||
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 &cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
-84
@@ -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.
|
||||||
|
//
|
||||||
|
// The validate callback handed to resolveARL is the real authentication, not
|
||||||
|
// a separate probe, so a stored ARL that still works is not sent twice.
|
||||||
|
// session is therefore only nil here when resolveARL had to log in again to
|
||||||
|
// mint a fresh ARL.
|
||||||
|
func resolveSession(ctx context.Context, arlCookie string) (*Session, error) {
|
||||||
|
if arlCookie != "" {
|
||||||
|
return authenticate(ctx, arlCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
var session *Session
|
||||||
|
validate := func(ctx context.Context, arl string) error {
|
||||||
|
s, err := authenticate(ctx, arl)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
session = s
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
arl, err := resolveARL(ctx, validate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
"nb": 10000,
|
||||||
"start": 0,
|
"start": 0,
|
||||||
"playlist_id": id,
|
|
||||||
"alb_id": id,
|
|
||||||
"lang": "en",
|
"lang": "en",
|
||||||
"tab": 0,
|
"tab": 0,
|
||||||
"tags": true,
|
"tags": true,
|
||||||
"header": true,
|
"header": true,
|
||||||
}
|
}
|
||||||
|
payload[kind.idKey()] = id
|
||||||
|
|
||||||
jsonData, err := json.Marshal(payload)
|
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)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.Session.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
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+15
-39
@@ -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"`
|
|
||||||
Cipher Cipher `json:"cipher"`
|
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
Sources []Source `json:"sources"`
|
Sources []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// 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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.printSummary(outputDir, time.Since(startTime))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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, " / ")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user