114 Commits
Author SHA1 Message Date
Mathis Maquenne 770efdb752 docs: update changelog for v1.5.0 2026-08-05 23:18:57 +02:00
Mathis Maquenne d3a536f744 docs: document update force and artist limit flags 2026-08-05 23:10:13 +02:00
Mathis Maquenne dddfad4984 Merge branch 'feat/wav-output' into dev 2026-08-05 23:02:29 +02:00
Mathis Maquenne bcb3558a73 docs: document wav download quality 2026-08-05 22:59:26 +02:00
Mathis Maquenne 84dd371fdd feat: tag wav files with id3 and info chunks 2026-08-05 22:51:43 +02:00
Mathis Maquenne b804c5e288 feat: add wav download quality 2026-08-05 22:51:43 +02:00
Mathis Maquenne b3777bf8eb feat: add lossless flac to wav converter 2026-08-05 22:51:43 +02:00
Mathis Maquenne eea049aae9 refactor: unexport internal identifiers and fix naming nits 2026-08-05 21:55:59 +02:00
Mathis Maquenne c1775e5c04 style: prefer stdlib idioms for errors and http methods 2026-08-05 21:43:59 +02:00
Mathis Maquenne a695fb4de3 fix: resolve bugs found in pre-release review 2026-08-05 21:33:08 +02:00
Mathis Maquenne 5bb1448650 refactor: validate limit only for artist downloads 2026-08-05 12:42:51 +02:00
Mathis Maquenne e90a56ec53 fix: use filepath helpers for filesystem paths 2026-08-05 12:41:55 +02:00
Mathis Maquenne b616a58d7b ops: run go test in ci 2026-08-05 11:51:20 +02:00
Mathis Maquenne 66fda7424a test: add update version and checksum tests 2026-08-05 11:50:58 +02:00
Mathis Maquenne 3fe050ea15 test: add download, store, and scraper tests 2026-08-05 11:50:30 +02:00
Mathis Maquenne 563efe78bd test: add deezer crypto and filename tests 2026-08-05 11:49:21 +02:00
Mathis Maquenne f8b301e24c style: rename accessors to idiomatic names 2026-08-05 11:47:53 +02:00
Mathis Maquenne 88319ebb3c refactor: rename updater package to update 2026-08-05 11:46:34 +02:00
Mathis Maquenne 5c95554855 refactor: consolidate download domain into download package 2026-08-05 11:46:10 +02:00
Mathis Maquenne dbb808f371 refactor: replace fileutil with fsutil and local hashing 2026-08-05 11:42:35 +02:00
Mathis Maquenne 12dbbb5e10 refactor: move resource info rendering into downloader 2026-08-05 11:41:24 +02:00
Mathis Maquenne dbd7837b17 refactor: fold auth and crypto into deezer domain 2026-08-05 11:40:14 +02:00
Mathis Maquenne 16c71c8688 refactor: decouple tag writers from deezer models 2026-08-05 11:37:59 +02:00
Mathis Maquenne 3c9da5f24a refactor: build cobra commands with constructors 2026-08-05 11:35:52 +02:00
Mathis Maquenne 0ab25addd5 refactor: open track store explicitly and remove global state 2026-08-05 11:34:27 +02:00
Mathis Maquenne 714d567603 refactor: introduce typed resource kind 2026-08-05 11:33:13 +02:00
Mathis Maquenne 3ec258b876 refactor: remove dead logger package and unused fields 2026-08-05 11:31:17 +02:00
Mathis Maquenne 97153c1688 docs: document credential login and update CLI output 2026-08-05 10:47:49 +02:00
Mathis Maquenne 1fbbd60106 refactor: use track terminology instead of song 2026-07-30 12:53:03 +02:00
Mathis Maquenne 90b7bb25f0 Merge branch 'feat/auto-update' into dev 2026-07-30 11:32:12 +02:00
Mathis Maquenne 88c7b57648 ops: run ci on every branch push 2026-07-30 11:30:08 +02:00
Mathis Maquenne 4286ccd626 docs: document self-update and new release asset names 2026-07-30 11:30:08 +02:00
Mathis Maquenne 887fc682fe ops: add ci workflow 2026-07-30 11:30:08 +02:00
Mathis Maquenne 3acd1aeb4f ops: add goreleaser configuration and release workflow 2026-07-30 11:30:08 +02:00
Mathis Maquenne 544fd09199 feat: notify when a new version is available 2026-07-30 11:30:08 +02:00
Mathis Maquenne b734ad802a feat: add update command 2026-07-30 11:30:08 +02:00
Mathis Maquenne eb93d01c19 feat: add updater package for in-place self-update 2026-07-30 11:30:08 +02:00
Mathis Maquenne 0e0cbdad5e feat: add version command 2026-07-30 11:30:08 +02:00
Mathis Maquenne 785bb58cb5 feat: add buildinfo package for version information 2026-07-30 11:30:08 +02:00
Mathis Maquenne cab49d2818 build: add golang.org/x/mod dependency 2026-07-30 11:30:08 +02:00
Mathis Maquenne c544c9406c fix: exit with a non-zero status when a command fails 2026-07-30 11:29:06 +02:00
Mathis Maquenne 0287cfba9c Merge branch 'feat/credential-login' into dev 2026-07-30 11:24:49 +02:00
Mathis Maquenne c2f60be18e refactor: use ARL initialism casing 2026-07-30 11:13:13 +02:00
Mathis Maquenne 44a57d5f3a feat: add login and logout commands 2026-07-29 11:33:50 +02:00
Mathis Maquenne 09fa93f1e4 refactor: no longer require DEEZER_ARL at startup 2026-07-29 11:33:50 +02:00
Mathis Maquenne f45ae6aee6 feat: retry with credential-based login when the ARL is invalid 2026-07-29 11:33:50 +02:00
Mathis Maquenne 56b0f404d6 feat: add internal/auth package for credential-based login 2026-07-29 11:33:50 +02:00
Mathis Maquenne 3ce5c4d365 build: add go-keyring dependency 2026-07-29 11:33:42 +02:00
Mathis Maquenne 465f54e466 refactor: disambiguate blowfish crypto helper names 2026-07-29 11:28:39 +02:00
Mathis Maquenne 163fa18845 feat: add AES-ECB crypto helpers for email/password login 2026-07-29 11:28:39 +02:00
Mathis Maquenne 020f8c1162 chore: update dependencies 2026-07-29 11:28:33 +02:00
Mathis Maquenne 9c7580d256 docs: update README and CHANGELOG for v1.4.0 2026-03-01 22:55:26 +01:00
Mathis Maquenne d50945a7ef fix: zero-pad track numbers and show warning count in summary 2026-03-01 22:29:38 +01:00
Mathis Maquenne 08160b0290 refactor: drop config.toml and Viper, use DEEZER_ARL env var 2026-03-01 22:19:14 +01:00
Mathis Maquenne cf699834f6 refactor: hardcode secret key in crypto package 2026-03-01 21:57:38 +01:00
Mathis Maquenne 650d578b80 refactor: remove disabled watcher feature 2026-03-01 21:56:54 +01:00
Mathis Maquenne acf468eddd chore: update all dependencies 2026-03-01 21:55:42 +01:00
Mathis Maquenne 08b608ec80 refactor: simplify codebase (-209 lines) 2026-03-01 21:55:42 +01:00
Mathis Maquenne cd45c3f40a perf(downloader): fetch metadata concurrently with stream download 2026-03-01 21:29:02 +01:00
Mathis Maquenne 0a8da3d0e7 feat(tags): filter genres to avoid unusual tags 2025-09-12 14:41:29 +02:00
Mathis Maquenne 2ef1ba42a8 docs: update for v1.3.0 2025-09-11 01:18:14 +02:00
Mathis Maquenne e4c421ff00 feat: add CTA after download and mention it in README 2025-09-11 00:32:42 +02:00
Mathis Maquenne 9d87f635d0 refactor(downloader): improve structure and readability 2025-09-11 00:05:17 +02:00
Mathis Maquenne 8c9b327b2c feat(tags): fetch genre and add it to file tags with --genre 2025-09-10 16:05:53 +02:00
Mathis Maquenne 0493866141 refactor: move BPM fetching logic into provider package 2025-09-10 15:09:24 +02:00
Mathis Maquenne 523b562ed0 Merge branch 'feature/download-track' into dev 2025-09-10 11:11:40 +02:00
Mathis Maquenne c2b4fcfa11 feat(cli): simplify output for individual songs 2025-09-10 11:10:52 +02:00
Felipe Marinho 9b23e3a8d9 feat: download single tracks (#5)
Introduces the `godeez download track <ID>` command, allowing users to download individual songs directly.

Single tracks are saved into a "Singles" folder by default to keep them organized. The README has been updated with documentation and examples for this new functionality.
2025-09-10 10:26:58 +02:00
Mathis Maquenne cf7f76948b chore(tags): add the version of a track to the title tag 2025-09-09 23:47:33 +02:00
Mathis Maquenne 4b2c9c92b8 chore(tags): add copyright tag 2025-09-09 10:37:56 +02:00
Mathis Maquenne 22ddb2408b fix(media): handle empty media 2025-09-09 10:05:42 +02:00
Mathis Maquenne 3022e226ce fix(build): comment out unfinished autostart implementation 2025-08-18 11:29:02 +02:00
Mathis Maquenne c4329431b5 docs: update for v1.2.0 2025-08-18 11:20:29 +02:00
Mathis Maquenne be40910480 chore(deps): update all dependencies 2025-08-18 10:49:42 +02:00
Mathis Maquenne 1813a16bb7 feat(cli): change default download quality from flac to mp3_320 2025-08-09 19:40:06 +02:00
Mathis Maquenne 283c5d76e9 feat(cli): add --strict flag to fail track download if quality is unavailable 2025-08-09 19:17:29 +02:00
Mathis Maquenne 7154c15cc3 refactor(cli): remove --quality=best flag since fallback is now automatic 2025-08-06 22:28:58 +02:00
Mathis Maquenne 50fde9dfa9 feat(media): fallback to lower quality if requested format is unavailable 2025-08-06 22:09:06 +02:00
Mathis Maquenne b4f69396af fix(song): handle empty SNG_CONTRIBUTORS array during unmarshal 2025-08-06 20:45:27 +02:00
Mathis Maquenne fe3491e16d chore(watcher): disable feature due to known DB concurrency issues 2025-08-06 18:18:17 +02:00
Mathis Maquenne ef018b38ad fix(logger): initialize downloader with nil logger to avoid error 2025-06-23 21:03:27 -04:00
Mathis Maquenne 33e774d078 refactor: simplify homeDir and appConfig usage 2025-06-23 20:58:08 -04:00
Mathis Maquenne a619b37922 feat(watcher): add macOS autostart support via launchd 2025-06-23 20:03:56 -04:00
Mathis Maquenne 31d8a3b04f feat(watch): add command to remove a playlist from the watch list 2025-06-22 17:51:17 -04:00
Mathis Maquenne 95c81a2a67 feat(watch): add command to list watched playlists 2025-06-22 17:47:54 -04:00
Mathis Maquenne 498f7c8d3f feat(watch): add command to register a playlist to the watch list 2025-06-22 17:47:05 -04:00
Mathis Maquenne b19c92f227 feat: watch playlists and download newly added tracks 2025-06-22 17:35:16 -04:00
Mathis Maquenne 99bdc22a25 fix(cli): restrict --limit option to download artist command 2025-06-21 16:22:16 -04:00
Mathis Maquenne 0680d8c8e7 chore(deps): update golang.org/x/crypto and golang.org/x/net to patch security vulnerabilities 2025-06-20 20:07:51 -04:00
Mathis Maquenne 67b4fe404e feat: download artist's top tracks with --limit flag 2025-06-20 20:00:59 -04:00
Mathis Maquenne 1fdd0e8c19 docs(readme): clarify quality limitation for free accounts 2025-06-16 16:47:00 -04:00
Mathis Maquenne b4e00e9306 docs(changelog): update for v1.1.1 2025-06-16 16:44:42 -04:00
Mathis Maquenne 327d55cff5 fix: allow downloads without premium account 2025-06-16 16:36:14 -04:00
Mathis Maquenne ce1690137e fix: adjust stream timeout and filename spacing 2025-05-19 22:53:11 +02:00
Mathis Maquenne 7daee4dab6 docs: add changelog 2025-05-19 17:12:22 +02:00
Mathis Maquenne 5951e13abe chore(cli): update descriptions 2025-05-19 16:41:08 +02:00
Mathis Maquenne 56e3e894ab docs: update README 2025-05-19 16:38:36 +02:00
Mathis Maquenne 68f22ce571 refactor: rework output directory handling 2025-05-19 15:38:18 +02:00
Mathis Maquenne 674eb99ffd feat(cli): add bpm flag to enable BPM and key tagging 2025-05-19 14:24:22 +02:00
Mathis Maquenne adfd71204d refactor(cli): remove multi-download support for albums/playlists 2025-05-19 13:59:01 +02:00
Mathis Maquenne e50395ae1d refactor: change output file name 2025-05-19 13:41:25 +02:00
Mathis Maquenne 0a9e6c9a4d refactor(cli): improve output display 2025-05-19 13:35:06 +02:00
Mathis Maquenne 5d18ab2088 fix(context): ignore cancellation errors triggered by user 2025-05-18 22:14:18 +02:00
Mathis Maquenne ebfb312718 refactor(crypto): use IV as a constant 2025-05-07 20:57:12 +02:00
Mathis Maquenne 67af80403b feat(cli): add timeout flag 2025-05-01 20:45:27 +02:00
Mathis Maquenne 0bae78a1ff refactor: major project restructure 2025-05-01 20:30:56 +02:00
Mathis Maquenne c74531b36c perf: download music and retrieve data in parallel 2025-04-26 18:30:48 +02:00
Mathis Maquenne 917ac5f6c2 feat: retrieve modified file paths using hashes 2025-04-26 14:10:27 +02:00
Mathis Maquenne 6d3935c744 feat: delete file if download fails 2025-04-26 13:17:03 +02:00
Mathis Maquenne f931cad746 feat: add local DB to save downloads 2025-04-26 13:09:59 +02:00
Mathis Maquenne 6675834842 refactor: change default output directory 2025-04-26 00:49:56 +02:00
Mathis Maquenne 7302685e34 feat(tags): differentiate between minor and major keys 2025-04-26 00:01:35 +02:00
Mathis Maquenne ddc8240da8 refactor: use a better API to download playlists with more than 40 tracks 2025-04-26 00:01:27 +02:00
Mathis Maquenne d0599f654e feat(tags): add tempo and key 2025-04-26 00:01:19 +02:00
79 changed files with 5802 additions and 936 deletions
-4
View File
@@ -1,4 +0,0 @@
arl_cookie = '1d9e90abb452a61b1b7463f0953e1b303c4e2e7e5fd404fde9b385f4de01c340ac0f62f1c8c1550405b0b9beded0e28c481e96a6148e8f4548351add5d7db746b2785ecf83b1768e5dd8cc73b1ad30c18d07c9cb37f5c6b9cd7a78a4de2aff11'
license_token = 'AAAAA1bcDFgHJKLmnOPqrsTUvwXYZ12o3lmNOjR2xWiQRT3v5zxL4mKoWjxL82jD7sWx93KfYGsN2IkdPxsUw9PTas7oFbgR3nY9qpZmVyHFJKLPxT7sWmqErXyTn5iKoNpRtV7bPdQZ8qLmWoJpU2nKrXz6vW'
secret_key = 'hTv1IAw19qWy9i3f'
iv = '0001020304050607'
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
#
# Extracts the CHANGELOG.md section for a version and formats it as the GitHub
# release body. Usage: release-notes.sh v1.5.0
#
# Exits non-zero when the version has no section, so a tag can never be
# published with empty or stale release notes.
set -euo pipefail
if [ $# -ne 1 ]; then
echo "usage: $0 <version>" >&2
exit 2
fi
version="${1#v}"
changelog="${CHANGELOG_FILE:-CHANGELOG.md}"
if [ ! -f "$changelog" ]; then
echo "$changelog not found" >&2
exit 1
fi
notes=$(awk -v ver="$version" '
BEGIN { heading = "## [" ver "]" }
index($0, heading) == 1 { found = 1; next }
found && index($0, "## [") == 1 { exit }
found { print }
' "$changelog")
if [ -z "${notes//[[:space:]]/}" ]; then
echo "no $changelog section found for version $version" >&2
exit 1
fi
printf "## What's new in v%s\n%s\n" "$version" "$notes"
+40
View File
@@ -0,0 +1,40 @@
name: ci
on:
push:
branches: ['**']
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Not gofmt'd:"
echo "$unformatted"
exit 1
fi
- run: go vet ./...
- run: go test ./...
- run: go build ./...
# Catches a broken release config before a tag is pushed.
- uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: check
+33
View File
@@ -0,0 +1,33 @@
name: release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# Fails the release if CHANGELOG.md has no section for this tag.
- name: Build release notes from CHANGELOG.md
run: .github/scripts/release-notes.sh "${{ github.ref_name }}" > "${RUNNER_TEMP}/release-notes.md"
- uses: goreleaser/goreleaser-action@v6
with:
version: '~> v2'
args: release --clean --release-notes=${{ runner.temp }}/release-notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3
View File
@@ -0,0 +1,3 @@
.DS_Store
build.sh
builds/
+47
View File
@@ -0,0 +1,47 @@
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 notes are written by hand in CHANGELOG.md
changelog:
disable: true
release:
prerelease: auto
+111
View File
@@ -0,0 +1,111 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.5.0] - 2026-08-05
### Added
- Add new `login` and `logout` commands to authenticate with your Deezer email and password. Credentials are stored in the system keyring. Requires `DEEZER_MOBILE_API_KEY` and `DEEZER_MOBILE_GW_KEY` to be set.
- Add new `update` command to replace the binary in place with the latest release, with `--check` to only report availability and `--force` to reinstall.
- Add new `version` command to print the version, commit, build date, and platform.
- Notify when a newer version is available after a download completes.
- Add WAV download quality (`--quality=wav`): the FLAC stream is converted locally to lossless WAV.
### Changed
- `DEEZER_ARL` is now optional. When it is unset, the credentials stored by `godeez login` are used instead, and expired sessions are renewed automatically.
- Release binaries are now named `godeez_<version>_<os>_<arch>` (previously `godeez-<version>-<os>-<arch>`) and are published alongside a `checksums.txt` file.
### Fixed
- Interrupted downloads no longer leave partial files behind.
- Avoid overwriting an existing file when another track resolves to the same name.
- Report a clear error when the database is already in use by another process.
- Migrate the legacy database when `~/.godeez` and `~/Music/GoDeez` are on different filesystems.
- Write metadata tags even when the cover art or track duration is missing.
## [1.4.0] - 2026-03-01
### Added
- Automatic database migration from `~/.godeez/tracks.db` to `~/Music/GoDeez/.tracks.db`.
### Changed
- Configuration now uses `DEEZER_ARL` environment variable (replaces `config.toml`).
- Database moved from `~/.godeez/tracks.db` to `~/Music/GoDeez/.tracks.db`.
- Show warning count in download summary.
### Removed
- `config.toml` configuration file and `~/.godeez` directory.
- `--config` flag from CLI.
- `secret_key` and `output_dir` configuration options.
- Watcher feature (`watch` subcommands).
### Fixed
- Track number zero-padding for correct file sorting.
## [1.3.0] - 2025-09-11
### Added
- Add new `track` command to download individual tracks.
- Add `--genre` flag to fetch and embed genre information into file metadata tags.
### Fixed
- Handle empty media resources gracefully to prevent crashes.
## [1.2.0] - 2025-08-18
### Added
- Add new `artist` command to download an artists top tracks.
- Add `--limit` flag for the `artist` command to restrict the number of tracks.
- Add `--strict` flag for downloads: fail if the requested quality is unavailable.
### Changed
- Set default download quality to MP3 320 kbps.
### Removed
- Remove `--quality=best` option. Fallback to lower quality is now the default behavior; use the `--strict` flag to prevent fallback.
### Fixed
- Handle error when `SNG_CONTRIBUTORS` metadata is empty.
## [1.1.1] - 2025-06-16
### Fixed
- Restore ability to download tracks without a Deezer Premium account (limited to MP3 128 kbps for free accounts).
## [1.1.0] - 2025-05-19
### Added
- Support downloading full albums and playlists with more than 40 tracks (previous limit removed).
- Fetch and embed BPM and musical key into metadata tags.
- Add local database system (`tracks.db`) to track downloaded files and avoid re-downloading, even if files are renamed or moved.
- Improve CLI output formatting for a cleaner and more informative user experience.
### Changed
- Replace the `godeez` file in the users home directory with a `.godeez/` directory, which now stores both `config.toml` and `tracks.db`.
👉 If upgrading, move your existing config into `.godeez/config.toml`.
- Simplify `config.toml`: remove the need for `iv` and `license_token`.
- Automatically delete corrupted or incomplete files on download failure.
## [1.0.0] - 2024-10-15
### Added
- Initial release of **GoDeez** with basic Deezer album and playlist downloading capabilities.
+176 -64
View File
@@ -2,86 +2,160 @@
# GoDeez # GoDeez
[![GitHub release](https://img.shields.io/github/v/release/mathismqn/godeez)](https://github.com/mathismqn/godeez/releases)
[![License](https://img.shields.io/github/license/mathismqn/godeez)](https://github.com/mathismqn/godeez/blob/main/LICENSE)
[![Last commit](https://img.shields.io/github/last-commit/mathismqn/godeez)](https://github.com/mathismqn/godeez/commits/main)
A simple Go tool for downloading music from [Deezer](https://www.deezer.com). A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
[Features](#features) • [Features](#features) •
[Installation](#installation) • [Installation](#installation) •
[Updating](#updating) •
[Configuration](#configuration) • [Configuration](#configuration) •
[Usage](#usage) [Usage](#usage)
[Contributing](#contributing) •
[License](#license)
</div> </div>
## Features ## Features
* Download playlists and albums from Deezer - Download playlists, albums, artists' top tracks, and individual tracks
* Select audio quality: MP3 128kbps, MP3 320kbps, or FLAC - Choose audio quality: MP3 128 kbps, MP3 320 kbps (default), or lossless FLAC/WAV (⚠️ non-premium accounts are limited to 128 kbps)
* Automatically adds metadata tags to downloaded files - Authenticate with an ARL cookie or with your Deezer email and password
* Cross-platform support (works on Windows, macOS, and Linux) - Automatically embed metadata tags (artist, album, title, artwork, etc.)
* Simple and easy-to-use CLI - Fetch and tag tracks with BPM, musical key, and genre
- Works on Windows, macOS, and Linux
## 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.0.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 file named .godeez will be automatically generated 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).
This configuration file contains several important variables that you need to fill out manually. Below are the steps for retrieving and setting each variable. ### ARL cookie
### Steps to configure Set your ARL cookie as an environment variable:
1. Run the application for the first time: This generates the .godeez configuration file in your home directory. ```bash
2. Edit the configuration file: Open the .godeez file with a text editor to set the required variables. export DEEZER_ARL="your_arl_cookie_here"
```
### Variables to configure To make it persistent, add the line above to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.).
Here are the key variables you need to set in the .godeez file: #### How to retrieve your ARL cookie
1. `arl_cookie` 1. Open your browser and log in to your [Deezer](https://www.deezer.com) account.
* **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. 2. Open the **Developer Tools** (right-click on the page and select **Inspect**, or press <kbd>F12</kbd>).
* **How to retrieve it**: 3. Navigate to the **Application** tab (Chrome/Edge) or **Storage** tab (Firefox).
1. Open your browser and log in to your Deezer account. 4. In the left panel, look for **Cookies** and select **https://www.deezer.com**.
2. Open the Developer Tools (right-click on the page and select “Inspect” or press F12). 5. Find the `arl` cookie and copy its value.
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. `license_token` > **Note:** The ARL cookie may expire after some time. If you get authentication errors, retrieve a fresh cookie using the steps above.
* **What is it?**: The license_token is required to access Deezers media URLs for downloading songs. This token is found in the network requests your browser makes when playing a song.
* **How to retrieve it**:
1. Open Developer Tools in your browser (right-click on the page and select “Inspect” or press F12).
2. Go to the Network tab.
3. Start playing a song on Deezer and look for a request to `https://media.deezer.com/v1/get_url`.
4. Select the request and in the Request Data section, find the license_token.
5. Copy the license_token value.
3. `secret_key` ### Email and password
* **What is it?**: The secret_key is a cryptographic value used alongside the iv to decrypt Deezers media files.
* **How to retrieve it?**: While we cannot provide the specific secret_key in this documentation, it can be found online through various sources or developer communities that focus on Deezer.
4. `iv` Instead of copying a cookie, you can log in once with your Deezer account:
* **What is it?**: This is another cryptographic variable needed to decrypt media streams from Deezer.
* **How to retrieve it?**: The iv can be found in the [.example-config](https://github.com/mathismqn/godeez/blob/main/.example-config) file included with this project. ```bash
godeez login
```
You will be prompted for your email and password. On success, **GoDeez** stores your credentials in your system keyring under the service name `godeez`. From then on, **GoDeez** authenticates on its own and renews the session when it expires.
To remove the stored credentials:
```bash
godeez logout
```
#### Gateway keys
Email/password login goes through Deezer's mobile gateway, which requires two keys:
```bash
export DEEZER_MOBILE_API_KEY="your_api_key_here"
export DEEZER_MOBILE_GW_KEY="your_gateway_key" # exactly 16 characters
```
**GoDeez** does not bundle these keys, so you have to supply your own. For background on what they are and where they live in Deezer's clients, see [this write-up](https://gist.github.com/svbnet/b79b705a4c19d74896670c1ac7ad627e). If either variable is missing, `godeez login` exits with an error.
> **Note:** `DEEZER_ARL` takes precedence over stored credentials. If it is set, **GoDeez** always uses the cookie and never falls back to your login, so unset it (and remove it from your shell profile) before running `godeez login`.
> **Note:** The keyring entry holds your password alongside the ARL because the password is reused to renew expired sessions. On Linux, the keyring requires a running secret service; without one, `godeez login` fails with `system keyring is unavailable`.
### Output directory
Downloaded files are saved to `~/Music/GoDeez`. The download database (`.tracks.db`) is stored in the same directory as your music.
> **Upgrading from v1.3.0?** The `~/.godeez` directory and `config.toml` are no longer used. Set the `DEEZER_ARL` environment variable instead. Your existing database will be migrated automatically on first run.
## Usage ## Usage
### CLI Overview ### CLI overview
When you run **godeez** without any additional commands, youll see a general help menu: Running `godeez` without arguments shows the help menu:
```bash
```text
GoDeez is a tool to download music from Deezer GoDeez is a tool to download music from Deezer
Usage: Usage:
@@ -89,47 +163,85 @@ 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 is $HOME/.godeez) -h, --help help for godeez
-h, --help help for godeez
Use "godeez [command] --help" for more information about a command. Use "godeez [command] --help" for more information about a command.
``` ```
This provides an overview of the available commands and flags.
To download music, you need to use the download command. Heres how the CLI looks when you run `godeez download`: ### Download commands
```bash
Download songs from Deezer ```text
Download tracks from Deezer
Usage: Usage:
godeez download [command] godeez download [command]
Available Commands: Available Commands:
album Download songs from one or more albums album Download tracks from an album
playlist Download songs from one or more playlists artist Download an artist's top tracks
playlist Download tracks from a playlist
track Download a single track
Flags: Flags:
-h, --help help for download --bpm fetch BPM/key and add to file tags
-o, --output string output directory (default is current directory) --genre fetch genre and add to file tags
-q, --quality string download quality [mp3_128, mp3_320, flac, best] (default is best) -h, --help help for download
-q, --quality string download quality [mp3_128, mp3_320, flac, wav] (default "mp3_320")
Global Flags: --strict fail the download if the requested quality is unavailable
--config string config file (default is $HOME/.godeez) -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 its a bug fix, a new feature, or improving documentation, your input is valuable.
If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion. To contribute, fork the repository and open a pull request. To report a bug or suggest a feature, open an issue instead.
Every bit of support counts, so dont forget to give the project a star if you enjoy using it. Thank you for helping make this project better!
## Support the project
If **GoDeez** helps you enjoy your music collection, please consider giving it a star ⭐
**Why star the project?**
- Helps more music lovers discover it
- Shows appreciation for the work and keeps me motivated
- Takes one click, and it means a lot
## License ## License
This project is licensed under the MIT License. See the [LICENSE](https://github.com/mathismqn/godeez/blob/main/LICENSE) file for details. This project is licensed under the MIT License. See the [LICENSE](https://github.com/mathismqn/godeez/blob/main/LICENSE) file for details.
---
> ⚠️ This tool is provided for educational and personal use only. Please ensure your usage complies with Deezer's Terms of Service.
+65 -134
View File
@@ -1,158 +1,89 @@
package cmd package cmd
import ( import (
"context"
"errors"
"fmt" "fmt"
"os"
"path"
"strings" "strings"
"time"
"github.com/flytam/filenamify" "github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/tags" "github.com/mathismqn/godeez/internal/download"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var outputDir string func newDownloadCmd() *cobra.Command {
var quality string cmd := &cobra.Command{
Use: "download",
var downloadCmd = &cobra.Command{ Short: "Download tracks from Deezer",
Use: "download", Annotations: map[string]string{updateNoticeAnnotation: "true"},
Short: "Download songs from Deezer",
}
func init() {
RootCmd.AddCommand(downloadCmd)
downloadCmd.PersistentFlags().StringVarP(&outputDir, "output", "o", "", "output directory (default is current directory)")
downloadCmd.PersistentFlags().StringVarP(&quality, "quality", "q", "", "download quality [mp3_128, mp3_320, flac, best] (default is best)")
}
func validateInput() {
if quality == "" {
quality = "best"
} }
validQualities := map[string]bool{ opts := &download.Options{}
"mp3_128": true, cmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac, wav]")
"mp3_320": true, cmd.PersistentFlags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
"flac": true, cmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
"best": true, 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")
if !validQualities[quality] {
fmt.Fprintf(os.Stderr, "Error: invalid quality option: %s\n", quality) cmd.AddCommand(
os.Exit(1) newDownloadSubCmd(deezer.KindAlbum, opts),
} newDownloadSubCmd(deezer.KindPlaylist, opts),
newDownloadSubCmd(deezer.KindArtist, opts),
newDownloadSubCmd(deezer.KindTrack, opts),
)
return cmd
} }
func downloadContent(contentType string, args []string) { func newDownloadSubCmd(kind deezer.Kind, opts *download.Options) *cobra.Command {
nArgs := len(args) cmd := &cobra.Command{
separator := "--------------------------------------------------" Use: fmt.Sprintf("%s <%s_id>", kind, kind),
Short: downloadShort(kind),
for i, id := range args { Args: cobra.ExactArgs(1),
fmt.Println(separator) PreRunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("[%d/%d] Getting data for %s %s...", i+1, nArgs, contentType, id) opts.Quality = strings.ToLower(opts.Quality)
return opts.Validate(kind)
var resource deezer.Resource },
var songs []*deezer.Song RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
switch contentType {
case "album":
album := &deezer.Album{}
if err := deezer.GetData(album, id); err != nil {
fmt.Printf("\r[%d/%d] Getting data for album %s... FAILED\n", i+1, nArgs, id)
fmt.Fprintf(os.Stderr, "Error: could not get album data: %v\n", err)
continue
}
resource = album
songs = album.GetSongs()
case "playlist":
playlist := &deezer.Playlist{}
if err := deezer.GetData(playlist, id); err != nil {
fmt.Printf("\r[%d/%d] Getting data for playlist %s... FAILED\n", i+1, nArgs, id)
fmt.Fprintf(os.Stderr, "Error: could not get playlist data: %v\n", err)
continue
}
if playlist.Data.Status == 1 && playlist.Data.CollabKey == "" {
fmt.Printf("\r[%d/%d] Getting data for playlist %s... FAILED\n", i+1, nArgs, id)
fmt.Fprintf(os.Stderr, "Error: playlist is private and no valid arl cookie was provided\n")
continue
}
resource = playlist
songs = playlist.GetSongs()
}
fmt.Printf("\r[%d/%d] Getting data for %s %s... DONE\n", i+1, nArgs, contentType, id)
output := resource.GetOutputPath(outputDir)
if _, err := os.Stat(output); os.IsNotExist(err) {
if err := os.MkdirAll(output, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not create output directory: %v\n", err)
continue
}
}
title := resource.GetTitle()
fmt.Printf("Starting download of %s: %s\n", contentType, title)
for _, song := range songs {
songTitle := song.Title
if song.Version != "" {
songTitle = fmt.Sprintf("%s %s", song.Title, song.Version)
}
fmt.Printf(" Downloading %s...", songTitle)
media, err := song.GetMediaData(quality)
if err != nil { if err != nil {
fmt.Printf("\r Downloading %s... FAILED\n", songTitle) return err
fmt.Fprintf(os.Stderr, "Error: could not get media data: %v\n", err)
if err.Error() == "invalid license token" {
os.Exit(1)
}
continue
} }
config.MigrateLegacy(cfg.OutputDir)
if len(media.Data) == 0 || len(media.Data[0].Media) == 0 || len(media.Data[0].Media[0].Sources) == 0 { st, err := store.Open(cfg.OutputDir)
fmt.Printf("\r Downloading %s... FAILED\n", songTitle)
fmt.Fprintf(os.Stderr, "Error: could not get media sources\n")
continue
}
url := media.Data[0].Media[0].Sources[0].URL
for _, source := range media.Data[0].Media[0].Sources {
if source.Provider == "ak" {
url = source.URL
break
}
}
ext := "mp3"
if media.Data[0].Media[0].Format == "FLAC" {
ext = "flac"
}
trackNumber := ""
if contentType == "album" {
trackNumber = song.TrackNumber + "."
}
fileName := fmt.Sprintf("%s %s - %s.%s", trackNumber, songTitle, strings.Join(song.Contributors.MainArtists, ", "), ext)
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
filePath := path.Join(output, fileName)
err = media.Download(url, filePath, song.ID)
if err != nil { if err != nil {
fmt.Printf("\r Downloading %s... FAILED\n", songTitle) return err
fmt.Fprintf(os.Stderr, "Error: could not download song: %v\n", err)
continue
} }
fmt.Printf("\r Downloading %s... DONE\n", songTitle) defer st.Close()
if err := tags.AddTags(resource, song, filePath); err != nil { err = download.New(cfg, st, kind).Run(cmd.Context(), *opts, args[0])
fmt.Fprintf(os.Stderr, "Error: could not add tags to song: %v\n", err) if errors.Is(err, context.Canceled) {
return nil
} }
} return err
},
} }
fmt.Println(separator) if kind == deezer.KindArtist {
fmt.Println("All downloads completed") cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of tracks to download")
}
return cmd
}
func downloadShort(kind deezer.Kind) string {
switch kind {
case deezer.KindArtist:
return "Download an artist's top tracks"
case deezer.KindTrack:
return "Download a single track"
case deezer.KindAlbum:
return "Download tracks from an album"
default:
return fmt.Sprintf("Download tracks from a %s", kind)
}
} }
-17
View File
@@ -1,17 +0,0 @@
package cmd
import "github.com/spf13/cobra"
var albumCmd = &cobra.Command{
Use: "album [album_id...]",
Short: "Download songs from one or more albums",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
validateInput()
downloadContent("album", args)
},
}
func init() {
downloadCmd.AddCommand(albumCmd)
}
-17
View File
@@ -1,17 +0,0 @@
package cmd
import "github.com/spf13/cobra"
var playlistCmd = &cobra.Command{
Use: "playlist [playlist_id...]",
Short: "Download songs from one or more playlists",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
validateInput()
downloadContent("playlist", args)
},
}
func init() {
downloadCmd.AddCommand(playlistCmd)
}
+96
View File
@@ -0,0 +1,96 @@
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
}
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()
}
}
func readCredentials() (string, string, error) {
fmt.Print("Email: ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", "", err
}
email := strings.TrimSpace(line)
fmt.Print("Password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", "", err
}
return email, string(passwordBytes), nil
}
+25
View File
@@ -0,0 +1,25 @@
package cmd
import (
"fmt"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/spf13/cobra"
)
func newLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Remove stored Deezer credentials",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := deezer.ClearCredentials(); err != nil {
return err
}
fmt.Println("Successfully logged out.")
return nil
},
}
}
+71 -60
View File
@@ -1,78 +1,89 @@
package cmd package cmd
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"path" "slices"
"github.com/mathismqn/godeez/internal/config" "github.com/mathismqn/godeez/internal/buildinfo"
"github.com/mathismqn/godeez/internal/update"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "golang.org/x/term"
) )
var cfgFile string const updateNoticeAnnotation = "godeez:update-notice"
var RootCmd = &cobra.Command{ func Execute(ctx context.Context) error {
Use: "godeez", root := newRootCmd()
Short: "GoDeez is a tool to download music from Deezer",
Run: func(cmd *cobra.Command, args []string) { var notice <-chan string
cmd.Help() if wantsUpdateNotice(root) {
}, notice = update.StartCheck(ctx)
}
err := root.ExecuteContext(ctx)
printUpdateNotice(notice)
return err
} }
func init() { func newRootCmd() *cobra.Command {
RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.godeez)") root := &cobra.Command{
cobra.OnInitialize(initConfig) Use: "godeez",
Short: "GoDeez is a tool to download music from Deezer",
SilenceUsage: true,
}
root.AddCommand(
newDownloadCmd(),
newLoginCmd(),
newLogoutCmd(),
newUpdateCmd(),
newVersionCmd(),
)
return root
} }
func initConfig() { func wantsUpdateNotice(root *cobra.Command) bool {
if cfgFile != "" { if !term.IsTerminal(int(os.Stderr.Fd())) {
viper.SetConfigFile(cfgFile) return false
} else { }
homedir, err := os.UserHomeDir()
if err != nil { args := os.Args[1:]
fmt.Fprintf(os.Stderr, "Error: could not get home directory: %v\n", err) if slices.Contains(args, "-h") || slices.Contains(args, "--help") {
os.Exit(1) 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
}
func printUpdateNotice(notice <-chan string) {
select {
case latest := <-notice:
if latest == "" {
return
} }
path := path.Join(homedir, ".godeez") fmt.Fprintf(os.Stderr, "\n ┌ Update available: %s → %s\n └ Run `godeez update` to install\n",
if _, err := os.Stat(path); os.IsNotExist(err) { buildinfo.Version(), latest)
fmt.Printf("Config file not found, creating one at %s\n", path) default:
content := []byte("arl_cookie = ''\nlicense_token = ''\nsecret_key = ''\niv = '0001020304050607'\n")
if err := os.WriteFile(path, content, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not create config file: %v\n", err)
os.Exit(1)
}
}
viper.AddConfigPath(homedir)
viper.SetConfigName(".godeez")
}
viper.SetConfigType("toml")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not read config file: %v\n", err)
os.Exit(1)
}
cfg := &config.Cfg
if err := viper.Unmarshal(&cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not unmarshal config file: %v\n", err)
os.Exit(1)
}
if cfg.LicenseToken == "" {
fmt.Fprintln(os.Stderr, "Error: license_token is not set in config file")
os.Exit(1)
}
if cfg.SecretKey == "" {
fmt.Fprintln(os.Stderr, "Error: secret_key is not set in config file")
os.Exit(1)
}
if cfg.IV == "" {
fmt.Fprintln(os.Stderr, "Error: iv is not set in config file")
os.Exit(1)
} }
} }
+76
View File
@@ -0,0 +1,76 @@
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
}
func runUpdate(ctx context.Context, opts *updateOptions) error {
if err := update.CheckUpdatable(); err != nil {
return err
}
u := update.New()
u.Out = os.Stdout
current := buildinfo.Version()
release, err := u.Latest(ctx)
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}
latest := release.Version()
fmt.Printf("Current: %s\nLatest: %s\n", current, latest)
if !update.IsNewer(current, latest) && !opts.force {
fmt.Println("Already up to date.")
return nil
}
if opts.checkOnly {
fmt.Printf("Run `godeez update` to install %s.\n", latest)
return nil
}
if err := u.Apply(ctx, release); err != nil {
return err
}
fmt.Printf("Updated to %s.\n", latest)
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package cmd
import (
"fmt"
"runtime"
"github.com/mathismqn/godeez/internal/buildinfo"
"github.com/spf13/cobra"
)
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the current version of GoDeez",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("godeez %s\n", buildinfo.Version())
if commit := buildinfo.Commit(); commit != "" {
fmt.Printf(" commit: %s\n", commit)
}
if date := buildinfo.Date(); date != "" {
fmt.Printf(" built: %s\n", date)
}
fmt.Printf(" go: %s\n", runtime.Version())
fmt.Printf(" platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}
}
+24 -24
View File
@@ -1,40 +1,40 @@
module github.com/mathismqn/godeez module github.com/mathismqn/godeez
go 1.23.1 go 1.25.0
require ( require (
github.com/spf13/cobra v1.8.1 github.com/mewkiz/flac v1.0.13
github.com/spf13/viper v1.19.0 github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/mod v0.38.0
golang.org/x/term v0.45.0
) )
require ( require (
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/icza/bitio v1.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/spf13/afero v1.11.0 // indirect github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/spf13/cast v1.6.0 // indirect golang.org/x/net v0.57.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect golang.org/x/sys v0.47.0 // indirect
go.uber.org/atomic v1.9.0 // indirect golang.org/x/text v0.40.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
require ( require (
github.com/PuerkitoBio/goquery v1.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/flytam/filenamify v1.2.0 github.com/flytam/filenamify v1.2.0
github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.1 github.com/go-flac/go-flac/v2 v2.0.4
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/crypto v0.28.0 go.etcd.io/bbolt v1.5.0
golang.org/x/crypto v0.54.0
) )
+58 -72
View File
@@ -1,113 +1,99 @@
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= github.com/bogem/id3v2/v2 v2.1.4 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/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI= github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8= github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac/v2 v2.0.1 h1:1zilNkbmmpK9DLsz2NbjLHG8avOmthYqUfVc9YKB/Ps= github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac/v2 v2.0.1/go.mod h1:hvgeR2hElLbwk0Q1/vMazIDmIc2LAFSd9Bx/Fk6ViKo= github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.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.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= 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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-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.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-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.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.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.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+189
View File
@@ -0,0 +1,189 @@
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 = 44
formatPCM = 1
ctxCheckInterval = 64
maxDataSize = math.MaxUint32 - (headerSize - 8)
)
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)
}
}()
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)
}
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
}
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))
}
}
func patchSizes(file *os.File, dataSize int64) error {
buf := make([]byte, 4)
binary.LittleEndian.PutUint32(buf, uint32(headerSize-8+dataSize+dataSize%2))
if _, err := file.WriteAt(buf, 4); err != nil {
return err
}
binary.LittleEndian.PutUint32(buf, uint32(dataSize))
_, err := file.WriteAt(buf, headerSize-4)
return err
}
+291
View File
@@ -0,0 +1,291 @@
package audio
import (
"bytes"
"context"
"encoding/binary"
"os"
"path/filepath"
"testing"
"github.com/mewkiz/flac"
"github.com/mewkiz/flac/frame"
"github.com/mewkiz/flac/meta"
)
func testSamples(nChannels, bitsPerSample, nSamples int) [][]int32 {
max := int32(1)<<(bitsPerSample-1) - 1
min := -int32(1) << (bitsPerSample - 1)
channels := make([][]int32, nChannels)
for c := range channels {
samples := make([]int32, nSamples)
for i := range samples {
switch i {
case 0:
samples[i] = min
case 1:
samples[i] = max
case 2:
samples[i] = 0
default:
samples[i] = int32(i*(c+1)) % max
if i%3 == 0 {
samples[i] = -samples[i]
}
}
}
channels[c] = samples
}
return channels
}
func writeTestFLAC(t *testing.T, path string, sampleRate uint32, bitsPerSample uint8, channels [][]int32) {
t.Helper()
nSamples := len(channels[0])
info := &meta.StreamInfo{
BlockSizeMin: uint16(nSamples),
BlockSizeMax: uint16(nSamples),
SampleRate: sampleRate,
NChannels: uint8(len(channels)),
BitsPerSample: bitsPerSample,
NSamples: uint64(nSamples),
}
file, err := os.Create(path)
if err != nil {
t.Fatalf("create flac: %v", err)
}
defer file.Close()
enc, err := flac.NewEncoder(file, info)
if err != nil {
t.Fatalf("new encoder: %v", err)
}
subframes := make([]*frame.Subframe, len(channels))
for c, samples := range channels {
subframes[c] = &frame.Subframe{
SubHeader: frame.SubHeader{Pred: frame.PredVerbatim},
Samples: samples,
NSamples: nSamples,
}
}
channelsLayout := frame.ChannelsMono
if len(channels) == 2 {
channelsLayout = frame.ChannelsLR
}
f := &frame.Frame{
Header: frame.Header{
HasFixedBlockSize: true,
BlockSize: uint16(nSamples),
SampleRate: sampleRate,
Channels: channelsLayout,
BitsPerSample: bitsPerSample,
},
Subframes: subframes,
}
if err := enc.WriteFrame(f); err != nil {
t.Fatalf("write frame: %v", err)
}
if err := enc.Close(); err != nil {
t.Fatalf("close encoder: %v", err)
}
}
func expectedPCM(channels [][]int32, bytesPerSample int) []byte {
var buf bytes.Buffer
for i := range channels[0] {
for _, samples := range channels {
sample := samples[i]
switch bytesPerSample {
case 1:
buf.WriteByte(byte(sample + 128))
case 2:
buf.Write([]byte{byte(sample), byte(sample >> 8)})
case 3:
buf.Write([]byte{byte(sample), byte(sample >> 8), byte(sample >> 16)})
}
}
}
return buf.Bytes()
}
func TestFLACToWAV(t *testing.T) {
tests := []struct {
name string
sampleRate uint32
bitsPerSample uint8
nChannels int
nSamples int
}{
{"16 bit stereo", 44100, 16, 2, 512},
{"16 bit mono", 44100, 16, 1, 512},
{"24 bit stereo", 48000, 24, 2, 333},
{"8 bit mono", 22050, 8, 1, 128},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "in.flac")
dst := filepath.Join(dir, "out.wav")
channels := testSamples(tt.nChannels, int(tt.bitsPerSample), tt.nSamples)
writeTestFLAC(t, src, tt.sampleRate, tt.bitsPerSample, channels)
if err := FLACToWAV(context.Background(), src, dst); err != nil {
t.Fatalf("FLACToWAV() error = %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read wav: %v", err)
}
bytesPerSample := int(tt.bitsPerSample) / 8
blockAlign := uint16(tt.nChannels * bytesPerSample)
want := expectedPCM(channels, bytesPerSample)
dataSize := uint32(len(want))
pad := dataSize % 2
if len(got) != headerSize+len(want)+int(pad) {
t.Fatalf("file size = %d, want %d", len(got), headerSize+len(want)+int(pad))
}
if string(got[0:4]) != "RIFF" || string(got[8:12]) != "WAVE" {
t.Errorf("magic = %q %q, want \"RIFF\" \"WAVE\"", got[0:4], got[8:12])
}
if size := binary.LittleEndian.Uint32(got[4:8]); size != headerSize-8+dataSize+pad {
t.Errorf("riff size = %d, want %d", size, headerSize-8+dataSize+pad)
}
if string(got[12:16]) != "fmt " {
t.Errorf("fmt chunk id = %q, want \"fmt \"", got[12:16])
}
if size := binary.LittleEndian.Uint32(got[16:20]); size != 16 {
t.Errorf("fmt chunk size = %d, want 16", size)
}
if format := binary.LittleEndian.Uint16(got[20:22]); format != formatPCM {
t.Errorf("format = %d, want %d", format, formatPCM)
}
if n := binary.LittleEndian.Uint16(got[22:24]); n != uint16(tt.nChannels) {
t.Errorf("channels = %d, want %d", n, tt.nChannels)
}
if rate := binary.LittleEndian.Uint32(got[24:28]); rate != tt.sampleRate {
t.Errorf("sample rate = %d, want %d", rate, tt.sampleRate)
}
if rate := binary.LittleEndian.Uint32(got[28:32]); rate != tt.sampleRate*uint32(blockAlign) {
t.Errorf("byte rate = %d, want %d", rate, tt.sampleRate*uint32(blockAlign))
}
if align := binary.LittleEndian.Uint16(got[32:34]); align != blockAlign {
t.Errorf("block align = %d, want %d", align, blockAlign)
}
if bits := binary.LittleEndian.Uint16(got[34:36]); bits != uint16(tt.bitsPerSample) {
t.Errorf("bits per sample = %d, want %d", bits, tt.bitsPerSample)
}
if string(got[36:40]) != "data" {
t.Errorf("data chunk id = %q, want \"data\"", got[36:40])
}
if size := binary.LittleEndian.Uint32(got[40:44]); size != dataSize {
t.Errorf("data chunk size = %d, want %d", size, dataSize)
}
if !bytes.Equal(got[headerSize:headerSize+len(want)], want) {
t.Error("pcm payload does not match the source samples")
}
})
}
}
func TestFLACToWAVErrors(t *testing.T) {
dir := t.TempDir()
notFLAC := filepath.Join(dir, "not.flac")
if err := os.WriteFile(notFLAC, []byte("this is not a flac file"), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
valid := filepath.Join(dir, "valid.flac")
writeTestFLAC(t, valid, 44100, 16, testSamples(2, 16, 64))
data, err := os.ReadFile(valid)
if err != nil {
t.Fatalf("read flac: %v", err)
}
truncated := filepath.Join(dir, "truncated.flac")
if err := os.WriteFile(truncated, data[:len(data)/2], 0644); err != nil {
t.Fatalf("write file: %v", err)
}
tests := []struct {
name string
path string
}{
{"not a flac file", notFLAC},
{"missing file", filepath.Join(dir, "missing.flac")},
{"truncated file", truncated},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dst := filepath.Join(t.TempDir(), "out.wav")
if err := FLACToWAV(context.Background(), tt.path, dst); err == nil {
t.Error("FLACToWAV() error = nil, want error")
}
if _, err := os.Stat(dst); err == nil {
t.Error("FLACToWAV() left an output file behind")
}
})
}
}
func TestFLACToWAVCanceled(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "in.flac")
dst := filepath.Join(dir, "out.wav")
writeTestFLAC(t, src, 44100, 16, testSamples(2, 16, 512))
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := FLACToWAV(ctx, src, dst); err == nil {
t.Error("FLACToWAV() error = nil, want context.Canceled")
}
if _, err := os.Stat(dst); err == nil {
t.Error("FLACToWAV() left an output file behind")
}
matches, _ := filepath.Glob(filepath.Join(dir, ".godeez-*.part"))
if len(matches) > 0 {
t.Errorf("FLACToWAV() left %d part files behind", len(matches))
}
}
func TestBytesPerSample(t *testing.T) {
tests := []struct {
bitsPerSample uint8
want int
wantErr bool
}{
{8, 1, false},
{16, 2, false},
{24, 3, false},
{4, 0, true},
{12, 0, true},
{20, 0, true},
{32, 0, true},
}
for _, tt := range tests {
got, err := bytesPerSample(tt.bitsPerSample)
if (err != nil) != tt.wantErr {
t.Errorf("bytesPerSample(%d) error = %v, wantErr %v", tt.bitsPerSample, err, tt.wantErr)
}
if got != tt.want {
t.Errorf("bytesPerSample(%d) = %d, want %d", tt.bitsPerSample, got, tt.want)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
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"
var (
version = devVersion
commit = ""
date = ""
)
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
}
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
}
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)
}
+28 -5
View File
@@ -1,10 +1,33 @@
package config package config
import (
"fmt"
"os"
"path/filepath"
"github.com/mathismqn/godeez/internal/fsutil"
)
type Config struct { type Config struct {
LicenseToken string `mapstructure:"license_token"` ARLCookie string
ArlCookie string `mapstructure:"arl_cookie"` OutputDir string
SecretKey string `mapstructure:"secret_key"`
IV string `mapstructure:"iv"`
} }
var Cfg Config func Load() (*Config, error) {
arl := os.Getenv("DEEZER_ARL")
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
outputDir := filepath.Join(homeDir, "Music", "GoDeez")
if err := fsutil.EnsureDir(outputDir); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
return &Config{
ARLCookie: arl,
OutputDir: outputDir,
}, nil
}
+65
View File
@@ -0,0 +1,65 @@
package config
import (
"io"
"os"
"path/filepath"
)
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
}
-41
View File
@@ -1,41 +0,0 @@
package crypto
import (
"crypto/cipher"
"crypto/md5"
"encoding/hex"
"fmt"
"github.com/mathismqn/godeez/internal/config"
"golang.org/x/crypto/blowfish"
)
func GetBlowfishKey(songID string) []byte {
hash := md5.Sum([]byte(songID))
hashHex := fmt.Sprintf("%x", hash)
key := []byte(config.Cfg.SecretKey)
for i := 0; i < len(hash); i++ {
key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16]
}
return key
}
func DecryptBlowfish(data, key []byte) ([]byte, error) {
block, err := blowfish.NewCipher(key)
if err != nil {
return nil, err
}
iv, err := hex.DecodeString(config.Cfg.IV)
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data)
return decrypted, nil
}
+33 -32
View File
@@ -3,46 +3,47 @@ package deezer
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path" "path/filepath"
"github.com/flytam/filenamify" "github.com/flytam/filenamify"
) )
type Album struct { type Album struct {
Data struct { Results struct {
Title string `json:"ALB_TITLE"` Data struct {
Artist string `json:"ART_NAME"` Title string `json:"ALB_TITLE"`
OriginalReleaseDate string `json:"ORIGINAL_RELEASE_DATE"` Artist string `json:"ART_NAME"`
PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"` OriginalReleaseDate string `json:"ORIGINAL_RELEASE_DATE"`
Label string `json:"LABEL_NAME"` PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"`
ProducerLine string `json:"PRODUCER_LINE"` Label string `json:"LABEL_NAME"`
} `json:"DATA"` ProducerLine string `json:"PRODUCER_LINE"`
Songs struct { Copyright string `json:"COPYRIGHT"`
Data []*Song `json:"data"` Duration string `json:"DURATION"`
} `json:"SONGS"` } `json:"DATA"`
Tracks struct {
Data []*Track `json:"data"`
} `json:"SONGS"`
} `json:"results"`
} }
func (a *Album) GetURL(id string) string { func (a *Album) Title() string {
return "https://www.deezer.com/en/album/" + id return a.Results.Data.Title
} }
func (a *Album) UnmarshalData(data []byte) error { func (a *Album) Tracks() []*Track {
return a.Results.Tracks.Data
}
func (a *Album) SetTracks(t []*Track) {
a.Results.Tracks.Data = t
}
func (a *Album) OutputDir(outputDir string) string {
base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{})
return filepath.Join(outputDir, base)
}
func (a *Album) decode(data []byte) error {
return json.Unmarshal(data, a) return json.Unmarshal(data, a)
} }
func (a *Album) GetSongs() []*Song {
return a.Songs.Data
}
func (a *Album) GetOutputPath(outputDir string) string {
base := fmt.Sprintf("%s - %s", a.Data.Artist, a.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{})
outputPath := path.Join(outputDir, base)
outputPath, _ = filenamify.Filenamify(outputPath, filenamify.Options{})
return outputPath
}
func (a *Album) GetTitle() string {
return a.Data.Title
}
+40
View File
@@ -0,0 +1,40 @@
package deezer
import (
"encoding/json"
"path/filepath"
"github.com/flytam/filenamify"
)
type Artist struct {
Results struct {
Data struct {
Name string `json:"ART_NAME"`
} `json:"DATA"`
Tracks struct {
Data []*Track `json:"data"`
} `json:"TOP"`
} `json:"results"`
}
func (a *Artist) Title() string {
return a.Results.Data.Name
}
func (a *Artist) Tracks() []*Track {
return a.Results.Tracks.Data
}
func (a *Artist) SetTracks(t []*Track) {
a.Results.Tracks.Data = t
}
func (a *Artist) OutputDir(outputDir string) string {
base, _ := filenamify.Filenamify(a.Results.Data.Name, filenamify.Options{})
return filepath.Join(outputDir, base)
}
func (a *Artist) decode(data []byte) error {
return json.Unmarshal(data, a)
}
+39
View File
@@ -0,0 +1,39 @@
package deezer
import (
"crypto/cipher"
"crypto/md5"
"encoding/hex"
"golang.org/x/crypto/blowfish"
)
var (
blowfishIV = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
blowfishSecretKey = []byte("g4el58wc0zvf9na1")
)
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
}
func DecryptBlowfish(data, key []byte) ([]byte, error) {
block, err := blowfish.NewCipher(key)
if err != nil {
return nil, err
}
decrypted := make([]byte, len(data))
cipher.NewCBCDecrypter(block, blowfishIV).CryptBlocks(decrypted, data)
return decrypted, nil
}
+56
View File
@@ -0,0 +1,56 @@
package deezer
import (
"bytes"
"crypto/cipher"
"encoding/hex"
"testing"
"golang.org/x/crypto/blowfish"
)
func TestBlowfishKey(t *testing.T) {
tests := []struct {
trackID string
want string
}{
{"3135556", "6c6c666b39662c37652575603c643439"},
{"123456789", "6d34656061377f31322a7336393f626b"},
{"1", "3464656e343a7d3a672c236a33696061"},
}
for _, tt := range tests {
got := hex.EncodeToString(BlowfishKey(tt.trackID))
if got != tt.want {
t.Errorf("BlowfishKey(%q) = %s, want %s", tt.trackID, got, tt.want)
}
}
}
func TestDecryptBlowfishRoundTrip(t *testing.T) {
key := BlowfishKey("3135556")
plaintext := bytes.Repeat([]byte("01234567"), 16)
block, err := blowfish.NewCipher(key)
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
encrypted := make([]byte, len(plaintext))
cipher.NewCBCEncrypter(block, blowfishIV).CryptBlocks(encrypted, plaintext)
decrypted, err := DecryptBlowfish(encrypted, key)
if err != nil {
t.Fatalf("DecryptBlowfish: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Errorf("round trip mismatch: got %x, want %x", decrypted, plaintext)
}
}
func TestDecryptBlowfishInvalidKey(t *testing.T) {
if _, err := DecryptBlowfish(make([]byte, 8), nil); err == nil {
t.Error("expected error for nil key")
}
}
+222
View File
@@ -0,0 +1,222 @@
package deezer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
type Client struct {
Session *Session
}
func NewClient(ctx context.Context, arlCookie string) (*Client, error) {
session, err := resolveSession(ctx, arlCookie)
if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
return &Client{
Session: session,
}, nil
}
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
}
func (c *Client) FetchResource(ctx context.Context, kind Kind, id string) (Resource, error) {
resource, err := kind.newResource()
if err != nil {
return nil, err
}
payload := map[string]any{
"nb": 10000,
"start": 0,
"lang": "en",
"tab": 0,
"tags": true,
"header": true,
}
payload[kind.idKey()] = id
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, err
}
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", kind.pageMethod(), c.Session.apiToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
resp, err := c.Session.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
bodyStr := string(body)
for _, check := range []struct {
marker string
errMsg string
}{
{`"DATA_ERROR":"playlist::getData"`, "invalid playlist ID"},
{`"DATA_ERROR":"album::getData"`, "invalid album ID"},
{`"DATA_ERROR":"artist::getData"`, "invalid artist ID"},
{`"DATA_ERROR":"song::getData"`, "invalid track ID"},
} {
if strings.Contains(bodyStr, check.marker) {
return nil, errors.New(check.errMsg)
}
}
if strings.Contains(bodyStr, `"results":{}`) {
return nil, errors.New("unexpected response")
}
if err := resource.decode(body); err != nil {
return nil, err
}
return resource, nil
}
func (c *Client) FetchMedia(ctx context.Context, track *Track, quality string) (*Media, error) {
qualityFormats := map[string]string{
"mp3_128": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
"mp3_320": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
"flac": `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
}
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.licenseToken, qualityFormats[quality], track.TrackToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
if err != nil {
return nil, err
}
resp, err := c.Session.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var media Media
if err := json.Unmarshal(body, &media); err != nil {
return nil, err
}
if len(media.Errors) > 0 {
if media.Errors[0].Code == 1000 {
return nil, errors.New("invalid license token")
}
return nil, errors.New(media.Errors[0].Message)
}
if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 {
if media.Data[0].Errors[0].Code == 2002 {
return nil, errors.New("invalid track token")
}
return nil, errors.New(media.Data[0].Errors[0].Message)
}
if len(media.Data) == 0 || len(media.Data[0].Media) == 0 || len(media.Data[0].Media[0].Sources) == 0 {
return nil, errors.New("no sources found")
}
return &media, nil
}
func (c *Client) FetchCoverImage(ctx context.Context, track *Track) ([]byte, error) {
url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", track.Cover)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.Session.HTTPClient.Do(req)
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)
}
func (c *Client) MediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, media.URL(), nil)
if err != nil {
return nil, err
}
streamingClient := *c.Session.HTTPClient
streamingClient.Timeout = 0
resp, err := streamingClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return resp.Body, nil
}
+61
View File
@@ -0,0 +1,61 @@
package deezer
import (
"encoding/json"
"errors"
"fmt"
"github.com/zalando/go-keyring"
)
const (
keyringService = "godeez"
keyringUser = "default"
)
type Credentials struct {
Email string `json:"email"`
Password string `json:"password"`
ARL string `json:"arl,omitempty"`
}
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
}
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
}
+42
View File
@@ -0,0 +1,42 @@
package deezer
import (
"crypto/aes"
"crypto/cipher"
"fmt"
)
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)
}
func ecbTransform(key, data []byte, op func(cipher.Block, []byte, []byte)) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
bs := block.BlockSize()
if len(data)%bs != 0 {
return nil, fmt.Errorf("data length %d is not a multiple of the AES block size", len(data))
}
out := make([]byte, len(data))
for i := 0; i < len(data); i += bs {
op(block, out[i:i+bs], data[i:i+bs])
}
return out, nil
}
+72
View File
@@ -0,0 +1,72 @@
package deezer
import (
"bytes"
"testing"
)
func TestZeroPad(t *testing.T) {
tests := []struct {
length int
want int
}{
{0, 0},
{1, 16},
{15, 16},
{16, 16},
{17, 32},
}
for _, tt := range tests {
padded := zeroPad(make([]byte, tt.length))
if len(padded) != tt.want {
t.Errorf("zeroPad(len %d) = len %d, want %d", tt.length, len(padded), tt.want)
}
}
}
func TestZeroPadPreservesData(t *testing.T) {
data := []byte("secret")
padded := zeroPad(data)
if !bytes.Equal(padded[:len(data)], data) {
t.Errorf("zeroPad changed data: got %q", padded[:len(data)])
}
for _, b := range padded[len(data):] {
if b != 0 {
t.Errorf("padding is not zero: %v", padded)
}
}
}
func TestECBRoundTrip(t *testing.T) {
key := []byte("0123456789abcdef")
plaintext := zeroPad([]byte("some secret data"))
encrypted, err := ecbEncrypt(key, plaintext)
if err != nil {
t.Fatalf("ecbEncrypt: %v", err)
}
if bytes.Equal(encrypted, plaintext) {
t.Fatal("encrypted data equals plaintext")
}
decrypted, err := ecbDecrypt(key, encrypted)
if err != nil {
t.Fatalf("ecbDecrypt: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Errorf("round trip mismatch: got %q, want %q", decrypted, plaintext)
}
}
func TestECBRejectsPartialBlock(t *testing.T) {
key := []byte("0123456789abcdef")
if _, err := ecbEncrypt(key, make([]byte, 15)); err == nil {
t.Error("expected error for data not a multiple of the block size")
}
if _, err := ecbDecrypt(key, make([]byte, 17)); err == nil {
t.Error("expected error for data not a multiple of the block size")
}
}
+269
View File
@@ -0,0 +1,269 @@
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 = "012345689abdef"
)
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
}
func CheckGatewayEnv() error {
_, _, err := gatewayEnv()
return err
}
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
}
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
}
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
}
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
}
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)
}
func genUniqID() string {
b := make([]byte, 32)
for i := range b {
b[i] = nonceAlphabet[rand.IntN(len(nonceAlphabet))]
}
return string(b)
}
+57
View File
@@ -0,0 +1,57 @@
package deezer
import "fmt"
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 ""
}
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 ""
}
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)
}
+54
View File
@@ -0,0 +1,54 @@
package deezer
import (
"context"
"errors"
"fmt"
)
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")
}
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
}
+11 -82
View File
@@ -1,98 +1,27 @@
package deezer package deezer
import (
"fmt"
"net/http"
"os"
"github.com/mathismqn/godeez/internal/crypto"
)
type Media struct { type Media struct {
Errors []MediaError `json:"errors"` Errors []mediaError `json:"errors"`
Data []struct { Data []struct {
Media []struct { Media []struct {
Type string `json:"media_type"` Format string `json:"format"`
Cipher Cipher `json:"cipher"` Sources []struct {
Format string `json:"format"` URL string `json:"url"`
Sources []Source `json:"sources"` } `json:"sources"`
} }
Errors []MediaError `json:"errors"` Errors []mediaError `json:"errors"`
} `json:"data"` } `json:"data"`
} }
type MediaError struct { type mediaError struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} }
type Cipher struct { func (m *Media) URL() string {
Type string `json:"type"` 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"`
}
const ChunkSize = 2048
func (m *Media) Download(url, path, songID string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
key := crypto.GetBlowfishKey(songID)
buffer := make([]byte, ChunkSize)
for chunk := 0; ; chunk++ {
totalRead := 0
for totalRead < ChunkSize {
n, err := resp.Body.Read(buffer[totalRead:])
if err != nil {
if err.Error() == "EOF" {
break
}
return err
}
if n > 0 {
totalRead += n
}
}
if totalRead == 0 {
break
}
if chunk%3 == 0 && totalRead == ChunkSize {
buffer, err = crypto.DecryptBlowfish(buffer, key)
if err != nil {
return err
}
}
_, err = file.Write(buffer[:totalRead])
if err != nil {
return err
}
if totalRead < ChunkSize {
break
}
}
return nil
} }
+27 -28
View File
@@ -2,42 +2,41 @@ package deezer
import ( import (
"encoding/json" "encoding/json"
"path" "path/filepath"
"github.com/flytam/filenamify" "github.com/flytam/filenamify"
) )
type Playlist struct { type Playlist struct {
Data struct { Results struct {
Title string `json:"TITLE"` Data struct {
Status int `json:"STATUS"` Title string `json:"TITLE"`
CollabKey string `json:"COLLAB_KEY"` Creator string `json:"PARENT_USERNAME"`
} `json:"DATA"` Duration int `json:"DURATION"`
Songs struct { } `json:"DATA"`
Data []*Song `json:"data"` Tracks struct {
} `json:"SONGS"` Data []*Track `json:"data"`
} `json:"SONGS"`
} `json:"results"`
} }
func (p *Playlist) GetURL(id string) string { func (p *Playlist) Title() string {
return "https://www.deezer.com/en/playlist/" + id return p.Results.Data.Title
} }
func (p *Playlist) UnmarshalData(data []byte) error { func (p *Playlist) Tracks() []*Track {
return p.Results.Tracks.Data
}
func (p *Playlist) SetTracks(t []*Track) {
p.Results.Tracks.Data = t
}
func (p *Playlist) OutputDir(outputDir string) string {
base, _ := filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
return filepath.Join(outputDir, base)
}
func (p *Playlist) decode(data []byte) error {
return json.Unmarshal(data, p) return json.Unmarshal(data, p)
} }
func (p *Playlist) GetSongs() []*Song {
return p.Songs.Data
}
func (p *Playlist) GetOutputPath(outputDir string) string {
p.Data.Title, _ = filenamify.Filenamify(p.Data.Title, filenamify.Options{})
outputPath := path.Join(outputDir, p.Data.Title)
outputPath, _ = filenamify.Filenamify(outputPath, filenamify.Options{})
return outputPath
}
func (p *Playlist) GetTitle() string {
return p.Data.Title
}
+5 -53
View File
@@ -1,57 +1,9 @@
package deezer package deezer
import (
"fmt"
"io"
"net/http"
"regexp"
"github.com/mathismqn/godeez/internal/config"
)
type Resource interface { type Resource interface {
GetURL(id string) string Title() string
UnmarshalData(data []byte) error Tracks() []*Track
GetSongs() []*Song SetTracks(tracks []*Track)
GetOutputPath(outputDir string) string OutputDir(outputDir string) string
GetTitle() string decode(data []byte) error
}
func GetData(r Resource, id string) error {
url := r.GetURL(id)
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
if config.Cfg.ArlCookie != "" {
req.AddCookie(&http.Cookie{
Name: "arl",
Value: config.Cfg.ArlCookie,
})
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("resource not found")
}
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
re := regexp.MustCompile(`window\.__DZR_APP_STATE__ = (\{.*\})`)
matches := re.FindStringSubmatch(string(body))
if len(matches) != 2 {
return fmt.Errorf("error parsing response")
}
return r.UnmarshalData([]byte(matches[1]))
} }
+87
View File
@@ -0,0 +1,87 @@
package deezer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"time"
)
var ErrInvalidARL = errors.New("invalid or expired ARL cookie")
type Session struct {
apiToken string
licenseToken string
HTTPClient *http.Client
Premium bool
}
func authenticate(ctx context.Context, arlCookie string) (*Session, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
client := &http.Client{
Timeout: 20 * time.Second,
Jar: jar,
}
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, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.AddCookie(&http.Cookie{
Name: "arl",
Value: arlCookie,
})
resp, err := client.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
}
var res struct {
Results struct {
APIToken string `json:"checkForm"`
User struct {
ID int `json:"USER_ID"`
Options struct {
LicenseToken string `json:"license_token"`
MobileOffline bool `json:"mobile_offline"`
WebOffline bool `json:"web_offline"`
} `json:"OPTIONS"`
} `json:"USER"`
} `json:"results"`
}
if err := json.Unmarshal(body, &res); err != nil {
return nil, err
}
if res.Results.User.ID == 0 {
return nil, ErrInvalidARL
}
opts := res.Results.User.Options
return &Session{
apiToken: res.Results.APIToken,
licenseToken: opts.LicenseToken,
HTTPClient: client,
Premium: opts.MobileOffline || opts.WebOffline,
}, nil
}
+36
View File
@@ -0,0 +1,36 @@
package deezer
import (
"encoding/json"
"path/filepath"
)
type Single struct {
Results struct {
Data *Track `json:"DATA"`
} `json:"results"`
}
func (s *Single) Title() string {
if s.Results.Data == nil {
return ""
}
return s.Results.Data.FullTitle()
}
func (s *Single) Tracks() []*Track {
if s.Results.Data == nil {
return nil
}
return []*Track{s.Results.Data}
}
func (s *Single) SetTracks(tracks []*Track) {}
func (s *Single) OutputDir(outputDir string) string {
return filepath.Join(outputDir, "Singles")
}
func (s *Single) decode(data []byte) error {
return json.Unmarshal(data, s)
}
-95
View File
@@ -1,95 +0,0 @@
package deezer
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/mathismqn/godeez/internal/config"
)
type Song struct {
ID string `json:"SNG_ID"`
Artist string `json:"ART_NAME"`
Title string `json:"SNG_TITLE"`
Version string `json:"VERSION"`
Cover string `json:"ALB_PICTURE"`
Contributors struct {
MainArtists []string `json:"main_artist"`
Composers []string `json:"composer"`
Authors []string `json:"author"`
} `json:"SNG_CONTRIBUTORS"`
Duration string `json:"DURATION"`
Gain string `json:"GAIN"`
ISRC string `json:"ISRC"`
TrackNumber string `json:"TRACK_NUMBER"`
TrackToken string `json:"TRACK_TOKEN"`
}
func (s *Song) GetMediaData(quality string) (*Media, error) {
var formats string
switch quality {
case "mp3_128":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "mp3_320":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"}]`
case "flac":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"}]`
case "best":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
}
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, config.Cfg.LicenseToken, formats, s.TrackToken)
resp, err := http.Post("https://media.deezer.com/v1/get_url", "application/json", bytes.NewBuffer([]byte(reqBody)))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var media Media
err = json.Unmarshal(body, &media)
if err != nil {
return nil, err
}
if len(media.Errors) > 0 {
if media.Errors[0].Code == 1000 {
return nil, fmt.Errorf("invalid license token")
}
return nil, fmt.Errorf("%s", media.Errors[0].Message)
}
if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 {
if media.Data[0].Errors[0].Code == 2002 {
return nil, fmt.Errorf("invalid track token")
}
return nil, fmt.Errorf("%s", media.Data[0].Errors[0].Message)
}
return &media, nil
}
func (s *Song) GetCoverImage() ([]byte, error) {
url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", s.Cover)
resp, err := http.Get(url)
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)
}
+91
View File
@@ -0,0 +1,91 @@
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"`
}
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 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
}
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})
base = truncateBytes(base, 255-len(ext)-1-len("-id3v2"))
return base + "." + ext
}
func truncateBytes(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
if len(s) <= maxLen {
return s
}
last := 0
for i := range s {
if i > maxLen {
return s[:last]
}
last = i
}
return s[:last]
}
+116
View File
@@ -0,0 +1,116 @@
package deezer
import (
"encoding/json"
"strings"
"testing"
)
func TestFullTitle(t *testing.T) {
tests := []struct {
title string
version string
want string
}{
{"Song", "", "Song"},
{"Song", "(Remix)", "Song (Remix)"},
}
for _, tt := range tests {
track := &Track{Title: tt.title, Version: tt.version}
if got := track.FullTitle(); got != tt.want {
t.Errorf("FullTitle() = %q, want %q", got, tt.want)
}
}
}
func TestFilename(t *testing.T) {
tests := []struct {
name string
track Track
kind Kind
mediaFormat string
want string
}{
{
name: "album with numeric track number",
track: Track{Artist: "Artist", Title: "Song", TrackNumber: "1"},
kind: KindAlbum,
mediaFormat: "MP3_320",
want: "01. Artist - Song.mp3",
},
{
name: "album with non-numeric track number",
track: Track{Artist: "Artist", Title: "Song", TrackNumber: "A"},
kind: KindAlbum,
mediaFormat: "MP3_320",
want: "A. Artist - Song.mp3",
},
{
name: "playlist has no prefix",
track: Track{Artist: "Artist", Title: "Song", TrackNumber: "1"},
kind: KindPlaylist,
mediaFormat: "MP3_128",
want: "Artist - Song.mp3",
},
{
name: "flac extension",
track: Track{Artist: "Artist", Title: "Song"},
kind: KindTrack,
mediaFormat: "FLAC",
want: "Artist - Song.flac",
},
{
name: "wav extension",
track: Track{Artist: "Artist", Title: "Song"},
kind: KindTrack,
mediaFormat: "WAV",
want: "Artist - Song.wav",
},
{
name: "version appended",
track: Track{Artist: "Artist", Title: "Song", Version: "(Live)"},
kind: KindTrack,
mediaFormat: "MP3_320",
want: "Artist - Song (Live).mp3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.track.Filename(tt.kind, tt.mediaFormat); got != tt.want {
t.Errorf("Filename() = %q, want %q", got, tt.want)
}
})
}
}
func TestFilenameSanitizesSeparators(t *testing.T) {
track := Track{Artist: "AC/DC", Title: "Song"}
got := track.Filename(KindTrack, "MP3_320")
if strings.ContainsRune(got, '/') {
t.Errorf("Filename() contains a path separator: %q", got)
}
if !strings.HasSuffix(got, ".mp3") {
t.Errorf("Filename() = %q, want .mp3 suffix", got)
}
}
func TestContributorsUnmarshalJSON(t *testing.T) {
var c Contributors
if err := json.Unmarshal([]byte("[]"), &c); err != nil {
t.Fatalf("unmarshal empty array: %v", err)
}
if len(c.MainArtists) != 0 || len(c.Composers) != 0 || len(c.Authors) != 0 {
t.Errorf("expected empty contributors, got %+v", c)
}
data := `{"main_artist":["A","B"],"composer":["C"],"author":["D"]}`
if err := json.Unmarshal([]byte(data), &c); err != nil {
t.Fatalf("unmarshal object: %v", err)
}
if len(c.MainArtists) != 2 || c.MainArtists[0] != "A" || len(c.Composers) != 1 || len(c.Authors) != 1 {
t.Errorf("unexpected contributors: %+v", c)
}
}
+155
View File
@@ -0,0 +1,155 @@
package download
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
)
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)
}
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
}
func parseBPM(html string) (bpmKey, error) {
bpmMatch := bpmRegex.FindStringSubmatch(html)
keyMatch := keyRegex.FindStringSubmatch(html)
modeMatch := modeRegex.FindStringSubmatch(html)
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
return bpmKey{}, errors.New("no data found")
}
bpm := bpmMatch[1]
key := strings.SplitN(keyMatch[1], "/", 2)[0]
key = strings.ReplaceAll(key, "\u266f", "#")
key = strings.ReplaceAll(key, "\u266d", "b")
if modeMatch[1] == "minor" {
key += "m"
}
return bpmKey{BPM: bpm, Key: key}, nil
}
+42
View File
@@ -0,0 +1,42 @@
package download
import "testing"
func bpmHTML(bpm, key, mode string) string {
return `tempo of <span class="x">` + bpm + ` BPM</span>` +
` with a <span class="x">` + key + `</span> key` +
` and a <span class="x">` + mode + `</span> mode`
}
func TestParseBPM(t *testing.T) {
tests := []struct {
name string
html string
wantBPM string
wantKey string
}{
{"major key", bpmHTML("128", "A", "major"), "128", "A"},
{"minor key gets m suffix", bpmHTML("90", "F", "minor"), "90", "Fm"},
{"unicode sharp normalized", bpmHTML("124", "C♯", "major"), "124", "C#"},
{"unicode flat normalized", bpmHTML("100", "B♭", "minor"), "100", "Bbm"},
{"enharmonic pair keeps first", bpmHTML("110", "A♯/B♭", "major"), "110", "A#"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseBPM(tt.html)
if err != nil {
t.Fatalf("parseBPM: %v", err)
}
if got.BPM != tt.wantBPM || got.Key != tt.wantKey {
t.Errorf("parseBPM() = %+v, want BPM %q Key %q", got, tt.wantBPM, tt.wantKey)
}
})
}
}
func TestParseBPMNoData(t *testing.T) {
if _, err := parseBPM("<html>nothing here</html>"); err == nil {
t.Error("expected error for page without BPM data")
}
}
+118
View File
@@ -0,0 +1,118 @@
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,
}
}
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)
}
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
}
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
}
func (d *Downloader) downloadAllTracks(ctx context.Context, resource deezer.Resource, opts Options, outputDir string) error {
tracks := resource.Tracks()
startTime := time.Now()
if d.kind != deezer.KindTrack {
fmt.Printf("%s\n\nStarting download...\n\n", resourceInfo(resource))
}
progress := newProgressTracker(len(tracks), d.kind)
for i, track := range tracks {
if ctx.Err() != nil {
return ctx.Err()
}
sp := progress.startDownload(i, track)
result := d.downloadTrack(ctx, resource, track, opts, outputDir)
sp.Stop()
if result.err != nil && errors.Is(result.err, context.Canceled) {
return result.err
}
progress.handleResult(i, track, result)
}
progress.printSummary(outputDir, time.Since(startTime))
return nil
}
+131
View File
@@ -0,0 +1,131 @@
package download
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"unicode"
"unicode/utf8"
"github.com/PuerkitoBio/goquery"
)
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
}
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)
}
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
}
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
}
func formatTags(tags []string) string {
formatted := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
words := strings.Fields(tag)
for i, w := range words {
r, size := utf8.DecodeRuneInString(w)
words[i] = string(unicode.ToUpper(r)) + strings.ToLower(w[size:])
}
formatted = append(formatted, strings.Join(words, " "))
}
return strings.Join(formatted, " / ")
}
+51
View File
@@ -0,0 +1,51 @@
package download
import (
"slices"
"testing"
)
func TestMatchesKeyword(t *testing.T) {
if !matchesKeyword("Deep House", electronicKeywords) {
t.Error("expected Deep House to match electronic keywords")
}
if !matchesKeyword("classic rock", nonElectronicKeywords) {
t.Error("expected classic rock to match non-electronic keywords")
}
if matchesKeyword("Spoken Word", electronicKeywords) {
t.Error("did not expect Spoken Word to match electronic keywords")
}
}
func TestFilterTags(t *testing.T) {
got := filterTags([]string{"Deep House", "Rock", "Spoken Word"})
want := []string{"Deep House", "Rock"}
if !slices.Equal(got, want) {
t.Errorf("filterTags() = %v, want %v", got, want)
}
if got := filterTags([]string{"Rock", "Jazz"}); len(got) != 0 {
t.Errorf("filterTags() = %v, want empty", got)
}
if got := filterTags(nil); len(got) != 0 {
t.Errorf("filterTags(nil) = %v, want empty", got)
}
}
func TestFormatTags(t *testing.T) {
tests := []struct {
tags []string
want string
}{
{[]string{"deep house"}, "Deep House"},
{[]string{"TECHNO", "trance"}, "Techno / Trance"},
{[]string{" house ", ""}, "House"},
}
for _, tt := range tests {
if got := formatTags(tt.tags); got != tt.want {
t.Errorf("formatTags(%v) = %q, want %q", tt.tags, got, tt.want)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
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
}
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.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
}
func (d *Downloader) initHashIndex(ctx context.Context) error {
d.hashIndexOnce.Do(func() {
d.hashIndex, d.hashIndexErr = newHashIndex(ctx, d.appConfig.OutputDir)
})
return d.hashIndexErr
}
+71
View File
@@ -0,0 +1,71 @@
package download
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestHashFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(path, []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
got, err := hashFile(path)
if err != nil {
t.Fatalf("hashFile: %v", err)
}
want := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
if got != want {
t.Errorf("hashFile() = %s, want %s", got, want)
}
}
func TestHashFileMissing(t *testing.T) {
if _, err := hashFile(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("expected error for missing file")
}
}
func TestHashIndexFind(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sub", "track.mp3")
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
index, err := newHashIndex(context.Background(), dir)
if err != nil {
t.Fatalf("newHashIndex: %v", err)
}
hash := "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
found, ok := index.find(hash)
if !ok || found != path {
t.Errorf("find(%s) = %q, %v; want %q, true", hash, found, ok, path)
}
if _, ok := index.find("deadbeef"); ok {
t.Error("find() reported a match for an unknown hash")
}
}
func TestHashIndexCanceledContext(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := newHashIndex(ctx, dir); err == nil {
t.Error("expected error for canceled context")
}
}
+111
View File
@@ -0,0 +1,111 @@
package download
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/mathismqn/godeez/internal/deezer"
)
func resourceInfo(resource deezer.Resource) string {
switch r := resource.(type) {
case *deezer.Album:
return albumInfo(r)
case *deezer.Playlist:
return playlistInfo(r)
case *deezer.Artist:
return artistInfo(r)
case *deezer.Single:
return singleInfo(r)
}
return ""
}
func albumInfo(a *deezer.Album) string {
duration, err := strconv.Atoi(a.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Album Info ] =================
Title: %s
Artist: %s
Tracks: %d
Duration: %s
==================================================`,
a.Results.Data.Title,
a.Results.Data.Artist,
len(a.Results.Tracks.Data),
time.Duration(duration)*time.Second,
)
}
func playlistInfo(p *deezer.Playlist) string {
return fmt.Sprintf(
`=============== [ Playlist Info ] ===============
Title: %s
Creator: %s
Tracks: %d
Duration: %s
=================================================`,
p.Results.Data.Title,
p.Results.Data.Creator,
len(p.Results.Tracks.Data),
time.Duration(p.Results.Data.Duration)*time.Second,
)
}
func artistInfo(a *deezer.Artist) string {
tracks := a.Results.Tracks.Data
count := len(tracks)
totalSec := 0
for _, t := range tracks {
if d, err := strconv.Atoi(t.Duration); err == nil {
totalSec += d
}
}
limit := min(3, count)
var b strings.Builder
fmt.Fprintf(&b, "============= [ Artist Info ] =============\n")
fmt.Fprintf(&b, "Artist: %s\n", a.Results.Data.Name)
fmt.Fprintf(&b, "Tracks: %d\n", count)
fmt.Fprintf(&b, "Playtime: %s\n", time.Duration(totalSec)*time.Second)
fmt.Fprintf(&b, "-------------------------------------------\n")
fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit)
for i := 0; i < limit; i++ {
t := tracks[i]
fmt.Fprintf(&b, " %2d. %s %s\n", i+1, t.Artist, t.FullTitle())
}
fmt.Fprintf(&b, "===========================================\n")
return b.String()
}
func singleInfo(s *deezer.Single) string {
if s.Results.Data == nil {
return "Track: No data available"
}
duration, err := strconv.Atoi(s.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Track Info ] =================
Title: %s
Artist: %s
Duration: %s
==================================================`,
s.Results.Data.FullTitle(),
s.Results.Data.Artist,
time.Duration(duration)*time.Second,
)
}
+79
View File
@@ -0,0 +1,79 @@
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
}
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
}
+55
View File
@@ -0,0 +1,55 @@
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
}
func (o *Options) sourceQuality() string {
if o.Quality == "wav" {
return "flac"
}
return o.Quality
}
func (o *Options) convertsToWAV() bool {
return o.Quality == "wav"
}
func (o *Options) Validate(kind deezer.Kind) error {
if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality)
}
if o.Timeout <= 0 {
return errors.New("timeout must be a positive duration")
}
if kind == deezer.KindArtist {
if o.Limit <= 0 {
return errors.New("limit must be a positive integer")
}
if o.Limit > 100 {
return errors.New("limit must not exceed 100")
}
}
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package download
import (
"testing"
"time"
"github.com/mathismqn/godeez/internal/deezer"
)
func TestOptionsSourceQuality(t *testing.T) {
tests := []struct {
quality string
want string
wantWAV bool
}{
{"mp3_128", "mp3_128", false},
{"mp3_320", "mp3_320", false},
{"flac", "flac", false},
{"wav", "flac", true},
}
for _, tt := range tests {
t.Run(tt.quality, func(t *testing.T) {
opts := Options{Quality: tt.quality}
if got := opts.sourceQuality(); got != tt.want {
t.Errorf("sourceQuality() = %q, want %q", got, tt.want)
}
if got := opts.convertsToWAV(); got != tt.wantWAV {
t.Errorf("convertsToWAV() = %v, want %v", got, tt.wantWAV)
}
})
}
}
func TestOptionsValidate(t *testing.T) {
valid := Options{Quality: "mp3_320", Timeout: time.Minute, Limit: 10}
tests := []struct {
name string
mutate func(o *Options)
kind deezer.Kind
wantErr bool
}{
{"valid", func(o *Options) {}, deezer.KindAlbum, false},
{"mp3_128", func(o *Options) { o.Quality = "mp3_128" }, deezer.KindAlbum, false},
{"flac", func(o *Options) { o.Quality = "flac" }, deezer.KindAlbum, false},
{"wav", func(o *Options) { o.Quality = "wav" }, deezer.KindAlbum, false},
{"invalid quality", func(o *Options) { o.Quality = "ogg" }, deezer.KindAlbum, true},
{"uppercase quality", func(o *Options) { o.Quality = "MP3_320" }, deezer.KindAlbum, true},
{"zero timeout", func(o *Options) { o.Timeout = 0 }, deezer.KindAlbum, true},
{"negative timeout", func(o *Options) { o.Timeout = -time.Second }, deezer.KindAlbum, true},
{"artist zero limit", func(o *Options) { o.Limit = 0 }, deezer.KindArtist, true},
{"artist limit too high", func(o *Options) { o.Limit = 101 }, deezer.KindArtist, true},
{"artist limit at max", func(o *Options) { o.Limit = 100 }, deezer.KindArtist, false},
{"album ignores zero limit", func(o *Options) { o.Limit = 0 }, deezer.KindAlbum, false},
{"track ignores zero limit", func(o *Options) { o.Limit = 0 }, deezer.KindTrack, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := valid
tt.mutate(&opts)
err := opts.Validate(tt.kind)
if (err != nil) != tt.wantErr {
t.Errorf("Validate(%s) error = %v, wantErr %v", tt.kind, err, tt.wantErr)
}
})
}
}
+119
View File
@@ -0,0 +1,119 @@
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()
}
}
}
func (*progressTracker) showSupportMessage() {
if rand.Float64() < 0.1 {
fmt.Println("\n⭐ Enjoying GoDeez? Star it on GitHub: https://github.com/mathismqn/godeez")
}
}
+36
View File
@@ -0,0 +1,36 @@
package download
import (
"context"
"github.com/mathismqn/godeez/internal/fsutil"
)
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
}
+105
View File
@@ -0,0 +1,105 @@
package download
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fsutil"
)
const chunkSize = 2048
func sweepPartFiles(dir string) {
matches, err := filepath.Glob(filepath.Join(dir, fsutil.PartPattern))
if err != nil {
return
}
for _, match := range matches {
os.Remove(match)
}
}
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
}
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 := 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:
}
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
}
if chunk%3 == 0 && totalRead == chunkSize {
buffer, err = deezer.DecryptBlowfish(buffer, key)
if err != nil {
return "", err
}
}
if _, err = file.Write(buffer[:totalRead]); err != nil {
return "", err
}
if totalRead < chunkSize {
break
}
}
if err := file.Sync(); err != nil {
return "", err
}
if err := file.Close(); err != nil {
return "", err
}
done = true
return tmpPath, nil
}
+40
View File
@@ -0,0 +1,40 @@
package download
import (
"strings"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/tag"
)
func buildTagMetadata(resource deezer.Resource, track *deezer.Track, cover []byte, bpm bpmKey, genre string) tag.Metadata {
m := tag.Metadata{
Title: track.FullTitle(),
Artists: strings.Join(track.Contributors.MainArtists, ", "),
Composers: strings.Join(track.Contributors.Composers, ", "),
Lyricists: strings.Join(track.Contributors.Authors, ", "),
Genre: genre,
BPM: bpm.BPM,
Key: bpm.Key,
TrackNumber: track.TrackNumber,
Duration: track.Duration,
Gain: track.Gain,
ISRC: track.ISRC,
Cover: cover,
}
if album, ok := resource.(*deezer.Album); ok {
data := album.Results.Data
m.Album = &tag.AlbumMetadata{
Artist: data.Artist,
Title: data.Title,
Label: data.Label,
OriginalReleaseDate: data.OriginalReleaseDate,
ReleaseDate: data.PhysicalReleaseDate,
ProducerLine: data.ProducerLine,
Copyright: data.Copyright,
}
}
return m
}
+137
View File
@@ -0,0 +1,137 @@
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"
)
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
if err := ctx.Err(); err != nil {
fsutil.Remove(outputPath)
return downloadResult{err: err}
}
warnings = append(warnings, metadata.warnings...)
warnings = append(warnings, d.finalizeDownload(resource, track, outputPath, outputFormat, metadata.genre, cover, metadata.bpmKey)...)
return downloadResult{warnings: warnings}
}
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
}
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
}
+34
View File
@@ -0,0 +1,34 @@
package fsutil
import (
"fmt"
"os"
)
const PartPattern = ".godeez-*.part"
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 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)
}
+57
View File
@@ -0,0 +1,57 @@
package store
import (
"encoding/json"
"errors"
"fmt"
"time"
"go.etcd.io/bbolt"
)
type DownloadInfo struct {
TrackID string `json:"song_id"`
Quality string `json:"quality"`
Path string `json:"path"`
Hash string `json:"hash"`
Downloaded time.Time `json:"downloaded_at"`
}
var trackBucket = []byte("tracks")
func (s *Store) DownloadInfo(trackID string) (*DownloadInfo, error) {
var info DownloadInfo
if err := s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(trackBucket)
if b == nil {
return errors.New("bucket not found")
}
data := b.Get([]byte(trackID))
if data == nil {
return errors.New("not found")
}
return json.Unmarshal(data, &info)
}); err != nil {
return nil, err
}
return &info, nil
}
func (s *Store) PutDownloadInfo(d *DownloadInfo) error {
return s.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(trackBucket)
if err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
data, err := json.Marshal(d)
if err != nil {
return err
}
return b.Put([]byte(d.TrackID), data)
})
}
+31
View File
@@ -0,0 +1,31 @@
package store
import (
"errors"
"fmt"
"path/filepath"
"time"
"go.etcd.io/bbolt"
bolterrors "go.etcd.io/bbolt/errors"
)
type Store struct {
db *bbolt.DB
}
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 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 &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestOpenPutGet(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
if _, err := os.Stat(filepath.Join(dir, ".tracks.db")); err != nil {
t.Errorf("expected .tracks.db to exist: %v", err)
}
info := &DownloadInfo{
TrackID: "123",
Quality: "MP3_320",
Path: "/music/track.mp3",
Hash: "abc",
Downloaded: time.Now().Truncate(time.Second),
}
if err := s.PutDownloadInfo(info); err != nil {
t.Fatalf("PutDownloadInfo: %v", err)
}
got, err := s.DownloadInfo("123")
if err != nil {
t.Fatalf("DownloadInfo: %v", err)
}
if got.TrackID != info.TrackID || got.Quality != info.Quality || got.Path != info.Path || got.Hash != info.Hash {
t.Errorf("DownloadInfo() = %+v, want %+v", got, info)
}
}
func TestDownloadInfoNotFound(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open: %v", err)
}
defer s.Close()
if _, err := s.DownloadInfo("missing"); err == nil {
t.Error("expected error for unknown track ID")
}
}
func TestClose(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := s.Close(); err != nil {
t.Errorf("Close: %v", err)
}
}
+85
View File
@@ -0,0 +1,85 @@
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()
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)
}
func (t *flacTagger) addTag(name, value string) {
if value != "" {
t.cmts.Add(name, value)
}
}
func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int) {
for idx, meta := range file.Meta {
if meta.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err == nil {
return cmt, idx
}
}
}
return nil, 0
}
+87
View File
@@ -0,0 +1,87 @@
package tag
import (
"strconv"
"strings"
"github.com/bogem/id3v2/v2"
)
type id3v2Tagger struct {
tag *id3v2.Tag
}
func (t *id3v2Tagger) write(m Metadata) error {
defer t.tag.Close()
applyID3Frames(t.tag, m)
return t.tag.Save()
}
func applyID3Frames(tag *id3v2.Tag, m Metadata) {
if m.Album != nil {
year := m.Album.ReleaseDate
if parts := strings.Split(year, "-"); len(parts) == 3 {
year = parts[0]
}
addID3Text(tag, "TRCK", m.TrackNumber)
addID3Text(tag, "TPE2", m.Album.Artist)
addID3Text(tag, "TALB", m.Album.Title)
addID3Text(tag, "TPUB", m.Album.Label)
addID3Text(tag, "TDOR", m.Album.OriginalReleaseDate)
addID3Text(tag, "TYER", year)
addID3Comment(tag, m.Album.ProducerLine)
addID3Text(tag, "TCOP", m.Album.Copyright)
}
addID3Text(tag, "TPE1", m.Artists)
addID3Text(tag, "TIT2", m.Title)
addID3Text(tag, "TCOM", m.Composers)
addID3Text(tag, "TEXT", m.Lyricists)
addID3Text(tag, "TCON", m.Genre)
if duration, err := strconv.Atoi(m.Duration); err == nil {
addID3Text(tag, "TLEN", strconv.Itoa(duration*1000))
}
addID3Text(tag, "TBPM", m.BPM)
addID3Text(tag, "TKEY", m.Key)
addID3TXXX(tag, "GAIN", m.Gain)
addID3TXXX(tag, "ISRC", m.ISRC)
if len(m.Cover) > 0 {
tag.AddAttachedPicture(id3v2.PictureFrame{
Encoding: tag.DefaultEncoding(),
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Cover",
Picture: m.Cover,
})
}
}
func addID3Text(tag *id3v2.Tag, name, value string) {
if value != "" {
tag.AddTextFrame(name, tag.DefaultEncoding(), value)
}
}
func addID3Comment(tag *id3v2.Tag, value string) {
if value != "" {
tag.AddCommentFrame(id3v2.CommentFrame{
Encoding: tag.DefaultEncoding(),
Language: "eng",
Text: value,
})
}
}
func addID3TXXX(tag *id3v2.Tag, description, value string) {
if value != "" {
tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
Encoding: tag.DefaultEncoding(),
Description: description,
Value: value,
})
}
}
+72
View File
@@ -0,0 +1,72 @@
package tag
import (
"path/filepath"
"github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac/v2"
)
type AlbumMetadata struct {
Artist string
Title string
Label string
OriginalReleaseDate string
ReleaseDate string
ProducerLine string
Copyright string
}
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
}
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)
}
+256
View File
@@ -0,0 +1,256 @@
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
}
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
}
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()
}
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})
}
}
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
}
func copyChunks(dst io.Writer, src io.Reader) (int64, error) {
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)
}
func skipPad(src io.Reader, payloadSize int64) error {
if payloadSize%2 == 0 {
return nil
}
if _, err := io.CopyN(io.Discard, src, 1); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
+246
View File
@@ -0,0 +1,246 @@
package tag
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func minimalWAV(audio []byte) []byte {
var body bytes.Buffer
body.WriteString("WAVE")
fmtPayload := make([]byte, 0, 16)
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 1)
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 2)
fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 44100)
fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 176400)
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 4)
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 16)
writeChunk(&body, "fmt ", fmtPayload)
writeChunk(&body, "data", audio)
var out bytes.Buffer
out.WriteString("RIFF")
binary.Write(&out, binary.LittleEndian, uint32(body.Len()))
out.Write(body.Bytes())
return out.Bytes()
}
func parseChunks(t *testing.T, data []byte) map[string][]byte {
t.Helper()
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
t.Fatalf("bad riff header: %q %q", data[0:4], data[8:12])
}
if size := binary.LittleEndian.Uint32(data[4:8]); int(size) != len(data)-8 {
t.Errorf("riff size = %d, want %d", size, len(data)-8)
}
chunks := make(map[string][]byte)
for offset := 12; offset < len(data); {
if offset%2 != 0 {
t.Errorf("chunk at offset %d is not word aligned", offset)
}
if offset+8 > len(data) {
t.Fatalf("truncated chunk header at offset %d", offset)
}
id := string(data[offset : offset+4])
size := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
if offset+8+size > len(data) {
t.Fatalf("chunk %q at offset %d overruns the file", id, offset)
}
if _, ok := chunks[id]; ok {
t.Errorf("duplicate %q chunk", id)
}
chunks[id] = data[offset+8 : offset+8+size]
offset += 8 + size + size%2
}
return chunks
}
func testMetadata() Metadata {
return Metadata{
Title: "Song",
Artists: "Artist",
Genre: "Rock",
BPM: "120",
Key: "Am",
TrackNumber: "3",
Duration: "215",
Gain: "-7.5",
ISRC: "FR1234567890",
Cover: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00},
Album: &AlbumMetadata{
Artist: "Album Artist",
Title: "Album",
Label: "Label",
ReleaseDate: "2024-05-01",
ProducerLine: "Producer line",
Copyright: "Copyright",
},
}
}
func writeTestWAV(t *testing.T, audio []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "track.wav")
if err := os.WriteFile(path, minimalWAV(audio), 0644); err != nil {
t.Fatalf("write wav: %v", err)
}
return path
}
func TestWriteWAV(t *testing.T) {
audio := bytes.Repeat([]byte{0x11, 0x22, 0x33, 0x44}, 16)
path := writeTestWAV(t, audio)
original := parseChunks(t, minimalWAV(audio))
if err := Write(path, testMetadata()); err != nil {
t.Fatalf("Write() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read wav: %v", err)
}
chunks := parseChunks(t, data)
if !bytes.Equal(chunks["data"], original["data"]) {
t.Error("data chunk was modified")
}
if !bytes.Equal(chunks["fmt "], original["fmt "]) {
t.Error("fmt chunk was modified")
}
id3, ok := chunks["id3 "]
if !ok {
t.Fatal("missing id3 chunk")
}
if string(id3[0:3]) != "ID3" {
t.Errorf("id3 chunk does not start with an ID3 header: %q", id3[0:3])
}
for _, want := range []string{"Song", "Artist", "Album", "120", "Am", "FR1234567890", "Rock"} {
if !bytes.Contains(id3, []byte(want)) {
t.Errorf("id3 chunk is missing %q", want)
}
}
if !bytes.Contains(id3, []byte{0xff, 0xd8, 0xff, 0xe0}) {
t.Error("id3 chunk is missing the cover art")
}
list, ok := chunks["LIST"]
if !ok {
t.Fatal("missing LIST chunk")
}
if string(list[0:4]) != "INFO" {
t.Errorf("LIST form = %q, want \"INFO\"", list[0:4])
}
for _, want := range []struct{ id, value string }{
{"INAM", "Song"},
{"IART", "Artist"},
{"IGNR", "Rock"},
{"ITRK", "3"},
{"IPRD", "Album"},
{"ICRD", "2024"},
{"ICMT", "Producer line"},
{"ICOP", "Copyright"},
} {
if !bytes.Contains(list, append([]byte(want.id), append([]byte{byte(len(want.value) + 1), 0, 0, 0}, want.value...)...)) {
t.Errorf("LIST chunk is missing %s = %q", want.id, want.value)
}
}
}
func TestWriteWAVIsIdempotent(t *testing.T) {
audio := bytes.Repeat([]byte{0x01, 0x02}, 32)
path := writeTestWAV(t, audio)
if err := Write(path, testMetadata()); err != nil {
t.Fatalf("first Write() error = %v", err)
}
first, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read wav: %v", err)
}
firstChunks := parseChunks(t, first)
if err := Write(path, testMetadata()); err != nil {
t.Fatalf("second Write() error = %v", err)
}
second, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read wav: %v", err)
}
secondChunks := parseChunks(t, second)
if len(first) != len(second) {
t.Errorf("re-tagging changed the file size: %d bytes then %d bytes", len(first), len(second))
}
if len(firstChunks) != len(secondChunks) {
t.Errorf("chunk count = %d, want %d", len(secondChunks), len(firstChunks))
}
for id, payload := range firstChunks {
got, ok := secondChunks[id]
if !ok {
t.Errorf("re-tagging dropped the %q chunk", id)
continue
}
if len(got) != len(payload) {
t.Errorf("%q chunk size = %d, want %d", id, len(got), len(payload))
}
}
if !bytes.Equal(secondChunks["data"], audio) {
t.Error("data chunk was modified")
}
if !bytes.Equal(secondChunks["fmt "], firstChunks["fmt "]) {
t.Error("fmt chunk was modified")
}
if !bytes.Equal(secondChunks["LIST"], firstChunks["LIST"]) {
t.Error("LIST chunk was modified")
}
}
func TestWriteWAVOddSizedChunks(t *testing.T) {
audio := bytes.Repeat([]byte{0x07}, 33)
path := writeTestWAV(t, audio)
if err := Write(path, testMetadata()); err != nil {
t.Fatalf("Write() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read wav: %v", err)
}
chunks := parseChunks(t, data)
if !bytes.Equal(chunks["data"], audio) {
t.Error("data chunk was modified")
}
if _, ok := chunks["id3 "]; !ok {
t.Error("missing id3 chunk")
}
}
func TestWriteWAVRejectsNonWAV(t *testing.T) {
path := filepath.Join(t.TempDir(), "track.wav")
if err := os.WriteFile(path, []byte("this is not a wav file at all"), 0644); err != nil {
t.Fatalf("write file: %v", err)
}
if err := Write(path, testMetadata()); err == nil {
t.Error("Write() error = nil, want error")
}
if _, err := os.Stat(path + ".tmp"); err == nil {
t.Error("Write() left a temp file behind")
}
}
-87
View File
@@ -1,87 +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 string) error {
if album, ok := resource.(*deezer.Album); ok {
dateParts := strings.Split(album.Data.PhysicalReleaseDate, "-")
if len(dateParts) == 3 {
album.Data.PhysicalReleaseDate = dateParts[0]
}
t.addTag("ALBUM", album.Data.Title)
t.addTag("ALBUMARTIST", album.Data.Artist)
t.addTag("PUBLISHER", album.Data.Label)
t.addTag("ORIGINALDATE", album.Data.OriginalReleaseDate)
t.addTag("DATE", album.Data.PhysicalReleaseDate)
t.addTag("COMMENT", album.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)
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
}
-67
View File
@@ -1,67 +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 string) error {
defer t.Tag.Close()
duration, _ := strconv.Atoi(song.Duration)
song.Duration = fmt.Sprintf("%d", duration*1000)
if album, ok := resource.(*deezer.Album); ok {
t.addTag("TALB", album.Data.Title)
t.addTag("TPE2", album.Data.Artist)
t.addTag("TPUB", album.Data.Label)
t.addTag("TDOR", album.Data.OriginalReleaseDate)
t.addTag("TYER", album.Data.PhysicalReleaseDate)
t.addTag("COMM", album.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)
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)
}
}
-52
View File
@@ -1,52 +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 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, filePath string) error {
tagger, err := NewTagger(filePath)
if err != nil {
return err
}
cover, err := song.GetCoverImage()
if err != nil {
return err
}
return tagger.AddTags(resource, song, cover, filePath)
}
+211
View File
@@ -0,0 +1,211 @@
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"
)
var managedPrefixes = []string{
"/nix/store",
"/opt/homebrew",
"/usr/local/Cellar",
"/home/linuxbrew",
"/snap",
"/var/lib/flatpak",
}
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
}
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
}
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)
}
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)
}
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
}
func (u *Updater) replaceBinary(target, tmp string) error {
if runtime.GOOS != "windows" {
return os.Rename(tmp, target)
}
old := target + ".old"
os.Remove(old)
if err := os.Rename(target, old); err != nil {
return fmt.Errorf("failed to move the current binary aside: %w", err)
}
if err := os.Rename(tmp, target); err != nil {
if rollbackErr := os.Rename(old, target); rollbackErr != nil {
return fmt.Errorf("failed to install the new binary: %w; the previous one could not be restored from %s: %v",
err, old, rollbackErr)
}
return fmt.Errorf("failed to install the new binary: %w", err)
}
os.Remove(old)
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package update
import (
"strings"
"testing"
)
func TestParseChecksums(t *testing.T) {
checksums := `ABCDEF0123 godeez_1.0.0_darwin_arm64
deadbeef *godeez_1.0.0_linux_amd64
malformed-line
one two three
cafebabe godeez_1.0.0_windows_amd64.exe
`
tests := []struct {
name string
asset string
want string
}{
{"plain name lowercased", "godeez_1.0.0_darwin_arm64", "abcdef0123"},
{"star-prefixed name", "godeez_1.0.0_linux_amd64", "deadbeef"},
{"windows asset", "godeez_1.0.0_windows_amd64.exe", "cafebabe"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseChecksums(strings.NewReader(checksums), tt.asset)
if err != nil {
t.Fatalf("parseChecksums: %v", err)
}
if got != tt.want {
t.Errorf("parseChecksums() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseChecksumsMissing(t *testing.T) {
if _, err := parseChecksums(strings.NewReader("abc other_asset\n"), "godeez_1.0.0_darwin_arm64"); err == nil {
t.Error("expected error for missing asset name")
}
}
+118
View File
@@ -0,0 +1,118 @@
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 = 24 * time.Hour
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
}
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)
}
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 ""
}
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
}
+83
View File
@@ -0,0 +1,83 @@
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 = 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
}
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
}
+56
View File
@@ -0,0 +1,56 @@
package update
import (
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/mathismqn/godeez/internal/buildinfo"
)
const (
apiTimeout = 30 * time.Second
downloadTimeout = 5 * time.Minute
tmpPattern = ".godeez-update-*"
)
type Updater struct {
client *http.Client
Out io.Writer
}
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
}
+35
View File
@@ -0,0 +1,35 @@
package update
import (
"strings"
"golang.org/x/mod/semver"
)
func trimV(v string) string {
return strings.TrimPrefix(strings.TrimSpace(v), "v")
}
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
}
func IsNewer(current, latest string) bool {
c, l := canonical(current), canonical(latest)
if c == "" || l == "" {
return false
}
return semver.Compare(l, c) > 0
}
+65
View File
@@ -0,0 +1,65 @@
package update
import "testing"
func TestIsNewer(t *testing.T) {
tests := []struct {
current string
latest string
want bool
}{
{"1.0.0", "1.0.1", true},
{"v1.0.0", "v1.1.0", true},
{"1.0.0", "v2.0.0", true},
{"1.0.0", "1.0.0", false},
{"1.1.0", "1.0.0", false},
{" 1.0.0 ", "1.0.1", true},
{"1.0.0", "1.0.1-rc.1", true},
{"1.0.0-rc.1", "1.0.0", true},
{"dev", "1.0.0", false},
{"1.0.0", "not-a-version", false},
{"", "1.0.0", false},
{"1.0.0", "", false},
}
for _, tt := range tests {
if got := IsNewer(tt.current, tt.latest); got != tt.want {
t.Errorf("IsNewer(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want)
}
}
}
func TestTrimV(t *testing.T) {
tests := []struct {
in string
want string
}{
{"v1.2.3", "1.2.3"},
{"1.2.3", "1.2.3"},
{" v1.2.3 ", "1.2.3"},
}
for _, tt := range tests {
if got := trimV(tt.in); got != tt.want {
t.Errorf("trimV(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestCanonical(t *testing.T) {
tests := []struct {
in string
want string
}{
{"1.2.3", "v1.2.3"},
{"v1.2.3", "v1.2.3"},
{"", ""},
{"garbage", ""},
}
for _, tt := range tests {
if got := canonical(tt.in); got != tt.want {
t.Errorf("canonical(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
+14 -2
View File
@@ -1,7 +1,19 @@
package main package main
import "github.com/mathismqn/godeez/cmd" import (
"context"
"os"
"os/signal"
"github.com/mathismqn/godeez/cmd"
)
func main() { func main() {
cmd.RootCmd.Execute() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if err := cmd.Execute(ctx); err != nil {
stop()
os.Exit(1)
}
} }