Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6bf2f34c | ||
|
|
b1c15341fd | ||
|
|
765595133f | ||
|
|
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 |
@@ -0,0 +1,87 @@
|
|||||||
|
# GoDeez
|
||||||
|
|
||||||
|
GoDeez is a Go CLI that downloads music (tracks, albums, playlists, artist top
|
||||||
|
tracks) from Deezer in MP3 or lossless FLAC/WAV, embeds metadata tags, and
|
||||||
|
tracks what has already been downloaded so repeat runs can skip it.
|
||||||
|
|
||||||
|
## Build, test, lint
|
||||||
|
|
||||||
|
Mirror what CI (`.github/workflows/ci.yml`) runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -l . # must print nothing; CI fails on unformatted files
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/download/ -run TestHashFile
|
||||||
|
go test ./internal/download/ -run TestHashFile -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Release packaging is validated with `goreleaser check` (config in
|
||||||
|
`.goreleaser.yaml`); releases are cut by pushing a `v*` tag, which requires a
|
||||||
|
matching section in `CHANGELOG.md` (`.github/scripts/release-notes.sh`
|
||||||
|
extracts it and fails the release otherwise).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The flow is `cmd` → `download.Downloader` → `deezer` client → `store`/`tag`/`audio`:
|
||||||
|
|
||||||
|
- **`cmd`** is a thin cobra layer (`download`, `login`, `logout`, `update`,
|
||||||
|
`version`). It parses flags and delegates; it does not contain download
|
||||||
|
logic. `cmd.Execute` also kicks off an async update check
|
||||||
|
(`internal/update.StartCheck`) before running the command and prints the
|
||||||
|
result after, so the network round trip overlaps with the command instead
|
||||||
|
of adding to startup time.
|
||||||
|
- **`internal/deezer`** is the API client. `Kind` (`album`/`playlist`/
|
||||||
|
`artist`/`track`) is the single source of truth mapping a resource type to
|
||||||
|
its gw-light page method, request id field, and `Resource` implementation
|
||||||
|
(`Album`/`Playlist`/`Artist`/`Single`). Adding a new resource kind means
|
||||||
|
extending every switch in `kind.go`. `Resource`'s `decode` method is
|
||||||
|
unexported to seal the interface to this package.
|
||||||
|
- **`internal/download`** (package `download`) drives the per-track pipeline
|
||||||
|
via `Downloader.Run`: fetch resource → for each track, resolve a media
|
||||||
|
source → decide skip/hash-dedupe → stream and decrypt → optionally convert
|
||||||
|
FLAC to WAV → write tags → record in the store. Tracks are processed
|
||||||
|
sequentially, one at a time. Most per-track failures (bad cover, failed BPM
|
||||||
|
lookup, etc.) are collected as warnings rather than aborting the whole run;
|
||||||
|
only `context.Canceled` stops the loop early.
|
||||||
|
- **`internal/store`** is a bbolt-backed ledger (`.tracks.db`) written as a
|
||||||
|
hidden file inside the output directory, so it travels with the music
|
||||||
|
library it describes. Losing it is harmless — the only cost is
|
||||||
|
re-downloading.
|
||||||
|
- **`internal/tag`** writes metadata per container format (ID3v2 for MP3,
|
||||||
|
Vorbis comments for FLAC, ID3 chunk + RIFF LIST/INFO for WAV), dispatching
|
||||||
|
on file extension. Taggers write through a temp file so a mid-write failure
|
||||||
|
can't corrupt existing audio.
|
||||||
|
- **`internal/config`** has no config file — the output directory
|
||||||
|
(`~/Music/GoDeez`) is fixed, and the only setting is the `DEEZER_ARL`
|
||||||
|
env var, which is optional (falls back to keyring-stored credentials from
|
||||||
|
`godeez login`).
|
||||||
|
- **`internal/buildinfo`** holds the version/commit/date injected by
|
||||||
|
goreleaser via `-ldflags`; unreleased builds report `dev`, which disables
|
||||||
|
update-related behavior (`IsDev()`).
|
||||||
|
- **`internal/update`** implements self-update: checks GitHub releases,
|
||||||
|
verifies the downloaded binary's SHA256 against the published
|
||||||
|
`checksums.txt`, and replaces the running binary in place.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Package- and function-level doc comments consistently explain *why*, not
|
||||||
|
just what — e.g. why a check happens where it does, or why an error is
|
||||||
|
handled a particular way. Preserve this style when touching existing code
|
||||||
|
or adding new exported functions.
|
||||||
|
- Most per-track errors during a download are non-fatal and surfaced as
|
||||||
|
warnings in the run summary; only cancellation (`context.Canceled`) should
|
||||||
|
abort the loop. Keep new failure modes inside this pattern unless they are
|
||||||
|
truly unrecoverable for the whole run.
|
||||||
|
- `Kind` in `internal/deezer/kind.go` is deliberately exhaustive with no
|
||||||
|
default case that silently returns a zero value in the id/method switches —
|
||||||
|
follow the same pattern (explicit cases, empty/error fallback) if extending
|
||||||
|
it.
|
||||||
|
- Credentials: `DEEZER_ARL` always takes precedence over keyring-stored
|
||||||
|
login credentials when both are present.
|
||||||
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
|
||||||
+78
-15
@@ -5,41 +5,104 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-08-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add new `login` and `logout` commands to authenticate with your Deezer email and password. Credentials are stored in the system keyring. Requires `DEEZER_MOBILE_API_KEY` and `DEEZER_MOBILE_GW_KEY` to be set.
|
||||||
|
- Add new `update` command to replace the binary in place with the latest release, with `--check` to only report availability and `--force` to reinstall.
|
||||||
|
- Add new `version` command to print the version, commit, build date, and platform.
|
||||||
|
- Notify when a newer version is available after a download completes.
|
||||||
|
- Add WAV download quality (`--quality=wav`): the FLAC stream is converted locally to lossless WAV.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `DEEZER_ARL` is now optional. When it is unset, the credentials stored by `godeez login` are used instead, and expired sessions are renewed automatically.
|
||||||
|
- Release binaries are now named `godeez_<version>_<os>_<arch>` (previously `godeez-<version>-<os>-<arch>`) and are published alongside a `checksums.txt` file.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Interrupted downloads no longer leave partial files behind.
|
||||||
|
- Avoid overwriting an existing file when another track resolves to the same name.
|
||||||
|
- Report a clear error when the database is already in use by another process.
|
||||||
|
- Migrate the legacy database when `~/.godeez` and `~/Music/GoDeez` are on different filesystems.
|
||||||
|
- Write metadata tags even when the cover art or track duration is missing.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Automatic database migration from `~/.godeez/tracks.db` to `~/Music/GoDeez/.tracks.db`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Configuration now uses `DEEZER_ARL` environment variable (replaces `config.toml`).
|
||||||
|
- Database moved from `~/.godeez/tracks.db` to `~/Music/GoDeez/.tracks.db`.
|
||||||
|
- Show warning count in download summary.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `config.toml` configuration file and `~/.godeez` directory.
|
||||||
|
- `--config` flag from CLI.
|
||||||
|
- `secret_key` and `output_dir` configuration options.
|
||||||
|
- Watcher feature (`watch` subcommands).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Track number zero-padding for correct file sorting.
|
||||||
|
|
||||||
|
## [1.3.0] - 2025-09-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add new `track` command to download individual tracks.
|
||||||
|
- Add `--genre` flag to fetch and embed genre information into file metadata tags.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Handle empty media resources gracefully to prevent crashes.
|
||||||
|
|
||||||
## [1.2.0] - 2025-08-18
|
## [1.2.0] - 2025-08-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- New `artist` command to download an artist’s top tracks.
|
|
||||||
- `--limit` flag for the `artist` command to restrict the number of tracks.
|
- Add new `artist` command to download an artist’s top tracks.
|
||||||
- `--strict` flag for downloads: fail if the requested quality is unavailable.
|
- 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
|
### Changed
|
||||||
- Default download quality is now **MP3 320kbps**.
|
|
||||||
|
- Set default download quality to MP3 320 kbps.
|
||||||
|
|
||||||
### Removed
|
### 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
|
### Fixed
|
||||||
|
|
||||||
- Handle error when `SNG_CONTRIBUTORS` metadata is empty.
|
- Handle error when `SNG_CONTRIBUTORS` metadata is empty.
|
||||||
|
|
||||||
## [1.1.1] - 2025-06-16
|
## [1.1.1] - 2025-06-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Restored ability to download tracks **without a Deezer Premium account** (limited to **MP3 128kbps** for free accounts).
|
|
||||||
|
- Restore ability to download tracks without a Deezer Premium account (limited to MP3 128 kbps for free accounts).
|
||||||
|
|
||||||
## [1.1.0] - 2025-05-19
|
## [1.1.0] - 2025-05-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Support for downloading full albums and playlists with more than 40 tracks (previous limit removed).
|
|
||||||
- Option to fetch and embed **BPM** and **musical key** into metadata tags.
|
- Support downloading full albums and playlists with more than 40 tracks (previous limit removed).
|
||||||
- New local **database system** (`tracks.db`) to track downloaded files and avoid re-downloading, even if files are renamed or moved.
|
- Fetch and embed BPM and musical key into metadata tags.
|
||||||
- Improved CLI **output formatting** for a cleaner and more informative user experience.
|
- Add local database system (`tracks.db`) to track downloaded files and avoid re-downloading, even if files are renamed or moved.
|
||||||
|
- Improve CLI output formatting for a cleaner and more informative user experience.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- The `.godeez` file in the user’s home directory has been replaced by a `.godeez/` directory.
|
|
||||||
It now stores both `config.toml` and the internal `tracks.db`.
|
- Replace the `godeez` file in the user’s home directory with a `.godeez/` directory, which now stores both `config.toml` and `tracks.db`.
|
||||||
If you're upgrading from an older version, move your existing config into `.godeez/config.toml`.
|
👉 If upgrading, move your existing config into `.godeez/config.toml`.
|
||||||
- Simplified `config.toml`: `iv` and `license_token` are no longer required.
|
- Simplify `config.toml`: remove the need for `iv` and `license_token`.
|
||||||
- Cleanup logic: corrupted or incomplete files are now automatically deleted on download failure.
|
- Automatically delete corrupted or incomplete files on download failure.
|
||||||
|
|
||||||
## [1.0.0] - 2024-10-15
|
## [1.0.0] - 2024-10-15
|
||||||
|
|
||||||
|
|||||||
@@ -6,93 +6,164 @@
|
|||||||
[](https://github.com/mathismqn/godeez/blob/main/LICENSE)
|
[](https://github.com/mathismqn/godeez/blob/main/LICENSE)
|
||||||
[](https://github.com/mathismqn/godeez/commits/main)
|
[](https://github.com/mathismqn/godeez/commits/main)
|
||||||
|
|
||||||
A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
|
Download music from [Deezer](https://www.deezer.com) in MP3 or lossless FLAC/WAV.
|
||||||
|
|
||||||
[Features](#features) •
|
[Features](#features) •
|
||||||
[Installation](#installation) •
|
[Installation](#installation) •
|
||||||
|
[Updating](#updating) •
|
||||||
[Configuration](#configuration) •
|
[Configuration](#configuration) •
|
||||||
[Usage](#usage) •
|
[Usage](#usage)
|
||||||
[Contributing](#contributing) •
|
|
||||||
[License](#license)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Download playlists, albums, and artists' top tracks from Deezer
|
- Download playlists, albums, artists' top tracks, and individual tracks
|
||||||
* Select audio quality: MP3 128kbps, MP3 320kbps (default), or FLAC (⚠️ non-premium accounts are limited to 128kbps)
|
- Choose audio quality: MP3 128 kbps, MP3 320 kbps (default), or lossless FLAC/WAV (⚠️ non-premium accounts are limited to MP3 128 kbps)
|
||||||
* Automatically adds metadata tags to downloaded files
|
- Authenticate with an ARL cookie or with your Deezer email and password
|
||||||
* Fetch and tag songs with BPM and musical key
|
- Automatically embed metadata tags (artist, album, title, artwork, etc.)
|
||||||
* Smart skip system: avoids re-downloading already existing files using hashes and metadata
|
- Fetch and tag tracks with BPM, musical key, and genre
|
||||||
* Cross-platform support (works on Windows, macOS, and Linux)
|
- Works on Windows, macOS, and Linux
|
||||||
* Simple and easy-to-use CLI
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
To install **GoDeez**, simply download the latest binary for your platform from the Releases page.
|
Prebuilt binaries are available for every release.
|
||||||
|
|
||||||
1. Go to the [Releases](https://github.com/mathismqn/godeez/releases) page.
|
1. Go to the [Releases](https://github.com/mathismqn/godeez/releases) page.
|
||||||
2. Download the appropriate binary for your operating system (Windows, macOS, or Linux).
|
2. Download the appropriate binary for your operating system and architecture, named `godeez_<version>_<os>_<arch>`.
|
||||||
3. Move the binary to a directory included in $PATH for easy access (optional but recommended).
|
3. (Optional) Move the binary to a directory on your `$PATH` for easier access.
|
||||||
|
|
||||||
Example (Linux/macOS):
|
Example (Linux/macOS):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Move the downloaded binary to /usr/local/bin for easy access from anywhere
|
# Make it executable and move it to /usr/local/bin for access from anywhere
|
||||||
mv godeez-1.2.0-linux-amd64 /usr/local/bin/godeez
|
chmod +x godeez_1.5.0_linux_amd64
|
||||||
|
mv godeez_1.5.0_linux_amd64 /usr/local/bin/godeez
|
||||||
|
```
|
||||||
|
|
||||||
|
Each release also includes a `checksums.txt`, so you can verify your download:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c checksums.txt --ignore-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
The macOS binaries are not signed with an Apple Developer certificate, so
|
||||||
|
Gatekeeper blocks them on first run. You only need to clear the quarantine flag
|
||||||
|
once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -d com.apple.quarantine /usr/local/bin/godeez
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
**GoDeez** can update itself to the latest release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for a new version
|
||||||
|
godeez update --check
|
||||||
|
|
||||||
|
# Download, verify, and install it
|
||||||
|
godeez update
|
||||||
|
|
||||||
|
# Reinstall even if already up to date
|
||||||
|
godeez update --force
|
||||||
|
```
|
||||||
|
|
||||||
|
The new binary is verified against the release's published SHA256 checksum
|
||||||
|
before it replaces the current one. If **GoDeez** lives in a directory you do
|
||||||
|
not own (such as `/usr/local/bin` on some systems), run `sudo godeez update`.
|
||||||
|
|
||||||
|
To disable new-version notifications:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GODEEZ_NO_UPDATE_CHECK=1
|
||||||
|
```
|
||||||
|
|
||||||
|
To check which version you are running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godeez version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The first time you run **GoDeez**, a configuration directory named `.godeez` will be automatically created in your home directory (`$HOME` on Linux/macOS, `%USERPROFILE%` on Windows).
|
**GoDeez** authenticates to Deezer in one of two ways: with an **ARL cookie** copied from your browser, or with your **email and password**. The ARL cookie works out of the box and is the recommended option; email/password login requires two extra keys that **GoDeez** does not ship (see below).
|
||||||
|
|
||||||
Inside this directory:
|
### ARL cookie
|
||||||
- `config.toml`: main configuration file which contains several important variables that you need to fill out manually
|
|
||||||
- `tracks.db`: internal database used to track downloaded files and prevent duplicates
|
|
||||||
|
|
||||||
### Steps to configure
|
Set your ARL cookie as an environment variable:
|
||||||
|
|
||||||
1. Run the application for the first time: this creates the `.godeez` directory and the `config.toml` file inside it.
|
```bash
|
||||||
2. Edit the `config.toml` file with a text editor to set the required values.
|
export DEEZER_ARL="your_arl_cookie_here"
|
||||||
|
|
||||||
### Variables to configure
|
|
||||||
|
|
||||||
Here are the key variables you need to set in `config.toml`:
|
|
||||||
|
|
||||||
1. `arl_cookie`
|
|
||||||
* **What is it?**: The `arl_cookie` is a session cookie used for authentication with Deezer. Without this cookie, the downloader cannot access your account to retrieve playlists, albums, or songs.
|
|
||||||
* **How to retrieve it**:
|
|
||||||
1. Open your browser and log in to your Deezer account.
|
|
||||||
2. Open the Developer Tools (right-click on the page and select “Inspect” or press F12).
|
|
||||||
3. Navigate to the Application tab (in Chrome/Edge) or Storage tab (in Firefox).
|
|
||||||
4. In the left panel, look for Cookies and select `https://www.deezer.com`.
|
|
||||||
5. Find the arl cookie and copy its value.
|
|
||||||
|
|
||||||
2. `secret_key`
|
|
||||||
* **What is it?**: The `secret_key` is a cryptographic value used to decrypt Deezer’s media files.
|
|
||||||
* **How to retrieve it?**: While we cannot provide the specific secret_key in this documentation, it can be found online through various sources or developer communities that focus on Deezer.
|
|
||||||
|
|
||||||
3. `output_dir` (optional)
|
|
||||||
* **What is it?**: The `output_dir` is the path where downloaded music files will be saved.
|
|
||||||
* **Default**: If left empty, it defaults to `~/Music/GoDeez`.
|
|
||||||
* **Note**: Once set, it's recommended not to change it, as this may interfere with the skip system that relies on consistent file paths and hash indexing to detect already downloaded songs.
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
Here's an example of a minimal `config.toml` you can customize:
|
|
||||||
```toml
|
|
||||||
# ~/.godeez/config.toml
|
|
||||||
|
|
||||||
arl_cookie = 'your_arl_cookie_here'
|
|
||||||
secret_key = 'your_secret_key_here'
|
|
||||||
output_dir = '' # optional
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To make it persistent, add the line above to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.).
|
||||||
|
|
||||||
|
#### How to retrieve your ARL cookie
|
||||||
|
|
||||||
|
1. Open your browser and log in to your [Deezer](https://www.deezer.com) account.
|
||||||
|
2. Open the **Developer Tools** (right-click on the page and select **Inspect**, or press <kbd>F12</kbd>).
|
||||||
|
3. Navigate to the **Application** tab (Chrome/Edge) or **Storage** tab (Firefox).
|
||||||
|
4. In the left panel, look for **Cookies** and select **https://www.deezer.com**.
|
||||||
|
5. Find the `arl` cookie and copy its value.
|
||||||
|
|
||||||
|
> **Note:** The ARL cookie may expire after some time. If you get authentication errors, retrieve a fresh cookie using the steps above.
|
||||||
|
|
||||||
|
### Email and password
|
||||||
|
|
||||||
|
Instead of copying a cookie, you can log in once with your Deezer account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godeez login
|
||||||
|
```
|
||||||
|
|
||||||
|
You will be prompted for your email and password. On success, **GoDeez** stores your credentials in your system keyring under the service name `godeez`. From then on, **GoDeez** authenticates on its own and renews the session when it expires.
|
||||||
|
|
||||||
|
To remove the stored credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godeez logout
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Gateway keys
|
||||||
|
|
||||||
|
Email/password login goes through Deezer's mobile gateway, which requires two keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DEEZER_MOBILE_API_KEY="your_api_key_here"
|
||||||
|
export DEEZER_MOBILE_GW_KEY="your_gateway_key" # exactly 16 characters
|
||||||
|
```
|
||||||
|
|
||||||
|
**GoDeez** does not bundle these keys, so you have to supply your own. For background on what they are and where they live in Deezer's clients, see [this write-up](https://gist.github.com/svbnet/b79b705a4c19d74896670c1ac7ad627e). If either variable is missing, `godeez login` exits with an error.
|
||||||
|
|
||||||
|
> **Note:** `DEEZER_ARL` takes precedence over stored credentials. If it is set, **GoDeez** always uses the cookie and never falls back to your login, so unset it (and remove it from your shell profile) before running `godeez login`.
|
||||||
|
|
||||||
|
> **Note:** The keyring entry holds your password alongside the ARL because the password is reused to renew expired sessions. On Linux, the keyring requires a running secret service; without one, `godeez login` fails with `system keyring is unavailable`.
|
||||||
|
|
||||||
|
### Output directory
|
||||||
|
|
||||||
|
Downloaded files are saved to `~/Music/GoDeez`. The download database (`.tracks.db`) is stored in the same directory as your music.
|
||||||
|
|
||||||
|
To use a different location, set `GODEEZ_OUTPUT_DIR`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GODEEZ_OUTPUT_DIR="/path/to/your/music"
|
||||||
|
```
|
||||||
|
|
||||||
|
A leading `~` is expanded to your home directory.
|
||||||
|
|
||||||
|
> **Upgrading from v1.3.0?** The `~/.godeez` directory and `config.toml` are no longer used. Set the `DEEZER_ARL` environment variable instead. Your existing database will be migrated automatically on first run.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### CLI Overview
|
### CLI overview
|
||||||
|
|
||||||
When you run `godeez` without any additional commands, you’ll see a general help menu:
|
Running `godeez` without arguments shows the help menu:
|
||||||
```bash
|
|
||||||
|
```text
|
||||||
GoDeez is a tool to download music from Deezer
|
GoDeez is a tool to download music from Deezer
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@@ -100,47 +171,80 @@ Usage:
|
|||||||
|
|
||||||
Available Commands:
|
Available Commands:
|
||||||
completion Generate the autocompletion script for the specified shell
|
completion Generate the autocompletion script for the specified shell
|
||||||
download Download songs from Deezer
|
download Download tracks from Deezer
|
||||||
help Help about any command
|
help Help about any command
|
||||||
|
login Log in to Deezer with your email and password
|
||||||
|
logout Remove stored Deezer credentials
|
||||||
|
update Update GoDeez to the latest version
|
||||||
|
version Print the current version of GoDeez
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--config string config file (default ~/.godeez/config.toml)
|
|
||||||
-h, --help help for godeez
|
-h, --help help for godeez
|
||||||
|
|
||||||
Use "godeez [command] --help" for more information about a command.
|
Use "godeez [command] --help" for more information about a command.
|
||||||
```
|
```
|
||||||
This provides an overview of the available commands and flags.
|
|
||||||
|
|
||||||
To download music, you need to use the download command. Here’s how the CLI looks when you run `godeez download`:
|
### Download commands
|
||||||
```bash
|
|
||||||
Download songs from Deezer
|
```text
|
||||||
|
Download tracks from Deezer
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
godeez download [command]
|
godeez download [command]
|
||||||
|
|
||||||
Available Commands:
|
Available Commands:
|
||||||
album Download songs from an album
|
album Download tracks from an album
|
||||||
artist Download top songs from an artist
|
artist Download an artist's top tracks
|
||||||
playlist Download songs from a playlist
|
playlist Download tracks from a playlist
|
||||||
|
track Download a single track
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
--bpm fetch BPM/key and add to file tags
|
--bpm fetch BPM/key and add to file tags
|
||||||
--config string config file (default ~/.godeez/config.toml)
|
--genre fetch genre and add to file tags
|
||||||
-h, --help help for download
|
-h, --help help for download
|
||||||
-q, --quality string download quality [mp3_128, mp3_320, flac] (default "mp3_320")
|
-q, --quality string download quality [mp3_128, mp3_320, flac, wav] (default "mp3_320")
|
||||||
--strict fail the song download if the quality is not available
|
--strict fail the download if the requested quality is unavailable
|
||||||
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
|
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
|
||||||
|
|
||||||
Use "godeez download [command] --help" for more information about a command.
|
Use "godeez download [command] --help" for more information about a command.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** The `artist` command takes an extra `-l, --limit` flag to choose how many top tracks to download (default 10, maximum 100).
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download an album
|
||||||
|
godeez download album 12345678
|
||||||
|
|
||||||
|
# Download a playlist
|
||||||
|
godeez download playlist 87654321
|
||||||
|
|
||||||
|
# Download an artist's top tracks (limit to 5 tracks)
|
||||||
|
godeez download artist 11223344 --limit 5
|
||||||
|
|
||||||
|
# Download a single track
|
||||||
|
godeez download track 98765432
|
||||||
|
|
||||||
|
# Download with specific quality, BPM, and genre data
|
||||||
|
godeez download track 98765432 --quality flac --bpm --genre
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions help make **GoDeez** a better tool for everyone, and any help is greatly appreciated.
|
Contributions make **GoDeez** better for everyone, and any help is greatly appreciated — whether it's a bug fix, a new feature, or a documentation improvement.
|
||||||
Whether it’s a bug fix, a new feature, or improving documentation, your input is valuable.
|
|
||||||
|
|
||||||
If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion.
|
To contribute, fork the repository and open a pull request. To report a bug or suggest a feature, open an issue instead.
|
||||||
Every bit of support counts, so don’t forget to give the project a star if you enjoy using it. Thank you for helping make this project better!
|
|
||||||
|
## Support the project
|
||||||
|
|
||||||
|
If **GoDeez** helps you enjoy your music collection, please consider giving it a star ⭐
|
||||||
|
|
||||||
|
**Why star the project?**
|
||||||
|
|
||||||
|
- Helps more music lovers discover it
|
||||||
|
- Shows appreciation for the work and keeps me motivated
|
||||||
|
- Takes one click, and it means a lot
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -148,4 +252,4 @@ This project is licensed under the MIT License. See the [LICENSE](https://github
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> ⚠️ This tool is provided for educational and personal use only. Please ensure your usage complies with 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.
|
||||||
|
|||||||
+64
-51
@@ -8,79 +8,92 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mathismqn/godeez/internal/config"
|
"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"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
func newDownloadCmd() *cobra.Command {
|
||||||
opts downloader.Options
|
cmd := &cobra.Command{
|
||||||
cfgPath string
|
|
||||||
)
|
|
||||||
|
|
||||||
var downloadCmd = &cobra.Command{
|
|
||||||
Use: "download",
|
Use: "download",
|
||||||
Short: "Download songs from Deezer",
|
Short: "Download tracks from Deezer",
|
||||||
}
|
Annotations: map[string]string{updateNoticeAnnotation: "true"},
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
cmd := &cobra.Command{
|
||||||
Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType),
|
Use: fmt.Sprintf("%s <%s_id>", kind, kind),
|
||||||
Short: fmt.Sprintf("Download songs from %s %s", article, resourceType),
|
Short: downloadShort(kind),
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig))
|
config.MigrateLegacy(cfg.OutputDir)
|
||||||
|
|
||||||
opts.Quality = strings.ToLower(opts.Quality)
|
st, err := store.Open(cfg.OutputDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
return opts.Validate()
|
err = download.New(cfg, st, kind).Run(cmd.Context(), *opts, args[0])
|
||||||
},
|
|
||||||
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) {
|
if errors.Is(err, context.Canceled) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if resourceType == "artist" {
|
if kind == deezer.KindArtist {
|
||||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download")
|
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of tracks to download")
|
||||||
cmd.Short = "Download top songs from an artist"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func downloadShort(kind deezer.Kind) string {
|
||||||
|
switch kind {
|
||||||
|
case deezer.KindArtist:
|
||||||
|
return "Download an artist's top tracks"
|
||||||
|
case deezer.KindTrack:
|
||||||
|
return "Download a single track"
|
||||||
|
case deezer.KindAlbum:
|
||||||
|
return "Download tracks from an album"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Download tracks from a %s", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLoginCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "login",
|
||||||
|
Short: "Log in to Deezer with your email and password",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := deezer.CheckGatewayEnv(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runLogin(cmd.Context())
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogin(ctx context.Context) error {
|
||||||
|
email, password, err := promptCredentials(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, username, err := deezer.Login(ctx, email, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Successfully logged in as %s.\n", username)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptCredentials reads the email and password, giving up if ctx is
|
||||||
|
// cancelled.
|
||||||
|
//
|
||||||
|
// Reading stdin cannot itself be cancelled, so the read runs in a goroutine
|
||||||
|
// and this selects on whichever finishes first. That goroutine outlives a
|
||||||
|
// cancelled prompt, which is why the channel is buffered.
|
||||||
|
//
|
||||||
|
// Terminal state is captured up front and restored on cancellation: Ctrl-C
|
||||||
|
// during the password prompt would otherwise leave echo disabled and the
|
||||||
|
// user's shell silently typing blind.
|
||||||
|
func promptCredentials(ctx context.Context) (string, string, error) {
|
||||||
|
oldState, stateErr := term.GetState(int(os.Stdin.Fd()))
|
||||||
|
|
||||||
|
type credentials struct {
|
||||||
|
email string
|
||||||
|
password string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
resultChan := make(chan credentials, 1)
|
||||||
|
go func() {
|
||||||
|
var c credentials
|
||||||
|
c.email, c.password, c.err = readCredentials()
|
||||||
|
resultChan <- c
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case c := <-resultChan:
|
||||||
|
return c.email, c.password, c.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
if stateErr == nil {
|
||||||
|
term.Restore(int(os.Stdin.Fd()), oldState)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return "", "", ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCredentials prompts on the terminal. The password is read with echo
|
||||||
|
// off so it neither appears on screen nor reaches the shell history.
|
||||||
|
func readCredentials() (string, string, error) {
|
||||||
|
fmt.Print("Email: ")
|
||||||
|
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
fmt.Print("Password: ")
|
||||||
|
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return email, string(passwordBytes), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLogoutCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "logout",
|
||||||
|
Short: "Remove stored Deezer credentials",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := deezer.ClearCredentials(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Successfully logged out.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
-17
@@ -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
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/buildinfo"
|
||||||
|
"github.com/mathismqn/godeez/internal/update"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
var RootCmd = &cobra.Command{
|
// 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"
|
||||||
|
|
||||||
|
// Execute runs the CLI.
|
||||||
|
//
|
||||||
|
// The update check is started before the command and collected after it, so
|
||||||
|
// the network round trip overlaps with work the user actually asked for
|
||||||
|
// instead of adding to the startup time.
|
||||||
|
func Execute(ctx context.Context) error {
|
||||||
|
root := newRootCmd()
|
||||||
|
|
||||||
|
var notice <-chan string
|
||||||
|
if wantsUpdateNotice(root) {
|
||||||
|
notice = update.StartCheck(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := root.ExecuteContext(ctx)
|
||||||
|
|
||||||
|
printUpdateNotice(notice)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRootCmd() *cobra.Command {
|
||||||
|
root := &cobra.Command{
|
||||||
Use: "godeez",
|
Use: "godeez",
|
||||||
Short: "GoDeez is a tool to download music from Deezer",
|
Short: "GoDeez is a tool to download music from Deezer",
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
/*
|
|
||||||
homeDir, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get home directory: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := watcher.EnsureAutostart(homeDir); err != nil {
|
root.AddCommand(
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to install autostart for watcher: %v\n", err)
|
newDownloadCmd(),
|
||||||
}
|
newLoginCmd(),
|
||||||
*/
|
newLogoutCmd(),
|
||||||
|
newUpdateCmd(),
|
||||||
|
newVersionCmd(),
|
||||||
|
)
|
||||||
|
|
||||||
return nil
|
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
|
module github.com/mathismqn/godeez
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
toolchain go1.24.4
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/mewkiz/flac v1.0.13
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/zalando/go-keyring v0.2.8
|
||||||
|
golang.org/x/mod v0.38.0
|
||||||
|
golang.org/x/term v0.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/icza/bitio v1.1.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.10.0 // indirect
|
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
|
||||||
github.com/spf13/afero v1.14.0 // indirect
|
golang.org/x/net v0.57.0 // indirect
|
||||||
github.com/spf13/cast v1.9.2 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
golang.org/x/text v0.40.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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/bogem/id3v2/v2 v2.1.4
|
||||||
github.com/briandowns/spinner v1.23.2
|
github.com/briandowns/spinner v1.23.2
|
||||||
github.com/flytam/filenamify v1.2.0
|
github.com/flytam/filenamify v1.2.0
|
||||||
@@ -38,7 +34,7 @@ require (
|
|||||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.7 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
go.etcd.io/bbolt v1.4.2
|
go.etcd.io/bbolt v1.5.0
|
||||||
golang.org/x/crypto v0.41.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.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||||
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
||||||
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
|
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
|
||||||
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
|
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
|
||||||
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
|
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
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 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
|
||||||
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
|
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
|
||||||
github.com/fsnotify/fsnotify v1.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 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
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-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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
||||||
|
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
|
||||||
|
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
|
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||||
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
|
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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/text v0.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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|
||||||
golang.org/x/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=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// Package audio converts downloaded audio between formats.
|
||||||
|
//
|
||||||
|
// Deezer does not serve wav, so a wav download is really a flac download
|
||||||
|
// followed by FLACToWAV. The conversion is lossless in both directions: flac
|
||||||
|
// decodes to exactly the PCM samples it was encoded from, so nothing is lost
|
||||||
|
// by going through it.
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/fsutil"
|
||||||
|
"github.com/mewkiz/flac"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// headerSize is the canonical PCM wav header: a 12 byte RIFF/WAVE header,
|
||||||
|
// a 24 byte fmt chunk, and an 8 byte data chunk header.
|
||||||
|
headerSize = 44
|
||||||
|
formatPCM = 1
|
||||||
|
|
||||||
|
// ctxCheckInterval is how often, in flac frames, cancellation is polled.
|
||||||
|
// A flac frame is a few thousand samples, so checking every frame would
|
||||||
|
// add a select to the innermost decode loop for no practical gain in
|
||||||
|
// responsiveness.
|
||||||
|
ctxCheckInterval = 64
|
||||||
|
|
||||||
|
// maxDataSize is the largest audio payload that still fits. RIFF stores
|
||||||
|
// its sizes as uint32, and the RIFF size field covers the header after
|
||||||
|
// its own first 8 bytes as well as the data, so the audio itself has to
|
||||||
|
// stay that much below the limit. This works out to roughly 6 hours of
|
||||||
|
// CD quality stereo, which no single track will reach, but silently
|
||||||
|
// producing a file with a wrapped size field would be worse than an
|
||||||
|
// error.
|
||||||
|
maxDataSize = math.MaxUint32 - (headerSize - 8)
|
||||||
|
)
|
||||||
|
|
||||||
|
// FLACToWAV decodes the flac at srcPath and writes it as a PCM wav to
|
||||||
|
// dstPath.
|
||||||
|
//
|
||||||
|
// The size is checked twice, once from the flac header before doing any work
|
||||||
|
// and once against the bytes actually written, because NSamples is zero in
|
||||||
|
// flac streams that were encoded without a known length.
|
||||||
|
//
|
||||||
|
// Output goes to a temporary file in the destination directory and is renamed
|
||||||
|
// into place at the end, so a cancelled or failed conversion never leaves a
|
||||||
|
// half decoded file where a playable one is expected.
|
||||||
|
func FLACToWAV(ctx context.Context, srcPath, dstPath string) error {
|
||||||
|
stream, err := flac.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stream.Close()
|
||||||
|
|
||||||
|
info := stream.Info
|
||||||
|
bytesPerSample, err := bytesPerSample(info.BitsPerSample)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.NChannels < 1 || info.NChannels > 2 {
|
||||||
|
return fmt.Errorf("unsupported channel count: %d", info.NChannels)
|
||||||
|
}
|
||||||
|
if size := int64(info.NSamples) * int64(info.NChannels) * int64(bytesPerSample); size > maxDataSize {
|
||||||
|
return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", size)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.CreateTemp(filepath.Dir(dstPath), fsutil.PartPattern)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpPath := file.Name()
|
||||||
|
done := false
|
||||||
|
defer func() {
|
||||||
|
if !done {
|
||||||
|
file.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// The header goes down with a zero data size and is patched afterwards:
|
||||||
|
// the real length is only known once every frame has been decoded, and
|
||||||
|
// buffering the whole stream in memory to find out first is not worth it.
|
||||||
|
w := bufio.NewWriter(file)
|
||||||
|
if err := writeHeader(w, info.SampleRate, info.NChannels, info.BitsPerSample, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSize, err := writeSamples(ctx, w, stream, int(info.NChannels), bytesPerSample)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dataSize > maxDataSize {
|
||||||
|
return fmt.Errorf("audio data of %d bytes exceeds the wav format limit", dataSize)
|
||||||
|
}
|
||||||
|
// RIFF chunks must end on an even offset. Only reachable with 8 or 24 bit
|
||||||
|
// mono, where a sample is an odd number of bytes.
|
||||||
|
if dataSize%2 != 0 {
|
||||||
|
if err := w.WriteByte(0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := patchSizes(file, dataSize); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := file.Sync(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, dstPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
done = true
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesPerSample(bitsPerSample uint8) (int, error) {
|
||||||
|
switch bitsPerSample {
|
||||||
|
case 8, 16, 24:
|
||||||
|
return int(bitsPerSample) / 8, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unsupported bit depth: %d", bitsPerSample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeader(w io.Writer, sampleRate uint32, nChannels, bitsPerSample uint8, dataSize uint32) error {
|
||||||
|
blockAlign := uint32(nChannels) * uint32(bitsPerSample) / 8
|
||||||
|
|
||||||
|
header := make([]byte, 0, headerSize)
|
||||||
|
header = append(header, "RIFF"...)
|
||||||
|
header = binary.LittleEndian.AppendUint32(header, uint32(headerSize-8)+dataSize)
|
||||||
|
header = append(header, "WAVE"...)
|
||||||
|
header = append(header, "fmt "...)
|
||||||
|
header = binary.LittleEndian.AppendUint32(header, 16)
|
||||||
|
header = binary.LittleEndian.AppendUint16(header, formatPCM)
|
||||||
|
header = binary.LittleEndian.AppendUint16(header, uint16(nChannels))
|
||||||
|
header = binary.LittleEndian.AppendUint32(header, sampleRate)
|
||||||
|
header = binary.LittleEndian.AppendUint32(header, sampleRate*blockAlign)
|
||||||
|
header = binary.LittleEndian.AppendUint16(header, uint16(blockAlign))
|
||||||
|
header = binary.LittleEndian.AppendUint16(header, uint16(bitsPerSample))
|
||||||
|
header = append(header, "data"...)
|
||||||
|
header = binary.LittleEndian.AppendUint32(header, dataSize)
|
||||||
|
|
||||||
|
_, err := w.Write(header)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSamples(ctx context.Context, w io.Writer, stream *flac.Stream, nChannels, bytesPerSample int) (int64, error) {
|
||||||
|
var dataSize int64
|
||||||
|
buf := make([]byte, 4)
|
||||||
|
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
if i%ctxCheckInterval == 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return dataSize, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frame, err := stream.ParseNext()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return dataSize, err
|
||||||
|
}
|
||||||
|
if len(frame.Subframes) != nChannels {
|
||||||
|
return dataSize, fmt.Errorf("frame %d has %d channels, want %d", frame.Num, len(frame.Subframes), nChannels)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range frame.Subframes[0].Samples {
|
||||||
|
for _, subframe := range frame.Subframes {
|
||||||
|
putSample(buf, subframe.Samples[i], bytesPerSample)
|
||||||
|
if _, err := w.Write(buf[:bytesPerSample]); err != nil {
|
||||||
|
return dataSize, err
|
||||||
|
}
|
||||||
|
dataSize += int64(bytesPerSample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// putSample encodes one sample little endian into buf.
|
||||||
|
//
|
||||||
|
// 8 bit wav is the odd one out: it stores unsigned samples biased by 128,
|
||||||
|
// while every wider depth is signed two's complement. Writing an 8 bit sample
|
||||||
|
// signed produces audio that sounds like loud static, so the bias is not
|
||||||
|
// optional.
|
||||||
|
func putSample(buf []byte, sample int32, bytesPerSample int) {
|
||||||
|
if bytesPerSample == 1 {
|
||||||
|
buf[0] = byte(sample + 128)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
value := uint32(sample)
|
||||||
|
for i := range bytesPerSample {
|
||||||
|
buf[i] = byte(value >> (8 * i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchSizes rewrites the two length fields once the real data size is known:
|
||||||
|
// the RIFF size at offset 4 and the data chunk size just before the samples
|
||||||
|
// begin.
|
||||||
|
//
|
||||||
|
// The pad byte counts towards the RIFF size but not towards the data chunk
|
||||||
|
// size, which is why only the first of the two includes it.
|
||||||
|
func patchSizes(file *os.File, dataSize int64) error {
|
||||||
|
buf := make([]byte, 4)
|
||||||
|
|
||||||
|
binary.LittleEndian.PutUint32(buf, uint32(headerSize-8+dataSize+dataSize%2))
|
||||||
|
if _, err := file.WriteAt(buf, 4); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
binary.LittleEndian.PutUint32(buf, uint32(dataSize))
|
||||||
|
_, err := file.WriteAt(buf, headerSize-4)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mewkiz/flac"
|
||||||
|
"github.com/mewkiz/flac/frame"
|
||||||
|
"github.com/mewkiz/flac/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testSamples(nChannels, bitsPerSample, nSamples int) [][]int32 {
|
||||||
|
max := int32(1)<<(bitsPerSample-1) - 1
|
||||||
|
min := -int32(1) << (bitsPerSample - 1)
|
||||||
|
|
||||||
|
channels := make([][]int32, nChannels)
|
||||||
|
for c := range channels {
|
||||||
|
samples := make([]int32, nSamples)
|
||||||
|
for i := range samples {
|
||||||
|
switch i {
|
||||||
|
case 0:
|
||||||
|
samples[i] = min
|
||||||
|
case 1:
|
||||||
|
samples[i] = max
|
||||||
|
case 2:
|
||||||
|
samples[i] = 0
|
||||||
|
default:
|
||||||
|
samples[i] = int32(i*(c+1)) % max
|
||||||
|
if i%3 == 0 {
|
||||||
|
samples[i] = -samples[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channels[c] = samples
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestFLAC(t *testing.T, path string, sampleRate uint32, bitsPerSample uint8, channels [][]int32) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
nSamples := len(channels[0])
|
||||||
|
info := &meta.StreamInfo{
|
||||||
|
BlockSizeMin: uint16(nSamples),
|
||||||
|
BlockSizeMax: uint16(nSamples),
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
NChannels: uint8(len(channels)),
|
||||||
|
BitsPerSample: bitsPerSample,
|
||||||
|
NSamples: uint64(nSamples),
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create flac: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
enc, err := flac.NewEncoder(file, info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new encoder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subframes := make([]*frame.Subframe, len(channels))
|
||||||
|
for c, samples := range channels {
|
||||||
|
subframes[c] = &frame.Subframe{
|
||||||
|
SubHeader: frame.SubHeader{Pred: frame.PredVerbatim},
|
||||||
|
Samples: samples,
|
||||||
|
NSamples: nSamples,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channelsLayout := frame.ChannelsMono
|
||||||
|
if len(channels) == 2 {
|
||||||
|
channelsLayout = frame.ChannelsLR
|
||||||
|
}
|
||||||
|
|
||||||
|
f := &frame.Frame{
|
||||||
|
Header: frame.Header{
|
||||||
|
HasFixedBlockSize: true,
|
||||||
|
BlockSize: uint16(nSamples),
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
Channels: channelsLayout,
|
||||||
|
BitsPerSample: bitsPerSample,
|
||||||
|
},
|
||||||
|
Subframes: subframes,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := enc.WriteFrame(f); err != nil {
|
||||||
|
t.Fatalf("write frame: %v", err)
|
||||||
|
}
|
||||||
|
if err := enc.Close(); err != nil {
|
||||||
|
t.Fatalf("close encoder: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedPCM(channels [][]int32, bytesPerSample int) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
for i := range channels[0] {
|
||||||
|
for _, samples := range channels {
|
||||||
|
sample := samples[i]
|
||||||
|
switch bytesPerSample {
|
||||||
|
case 1:
|
||||||
|
buf.WriteByte(byte(sample + 128))
|
||||||
|
case 2:
|
||||||
|
buf.Write([]byte{byte(sample), byte(sample >> 8)})
|
||||||
|
case 3:
|
||||||
|
buf.Write([]byte{byte(sample), byte(sample >> 8), byte(sample >> 16)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFLACToWAV(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sampleRate uint32
|
||||||
|
bitsPerSample uint8
|
||||||
|
nChannels int
|
||||||
|
nSamples int
|
||||||
|
}{
|
||||||
|
{"16 bit stereo", 44100, 16, 2, 512},
|
||||||
|
{"16 bit mono", 44100, 16, 1, 512},
|
||||||
|
{"24 bit stereo", 48000, 24, 2, 333},
|
||||||
|
{"8 bit mono", 22050, 8, 1, 128},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := filepath.Join(dir, "in.flac")
|
||||||
|
dst := filepath.Join(dir, "out.wav")
|
||||||
|
|
||||||
|
channels := testSamples(tt.nChannels, int(tt.bitsPerSample), tt.nSamples)
|
||||||
|
writeTestFLAC(t, src, tt.sampleRate, tt.bitsPerSample, channels)
|
||||||
|
|
||||||
|
if err := FLACToWAV(context.Background(), src, dst); err != nil {
|
||||||
|
t.Fatalf("FLACToWAV() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read wav: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesPerSample := int(tt.bitsPerSample) / 8
|
||||||
|
blockAlign := uint16(tt.nChannels * bytesPerSample)
|
||||||
|
want := expectedPCM(channels, bytesPerSample)
|
||||||
|
dataSize := uint32(len(want))
|
||||||
|
pad := dataSize % 2
|
||||||
|
|
||||||
|
if len(got) != headerSize+len(want)+int(pad) {
|
||||||
|
t.Fatalf("file size = %d, want %d", len(got), headerSize+len(want)+int(pad))
|
||||||
|
}
|
||||||
|
if string(got[0:4]) != "RIFF" || string(got[8:12]) != "WAVE" {
|
||||||
|
t.Errorf("magic = %q %q, want \"RIFF\" \"WAVE\"", got[0:4], got[8:12])
|
||||||
|
}
|
||||||
|
if size := binary.LittleEndian.Uint32(got[4:8]); size != headerSize-8+dataSize+pad {
|
||||||
|
t.Errorf("riff size = %d, want %d", size, headerSize-8+dataSize+pad)
|
||||||
|
}
|
||||||
|
if string(got[12:16]) != "fmt " {
|
||||||
|
t.Errorf("fmt chunk id = %q, want \"fmt \"", got[12:16])
|
||||||
|
}
|
||||||
|
if size := binary.LittleEndian.Uint32(got[16:20]); size != 16 {
|
||||||
|
t.Errorf("fmt chunk size = %d, want 16", size)
|
||||||
|
}
|
||||||
|
if format := binary.LittleEndian.Uint16(got[20:22]); format != formatPCM {
|
||||||
|
t.Errorf("format = %d, want %d", format, formatPCM)
|
||||||
|
}
|
||||||
|
if n := binary.LittleEndian.Uint16(got[22:24]); n != uint16(tt.nChannels) {
|
||||||
|
t.Errorf("channels = %d, want %d", n, tt.nChannels)
|
||||||
|
}
|
||||||
|
if rate := binary.LittleEndian.Uint32(got[24:28]); rate != tt.sampleRate {
|
||||||
|
t.Errorf("sample rate = %d, want %d", rate, tt.sampleRate)
|
||||||
|
}
|
||||||
|
if rate := binary.LittleEndian.Uint32(got[28:32]); rate != tt.sampleRate*uint32(blockAlign) {
|
||||||
|
t.Errorf("byte rate = %d, want %d", rate, tt.sampleRate*uint32(blockAlign))
|
||||||
|
}
|
||||||
|
if align := binary.LittleEndian.Uint16(got[32:34]); align != blockAlign {
|
||||||
|
t.Errorf("block align = %d, want %d", align, blockAlign)
|
||||||
|
}
|
||||||
|
if bits := binary.LittleEndian.Uint16(got[34:36]); bits != uint16(tt.bitsPerSample) {
|
||||||
|
t.Errorf("bits per sample = %d, want %d", bits, tt.bitsPerSample)
|
||||||
|
}
|
||||||
|
if string(got[36:40]) != "data" {
|
||||||
|
t.Errorf("data chunk id = %q, want \"data\"", got[36:40])
|
||||||
|
}
|
||||||
|
if size := binary.LittleEndian.Uint32(got[40:44]); size != dataSize {
|
||||||
|
t.Errorf("data chunk size = %d, want %d", size, dataSize)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got[headerSize:headerSize+len(want)], want) {
|
||||||
|
t.Error("pcm payload does not match the source samples")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFLACToWAVErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
notFLAC := filepath.Join(dir, "not.flac")
|
||||||
|
if err := os.WriteFile(notFLAC, []byte("this is not a flac file"), 0644); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := filepath.Join(dir, "valid.flac")
|
||||||
|
writeTestFLAC(t, valid, 44100, 16, testSamples(2, 16, 64))
|
||||||
|
data, err := os.ReadFile(valid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read flac: %v", err)
|
||||||
|
}
|
||||||
|
truncated := filepath.Join(dir, "truncated.flac")
|
||||||
|
if err := os.WriteFile(truncated, data[:len(data)/2], 0644); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{"not a flac file", notFLAC},
|
||||||
|
{"missing file", filepath.Join(dir, "missing.flac")},
|
||||||
|
{"truncated file", truncated},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dst := filepath.Join(t.TempDir(), "out.wav")
|
||||||
|
if err := FLACToWAV(context.Background(), tt.path, dst); err == nil {
|
||||||
|
t.Error("FLACToWAV() error = nil, want error")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
t.Error("FLACToWAV() left an output file behind")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFLACToWAVCanceled(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := filepath.Join(dir, "in.flac")
|
||||||
|
dst := filepath.Join(dir, "out.wav")
|
||||||
|
writeTestFLAC(t, src, 44100, 16, testSamples(2, 16, 512))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
if err := FLACToWAV(ctx, src, dst); err == nil {
|
||||||
|
t.Error("FLACToWAV() error = nil, want context.Canceled")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
t.Error("FLACToWAV() left an output file behind")
|
||||||
|
}
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(dir, ".godeez-*.part"))
|
||||||
|
if len(matches) > 0 {
|
||||||
|
t.Errorf("FLACToWAV() left %d part files behind", len(matches))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBytesPerSample(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
bitsPerSample uint8
|
||||||
|
want int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{8, 1, false},
|
||||||
|
{16, 2, false},
|
||||||
|
{24, 3, false},
|
||||||
|
{4, 0, true},
|
||||||
|
{12, 0, true},
|
||||||
|
{20, 0, true},
|
||||||
|
{32, 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, err := bytesPerSample(tt.bitsPerSample)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("bytesPerSample(%d) error = %v, wantErr %v", tt.bitsPerSample, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("bytesPerSample(%d) = %d, want %d", tt.bitsPerSample, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
package bpm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Metrics struct {
|
|
||||||
BPM string
|
|
||||||
Key string
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchMetrics(ctx context.Context, httpClient *http.Client, artist, title, duration string) (*Metrics, error) {
|
|
||||||
url, err := findSongURL(ctx, httpClient, artist, title, duration)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
html, err := fetchPage(ctx, httpClient, url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseMetrics(html)
|
|
||||||
}
|
|
||||||
|
|
||||||
func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
|
|
||||||
rootUrl := "https://songbpm.com"
|
|
||||||
reqUrl := rootUrl + "/searches"
|
|
||||||
|
|
||||||
values := url.Values{}
|
|
||||||
values.Add("query", fmt.Sprintf("%s %s", artist, title))
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBufferString(values.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Origin", "https://songbpm.com")
|
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
found bool
|
|
||||||
url string
|
|
||||||
)
|
|
||||||
|
|
||||||
doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, selection *goquery.Selection) bool {
|
|
||||||
lowerSelection := strings.ToLower(selection.Text())
|
|
||||||
lowerTitle := strings.ToLower(title)
|
|
||||||
lowerArtist := strings.ToLower(artist)
|
|
||||||
if !strings.Contains(lowerSelection, lowerTitle) || !strings.Contains(lowerSelection, lowerArtist) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
durationStr := strings.TrimSpace(selection.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text())
|
|
||||||
parts := strings.Split(durationStr, ":")
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
minutes, err := strconv.Atoi(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
seconds, err := strconv.Atoi(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
foundDuration := minutes*60 + seconds
|
|
||||||
duration, err := strconv.Atoi(duration)
|
|
||||||
if err != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if foundDuration <= (duration-2) || foundDuration >= (duration+2) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
url = selection.AttrOr("href", "")
|
|
||||||
found = true
|
|
||||||
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
return "", fmt.Errorf("no data found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return rootUrl + url, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(body), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseMetrics(html string) (*Metrics, error) {
|
|
||||||
bpmRegex := regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
|
|
||||||
bpmMatch := bpmRegex.FindStringSubmatch(html)
|
|
||||||
|
|
||||||
keyRegex := regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
|
|
||||||
keyMatch := keyRegex.FindStringSubmatch(html)
|
|
||||||
|
|
||||||
modeRegex := regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
|
|
||||||
modeMatch := modeRegex.FindStringSubmatch(html)
|
|
||||||
|
|
||||||
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
|
|
||||||
return nil, fmt.Errorf("no data found")
|
|
||||||
}
|
|
||||||
|
|
||||||
isMinor := false
|
|
||||||
bpm := bpmMatch[1]
|
|
||||||
key := keyMatch[1]
|
|
||||||
if modeMatch[1] == "minor" {
|
|
||||||
isMinor = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(key, "/") {
|
|
||||||
parts := strings.Split(key, "/")
|
|
||||||
key = parts[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
key = strings.ReplaceAll(key, "♯", "#")
|
|
||||||
key = strings.ReplaceAll(key, "♭", "b")
|
|
||||||
|
|
||||||
if isMinor && !strings.HasSuffix(key, "m") {
|
|
||||||
key += "m"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Metrics{
|
|
||||||
BPM: bpm,
|
|
||||||
Key: key,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Package buildinfo reports the version, commit and build date of the running
|
||||||
|
// binary.
|
||||||
|
//
|
||||||
|
// Release builds have these stamped in by goreleaser through -ldflags. When
|
||||||
|
// that has not happened, as with `go build` or `go install`, the values are
|
||||||
|
// recovered from the module metadata the toolchain embeds. Anything that
|
||||||
|
// cannot be established as a real release is reported as a development build,
|
||||||
|
// which is what disables the update machinery.
|
||||||
|
package buildinfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/mod/module"
|
||||||
|
"golang.org/x/mod/semver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// devVersion is the version reported by builds that were not produced by a
|
||||||
|
// release. Both the update check and `godeez update` refuse to run on them.
|
||||||
|
const devVersion = "dev"
|
||||||
|
|
||||||
|
// Injected at link time by goreleaser. They are unexported and read through
|
||||||
|
// the accessors below so nothing can depend on their zero values directly.
|
||||||
|
var (
|
||||||
|
version = devVersion
|
||||||
|
commit = ""
|
||||||
|
date = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version returns the release version without a leading "v", or devVersion
|
||||||
|
// for anything that is not a release build. Binaries built with `go install`
|
||||||
|
// carry no ldflags but do record the module version, so that is consulted
|
||||||
|
// before giving up.
|
||||||
|
func Version() string {
|
||||||
|
if version != devVersion {
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok {
|
||||||
|
if v := releaseVersion(info.Main.Version); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return devVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseVersion accepts v only if it names a published release, returning ""
|
||||||
|
// otherwise.
|
||||||
|
//
|
||||||
|
// Pseudo-versions describe a commit that was never tagged, and a build suffix
|
||||||
|
// marks a local or modified build. Treating either as a release would offer
|
||||||
|
// the user an update path from a version that does not exist.
|
||||||
|
func releaseVersion(v string) string {
|
||||||
|
if !semver.IsValid(v) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if semver.Build(v) != "" || module.IsPseudoVersion(v) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimPrefix(v, "v")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsDev() bool {
|
||||||
|
return Version() == devVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit returns the revision the binary was built from, falling back to the
|
||||||
|
// VCS stamp the Go toolchain records when building inside a repository. It
|
||||||
|
// returns "" when neither is available.
|
||||||
|
func Commit() string {
|
||||||
|
if commit != "" {
|
||||||
|
return commit
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok {
|
||||||
|
for _, s := range info.Settings {
|
||||||
|
if s.Key == "vcs.revision" {
|
||||||
|
return s.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func Date() string {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserAgent() string {
|
||||||
|
return fmt.Sprintf("godeez/%s (%s/%s)", Version(), runtime.GOOS, runtime.GOARCH)
|
||||||
|
}
|
||||||
+52
-64
@@ -1,83 +1,71 @@
|
|||||||
|
// Package config resolves where godeez reads its session from and writes its
|
||||||
|
// downloads to. There is no config file: the output directory defaults to
|
||||||
|
// ~/Music/GoDeez but can be overridden with GODEEZ_OUTPUT_DIR, and the only
|
||||||
|
// other setting is the DEEZER_ARL environment variable, which exists as an
|
||||||
|
// escape hatch for users who would rather not store credentials in the system
|
||||||
|
// keyring.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/mathismqn/godeez/internal/fileutil"
|
"github.com/mathismqn/godeez/internal/fsutil"
|
||||||
"github.com/mathismqn/godeez/internal/store"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ArlCookie string `mapstructure:"arl_cookie"`
|
ARLCookie string
|
||||||
SecretKey string `mapstructure:"secret_key"`
|
OutputDir string
|
||||||
OutputDir string `mapstructure:"output_dir"`
|
|
||||||
HomeDir string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfgPath string) (*Config, error) {
|
// Load resolves the configuration and creates the output directory.
|
||||||
homeDir, err := os.UserHomeDir()
|
//
|
||||||
|
// An empty ARLCookie is normal and not an error: it means fall back to the
|
||||||
|
// stored credentials, which is the usual path. Creating the directory here
|
||||||
|
// rather than at first write means a bad path fails immediately instead of
|
||||||
|
// after the first track has been fetched.
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
arl := os.Getenv("DEEZER_ARL")
|
||||||
|
|
||||||
|
outputDir, err := resolveOutputDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfgDir := filepath.Join(homeDir, ".godeez")
|
|
||||||
if err := fileutil.EnsureDir(cfgDir); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfgPath == "" {
|
|
||||||
cfgPath = path.Join(cfgDir, "config.toml")
|
|
||||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
|
||||||
fmt.Printf("Config file not found, creating one at %s\n", cfgPath)
|
|
||||||
|
|
||||||
content := []byte("arl_cookie = ''\nsecret_key = ''\noutput_dir = ''\n")
|
|
||||||
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create config file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viper.SetConfigFile(cfgPath)
|
|
||||||
viper.SetConfigType("toml")
|
|
||||||
viper.AutomaticEnv()
|
|
||||||
if err := viper.ReadInConfig(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
if err := fsutil.EnsureDir(outputDir); err != nil {
|
||||||
}
|
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
|
||||||
func (c *Config) Validate() 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOutputDir honours GODEEZ_OUTPUT_DIR when set, falling back to
|
||||||
|
// ~/Music/GoDeez otherwise.
|
||||||
|
//
|
||||||
|
// A leading "~" is expanded by hand because the shell only does that for
|
||||||
|
// unquoted arguments, not for values read out of the environment, so users
|
||||||
|
// setting this in a profile file would otherwise end up with a literal "~"
|
||||||
|
// directory.
|
||||||
|
func resolveOutputDir() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir := strings.TrimSpace(os.Getenv("GODEEZ_OUTPUT_DIR")); dir != "" {
|
||||||
|
if dir == "~" {
|
||||||
|
return homeDir, nil
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
|
||||||
|
return filepath.Join(homeDir, rest), nil
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(homeDir, "Music", "GoDeez"), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/flytam/filenamify"
|
"github.com/flytam/filenamify"
|
||||||
)
|
)
|
||||||
@@ -19,58 +17,33 @@ type Album struct {
|
|||||||
PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"`
|
PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"`
|
||||||
Label string `json:"LABEL_NAME"`
|
Label string `json:"LABEL_NAME"`
|
||||||
ProducerLine string `json:"PRODUCER_LINE"`
|
ProducerLine string `json:"PRODUCER_LINE"`
|
||||||
|
Copyright string `json:"COPYRIGHT"`
|
||||||
Duration string `json:"DURATION"`
|
Duration string `json:"DURATION"`
|
||||||
} `json:"DATA"`
|
} `json:"DATA"`
|
||||||
Songs struct {
|
Tracks struct {
|
||||||
Data []*Song `json:"data"`
|
Data []*Track `json:"data"`
|
||||||
} `json:"SONGS"`
|
} `json:"SONGS"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Album) String() string {
|
func (a *Album) Title() string {
|
||||||
duration, err := strconv.Atoi(a.Results.Data.Duration)
|
|
||||||
if err != nil {
|
|
||||||
duration = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
|
||||||
`================= [ Album Info ] =================
|
|
||||||
Title: %s
|
|
||||||
Artist: %s
|
|
||||||
Tracks: %d
|
|
||||||
Duration: %s
|
|
||||||
==================================================`,
|
|
||||||
a.Results.Data.Title,
|
|
||||||
a.Results.Data.Artist,
|
|
||||||
len(a.Results.Songs.Data),
|
|
||||||
time.Duration(duration)*time.Second,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Album) GetType() string {
|
|
||||||
return "Album"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Album) GetTitle() string {
|
|
||||||
return a.Results.Data.Title
|
return a.Results.Data.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Album) GetSongs() []*Song {
|
func (a *Album) Tracks() []*Track {
|
||||||
return a.Results.Songs.Data
|
return a.Results.Tracks.Data
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Album) SetSongs(s []*Song) {
|
func (a *Album) SetTracks(t []*Track) {
|
||||||
a.Results.Songs.Data = s
|
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 := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
|
||||||
base, _ = filenamify.Filenamify(base, filenamify.Options{})
|
base, _ = filenamify.Filenamify(base, filenamify.Options{})
|
||||||
outputDir = path.Join(outputDir, base)
|
return filepath.Join(outputDir, base)
|
||||||
|
|
||||||
return outputDir
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Album) Unmarshal(data []byte) error {
|
func (a *Album) decode(data []byte) error {
|
||||||
return json.Unmarshal(data, a)
|
return json.Unmarshal(data, a)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-54
@@ -2,11 +2,7 @@ package deezer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"path/filepath"
|
||||||
"path"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/flytam/filenamify"
|
"github.com/flytam/filenamify"
|
||||||
)
|
)
|
||||||
@@ -16,67 +12,29 @@ type Artist struct {
|
|||||||
Data struct {
|
Data struct {
|
||||||
Name string `json:"ART_NAME"`
|
Name string `json:"ART_NAME"`
|
||||||
} `json:"DATA"`
|
} `json:"DATA"`
|
||||||
Songs struct {
|
Tracks struct {
|
||||||
Data []*Song `json:"data"`
|
Data []*Track `json:"data"`
|
||||||
} `json:"TOP"`
|
} `json:"TOP"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Artist) GetType() string {
|
func (a *Artist) Title() string {
|
||||||
return "Artist"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Artist) GetTitle() string {
|
|
||||||
return a.Results.Data.Name
|
return a.Results.Data.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Artist) GetSongs() []*Song {
|
func (a *Artist) Tracks() []*Track {
|
||||||
return a.Results.Songs.Data
|
return a.Results.Tracks.Data
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Artist) SetSongs(s []*Song) {
|
func (a *Artist) SetTracks(t []*Track) {
|
||||||
a.Results.Songs.Data = s
|
a.Results.Tracks.Data = t
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Artist) GetOutputDir(outputDir string) string {
|
func (a *Artist) OutputDir(outputDir string) string {
|
||||||
base, _ := filenamify.Filenamify(a.GetTitle(), filenamify.Options{})
|
base, _ := filenamify.Filenamify(a.Results.Data.Name, filenamify.Options{})
|
||||||
return path.Join(outputDir, base)
|
return filepath.Join(outputDir, base)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Artist) Unmarshal(data []byte) error {
|
func (a *Artist) decode(data []byte) error {
|
||||||
return json.Unmarshal(data, a)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
-93
@@ -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
|
package deezer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathismqn/godeez/internal/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
AppConfig *config.Config
|
|
||||||
Session *Session
|
Session *Session
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(ctx context.Context, appConfig *config.Config) (*Client, error) {
|
// NewClient authenticates with Deezer and returns a client bound to the
|
||||||
session, err := Authenticate(ctx, appConfig.ArlCookie)
|
// resulting session. An empty arlCookie falls back to the credentials held
|
||||||
|
// in the system keyring.
|
||||||
|
func NewClient(ctx context.Context, arlCookie string) (*Client, error) {
|
||||||
|
session, err := resolveSession(ctx, arlCookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
AppConfig: appConfig,
|
|
||||||
Session: session,
|
Session: session,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FetchResource(ctx context.Context, resource Resource, id string) error {
|
// resolveSession authenticates with arlCookie when one is supplied, and
|
||||||
payload := map[string]interface{}{
|
// otherwise falls back to the stored credentials.
|
||||||
|
//
|
||||||
|
// The validate callback handed to resolveARL is the real authentication, not
|
||||||
|
// a separate probe, so a stored ARL that still works is not sent twice.
|
||||||
|
// session is therefore only nil here when resolveARL had to log in again to
|
||||||
|
// mint a fresh ARL.
|
||||||
|
func resolveSession(ctx context.Context, arlCookie string) (*Session, error) {
|
||||||
|
if arlCookie != "" {
|
||||||
|
return authenticate(ctx, arlCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
var session *Session
|
||||||
|
validate := func(ctx context.Context, arl string) error {
|
||||||
|
s, err := authenticate(ctx, arl)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
session = s
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
arl, err := resolveARL(ctx, validate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if session == nil {
|
||||||
|
session, err = authenticate(ctx, arl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchResource fetches the page for the given kind and id and decodes it
|
||||||
|
// into the matching Resource implementation.
|
||||||
|
//
|
||||||
|
// gw-light answers 200 even for an unknown id and reports the failure inside
|
||||||
|
// the JSON, so bad ids have to be detected by matching markers in the body
|
||||||
|
// rather than by reading the status code. The nb parameter is set far above
|
||||||
|
// any real tracklist length to pull an entire resource in one request and
|
||||||
|
// avoid paging.
|
||||||
|
func (c *Client) FetchResource(ctx context.Context, kind Kind, id string) (Resource, error) {
|
||||||
|
resource, err := kind.newResource()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
"nb": 10000,
|
"nb": 10000,
|
||||||
"start": 0,
|
"start": 0,
|
||||||
"lang": "en",
|
"lang": "en",
|
||||||
@@ -38,78 +101,85 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
|
|||||||
"tags": true,
|
"tags": true,
|
||||||
"header": true,
|
"header": true,
|
||||||
}
|
}
|
||||||
switch r := resource.(type) {
|
payload[kind.idKey()] = id
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(payload)
|
jsonData, err := json.Marshal(payload)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.Session.HttpClient.Do(req)
|
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", kind.pageMethod(), c.Session.apiToken)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.Session.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := string(body)
|
||||||
|
for _, check := range []struct {
|
||||||
|
marker string
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{`"DATA_ERROR":"playlist::getData"`, "invalid playlist ID"},
|
||||||
|
{`"DATA_ERROR":"album::getData"`, "invalid album ID"},
|
||||||
|
{`"DATA_ERROR":"artist::getData"`, "invalid artist ID"},
|
||||||
|
{`"DATA_ERROR":"song::getData"`, "invalid track ID"},
|
||||||
|
} {
|
||||||
|
if strings.Contains(bodyStr, check.marker) {
|
||||||
|
return nil, errors.New(check.errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(bodyStr, `"results":{}`) {
|
||||||
|
return nil, errors.New("unexpected response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resource.decode(body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resource, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchMedia resolves a playable source URL for track at the requested
|
||||||
|
// quality.
|
||||||
|
//
|
||||||
|
// Each quality maps to an ordered fallback chain, so asking for flac on a
|
||||||
|
// track that has none yields mp3_320 instead of an error; callers compare
|
||||||
|
// Media.Format against what they asked for to detect a downgrade. There is
|
||||||
|
// no wav entry because Deezer does not serve wav: the download package
|
||||||
|
// requests flac and converts locally.
|
||||||
|
//
|
||||||
|
// A 400 is accepted alongside 200 because the gateway uses it to return a
|
||||||
|
// structured error payload that is more useful than the status code.
|
||||||
|
func (c *Client) FetchMedia(ctx context.Context, track *Track, quality string) (*Media, error) {
|
||||||
|
qualityFormats := map[string]string{
|
||||||
|
"mp3_128": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
|
||||||
|
"mp3_320": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
|
||||||
|
"flac": `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.licenseToken, qualityFormats[quality], track.TrackToken)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.Session.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -125,37 +195,39 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
|
|||||||
}
|
}
|
||||||
|
|
||||||
var media Media
|
var media Media
|
||||||
err = json.Unmarshal(body, &media)
|
if err := json.Unmarshal(body, &media); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(media.Errors) > 0 {
|
if len(media.Errors) > 0 {
|
||||||
if media.Errors[0].Code == 1000 {
|
if media.Errors[0].Code == 1000 {
|
||||||
return nil, fmt.Errorf("invalid license token")
|
return nil, errors.New("invalid license token")
|
||||||
|
}
|
||||||
|
return nil, errors.New(media.Errors[0].Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("%s", media.Errors[0].Message)
|
|
||||||
}
|
|
||||||
if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 {
|
if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 {
|
||||||
if media.Data[0].Errors[0].Code == 2002 {
|
if media.Data[0].Errors[0].Code == 2002 {
|
||||||
return nil, fmt.Errorf("invalid track token")
|
return nil, errors.New("invalid track token")
|
||||||
|
}
|
||||||
|
return nil, errors.New(media.Data[0].Errors[0].Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("%s", media.Data[0].Errors[0].Message)
|
if len(media.Data) == 0 || len(media.Data[0].Media) == 0 || len(media.Data[0].Media[0].Sources) == 0 {
|
||||||
|
return nil, errors.New("no sources found")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &media, nil
|
return &media, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error) {
|
func (c *Client) FetchCoverImage(ctx context.Context, track *Track) ([]byte, error) {
|
||||||
url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", song.Cover)
|
url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", track.Cover)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.Session.HttpClient.Do(req)
|
resp, err := c.Session.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -168,18 +240,19 @@ func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error
|
|||||||
return io.ReadAll(resp.Body)
|
return io.ReadAll(resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) {
|
// MediaStream opens the audio stream for media. The caller owns the returned
|
||||||
url, err := media.GetURL()
|
// body and must close it.
|
||||||
|
//
|
||||||
|
// The session client is copied so its timeout can be cleared for this
|
||||||
|
// request: the session timeout is sized for short API calls and would abort
|
||||||
|
// a long track transfer. Cancellation is left to ctx.
|
||||||
|
func (c *Client) MediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, media.URL(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
streamingClient := *c.Session.HTTPClient
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
streamingClient := *c.Session.HttpClient
|
|
||||||
streamingClient.Timeout = 0
|
streamingClient.Timeout = 0
|
||||||
|
|
||||||
resp, err := streamingClient.Do(req)
|
resp, err := streamingClient.Do(req)
|
||||||
@@ -188,6 +261,7 @@ func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package deezer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/zalando/go-keyring"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyringService = "godeez"
|
||||||
|
keyringUser = "default"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credentials is the JSON blob stored as a single system keyring secret. The
|
||||||
|
// password is kept alongside the ARL so an expired session can be renewed
|
||||||
|
// without prompting; see Login. Nothing here is ever written to disk by
|
||||||
|
// godeez itself.
|
||||||
|
type Credentials struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
ARL string `json:"arl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCredentials returns the stored credentials, or nil with no error when
|
||||||
|
// the user has simply never logged in. That case is distinguished from a
|
||||||
|
// genuine keyring failure so callers can fall back to DEEZER_ARL instead of
|
||||||
|
// aborting.
|
||||||
|
func loadCredentials() (*Credentials, error) {
|
||||||
|
secret, err := keyring.Get(keyringService, keyringUser)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, keyring.ErrNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("system keyring is unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds Credentials
|
||||||
|
if err := json.Unmarshal([]byte(secret), &creds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCredentials(creds *Credentials) error {
|
||||||
|
data, err := json.Marshal(creds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := keyring.Set(keyringService, keyringUser, string(data)); err != nil {
|
||||||
|
return fmt.Errorf("system keyring is unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCredentials removes the stored credentials. Logging out when nothing
|
||||||
|
// is stored is not an error, so a missing entry is reported as success.
|
||||||
|
func ClearCredentials() error {
|
||||||
|
if err := keyring.Delete(keyringService, keyringUser); err != nil {
|
||||||
|
if errors.Is(err, keyring.ErrNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("system keyring is unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package deezer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zeroPad right pads data with zero bytes to a whole number of AES blocks.
|
||||||
|
// This is not PKCS#7 and is not unambiguously reversible, but it is what the
|
||||||
|
// mobile gateway expects for the password field.
|
||||||
|
func zeroPad(data []byte) []byte {
|
||||||
|
bs := aes.BlockSize
|
||||||
|
padded := make([]byte, len(data)+(bs-len(data)%bs)%bs)
|
||||||
|
copy(padded, data)
|
||||||
|
|
||||||
|
return padded
|
||||||
|
}
|
||||||
|
|
||||||
|
func ecbEncrypt(key, data []byte) ([]byte, error) {
|
||||||
|
return ecbTransform(key, data, (cipher.Block).Encrypt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ecbDecrypt(key, data []byte) ([]byte, error) {
|
||||||
|
return ecbTransform(key, data, (cipher.Block).Decrypt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ecbTransform applies op block by block in ECB mode.
|
||||||
|
//
|
||||||
|
// ECB leaks equality between identical plaintext blocks and would be the
|
||||||
|
// wrong choice for anything designed today, but it is the mode Deezer's
|
||||||
|
// mobile gateway uses, so interoperating requires it. The standard library
|
||||||
|
// deliberately ships no ECB mode, which is why this exists. Do not reuse it
|
||||||
|
// for anything outside the gateway handshake.
|
||||||
|
func ecbTransform(key, data []byte, op func(cipher.Block, []byte, []byte)) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bs := block.BlockSize()
|
||||||
|
if len(data)%bs != 0 {
|
||||||
|
return nil, fmt.Errorf("data length %d is not a multiple of the AES block size", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]byte, len(data))
|
||||||
|
for i := 0; i < len(data); i += bs {
|
||||||
|
op(block, out[i:i+bs], data[i:i+bs])
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package deezer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestZeroPad(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
length int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{0, 0},
|
||||||
|
{1, 16},
|
||||||
|
{15, 16},
|
||||||
|
{16, 16},
|
||||||
|
{17, 32},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
padded := zeroPad(make([]byte, tt.length))
|
||||||
|
if len(padded) != tt.want {
|
||||||
|
t.Errorf("zeroPad(len %d) = len %d, want %d", tt.length, len(padded), tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroPadPreservesData(t *testing.T) {
|
||||||
|
data := []byte("secret")
|
||||||
|
padded := zeroPad(data)
|
||||||
|
|
||||||
|
if !bytes.Equal(padded[:len(data)], data) {
|
||||||
|
t.Errorf("zeroPad changed data: got %q", padded[:len(data)])
|
||||||
|
}
|
||||||
|
for _, b := range padded[len(data):] {
|
||||||
|
if b != 0 {
|
||||||
|
t.Errorf("padding is not zero: %v", padded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestECBRoundTrip(t *testing.T) {
|
||||||
|
key := []byte("0123456789abcdef")
|
||||||
|
plaintext := zeroPad([]byte("some secret data"))
|
||||||
|
|
||||||
|
encrypted, err := ecbEncrypt(key, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecbEncrypt: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(encrypted, plaintext) {
|
||||||
|
t.Fatal("encrypted data equals plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := ecbDecrypt(key, encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ecbDecrypt: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(decrypted, plaintext) {
|
||||||
|
t.Errorf("round trip mismatch: got %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestECBRejectsPartialBlock(t *testing.T) {
|
||||||
|
key := []byte("0123456789abcdef")
|
||||||
|
|
||||||
|
if _, err := ecbEncrypt(key, make([]byte, 15)); err == nil {
|
||||||
|
t.Error("expected error for data not a multiple of the block size")
|
||||||
|
}
|
||||||
|
if _, err := ecbDecrypt(key, make([]byte, 17)); err == nil {
|
||||||
|
t.Error("expected error for data not a multiple of the block size")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package deezer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
gatewayBaseURL = "https://api.deezer.com/1.0/gateway.php"
|
||||||
|
gatewayUserAgent = "Deezer/6.1.22.49 (Android; 9; Tablet; us) innotek GmbH VirtualBox"
|
||||||
|
|
||||||
|
// nonceAlphabet omits '7' and 'c' on purpose. It mirrors the alphabet the
|
||||||
|
// Android client uses to build its uniq_id, and the gateway is picky about
|
||||||
|
// the shape of that value, so this must not be "completed" into a full hex
|
||||||
|
// alphabet.
|
||||||
|
nonceAlphabet = "012345689abdef"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The gateway only answers requests that look like they come from the Android
|
||||||
|
// app, so these describe a plausible device. They are deliberately generic
|
||||||
|
// rather than derived from the user's real machine: nothing here should
|
||||||
|
// identify the person running godeez.
|
||||||
|
const (
|
||||||
|
deviceOS = "Android"
|
||||||
|
deviceName = "VirtualBox"
|
||||||
|
deviceType = "tablet"
|
||||||
|
deviceModel = "VirtualBox"
|
||||||
|
devicePlatform = "innotek GmbH_x86_64_9"
|
||||||
|
deviceSerial = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
type mobileClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
apiKey string
|
||||||
|
gwKey []byte
|
||||||
|
sid string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckGatewayEnv reports whether the mobile gateway keys are present and
|
||||||
|
// well formed. It exists so the login command can fail immediately with a
|
||||||
|
// clear message instead of prompting for a password it cannot use.
|
||||||
|
func CheckGatewayEnv() error {
|
||||||
|
_, _, err := gatewayEnv()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatewayEnv reads the two mobile gateway keys from the environment.
|
||||||
|
//
|
||||||
|
// They are not shipped with godeez: they are Deezer's, and baking them into a
|
||||||
|
// public repository would be both a licensing problem and a fast route to
|
||||||
|
// having them revoked. Users who want email and password login supply their
|
||||||
|
// own, which is why this is the one feature gated behind environment
|
||||||
|
// variables. The gateway key doubles as an AES key, hence the exact length
|
||||||
|
// requirement.
|
||||||
|
func gatewayEnv() (string, string, error) {
|
||||||
|
apiKey := os.Getenv("DEEZER_MOBILE_API_KEY")
|
||||||
|
gwKey := os.Getenv("DEEZER_MOBILE_GW_KEY")
|
||||||
|
if apiKey == "" || gwKey == "" {
|
||||||
|
return "", "", errors.New("DEEZER_MOBILE_API_KEY and DEEZER_MOBILE_GW_KEY must be set to use email/password login")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(gwKey) != aes.BlockSize {
|
||||||
|
return "", "", fmt.Errorf("DEEZER_MOBILE_GW_KEY must be exactly %d bytes long", aes.BlockSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiKey, gwKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMobileClient() (*mobileClient, error) {
|
||||||
|
apiKey, gwKey, err := gatewayEnv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mobileClient{
|
||||||
|
httpClient: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
apiKey: apiKey,
|
||||||
|
gwKey: []byte(gwKey),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// login runs the three step mobile handshake and returns the resulting
|
||||||
|
// credentials plus the account's display name.
|
||||||
|
//
|
||||||
|
// The steps are ordered and stateful, so none of them can be skipped or
|
||||||
|
// reordered: authenticate yields a token and two one-shot AES keys,
|
||||||
|
// checkToken trades the token for a session id that gatewayRequest then
|
||||||
|
// attaches to every later call, and only then will userAuth accept the
|
||||||
|
// encrypted password and return an ARL.
|
||||||
|
func (m *mobileClient) login(ctx context.Context, email, password string) (*Credentials, string, error) {
|
||||||
|
token, tokenKey, userKey, err := m.authenticate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.checkToken(ctx, token, tokenKey); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
arl, username, err := m.userAuth(ctx, email, password, userKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Credentials{Email: email, Password: password, ARL: arl}, username, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticate performs the first handshake step and returns the session
|
||||||
|
// token, the key used to sign it back in checkToken, and the key used to
|
||||||
|
// encrypt the password in userAuth.
|
||||||
|
//
|
||||||
|
// The gateway packs all three into one hex blob encrypted under the gateway
|
||||||
|
// key, at fixed offsets: 64 bytes of token, then two 16 byte keys. The length
|
||||||
|
// check guards against a short or error response being sliced blindly.
|
||||||
|
//
|
||||||
|
// Errors arrive with a 200 status and are only visible as markers in the
|
||||||
|
// body, so they are matched as strings.
|
||||||
|
func (m *mobileClient) authenticate(ctx context.Context) (string, string, string, error) {
|
||||||
|
body, err := m.gatewayRequest(ctx, "mobile_auth", http.MethodGet, "uniq_id", genUniqID(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Results struct {
|
||||||
|
Token string `json:"TOKEN"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &res); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(string(body), "Undefined or invalid API key") {
|
||||||
|
return "", "", "", errors.New("DEEZER_MOBILE_API_KEY is invalid")
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), "GATEWAY_ERROR") || res.Results.Token == "" {
|
||||||
|
return "", "", "", errors.New("unexpected response from gateway")
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := hex.DecodeString(res.Results.Token)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := ecbDecrypt(m.gwKey, encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(decrypted) < 96 {
|
||||||
|
return "", "", "", errors.New("unexpected response from gateway")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := string(decrypted[0:64])
|
||||||
|
tokenKey := string(decrypted[64:80])
|
||||||
|
userKey := string(decrypted[80:96])
|
||||||
|
|
||||||
|
return token, tokenKey, userKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkToken proves possession of the token by returning it encrypted under
|
||||||
|
// tokenKey, and stores the session id the gateway hands back. Every
|
||||||
|
// subsequent request carries that id, so userAuth fails without this step.
|
||||||
|
func (m *mobileClient) checkToken(ctx context.Context, token, tokenKey string) error {
|
||||||
|
encrypted, err := ecbEncrypt([]byte(tokenKey), []byte(token))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authToken := hex.EncodeToString(encrypted)
|
||||||
|
|
||||||
|
body, err := m.gatewayRequest(ctx, "api_checkToken", http.MethodGet, "auth_token", authToken, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Results string `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.Results == "" {
|
||||||
|
return errors.New("unexpected response from gateway")
|
||||||
|
}
|
||||||
|
m.sid = res.Results
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userAuth exchanges the user's credentials for an ARL cookie and returns it
|
||||||
|
// along with the account's display name.
|
||||||
|
//
|
||||||
|
// The password is sent AES-ECB encrypted under userKey rather than in the
|
||||||
|
// clear. The empty and constant fields in the payload are not padding: the
|
||||||
|
// gateway rejects the request outright if any of them are missing.
|
||||||
|
func (m *mobileClient) userAuth(ctx context.Context, email, password, userKey string) (string, string, error) {
|
||||||
|
encryptedPassword, err := ecbEncrypt([]byte(userKey), zeroPad([]byte(password)))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]string{
|
||||||
|
"mail": email,
|
||||||
|
"password": hex.EncodeToString(encryptedPassword),
|
||||||
|
"device_serial": deviceSerial,
|
||||||
|
"platform": devicePlatform,
|
||||||
|
"custo_version_id": "",
|
||||||
|
"custo_partner": "",
|
||||||
|
"model": deviceModel,
|
||||||
|
"device_name": deviceName,
|
||||||
|
"device_os": deviceOS,
|
||||||
|
"device_type": deviceType,
|
||||||
|
"google_play_services_availability": "1",
|
||||||
|
"consent_string": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := m.gatewayRequest(ctx, "mobile_userAuth", http.MethodPost, "", "", jsonBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(string(body), "USER_AUTH_ERROR") {
|
||||||
|
return "", "", errors.New("invalid email or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Results struct {
|
||||||
|
ARL string `json:"ARL"`
|
||||||
|
UserID int `json:"USER_ID"`
|
||||||
|
BlogName string `json:"BLOG_NAME"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &res); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Results.ARL == "" || res.Results.UserID == 0 {
|
||||||
|
return "", "", errors.New("unexpected response from gateway")
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Results.ARL, res.Results.BlogName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mobileClient) gatewayRequest(ctx context.Context, method, httpMethod, paramKey, paramValue string, jsonBody []byte) ([]byte, error) {
|
||||||
|
u, err := url.Parse(gatewayBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("method", method)
|
||||||
|
q.Set("api_key", m.apiKey)
|
||||||
|
q.Set("output", "3")
|
||||||
|
if httpMethod == http.MethodPost {
|
||||||
|
q.Set("input", "3")
|
||||||
|
}
|
||||||
|
if m.sid != "" {
|
||||||
|
q.Set("sid", m.sid)
|
||||||
|
}
|
||||||
|
if paramKey != "" {
|
||||||
|
q.Set(paramKey, paramValue)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
var reqBody io.Reader
|
||||||
|
if jsonBody != nil {
|
||||||
|
reqBody = bytes.NewReader(jsonBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, httpMethod, u.String(), reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", gatewayUserAgent)
|
||||||
|
if jsonBody != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := m.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// genUniqID builds the 32 character device identifier sent with the first
|
||||||
|
// handshake request. It is regenerated per login on purpose, so that repeated
|
||||||
|
// logins are not linkable to one another by a stable device id.
|
||||||
|
func genUniqID() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = nonceAlphabet[rand.IntN(len(nonceAlphabet))]
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package deezer
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Kind is the type of Deezer resource being downloaded. It is the single
|
||||||
|
// source of truth for the four supported resources: the cmd package derives
|
||||||
|
// its download subcommands from these constants, and each kind maps to a
|
||||||
|
// gw-light page method, the request field naming its id, and a Resource
|
||||||
|
// implementation. Adding a kind means extending all three switches below.
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindAlbum Kind = "album"
|
||||||
|
KindPlaylist Kind = "playlist"
|
||||||
|
KindArtist Kind = "artist"
|
||||||
|
KindTrack Kind = "track"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k Kind) pageMethod() string {
|
||||||
|
switch k {
|
||||||
|
case KindAlbum:
|
||||||
|
return "Album"
|
||||||
|
case KindPlaylist:
|
||||||
|
return "Playlist"
|
||||||
|
case KindArtist:
|
||||||
|
return "Artist"
|
||||||
|
case KindTrack:
|
||||||
|
return "Track"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// idKey returns the request field that carries the resource id. The names are
|
||||||
|
// Deezer's own internal abbreviations and do not follow from the kind, so they
|
||||||
|
// have to be spelled out. A track is a "song" on the wire.
|
||||||
|
func (k Kind) idKey() string {
|
||||||
|
switch k {
|
||||||
|
case KindAlbum:
|
||||||
|
return "alb_id"
|
||||||
|
case KindPlaylist:
|
||||||
|
return "playlist_id"
|
||||||
|
case KindArtist:
|
||||||
|
return "art_id"
|
||||||
|
case KindTrack:
|
||||||
|
return "sng_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// newResource returns an empty Resource for the kind. KindTrack maps to
|
||||||
|
// Single because a single track page has its own response shape rather than
|
||||||
|
// being an album with one entry.
|
||||||
|
func (k Kind) newResource() (Resource, error) {
|
||||||
|
switch k {
|
||||||
|
case KindAlbum:
|
||||||
|
return &Album{}, nil
|
||||||
|
case KindPlaylist:
|
||||||
|
return &Playlist{}, nil
|
||||||
|
case KindArtist:
|
||||||
|
return &Artist{}, nil
|
||||||
|
case KindTrack:
|
||||||
|
return &Single{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unsupported resource type: %s", k)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package deezer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveARL returns a usable ARL cookie from the stored credentials, logging
|
||||||
|
// in again if the stored one has expired.
|
||||||
|
//
|
||||||
|
// validate is supplied by the caller so the check can be the real
|
||||||
|
// authentication rather than a throwaway probe. Only ErrInvalidARL triggers a
|
||||||
|
// re-login: any other validation failure is most likely the network or Deezer
|
||||||
|
// being down, and silently re-sending the password in that case would turn a
|
||||||
|
// transient outage into a spurious login attempt.
|
||||||
|
func resolveARL(ctx context.Context, validate func(ctx context.Context, arl string) error) (string, error) {
|
||||||
|
creds, err := loadCredentials()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds != nil && creds.ARL != "" {
|
||||||
|
verr := validate(ctx, creds.ARL)
|
||||||
|
if verr == nil {
|
||||||
|
return creds.ARL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(verr, ErrInvalidARL) {
|
||||||
|
return "", fmt.Errorf("stored session could not be validated: %w", verr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds != nil && creds.Email != "" && creds.Password != "" {
|
||||||
|
arl, _, err := Login(ctx, creds.Email, creds.Password)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stored session expired and could not be renewed automatically: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return arl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("run 'godeez login' or export DEEZER_ARL environment variable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates with email and password through the mobile gateway,
|
||||||
|
// persists the credentials to the system keyring, and returns the resulting
|
||||||
|
// ARL cookie and the account's display name.
|
||||||
|
//
|
||||||
|
// The password is stored, not just the ARL, because ARLs expire and renewing
|
||||||
|
// one without prompting the user again requires replaying the login.
|
||||||
|
func Login(ctx context.Context, email, password string) (string, string, error) {
|
||||||
|
client, err := newMobileClient()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, username, err := client.login(ctx, email, password)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveCredentials(creds); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return creds.ARL, username, nil
|
||||||
|
}
|
||||||
+15
-31
@@ -1,48 +1,32 @@
|
|||||||
package deezer
|
package deezer
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Media struct {
|
type Media struct {
|
||||||
Errors []MediaError `json:"errors"`
|
Errors []mediaError `json:"errors"`
|
||||||
Data []struct {
|
Data []struct {
|
||||||
Media []struct {
|
Media []struct {
|
||||||
Type string `json:"media_type"`
|
|
||||||
Cipher Cipher `json:"cipher"`
|
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
Sources []Source `json:"sources"`
|
Sources []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"sources"`
|
||||||
}
|
}
|
||||||
Errors []MediaError `json:"errors"`
|
Errors []mediaError `json:"errors"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaError struct {
|
type mediaError struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Cipher struct {
|
// URL and Format read the first source Deezer offered, which is the best one
|
||||||
Type string `json:"type"`
|
// available for the requested quality. Both index without checking because
|
||||||
|
// FetchMedia has already rejected empty and error responses; do not call them
|
||||||
|
// on a Media obtained any other way.
|
||||||
|
|
||||||
|
func (m *Media) URL() string {
|
||||||
|
return m.Data[0].Media[0].Sources[0].URL
|
||||||
}
|
}
|
||||||
|
|
||||||
type Source struct {
|
func (m *Media) Format() string {
|
||||||
URL string `json:"url"`
|
return m.Data[0].Media[0].Format
|
||||||
Provider string `json:"provider"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Media) GetURL() (string, error) {
|
|
||||||
if len(m.Data) == 0 || len(m.Data[0].Media) == 0 || len(m.Data[0].Media[0].Sources) == 0 {
|
|
||||||
return "", fmt.Errorf("no media sources found")
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-36
@@ -2,9 +2,7 @@ package deezer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"path/filepath"
|
||||||
"path"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/flytam/filenamify"
|
"github.com/flytam/filenamify"
|
||||||
)
|
)
|
||||||
@@ -13,54 +11,32 @@ type Playlist struct {
|
|||||||
Results struct {
|
Results struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
Title string `json:"TITLE"`
|
Title string `json:"TITLE"`
|
||||||
Status int `json:"STATUS"`
|
|
||||||
Creator string `json:"PARENT_USERNAME"`
|
Creator string `json:"PARENT_USERNAME"`
|
||||||
Duration int `json:"DURATION"`
|
Duration int `json:"DURATION"`
|
||||||
} `json:"DATA"`
|
} `json:"DATA"`
|
||||||
Songs struct {
|
Tracks struct {
|
||||||
Data []*Song `json:"data"`
|
Data []*Track `json:"data"`
|
||||||
} `json:"SONGS"`
|
} `json:"SONGS"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Playlist) String() string {
|
func (p *Playlist) Title() string {
|
||||||
return fmt.Sprintf(
|
|
||||||
`=============== [ Playlist Info ] ===============
|
|
||||||
Title: %s
|
|
||||||
Creator: %s
|
|
||||||
Tracks: %d
|
|
||||||
Duration: %s
|
|
||||||
=================================================`,
|
|
||||||
p.Results.Data.Title,
|
|
||||||
p.Results.Data.Creator,
|
|
||||||
len(p.Results.Songs.Data),
|
|
||||||
time.Duration(p.Results.Data.Duration)*time.Second,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Playlist) GetType() string {
|
|
||||||
return "Playlist"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Playlist) GetTitle() string {
|
|
||||||
return p.Results.Data.Title
|
return p.Results.Data.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Playlist) GetSongs() []*Song {
|
func (p *Playlist) Tracks() []*Track {
|
||||||
return p.Results.Songs.Data
|
return p.Results.Tracks.Data
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Playlist) SetSongs(s []*Song) {
|
func (p *Playlist) SetTracks(t []*Track) {
|
||||||
p.Results.Songs.Data = s
|
p.Results.Tracks.Data = t
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Playlist) GetOutputDir(outputDir string) string {
|
func (p *Playlist) OutputDir(outputDir string) string {
|
||||||
p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
|
base, _ := filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
|
||||||
outputDir = path.Join(outputDir, p.Results.Data.Title)
|
return filepath.Join(outputDir, base)
|
||||||
|
|
||||||
return outputDir
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Playlist) Unmarshal(data []byte) error {
|
func (p *Playlist) decode(data []byte) error {
|
||||||
return json.Unmarshal(data, p)
|
return json.Unmarshal(data, p)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
package deezer
|
package deezer
|
||||||
|
|
||||||
|
// Resource is a downloadable Deezer page: an Album, Playlist, Artist or
|
||||||
|
// Single. Implementations differ only in how they decode the gw-light
|
||||||
|
// response and where they place their output, so the accessors are
|
||||||
|
// intentionally thin and are not documented individually.
|
||||||
|
//
|
||||||
|
// The unexported decode method seals the interface. Only the four types in
|
||||||
|
// this package can satisfy it, which lets Kind.newResource stay an exhaustive
|
||||||
|
// switch and guarantees FetchResource never receives an implementation whose
|
||||||
|
// wire format it does not know.
|
||||||
type Resource interface {
|
type Resource interface {
|
||||||
GetTitle() string
|
Title() string
|
||||||
GetType() string
|
Tracks() []*Track
|
||||||
GetSongs() []*Song
|
SetTracks(tracks []*Track)
|
||||||
SetSongs(songs []*Song)
|
OutputDir(outputDir string) string
|
||||||
GetOutputDir(outputDir string) string
|
decode(data []byte) error
|
||||||
Unmarshal(data []byte) error
|
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-29
@@ -3,6 +3,7 @@ package deezer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,29 +11,30 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserDataResponse struct {
|
// ErrInvalidARL reports that an ARL cookie was rejected. Callers should treat
|
||||||
Results struct {
|
// it as recoverable and re-login rather than as a hard failure; resolveARL
|
||||||
APIToken string `json:"checkForm"`
|
// relies on that distinction to decide whether to renew a stored session.
|
||||||
User struct {
|
var ErrInvalidARL = errors.New("invalid or expired ARL cookie")
|
||||||
Id int `json:"USER_ID"`
|
|
||||||
Options struct {
|
|
||||||
LicenseToken string `json:"license_token"`
|
|
||||||
MobileOffline bool `json:"mobile_offline"`
|
|
||||||
WebOffline bool `json:"web_offline"`
|
|
||||||
} `json:"OPTIONS"`
|
|
||||||
} `json:"USER"`
|
|
||||||
} `json:"results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ArlCookie string
|
apiToken string
|
||||||
APIToken string
|
licenseToken string
|
||||||
LicenseToken string
|
HTTPClient *http.Client
|
||||||
HttpClient *http.Client
|
|
||||||
Premium bool
|
Premium bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
|
// authenticate exchanges an ARL cookie for a Session. It returns
|
||||||
|
// ErrInvalidARL if the cookie is rejected.
|
||||||
|
//
|
||||||
|
// The endpoint answers 200 with an empty user for a bad cookie rather than an
|
||||||
|
// error status, so a zero user id is the only reliable signal that the ARL is
|
||||||
|
// no longer valid. A cookie jar is required because gw-light sets session
|
||||||
|
// cookies that later calls depend on.
|
||||||
|
//
|
||||||
|
// Premium is inferred from the offline listening options, which are the
|
||||||
|
// closest thing the payload carries to a subscription flag; it gates the
|
||||||
|
// higher quality formats.
|
||||||
|
func authenticate(ctx context.Context, arlCookie string) (*Session, error) {
|
||||||
jar, err := cookiejar.New(nil)
|
jar, err := cookiejar.New(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -43,7 +45,7 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token="
|
url := "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token="
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -68,22 +70,32 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var res UserDataResponse
|
var res struct {
|
||||||
|
Results struct {
|
||||||
|
APIToken string `json:"checkForm"`
|
||||||
|
User struct {
|
||||||
|
ID int `json:"USER_ID"`
|
||||||
|
Options struct {
|
||||||
|
LicenseToken string `json:"license_token"`
|
||||||
|
MobileOffline bool `json:"mobile_offline"`
|
||||||
|
WebOffline bool `json:"web_offline"`
|
||||||
|
} `json:"OPTIONS"`
|
||||||
|
} `json:"USER"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
if err := json.Unmarshal(body, &res); err != nil {
|
if err := json.Unmarshal(body, &res); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.Results.User.Id == 0 {
|
if res.Results.User.ID == 0 {
|
||||||
return nil, fmt.Errorf("invalid arl cookie")
|
return nil, ErrInvalidARL
|
||||||
}
|
}
|
||||||
|
|
||||||
isPremium := res.Results.User.Options.MobileOffline || res.Results.User.Options.WebOffline
|
opts := res.Results.User.Options
|
||||||
|
|
||||||
return &Session{
|
return &Session{
|
||||||
ArlCookie: arlCookie,
|
apiToken: res.Results.APIToken,
|
||||||
APIToken: res.Results.APIToken,
|
licenseToken: opts.LicenseToken,
|
||||||
LicenseToken: res.Results.User.Options.LicenseToken,
|
HTTPClient: client,
|
||||||
HttpClient: client,
|
Premium: opts.MobileOffline || opts.WebOffline,
|
||||||
Premium: isPremium,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package deezer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Single struct {
|
||||||
|
Results struct {
|
||||||
|
Data *Track `json:"DATA"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Single) Title() string {
|
||||||
|
if s.Results.Data == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.Results.Data.FullTitle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Single) Tracks() []*Track {
|
||||||
|
if s.Results.Data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []*Track{s.Results.Data}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Single) SetTracks(tracks []*Track) {}
|
||||||
|
|
||||||
|
func (s *Single) OutputDir(outputDir string) string {
|
||||||
|
return filepath.Join(outputDir, "Singles")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Single) decode(data []byte) error {
|
||||||
|
return json.Unmarshal(data, s)
|
||||||
|
}
|
||||||
@@ -1,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,171 @@
|
|||||||
|
// Package download drives the end to end download of a Deezer resource.
|
||||||
|
//
|
||||||
|
// Run fetches the resource, then walks its tracks in order: resolve a media
|
||||||
|
// source, decide whether the track can be skipped, stream and decrypt it,
|
||||||
|
// optionally convert to wav, write tags, and record the result in the store
|
||||||
|
// so a later run can skip it. Tracks are processed one at a time.
|
||||||
|
//
|
||||||
|
// Most per-track failures are collected as warnings rather than aborting the
|
||||||
|
// run, so an unavailable cover or a failed BPM lookup does not cost the user
|
||||||
|
// the rest of an album. Only context cancellation stops the loop early.
|
||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/config"
|
||||||
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
|
"github.com/mathismqn/godeez/internal/fsutil"
|
||||||
|
"github.com/mathismqn/godeez/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Downloader struct {
|
||||||
|
appConfig *config.Config
|
||||||
|
store *store.Store
|
||||||
|
kind deezer.Kind
|
||||||
|
deezerClient *deezer.Client
|
||||||
|
|
||||||
|
hashIndexOnce sync.Once
|
||||||
|
hashIndex *hashIndex
|
||||||
|
hashIndexErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(appConfig *config.Config, st *store.Store, kind deezer.Kind) *Downloader {
|
||||||
|
return &Downloader{
|
||||||
|
appConfig: appConfig,
|
||||||
|
store: st,
|
||||||
|
kind: kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run downloads every track of the resource identified by id. opts is
|
||||||
|
// expected to have passed Validate already, which the cmd package does while
|
||||||
|
// parsing flags.
|
||||||
|
func (d *Downloader) Run(ctx context.Context, opts Options, id string) error {
|
||||||
|
if err := d.initDeezerClient(ctx, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resource, outputDir, err := d.prepareResource(ctx, id, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.downloadAllTracks(ctx, resource, opts, outputDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initDeezerClient authenticates and rejects quality settings the account
|
||||||
|
// cannot serve.
|
||||||
|
//
|
||||||
|
// The check runs against sourceQuality rather than the raw option because wav
|
||||||
|
// is produced locally from a flac source, so it carries the same premium
|
||||||
|
// requirement as flac. mp3_128 is the only format available without a
|
||||||
|
// subscription. Failing here keeps the user from watching a whole album
|
||||||
|
// download at a silently downgraded quality.
|
||||||
|
func (d *Downloader) initDeezerClient(ctx context.Context, opts Options) error {
|
||||||
|
var err error
|
||||||
|
d.deezerClient, err = deezer.NewClient(ctx, d.appConfig.ARLCookie)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.deezerClient.Session.Premium && opts.sourceQuality() != "mp3_128" {
|
||||||
|
return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareResource fetches the resource, applies the artist track limit, and
|
||||||
|
// makes sure the output directory exists.
|
||||||
|
//
|
||||||
|
// The limit only applies to artists because that is the one kind whose track
|
||||||
|
// list is unbounded: it is the artist's top tracks, not a finite album or
|
||||||
|
// playlist.
|
||||||
|
//
|
||||||
|
// Sweeping the part files last clears leftovers from a previous run that was
|
||||||
|
// killed mid-write. They are ignorable on their own, but they accumulate and
|
||||||
|
// would otherwise be mistaken for real downloads.
|
||||||
|
func (d *Downloader) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) {
|
||||||
|
resource, err := d.deezerClient.FetchResource(ctx, d.kind, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to fetch resource: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := resource.Tracks()
|
||||||
|
if len(tracks) == 0 {
|
||||||
|
if d.kind == deezer.KindTrack {
|
||||||
|
return nil, "", fmt.Errorf("track with ID %s not found", id)
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("%s has no tracks", d.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.kind == deezer.KindArtist && len(tracks) > opts.Limit {
|
||||||
|
resource.SetTracks(tracks[:opts.Limit])
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := resource.OutputDir(d.appConfig.OutputDir)
|
||||||
|
if err := fsutil.EnsureDir(outputDir); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
}
|
||||||
|
sweepPartFiles(outputDir)
|
||||||
|
|
||||||
|
return resource, outputDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadAllTracks runs the per-track pipeline over the whole resource and
|
||||||
|
// prints the summary.
|
||||||
|
//
|
||||||
|
// Cancellation is checked both before each track and against the result,
|
||||||
|
// because a track cancelled mid-stream surfaces the error through the result
|
||||||
|
// rather than through ctx. Any other per-track error is recorded and the loop
|
||||||
|
// continues.
|
||||||
|
func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error {
|
||||||
|
tracks := resource.Tracks()
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
if d.kind != deezer.KindTrack {
|
||||||
|
fmt.Printf("%s\n\nStarting download...\n\n", resourceInfo(resource))
|
||||||
|
}
|
||||||
|
|
||||||
|
progress := newProgressTracker(len(tracks), d.kind)
|
||||||
|
|
||||||
|
var m3uEntries []m3uEntry
|
||||||
|
|
||||||
|
for i, track := range tracks {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := progress.startDownload(i, track)
|
||||||
|
result := d.downloadTrack(ctx, resource, track, opts, outputDir)
|
||||||
|
sp.Stop()
|
||||||
|
|
||||||
|
if result.err != nil && errors.Is(result.err, context.Canceled) {
|
||||||
|
return result.err
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.handleResult(i, track, result)
|
||||||
|
|
||||||
|
if d.kind == deezer.KindPlaylist && result.err == nil {
|
||||||
|
m3uEntries = append(m3uEntries, m3uEntry{track: track, path: result.path})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written after the loop, not per track, so a run that fails partway
|
||||||
|
// through still produces a playlist covering whatever did make it to
|
||||||
|
// disk instead of no playlist at all.
|
||||||
|
if d.kind == deezer.KindPlaylist {
|
||||||
|
if err := writeM3U(outputDir, m3uEntries); err != nil {
|
||||||
|
fmt.Printf("\nWarning: failed to write m3u playlist: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.printSummary(outputDir, time.Since(startTime))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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,60 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// m3uEntry pairs a playlist track with the file it ended up at, so the m3u
|
||||||
|
// can be written after the fact without re-deriving the path.
|
||||||
|
type m3uEntry struct {
|
||||||
|
track *deezer.Track
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeM3U records a playlist's tracks in their original order as an
|
||||||
|
// extended M3U file next to the downloaded audio.
|
||||||
|
//
|
||||||
|
// This exists because the on-disk file names carry no ordering for
|
||||||
|
// playlists (unlike albums, which get a zero padded track number prefix):
|
||||||
|
// without an m3u, the sequence the user curated on Deezer is lost the moment
|
||||||
|
// the tracks land in a directory that any player will list alphabetically.
|
||||||
|
//
|
||||||
|
// Skipped tracks are included: they are already on disk from a previous run
|
||||||
|
// and belong at their playlist position just as much as one downloaded this
|
||||||
|
// run. Failed tracks are left out since there is nothing on disk to point
|
||||||
|
// at, and a broken entry would only confuse the player.
|
||||||
|
//
|
||||||
|
// Paths are written relative to outputDir so the m3u keeps working if the
|
||||||
|
// whole playlist folder is moved or copied elsewhere.
|
||||||
|
func writeM3U(outputDir string, entries []m3uEntry) error {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m3uPath := filepath.Join(outputDir, filepath.Base(outputDir)+".m3u")
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("#EXTM3U\n")
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
duration, err := strconv.Atoi(e.track.Duration)
|
||||||
|
if err != nil {
|
||||||
|
duration = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(outputDir, e.path)
|
||||||
|
if err != nil {
|
||||||
|
rel = e.path
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "#EXTINF:%d,%s - %s\n%s\n", duration, e.track.Artist, e.track.FullTitle(), rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(m3uPath, []byte(b.String()), 0644)
|
||||||
|
}
|
||||||
@@ -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{path: outputPath, warnings: warnings}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueOutputPath avoids clobbering an unrelated file by appending " (2)",
|
||||||
|
// " (3)" and so on until the name is free.
|
||||||
|
//
|
||||||
|
// The file this track already owns according to the store is exempt: a
|
||||||
|
// re-download of the same track should overwrite its own output rather than
|
||||||
|
// pile up numbered copies next to it.
|
||||||
|
func (d *Downloader) uniqueOutputPath(trackID, path string) string {
|
||||||
|
owned := ""
|
||||||
|
if info, err := d.store.DownloadInfo(trackID); err == nil {
|
||||||
|
owned = info.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := filepath.Ext(path)
|
||||||
|
stem := strings.TrimSuffix(path, ext)
|
||||||
|
candidate := path
|
||||||
|
for i := 2; candidate != owned && fsutil.Exists(candidate); i++ {
|
||||||
|
candidate = fmt.Sprintf("%s (%d)%s", stem, i, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizeDownload tags the finished file and records it in the store,
|
||||||
|
// returning any non-fatal problems as warnings.
|
||||||
|
//
|
||||||
|
// The hash is taken after tagging so it matches the bytes actually on disk,
|
||||||
|
// which is what the skip check later compares against. The download is
|
||||||
|
// recorded even when tagging or hashing failed: the audio is there, and
|
||||||
|
// refusing to record it would mean downloading it all over again next time.
|
||||||
|
func (d *Downloader) finalizeDownload(resource deezer.Resource, track *deezer.Track, outputPath, outputFormat, genre string, cover []byte, bpmKey bpmKey) []string {
|
||||||
|
var warnings []string
|
||||||
|
|
||||||
|
if err := tag.Write(outputPath, buildTagMetadata(resource, track, cover, bpmKey, genre)); err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := hashFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("failed to get file hash: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &store.DownloadInfo{
|
||||||
|
TrackID: track.ID,
|
||||||
|
Quality: outputFormat,
|
||||||
|
Path: outputPath,
|
||||||
|
Hash: hash,
|
||||||
|
Downloaded: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.store.PutDownloadInfo(info); err != nil {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("failed to save download info: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DownloadInfo records one completed download. Quality is stored so that
|
||||||
|
// re-requesting the same track at a higher quality is not mistaken for a
|
||||||
|
// duplicate, and Hash lets a file that has since been moved or renamed still
|
||||||
|
// be recognised.
|
||||||
type DownloadInfo struct {
|
type DownloadInfo struct {
|
||||||
SongID string `json:"song_id"`
|
TrackID string `json:"song_id"`
|
||||||
Quality string `json:"quality"`
|
Quality string `json:"quality"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
@@ -18,18 +23,21 @@ type DownloadInfo struct {
|
|||||||
|
|
||||||
var trackBucket = []byte("tracks")
|
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
|
var info DownloadInfo
|
||||||
|
|
||||||
if err := db.View(func(tx *bbolt.Tx) error {
|
if err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
b := tx.Bucket(trackBucket)
|
b := tx.Bucket(trackBucket)
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return fmt.Errorf("bucket not found")
|
return errors.New("bucket not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
data := b.Get([]byte(songID))
|
data := b.Get([]byte(trackID))
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return fmt.Errorf("not found")
|
return errors.New("not found")
|
||||||
}
|
}
|
||||||
return json.Unmarshal(data, &info)
|
return json.Unmarshal(data, &info)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -39,8 +47,8 @@ func GetDownloadInfo(songID string) (*DownloadInfo, error) {
|
|||||||
return &info, nil
|
return &info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadInfo) Save() error {
|
func (s *Store) PutDownloadInfo(d *DownloadInfo) error {
|
||||||
return db.Update(func(tx *bbolt.Tx) error {
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
b, err := tx.CreateBucketIfNotExists(trackBucket)
|
b, err := tx.CreateBucketIfNotExists(trackBucket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create bucket: %w", err)
|
return fmt.Errorf("failed to create bucket: %w", err)
|
||||||
@@ -51,6 +59,6 @@ func (d *DownloadInfo) Save() error {
|
|||||||
return err
|
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
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
|
bolterrors "go.etcd.io/bbolt/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
var db *bolt.DB
|
type Store struct {
|
||||||
|
db *bbolt.DB
|
||||||
|
}
|
||||||
|
|
||||||
func OpenDB(cfgDir string) error {
|
// Open opens the ledger in dir, creating it if needed.
|
||||||
var err error
|
//
|
||||||
|
// bbolt takes an exclusive file lock, so a second godeez running against the
|
||||||
dbPath := path.Join(cfgDir, "tracks.db")
|
// same output directory blocks here. The timeout turns that into a clear
|
||||||
db, err = bolt.Open(dbPath, 0600, nil)
|
// message rather than an apparent hang.
|
||||||
|
func Open(dir string) (*Store, error) {
|
||||||
|
db, err := bbolt.Open(filepath.Join(dir, ".tracks.db"), 0600, &bbolt.Options{Timeout: 5 * time.Second})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open database: %w", err)
|
if errors.Is(err, bolterrors.ErrTimeout) {
|
||||||
|
return nil, errors.New("database is already in use by another process")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,8 +11,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// The interrupt-cancelled context is threaded through every network call
|
||||||
|
// and file write, so Ctrl-C unwinds the download cleanly and leaves no
|
||||||
|
// partial files behind rather than killing the process mid-write.
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
cmd.RootCmd.ExecuteContext(ctx)
|
// Nothing is printed here because cobra has already reported the error.
|
||||||
|
// stop is called explicitly since the deferred call would not run before
|
||||||
|
// os.Exit.
|
||||||
|
if err := cmd.Execute(ctx); err != nil {
|
||||||
|
stop()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user