Compare commits
74
Commits
v1.2.0
...
b1ad9b4904
| 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 |
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
|
||||
+79
-16
@@ -5,44 +5,107 @@ 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/),
|
||||
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
|
||||
- New `artist` command to download an artist’s top tracks.
|
||||
- `--limit` flag for the `artist` command to restrict the number of tracks.
|
||||
- `--strict` flag for downloads: fail if the requested quality is unavailable.
|
||||
|
||||
- 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
|
||||
- Default download quality is now **MP3 320kbps**.
|
||||
|
||||
- Set default download quality to MP3 320 kbps.
|
||||
|
||||
### Removed
|
||||
- The `--quality=best` option. Fallback to lower quality is now the **default behavior**; use the new `--strict` flag if you want to prevent fallback.
|
||||
|
||||
- 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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.
|
||||
- New local **database system** (`tracks.db`) to track downloaded files and avoid re-downloading, even if files are renamed or moved.
|
||||
- Improved CLI **output formatting** for a cleaner and more informative user experience.
|
||||
|
||||
- Support downloading full albums and playlists with more than 40 tracks (previous limit removed).
|
||||
- Fetch and embed BPM and musical key into metadata tags.
|
||||
- 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
|
||||
- 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`.
|
||||
If you're upgrading from an older version, move your existing config into `.godeez/config.toml`.
|
||||
- Simplified `config.toml`: `iv` and `license_token` are no longer required.
|
||||
- Cleanup logic: corrupted or incomplete files are now automatically deleted on download failure.
|
||||
|
||||
- Replace the `godeez` file in the user’s home directory with a `.godeez/` directory, which now stores both `config.toml` and `tracks.db`.
|
||||
👉 If upgrading, move your existing config into `.godeez/config.toml`.
|
||||
- Simplify `config.toml`: remove the need for `iv` and `license_token`.
|
||||
- Automatically delete corrupted or incomplete files on download failure.
|
||||
|
||||
## [1.0.0] - 2024-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of **GoDeez** with basic Deezer album and playlist downloading capabilities.
|
||||
- Initial release of **GoDeez** with basic Deezer album and playlist downloading capabilities.
|
||||
|
||||
@@ -6,93 +6,156 @@
|
||||
[](https://github.com/mathismqn/godeez/blob/main/LICENSE)
|
||||
[](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) •
|
||||
[Installation](#installation) •
|
||||
[Updating](#updating) •
|
||||
[Configuration](#configuration) •
|
||||
[Usage](#usage) •
|
||||
[Contributing](#contributing) •
|
||||
[License](#license)
|
||||
[Usage](#usage)
|
||||
|
||||
</div>
|
||||
|
||||
## Features
|
||||
|
||||
* Download playlists, albums, and artists' top tracks from Deezer
|
||||
* Select audio quality: MP3 128kbps, MP3 320kbps (default), or FLAC (⚠️ non-premium accounts are limited to 128kbps)
|
||||
* Automatically adds metadata tags to downloaded files
|
||||
* Fetch and tag songs with BPM and musical key
|
||||
* Smart skip system: avoids re-downloading already existing files using hashes and metadata
|
||||
* Cross-platform support (works on Windows, macOS, and Linux)
|
||||
* Simple and easy-to-use CLI
|
||||
- Download playlists, albums, artists' top tracks, and individual tracks
|
||||
- Choose audio quality: MP3 128 kbps, MP3 320 kbps (default), or lossless FLAC/WAV (⚠️ non-premium accounts are limited to MP3 128 kbps)
|
||||
- Authenticate with an ARL cookie or with your Deezer email and password
|
||||
- Automatically embed metadata tags (artist, album, title, artwork, etc.)
|
||||
- Fetch and tag tracks with BPM, musical key, and genre
|
||||
- Works on Windows, macOS, and Linux
|
||||
|
||||
## 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.
|
||||
2. Download the appropriate binary for your operating system (Windows, macOS, or Linux).
|
||||
3. Move the binary to a directory included in $PATH for easy access (optional but recommended).
|
||||
2. Download the appropriate binary for your operating system and architecture, named `godeez_<version>_<os>_<arch>`.
|
||||
3. (Optional) Move the binary to a directory on your `$PATH` for easier access.
|
||||
|
||||
Example (Linux/macOS):
|
||||
|
||||
```bash
|
||||
# Move the downloaded binary to /usr/local/bin for easy access from anywhere
|
||||
mv godeez-1.2.0-linux-amd64 /usr/local/bin/godeez
|
||||
# Make it executable and move it to /usr/local/bin for access from anywhere
|
||||
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
|
||||
|
||||
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:
|
||||
- `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
|
||||
### ARL cookie
|
||||
|
||||
### 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.
|
||||
2. Edit the `config.toml` file with a text editor to set the required values.
|
||||
|
||||
### 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
|
||||
```bash
|
||||
export DEEZER_ARL="your_arl_cookie_here"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
### CLI Overview
|
||||
### CLI overview
|
||||
|
||||
When you run `godeez` without any additional commands, you’ll see a general help menu:
|
||||
```bash
|
||||
Running `godeez` without arguments shows the help menu:
|
||||
|
||||
```text
|
||||
GoDeez is a tool to download music from Deezer
|
||||
|
||||
Usage:
|
||||
@@ -100,47 +163,80 @@ Usage:
|
||||
|
||||
Available Commands:
|
||||
completion Generate the autocompletion script for the specified shell
|
||||
download Download songs from Deezer
|
||||
download Download tracks from Deezer
|
||||
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:
|
||||
--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.
|
||||
```
|
||||
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`:
|
||||
```bash
|
||||
Download songs from Deezer
|
||||
### Download commands
|
||||
|
||||
```text
|
||||
Download tracks from Deezer
|
||||
|
||||
Usage:
|
||||
godeez download [command]
|
||||
|
||||
Available Commands:
|
||||
album Download songs from an album
|
||||
artist Download top songs from an artist
|
||||
playlist Download songs from a playlist
|
||||
album Download tracks from an album
|
||||
artist Download an artist's top tracks
|
||||
playlist Download tracks from a playlist
|
||||
track Download a single track
|
||||
|
||||
Flags:
|
||||
--bpm fetch BPM/key and add to file tags
|
||||
--config string config file (default ~/.godeez/config.toml)
|
||||
--genre fetch genre and add to file tags
|
||||
-h, --help help for download
|
||||
-q, --quality string download quality [mp3_128, mp3_320, flac] (default "mp3_320")
|
||||
--strict fail the song download if the quality is not available
|
||||
-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)
|
||||
|
||||
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
|
||||
|
||||
Contributions help make **GoDeez** a better tool for everyone, and any help is greatly appreciated.
|
||||
Whether it’s a bug fix, a new feature, or improving documentation, your input is valuable.
|
||||
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.
|
||||
|
||||
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.
|
||||
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!
|
||||
To contribute, fork the repository and open a pull request. To report a bug or suggest a feature, open an issue instead.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+67
-54
@@ -8,79 +8,92 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/config"
|
||||
"github.com/mathismqn/godeez/internal/downloader"
|
||||
"github.com/mathismqn/godeez/internal/deezer"
|
||||
"github.com/mathismqn/godeez/internal/download"
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
opts downloader.Options
|
||||
cfgPath string
|
||||
)
|
||||
|
||||
var downloadCmd = &cobra.Command{
|
||||
Use: "download",
|
||||
Short: "Download songs from Deezer",
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(downloadCmd)
|
||||
|
||||
downloadCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "config file (default ~/.godeez/config.toml)")
|
||||
downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]")
|
||||
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.PersistentFlags().BoolVar(&opts.Strict, "strict", false, "fail the song download if the quality is not available")
|
||||
|
||||
downloadCmd.AddCommand(
|
||||
newDownloadCmd("album"),
|
||||
newDownloadCmd("playlist"),
|
||||
newDownloadCmd("artist"),
|
||||
)
|
||||
}
|
||||
|
||||
func newDownloadCmd(resourceType string) *cobra.Command {
|
||||
article := "a"
|
||||
if resourceType == "album" {
|
||||
article = "an"
|
||||
func newDownloadCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "download",
|
||||
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>", resourceType, resourceType),
|
||||
Short: fmt.Sprintf("Download songs from %s %s", article, resourceType),
|
||||
Use: fmt.Sprintf("%s <%s_id>", kind, kind),
|
||||
Short: downloadShort(kind),
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
appConfig, err := config.New(cfgPath)
|
||||
opts.Quality = strings.ToLower(opts.Quality)
|
||||
return opts.Validate(kind)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig))
|
||||
|
||||
opts.Quality = strings.ToLower(opts.Quality)
|
||||
|
||||
return opts.Validate()
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
appConfigVal := ctx.Value("appConfig")
|
||||
appConfig, _ := appConfigVal.(*config.Config)
|
||||
|
||||
dl := downloader.New(appConfig, resourceType)
|
||||
if err := dl.Run(ctx, opts, args[0]); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
config.MigrateLegacy(cfg.OutputDir)
|
||||
|
||||
st, err := store.Open(cfg.OutputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
return nil
|
||||
err = download.New(cfg, st, kind).Run(cmd.Context(), *opts, args[0])
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
if resourceType == "artist" {
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download")
|
||||
cmd.Short = "Download top songs from an artist"
|
||||
if kind == deezer.KindArtist {
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of tracks to download")
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
+103
-20
@@ -1,30 +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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/buildinfo"
|
||||
"github.com/mathismqn/godeez/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "godeez",
|
||||
Short: "GoDeez is a tool to download music from Deezer",
|
||||
SilenceUsage: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// TEMPORARILY DISABLED:
|
||||
// Watcher autostart (EnsureAutostart) has been disabled due to
|
||||
// concurrency issues with database access (e.g., when using `download`).
|
||||
// To re-enable, uncomment the line below.
|
||||
// updateNoticeAnnotation marks the commands that may print an update notice.
|
||||
// It is an annotation rather than a field because it is inherited: marking
|
||||
// the download command opts in all of its subcommands.
|
||||
const updateNoticeAnnotation = "godeez:update-notice"
|
||||
|
||||
/*
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
// 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()
|
||||
|
||||
if err := watcher.EnsureAutostart(homeDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to install autostart for watcher: %v\n", err)
|
||||
}
|
||||
*/
|
||||
var notice <-chan string
|
||||
if wantsUpdateNotice(root) {
|
||||
notice = update.StartCheck(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
err := root.ExecuteContext(ctx)
|
||||
|
||||
printUpdateNotice(notice)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "godeez",
|
||||
Short: "GoDeez is a tool to download music from Deezer",
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
root.AddCommand(
|
||||
newDownloadCmd(),
|
||||
newLoginCmd(),
|
||||
newLogoutCmd(),
|
||||
newUpdateCmd(),
|
||||
newVersionCmd(),
|
||||
)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// wantsUpdateNotice decides whether this invocation should check for updates.
|
||||
//
|
||||
// The aim is to nag only during real interactive use. Notices are suppressed
|
||||
// when stderr is not a terminal, so they cannot corrupt piped or scripted
|
||||
// output; on help output, where they are noise; on commands that only print
|
||||
// their usage; and on anything not explicitly opted in via the annotation,
|
||||
// which notably keeps `godeez version` and `godeez update` quiet.
|
||||
func wantsUpdateNotice(root *cobra.Command) bool {
|
||||
if !term.IsTerminal(int(os.Stderr.Fd())) {
|
||||
return false
|
||||
}
|
||||
|
||||
args := os.Args[1:]
|
||||
if slices.Contains(args, "-h") || slices.Contains(args, "--help") {
|
||||
return false
|
||||
}
|
||||
|
||||
target, _, err := root.Find(args)
|
||||
if err != nil || target == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if target.Run == nil && target.RunE == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for cmd := target; cmd != nil; cmd = cmd.Parent() {
|
||||
if cmd.Annotations[updateNoticeAnnotation] == "true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// printUpdateNotice prints the notice only if the check has already finished.
|
||||
//
|
||||
// The non-blocking receive is the point: the command is done and the user
|
||||
// should get their prompt back, so a check that is still in flight is
|
||||
// dropped rather than waited on. A nil channel, meaning no check was started,
|
||||
// takes the same path.
|
||||
func printUpdateNotice(notice <-chan string) {
|
||||
select {
|
||||
case latest := <-notice:
|
||||
if latest == "" {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n ┌ Update available: %s → %s\n └ Run `godeez update` to install\n",
|
||||
buildinfo.Version(), latest)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var watchCmd = &cobra.Command{
|
||||
Use: "watch",
|
||||
Short: "Watch playlists and auto-download new tracks",
|
||||
}
|
||||
|
||||
// TEMPORARILY DISABLED:
|
||||
// The `watch` command and all its subcommands are currently disabled
|
||||
// due to known issues (e.g., database access conflicts with `download`).
|
||||
// To re-enable, uncomment the line below.
|
||||
func init() {
|
||||
// RootCmd.AddCommand(watchCmd)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var watchAddCmd = &cobra.Command{
|
||||
Use: "add <playlist_id>",
|
||||
Short: "Add a playlist to the watch list",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := args[0]
|
||||
ok, err := store.IsWatched(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
fmt.Printf("Playlist %s is already being watched\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
playlist := &store.WatchedPlaylist{
|
||||
ID: id,
|
||||
Quality: strings.ToLower(opts.Quality),
|
||||
BPM: opts.BPM,
|
||||
Timeout: opts.Timeout,
|
||||
}
|
||||
|
||||
if err := playlist.Save(); err != nil {
|
||||
return fmt.Errorf("failed to add playlist %s to watch list: %w", id, err)
|
||||
}
|
||||
fmt.Printf("Playlist %s added to watch list\n", id)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
watchCmd.AddCommand(watchAddCmd)
|
||||
|
||||
watchAddCmd.Flags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]")
|
||||
watchAddCmd.Flags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
|
||||
watchAddCmd.Flags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var watchListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List watched playlists",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
playlists, err := store.ListWatchedPlaylists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list watched playlists: %w", err)
|
||||
}
|
||||
|
||||
if len(playlists) == 0 {
|
||||
fmt.Println("No watched playlists.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tQuality\tFetch BPM\tTimeout")
|
||||
fmt.Fprintln(w, "---\t-------\t----------\t-------")
|
||||
|
||||
for _, playlist := range playlists {
|
||||
fmt.Fprintf(w, "%s\t%s\t%t\t%s\n", playlist.ID, playlist.Quality, playlist.BPM, playlist.Timeout)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
watchCmd.AddCommand(watchListCmd)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var watchRemoveCmd = &cobra.Command{
|
||||
Use: "remove <playlist_id>",
|
||||
Short: "Remove a playlist from the watch list",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id := args[0]
|
||||
ok, err := store.IsWatched(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("playlist %s is not being watched", id)
|
||||
}
|
||||
|
||||
if err := store.RemoveWatchedPlaylist(id); err != nil {
|
||||
return fmt.Errorf("failed to remove playlist %s from watch list: %w", id, err)
|
||||
}
|
||||
fmt.Printf("Playlist %s removed from watch list\n", id)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
watchCmd.AddCommand(watchRemoveCmd)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/config"
|
||||
"github.com/mathismqn/godeez/internal/watcher"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var watchRunCmd = &cobra.Command{
|
||||
Use: "run",
|
||||
Short: "Start the background playlist watcher",
|
||||
Hidden: true,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
appConfig, err := config.New("")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig))
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := cmd.Context()
|
||||
appConfigVal := ctx.Value("appConfig")
|
||||
appConfig, _ := appConfigVal.(*config.Config)
|
||||
|
||||
w := watcher.New(appConfig)
|
||||
w.Run(ctx, opts)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
watchCmd.AddCommand(watchRunCmd)
|
||||
}
|
||||
@@ -1,36 +1,32 @@
|
||||
module github.com/mathismqn/godeez
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/mewkiz/flac v1.0.13
|
||||
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 (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.10.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/term v0.34.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/icza/bitio v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
|
||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/bogem/id3v2/v2 v2.1.4
|
||||
github.com/briandowns/spinner v1.23.2
|
||||
github.com/flytam/filenamify v1.2.0
|
||||
@@ -38,7 +34,7 @@ require (
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
go.etcd.io/bbolt v1.4.2
|
||||
golang.org/x/crypto v0.41.0
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
)
|
||||
|
||||
@@ -1,150 +1,99 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
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/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
|
||||
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
|
||||
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
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/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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
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/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/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
|
||||
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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
|
||||
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
|
||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
|
||||
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
|
||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
|
||||
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/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/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
|
||||
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
|
||||
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
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.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
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-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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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-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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
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 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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
-63
@@ -1,83 +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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/fileutil"
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/mathismqn/godeez/internal/fsutil"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ArlCookie string `mapstructure:"arl_cookie"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
OutputDir string `mapstructure:"output_dir"`
|
||||
HomeDir string
|
||||
ARLCookie string
|
||||
OutputDir string
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
cfgDir := filepath.Join(homeDir, ".godeez")
|
||||
if err := fileutil.EnsureDir(cfgDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||
outputDir := filepath.Join(homeDir, "Music", "GoDeez")
|
||||
if err := fsutil.EnsureDir(outputDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
if cfgPath == "" {
|
||||
cfgPath = path.Join(cfgDir, "config.toml")
|
||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||
fmt.Printf("Config file not found, creating one at %s\n", cfgPath)
|
||||
|
||||
content := []byte("arl_cookie = ''\nsecret_key = ''\noutput_dir = ''\n")
|
||||
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config file: %w", err)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
viper.SetConfigFile(cfgPath)
|
||||
viper.SetConfigType("toml")
|
||||
viper.AutomaticEnv()
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{HomeDir: homeDir}
|
||||
if err := viper.Unmarshal(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
if err := cfg.Validate(); 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() 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(c.HomeDir, "Music", "GoDeez")
|
||||
}
|
||||
|
||||
return nil
|
||||
return &Config{
|
||||
ARLCookie: arl,
|
||||
OutputDir: outputDir,
|
||||
}, 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
|
||||
}
|
||||
+12
-39
@@ -3,9 +3,7 @@ package deezer
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/flytam/filenamify"
|
||||
)
|
||||
@@ -19,58 +17,33 @@ type Album struct {
|
||||
PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"`
|
||||
Label string `json:"LABEL_NAME"`
|
||||
ProducerLine string `json:"PRODUCER_LINE"`
|
||||
Copyright string `json:"COPYRIGHT"`
|
||||
Duration string `json:"DURATION"`
|
||||
} `json:"DATA"`
|
||||
Songs struct {
|
||||
Data []*Song `json:"data"`
|
||||
Tracks struct {
|
||||
Data []*Track `json:"data"`
|
||||
} `json:"SONGS"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func (a *Album) String() 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 {
|
||||
func (a *Album) Title() string {
|
||||
return a.Results.Data.Title
|
||||
}
|
||||
|
||||
func (a *Album) GetSongs() []*Song {
|
||||
return a.Results.Songs.Data
|
||||
func (a *Album) Tracks() []*Track {
|
||||
return a.Results.Tracks.Data
|
||||
}
|
||||
|
||||
func (a *Album) SetSongs(s []*Song) {
|
||||
a.Results.Songs.Data = s
|
||||
func (a *Album) SetTracks(t []*Track) {
|
||||
a.Results.Tracks.Data = t
|
||||
}
|
||||
|
||||
func (a *Album) GetOutputDir(outputDir string) string {
|
||||
func (a *Album) OutputDir(outputDir string) string {
|
||||
base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
|
||||
base, _ = filenamify.Filenamify(base, filenamify.Options{})
|
||||
outputDir = path.Join(outputDir, base)
|
||||
|
||||
return outputDir
|
||||
return filepath.Join(outputDir, base)
|
||||
}
|
||||
|
||||
func (a *Album) Unmarshal(data []byte) error {
|
||||
func (a *Album) decode(data []byte) error {
|
||||
return json.Unmarshal(data, a)
|
||||
}
|
||||
|
||||
+12
-54
@@ -2,11 +2,7 @@ package deezer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/flytam/filenamify"
|
||||
)
|
||||
@@ -16,67 +12,29 @@ type Artist struct {
|
||||
Data struct {
|
||||
Name string `json:"ART_NAME"`
|
||||
} `json:"DATA"`
|
||||
Songs struct {
|
||||
Data []*Song `json:"data"`
|
||||
Tracks struct {
|
||||
Data []*Track `json:"data"`
|
||||
} `json:"TOP"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func (a *Artist) GetType() string {
|
||||
return "Artist"
|
||||
}
|
||||
|
||||
func (a *Artist) GetTitle() string {
|
||||
func (a *Artist) Title() string {
|
||||
return a.Results.Data.Name
|
||||
}
|
||||
|
||||
func (a *Artist) GetSongs() []*Song {
|
||||
return a.Results.Songs.Data
|
||||
func (a *Artist) Tracks() []*Track {
|
||||
return a.Results.Tracks.Data
|
||||
}
|
||||
|
||||
func (a *Artist) SetSongs(s []*Song) {
|
||||
a.Results.Songs.Data = s
|
||||
func (a *Artist) SetTracks(t []*Track) {
|
||||
a.Results.Tracks.Data = t
|
||||
}
|
||||
|
||||
func (a *Artist) GetOutputDir(outputDir string) string {
|
||||
base, _ := filenamify.Filenamify(a.GetTitle(), filenamify.Options{})
|
||||
return path.Join(outputDir, base)
|
||||
func (a *Artist) OutputDir(outputDir string) string {
|
||||
base, _ := filenamify.Filenamify(a.Results.Data.Name, filenamify.Options{})
|
||||
return filepath.Join(outputDir, base)
|
||||
}
|
||||
|
||||
func (a *Artist) Unmarshal(data []byte) error {
|
||||
func (a *Artist) decode(data []byte) error {
|
||||
return json.Unmarshal(data, a)
|
||||
}
|
||||
|
||||
func (a *Artist) String() string {
|
||||
tracks := a.GetSongs()
|
||||
count := len(tracks)
|
||||
|
||||
limit := 3
|
||||
if count < limit {
|
||||
limit = count
|
||||
}
|
||||
|
||||
totalSec := 0
|
||||
for _, s := range tracks {
|
||||
if d, err := strconv.Atoi(s.Duration); err == nil {
|
||||
totalSec += d
|
||||
}
|
||||
}
|
||||
totalDuration := time.Duration(totalSec) * time.Second
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "============= [ Artist Info ] =============\n")
|
||||
fmt.Fprintf(&b, "Artist: %s\n", a.GetTitle())
|
||||
fmt.Fprintf(&b, "Tracks: %d\n", count)
|
||||
fmt.Fprintf(&b, "Playtime: %s\n", totalDuration)
|
||||
fmt.Fprintf(&b, "-------------------------------------------\n")
|
||||
fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
s := tracks[i]
|
||||
title := s.GetTitle()
|
||||
fmt.Fprintf(&b, " %2d. %s – %s\n", i+1, s.Artist, title)
|
||||
}
|
||||
fmt.Fprintf(&b, "===========================================\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+169
-95
@@ -1,36 +1,99 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/config"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
AppConfig *config.Config
|
||||
Session *Session
|
||||
Session *Session
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context, appConfig *config.Config) (*Client, error) {
|
||||
session, err := Authenticate(ctx, appConfig.ArlCookie)
|
||||
// NewClient authenticates with Deezer and returns a client bound to the
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
AppConfig: appConfig,
|
||||
Session: session,
|
||||
Session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchResource(ctx context.Context, resource Resource, id string) error {
|
||||
payload := map[string]interface{}{
|
||||
// resolveSession authenticates with arlCookie when one is supplied, and
|
||||
// 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,
|
||||
"start": 0,
|
||||
"lang": "en",
|
||||
@@ -38,78 +101,85 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
|
||||
"tags": true,
|
||||
"header": true,
|
||||
}
|
||||
switch r := resource.(type) {
|
||||
case *Playlist:
|
||||
payload["playlist_id"] = id
|
||||
case *Album:
|
||||
payload["alb_id"] = id
|
||||
case *Artist:
|
||||
payload["art_id"] = id
|
||||
default:
|
||||
return fmt.Errorf("unsupported resource type: %T", r)
|
||||
}
|
||||
payload[kind.idKey()] = id
|
||||
|
||||
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", resource.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), `"DATA_ERROR":"artist::getData"`) {
|
||||
return fmt.Errorf("invalid artist ID")
|
||||
}
|
||||
if strings.Contains(string(body), `"results":{}`) {
|
||||
return fmt.Errorf("unexpected response")
|
||||
}
|
||||
|
||||
return resource.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"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
|
||||
case "flac":
|
||||
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 {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -125,37 +195,39 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
|
||||
}
|
||||
|
||||
var media Media
|
||||
err = json.Unmarshal(body, &media)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(body, &media); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(media.Errors) > 0 {
|
||||
if media.Errors[0].Code == 1000 {
|
||||
return nil, fmt.Errorf("invalid license token")
|
||||
return nil, errors.New("invalid license token")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s", media.Errors[0].Message)
|
||||
return nil, errors.New(media.Errors[0].Message)
|
||||
}
|
||||
|
||||
if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 {
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error) {
|
||||
url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", song.Cover)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
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", track.Cover)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.Session.HttpClient.Do(req)
|
||||
resp, err := c.Session.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,18 +240,19 @@ func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) {
|
||||
url, err := media.GetURL()
|
||||
// MediaStream opens the audio stream for media. The caller owns the returned
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamingClient := *c.Session.HttpClient
|
||||
streamingClient := *c.Session.HTTPClient
|
||||
streamingClient.Timeout = 0
|
||||
|
||||
resp, err := streamingClient.Do(req)
|
||||
@@ -188,6 +261,7 @@ func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
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
|
||||
}
|
||||
+16
-32
@@ -1,48 +1,32 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Media struct {
|
||||
Errors []MediaError `json:"errors"`
|
||||
Errors []mediaError `json:"errors"`
|
||||
Data []struct {
|
||||
Media []struct {
|
||||
Type string `json:"media_type"`
|
||||
Cipher Cipher `json:"cipher"`
|
||||
Format string `json:"format"`
|
||||
Sources []Source `json:"sources"`
|
||||
Format string `json:"format"`
|
||||
Sources []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
Errors []MediaError `json:"errors"`
|
||||
Errors []mediaError `json:"errors"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type MediaError struct {
|
||||
type mediaError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type Cipher struct {
|
||||
Type string `json:"type"`
|
||||
// URL and Format read the first source Deezer offered, which is the best one
|
||||
// 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 {
|
||||
URL string `json:"url"`
|
||||
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")
|
||||
}
|
||||
|
||||
return m.Data[0].Media[0].Sources[0].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
|
||||
func (m *Media) Format() string {
|
||||
return m.Data[0].Media[0].Format
|
||||
}
|
||||
|
||||
+12
-36
@@ -2,9 +2,7 @@ package deezer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"time"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/flytam/filenamify"
|
||||
)
|
||||
@@ -13,54 +11,32 @@ type Playlist struct {
|
||||
Results struct {
|
||||
Data struct {
|
||||
Title string `json:"TITLE"`
|
||||
Status int `json:"STATUS"`
|
||||
Creator string `json:"PARENT_USERNAME"`
|
||||
Duration int `json:"DURATION"`
|
||||
} `json:"DATA"`
|
||||
Songs struct {
|
||||
Data []*Song `json:"data"`
|
||||
Tracks struct {
|
||||
Data []*Track `json:"data"`
|
||||
} `json:"SONGS"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func (p *Playlist) String() 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 {
|
||||
func (p *Playlist) Title() string {
|
||||
return p.Results.Data.Title
|
||||
}
|
||||
|
||||
func (p *Playlist) GetSongs() []*Song {
|
||||
return p.Results.Songs.Data
|
||||
func (p *Playlist) Tracks() []*Track {
|
||||
return p.Results.Tracks.Data
|
||||
}
|
||||
|
||||
func (p *Playlist) SetSongs(s []*Song) {
|
||||
p.Results.Songs.Data = s
|
||||
func (p *Playlist) SetTracks(t []*Track) {
|
||||
p.Results.Tracks.Data = t
|
||||
}
|
||||
|
||||
func (p *Playlist) GetOutputDir(outputDir string) string {
|
||||
p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
|
||||
outputDir = path.Join(outputDir, p.Results.Data.Title)
|
||||
|
||||
return outputDir
|
||||
func (p *Playlist) OutputDir(outputDir string) string {
|
||||
base, _ := filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
|
||||
return filepath.Join(outputDir, base)
|
||||
}
|
||||
|
||||
func (p *Playlist) Unmarshal(data []byte) error {
|
||||
func (p *Playlist) decode(data []byte) error {
|
||||
return json.Unmarshal(data, p)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
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 {
|
||||
GetTitle() string
|
||||
GetType() string
|
||||
GetSongs() []*Song
|
||||
SetSongs(songs []*Song)
|
||||
GetOutputDir(outputDir string) string
|
||||
Unmarshal(data []byte) error
|
||||
Title() string
|
||||
Tracks() []*Track
|
||||
SetTracks(tracks []*Track)
|
||||
OutputDir(outputDir string) string
|
||||
decode(data []byte) error
|
||||
}
|
||||
|
||||
+41
-29
@@ -3,6 +3,7 @@ package deezer
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,29 +11,30 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserDataResponse 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"`
|
||||
}
|
||||
// ErrInvalidARL reports that an ARL cookie was rejected. Callers should treat
|
||||
// it as recoverable and re-login rather than as a hard failure; resolveARL
|
||||
// relies on that distinction to decide whether to renew a stored session.
|
||||
var ErrInvalidARL = errors.New("invalid or expired ARL cookie")
|
||||
|
||||
type Session struct {
|
||||
ArlCookie string
|
||||
APIToken string
|
||||
LicenseToken string
|
||||
HttpClient *http.Client
|
||||
apiToken string
|
||||
licenseToken string
|
||||
HTTPClient *http.Client
|
||||
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)
|
||||
if err != nil {
|
||||
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="
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -68,22 +70,32 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.Results.User.Id == 0 {
|
||||
return nil, fmt.Errorf("invalid arl cookie")
|
||||
if res.Results.User.ID == 0 {
|
||||
return nil, ErrInvalidARL
|
||||
}
|
||||
|
||||
isPremium := res.Results.User.Options.MobileOffline || res.Results.User.Options.WebOffline
|
||||
|
||||
opts := res.Results.User.Options
|
||||
return &Session{
|
||||
ArlCookie: arlCookie,
|
||||
APIToken: res.Results.APIToken,
|
||||
LicenseToken: res.Results.User.Options.LicenseToken,
|
||||
HttpClient: client,
|
||||
Premium: isPremium,
|
||||
apiToken: res.Results.APIToken,
|
||||
licenseToken: opts.LicenseToken,
|
||||
HTTPClient: client,
|
||||
Premium: opts.MobileOffline || opts.WebOffline,
|
||||
}, 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,65 +0,0 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/flytam/filenamify"
|
||||
)
|
||||
|
||||
type Contributors struct {
|
||||
MainArtists []string `json:"main_artist"`
|
||||
Composers []string `json:"composer"`
|
||||
Authors []string `json:"author"`
|
||||
}
|
||||
|
||||
func (c *Contributors) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "[]" {
|
||||
*c = Contributors{}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Alias Contributors
|
||||
aux := (*Alias)(c)
|
||||
|
||||
return json.Unmarshal(data, aux)
|
||||
}
|
||||
|
||||
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 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 (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,336 +0,0 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"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/logger"
|
||||
"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
|
||||
Logger *logger.Logger
|
||||
|
||||
hashIndexOnce sync.Once
|
||||
hashIndex *fileutil.HashIndex
|
||||
hashIndexErr error
|
||||
}
|
||||
|
||||
func New(appConfig *config.Config, resourceType string) *Client {
|
||||
return &Client{
|
||||
appConfig: appConfig,
|
||||
resourceType: resourceType,
|
||||
deezerClient: nil,
|
||||
Logger: logger.New(nil), // Initialize with a nil logger, can be set later
|
||||
}
|
||||
}
|
||||
|
||||
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{}
|
||||
case "artist":
|
||||
resource = &deezer.Artist{}
|
||||
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)
|
||||
}
|
||||
if c.resourceType == "artist" && len(songs) > opts.Limit {
|
||||
songs = songs[:opts.Limit]
|
||||
resource.SetSongs(songs)
|
||||
}
|
||||
|
||||
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++
|
||||
c.Logger.Errorf("Failed to download %s - %s: %v\n", song.Artist, song.Title, err)
|
||||
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n", trackProgress, song.Artist, song.Title, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
symbol := "✔"
|
||||
if len(warnings) > 0 {
|
||||
symbol = "⚠"
|
||||
}
|
||||
|
||||
downloaded++
|
||||
c.Logger.Infof("Downloaded %s - %s\n", song.Artist, song.Title)
|
||||
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, song.Title)
|
||||
|
||||
for _, w := range warnings {
|
||||
c.Logger.Warnf("Warning: %s\n", w)
|
||||
fmt.Printf(" Warning: %s\n", w)
|
||||
}
|
||||
}
|
||||
|
||||
if downloaded > 0 || failed > 0 {
|
||||
c.Logger.Infof("Playlist %s (%s): %d downloaded, %d skipped, %d failed\n", resource.GetTitle(), id, downloaded, skipped, failed)
|
||||
}
|
||||
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 nil, 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 nil, fmt.Errorf("failed to get media format: %w", err)
|
||||
}
|
||||
if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality {
|
||||
return nil, fmt.Errorf("requested quality '%s' not available", opts.Quality)
|
||||
}
|
||||
|
||||
if path, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
|
||||
return nil, 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 nil, 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 nil, fmt.Errorf("failed to stream to file: %w", err)
|
||||
}
|
||||
|
||||
if opts.Quality != strings.ToLower(mediaFormat) {
|
||||
warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat)))
|
||||
}
|
||||
|
||||
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,37 +0,0 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var validQualities = map[string]bool{
|
||||
"mp3_128": true,
|
||||
"mp3_320": true,
|
||||
"flac": true,
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Quality string
|
||||
Timeout time.Duration
|
||||
Limit int
|
||||
BPM bool
|
||||
Strict bool
|
||||
}
|
||||
|
||||
func (o *Options) Validate() error {
|
||||
if !validQualities[o.Quality] {
|
||||
return fmt.Errorf("invalid quality option: %s", o.Quality)
|
||||
}
|
||||
if o.Timeout <= 0 {
|
||||
return fmt.Errorf("timeout must be a positive duration")
|
||||
}
|
||||
if o.Limit <= 0 {
|
||||
return fmt.Errorf("limit must be a positive integer")
|
||||
}
|
||||
if o.Limit > 100 {
|
||||
return fmt.Errorf("limit must not exceed 100")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package logger
|
||||
|
||||
import "log"
|
||||
|
||||
type Logger struct {
|
||||
l *log.Logger
|
||||
}
|
||||
|
||||
func New(l *log.Logger) *Logger {
|
||||
return &Logger{l: l}
|
||||
}
|
||||
|
||||
func (l *Logger) Infof(format string, args ...any) {
|
||||
if l.l != nil {
|
||||
l.l.Printf("[INFO] "+format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Warnf(format string, args ...any) {
|
||||
if l.l != nil {
|
||||
l.l.Printf("[WARN] "+format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Errorf(format string, args ...any) {
|
||||
if l.l != nil {
|
||||
l.l.Printf("[ERROR] "+format, args...)
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,19 @@ package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
SongID string `json:"song_id"`
|
||||
TrackID string `json:"song_id"`
|
||||
Quality string `json:"quality"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
@@ -18,18 +23,21 @@ type DownloadInfo struct {
|
||||
|
||||
var trackBucket = []byte("tracks")
|
||||
|
||||
func GetDownloadInfo(songID string) (*DownloadInfo, error) {
|
||||
// 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
|
||||
|
||||
if err := db.View(func(tx *bbolt.Tx) error {
|
||||
if err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(trackBucket)
|
||||
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 {
|
||||
return fmt.Errorf("not found")
|
||||
return errors.New("not found")
|
||||
}
|
||||
return json.Unmarshal(data, &info)
|
||||
}); err != nil {
|
||||
@@ -39,8 +47,8 @@ func GetDownloadInfo(songID string) (*DownloadInfo, error) {
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (d *DownloadInfo) Save() error {
|
||||
return db.Update(func(tx *bbolt.Tx) error {
|
||||
func (s *Store) PutDownloadInfo(d *DownloadInfo) error {
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists(trackBucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket: %w", err)
|
||||
@@ -51,6 +59,6 @@ func (d *DownloadInfo) Save() error {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Put([]byte(d.SongID), data)
|
||||
return b.Put([]byte(d.TrackID), data)
|
||||
})
|
||||
}
|
||||
|
||||
+30
-10
@@ -1,22 +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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
"go.etcd.io/bbolt"
|
||||
bolterrors "go.etcd.io/bbolt/errors"
|
||||
)
|
||||
|
||||
var db *bolt.DB
|
||||
type Store struct {
|
||||
db *bbolt.DB
|
||||
}
|
||||
|
||||
func OpenDB(cfgDir string) error {
|
||||
var err error
|
||||
|
||||
dbPath := path.Join(cfgDir, "tracks.db")
|
||||
db, err = bolt.Open(dbPath, 0600, nil)
|
||||
// Open opens the ledger in dir, creating it if needed.
|
||||
//
|
||||
// bbolt takes an exclusive file lock, so a second godeez running against the
|
||||
// same output directory blocks here. The timeout turns that into a clear
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
return &Store{db: db}, 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)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type WatchedPlaylist struct {
|
||||
ID string `json:"id"`
|
||||
Quality string `json:"quality"`
|
||||
BPM bool `json:"bpm"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
}
|
||||
|
||||
var watchedBucket = []byte("watched")
|
||||
|
||||
func ListWatchedPlaylists() ([]*WatchedPlaylist, error) {
|
||||
var playlists []*WatchedPlaylist
|
||||
if err := db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(watchedBucket)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return b.ForEach(func(k, v []byte) error {
|
||||
var p WatchedPlaylist
|
||||
if err := json.Unmarshal(v, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
playlists = append(playlists, &p)
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return playlists, nil
|
||||
}
|
||||
|
||||
func (p *WatchedPlaylist) Save() error {
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists(watchedBucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Put([]byte(p.ID), data)
|
||||
})
|
||||
}
|
||||
|
||||
func RemoveWatchedPlaylist(playlistID string) error {
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(watchedBucket)
|
||||
if b == nil {
|
||||
return fmt.Errorf("bucket not found")
|
||||
}
|
||||
|
||||
return b.Delete([]byte(playlistID))
|
||||
})
|
||||
}
|
||||
|
||||
func IsWatched(playlistID string) (bool, error) {
|
||||
var found bool
|
||||
err := db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(watchedBucket)
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
found = b.Get([]byte(playlistID)) != nil
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return found, 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,39 +0,0 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func EnsureAutostart(homeDir string) error {
|
||||
if isAutostartInstalled(homeDir) || isTemporaryExecutable() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// return installAutostart(homeDir)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isAutostartInstalled(homeDir string) bool {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist")
|
||||
_, err := os.Stat(path)
|
||||
|
||||
return err == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isTemporaryExecutable() bool {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Contains(exe, "go-build")
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func installAutostart(homeDir string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.godeez.watch</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>%s</string>
|
||||
<string>watch</string>
|
||||
<string>run</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>`, exe)
|
||||
|
||||
path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist")
|
||||
if err := os.WriteFile(path, []byte(plist), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return exec.Command("launchctl", "load", path).Run()
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/config"
|
||||
"github.com/mathismqn/godeez/internal/downloader"
|
||||
"github.com/mathismqn/godeez/internal/logger"
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
)
|
||||
|
||||
type Watcher struct {
|
||||
appConfig *config.Config
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func New(appConfig *config.Config) *Watcher {
|
||||
logFile := filepath.Join(appConfig.HomeDir, ".godeez", "watcher.log")
|
||||
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open log file: %v\n", err)
|
||||
}
|
||||
|
||||
base := log.New(file, "", log.LstdFlags)
|
||||
log := logger.New(base)
|
||||
|
||||
return &Watcher{
|
||||
appConfig: appConfig,
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) Run(ctx context.Context, opts downloader.Options) {
|
||||
w.logger.Infof("Starting watcher...")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
playlists, err := store.ListWatchedPlaylists()
|
||||
if err != nil {
|
||||
w.logger.Errorf("Failed to list watched playlists: %v\n", err)
|
||||
} else {
|
||||
for _, playlist := range playlists {
|
||||
dl := downloader.New(w.appConfig, "playlist")
|
||||
dl.Logger = w.logger
|
||||
if err := dl.Run(ctx, opts, playlist.ID); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
w.logger.Errorf("Playlist %s: %v\n", playlist.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(15 * time.Minute):
|
||||
// Continue to the next iteration to check for updates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
@@ -9,8 +11,17 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
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