42 Commits
Author SHA1 Message Date
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
33 changed files with 1309 additions and 858 deletions
+69 -9
View File
@@ -5,21 +5,81 @@ 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.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 songs.
- 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 320kbps**.
### 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 128kbps** for free accounts).
## [1.1.0] - 2025-05-19
### Added
- Support for downloading full albums and playlists with more than 40 tracks (previous limit removed).
- Option to fetch and embed **BPM** and **musical key** into metadata tags.
- New local **database system** (`tracks.db`) to track downloaded files and avoid re-downloading, even if files are renamed or moved.
- Improved CLI **output formatting** for a cleaner and more informative user experience.
- Support downloading full albums and playlists with more than 40 tracks (previous limit removed).
- Fetch and embed **BPM** and **musical key** into metadata tags.
- Add local **database system** (`tracks.db`) to track downloaded files and avoid re-downloading, even if files are renamed or moved.
- Improve CLI **output formatting** for a cleaner and more informative user experience.
### Changed
- The `.godeez` file in the users home directory has been replaced by a `.godeez/` directory.
It now stores both `config.toml` and the internal `tracks.db`.
If you're upgrading from an older version, move your existing config into `.godeez/config.toml`.
- Simplified `config.toml`: `iv` and `license_token` are no longer required.
- Cleanup logic: corrupted or incomplete files are now automatically deleted on download failure.
- Replace the `godeez` file in the 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
+75 -63
View File
@@ -13,85 +13,68 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
[Configuration](#configuration) •
[Usage](#usage) •
[Contributing](#contributing) •
[Support](#⭐-support-the-project) •
[License](#license)
</div>
## Features
* Download playlists and albums from Deezer
* Select audio quality: MP3 128kbps, MP3 320kbps, or FLAC
* Automatically adds metadata tags to downloaded files
* Fetch and tag songs with BPM and musical key
* Smart skip system: avoids re-downloading already existing files using hashes and metadata
* Cross-platform support (works on Windows, macOS, and Linux)
* Simple and easy-to-use CLI
- Download playlists, albums, artists top tracks, and individual tracks
- Choose audio quality: **MP3 128kbps**, **MP3 320kbps** (default), or **FLAC** (⚠️ nonpremium accounts are limited to 128kbps)
- Automatically embed metadata tags (artist, album, title, artwork, etc.)
- Fetch and tag songs with **BPM**, **musical key**, and **genre**
- Skip already-downloaded files using hashes and metadata
- Support Windows, macOS, and Linux
- Provide a simple, easy-to-use CLI
## Installation
To install **GoDeez**, simply download the latest binary for your platform from the Releases page.
To install **GoDeez**, download the latest binary for your platform from 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).
3. Move the binary to a directory included in $PATH for easy access (optional but recommended).
3. (Optional) Move the binary to a directory included in `$PATH` for easier access.
Example (Linux/macOS):
```bash
# Move the downloaded binary to /usr/local/bin for easy access from anywhere
mv godeez-1.1.0-linux-amd64 /usr/local/bin/godeez
mv godeez-1.4.0-linux-amd64 /usr/local/bin/godeez
```
## Configuration
The first time you run **GoDeez**, a configuration directory named `.godeez` will be automatically created in your home directory (`$HOME` on Linux/macOS, `%USERPROFILE%` on Windows).
**GoDeez** requires a Deezer ARL cookie for authentication. Set it as an environment variable:
Inside this directory:
- `config.toml`: main configuration file which contains several important variables that you need to fill out manually
- `tracks.db`: internal database used to track downloaded files and prevent duplicates
### Steps to configure
1. Run the application for the first time: this creates the `.godeez` directory and the `config.toml` file inside it.
2. Edit the `config.toml` file with a text editor to set the required values.
### Variables to configure
Here are the key variables you need to set in `config.toml`:
1. `arl_cookie`
* **What is it?**: The `arl_cookie` is a session cookie used for authentication with Deezer. Without this cookie, the downloader cannot access your account to retrieve playlists, albums, or songs.
* **How to retrieve it**:
1. Open your browser and log in to your Deezer account.
2. Open the Developer Tools (right-click on the page and select “Inspect” or press F12).
3. Navigate to the Application tab (in Chrome/Edge) or Storage tab (in Firefox).
4. In the left panel, look for Cookies and select `https://www.deezer.com`.
5. Find the arl cookie and copy its value.
2. `secret_key`
* **What is it?**: The `secret_key` is a cryptographic value used to decrypt Deezers media files.
* **How to retrieve it?**: While we cannot provide the specific secret_key in this documentation, it can be found online through various sources or developer communities that focus on Deezer.
3. `output_dir` (optional)
* **What is it?**: The `output_dir` is the path where downloaded music files will be saved.
* **Default**: If left empty, it defaults to `~/Music/GoDeez`.
* **Note**: Once set, it's recommended not to change it, as this may interfere with the skip system that relies on consistent file paths and hash indexing to detect already downloaded songs.
### Example
Here's an example of a minimal `config.toml` you can customize:
```toml
# ~/.godeez/config.toml
arl_cookie = 'your_arl_cookie_here'
secret_key = 'your_secret_key_here'
output_dir = '' # optional
```bash
export DEEZER_ARL=”your_arl_cookie_here”
```
To make it persistent, add the line above to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.).
### How to retrieve your ARL cookie
1. Open your browser and log in to your [Deezer](https://www.deezer.com) account.
2. Open the Developer Tools (right-click on the page and select “Inspect” or press F12).
3. Navigate to the **Application** tab (Chrome/Edge) or **Storage** tab (Firefox).
4. In the left panel, look for **Cookies** and select `https://www.deezer.com`.
5. Find the `arl` cookie and copy its value.
> **Note:** The ARL cookie may expire after some time. If you get authentication errors, retrieve a fresh cookie using the steps above.
### Output directory
Downloaded files are saved to `~/Music/GoDeez`. The download database (`.tracks.db`) is stored alongside your music in the output directory.
> **Upgrading from v1.3.0?** The `~/.godeez` directory and `config.toml` are no longer used. Set the `DEEZER_ARL` environment variable instead. Your existing database will be migrated automatically on first run.
## Usage
### 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
GoDeez is a tool to download music from Deezer
@@ -104,14 +87,13 @@ Available Commands:
help Help about any command
Flags:
--config string config file (default ~/.godeez/config.toml)
-h, --help help for godeez
-h, --help help for godeez
Use "godeez [command] --help" for more information about a command.
```
This provides an overview of the available commands and flags.
To download music, you need to use the download command. Heres how the CLI looks when you run `godeez download`:
### Download commands
```bash
Download songs from Deezer
@@ -119,28 +101,58 @@ Usage:
godeez download [command]
Available Commands:
album Download songs from album
playlist Download songs from playlist
album Download songs from an album
artist Download top songs from an artist
playlist Download songs from a playlist
track Download a single track
Flags:
--bpm fetch BPM/key and add to file tags
--config string config file (default ~/.godeez/config.toml)
--genre fetch genre and add to file tags
-h, --help help for download
-q, --quality string download quality [mp3_128, mp3_320, flac, best] (default "best")
-q, --quality string download quality [mp3_128, mp3_320, flac] (default "mp3_320")
--strict fail the song download if the quality is not available
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
Global Flags:
--config string config file (default ~/.godeez/config.toml)
Use "godeez download [command] --help" for more information about a command.
```
### Examples
```bash
# Download an album
godeez download album 12345678
# Download a playlist
godeez download playlist 87654321
# Download top tracks from an artist
godeez download artist 11223344 --limit 5
# Download a single track
godeez download track 98765432
# Download with specific quality, BPM and genre data
godeez download track 98765432 --quality flac --bpm --genre
```
## Contributing
Contributions help make **GoDeez** a better tool for everyone, and any help is greatly appreciated.
Whether 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.
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 us?**
- Helps more music lovers discover the project
- Shows appreciation for the work and motivates development
- Takes just one click but means the world to us!
## License
+41 -12
View File
@@ -4,12 +4,18 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/downloader"
"github.com/spf13/cobra"
)
type contextKey string
const appConfigKey contextKey = "appConfig"
var opts downloader.Options
var downloadCmd = &cobra.Command{
@@ -20,39 +26,62 @@ var downloadCmd = &cobra.Command{
func init() {
RootCmd.AddCommand(downloadCmd)
downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "best", "download quality [mp3_128, mp3_320, flac, best]")
downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]")
downloadCmd.PersistentFlags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
downloadCmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
downloadCmd.PersistentFlags().BoolVar(&opts.Genre, "genre", false, "fetch genre and add to file tags")
downloadCmd.PersistentFlags().BoolVar(&opts.Strict, "strict", false, "fail the song download if the quality is not available")
downloadCmd.AddCommand(
newDownloadCmd("album"),
newDownloadCmd("playlist"),
newDownloadCmd("artist"),
newDownloadCmd("track"),
)
}
func newDownloadCmd(resourceType string) *cobra.Command {
cmd := &cobra.Command{
Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType),
Short: fmt.Sprintf("Download songs from %s", resourceType),
Short: downloadShort(resourceType),
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
appConfig, err := config.New()
if err != nil {
return err
}
cmd.SetContext(context.WithValue(cmd.Context(), appConfigKey, appConfig))
opts.Quality = strings.ToLower(opts.Quality)
return opts.Validate()
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dl := downloader.New(appConfig, resourceType)
appConfig := cmd.Context().Value(appConfigKey).(*config.Config)
if err := dl.Run(ctx, opts, args[0]); err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
return err
err := downloader.New(appConfig, resourceType).Run(cmd.Context(), opts, args[0])
if errors.Is(err, context.Canceled) {
return nil
}
return nil
return err
},
}
if resourceType == "artist" {
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download")
}
return cmd
}
func downloadShort(resourceType string) string {
switch resourceType {
case "artist":
return "Download top songs from an artist"
case "track":
return "Download a single track"
case "album":
return "Download songs from an album"
default:
return fmt.Sprintf("Download songs from a %s", resourceType)
}
}
-16
View File
@@ -1,27 +1,11 @@
package cmd
import (
"github.com/mathismqn/godeez/internal/config"
"github.com/spf13/cobra"
)
var (
cfgPath string
appConfig *config.Config
)
var RootCmd = &cobra.Command{
Use: "godeez",
Short: "GoDeez is a tool to download music from Deezer",
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
var err error
appConfig, err = config.New(cfgPath)
return err
},
}
func init() {
RootCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "config file (default ~/.godeez/config.toml)")
}
+17 -35
View File
@@ -1,49 +1,31 @@
module github.com/mathismqn/godeez
go 1.23.1
go 1.25.0
require github.com/spf13/cobra v1.10.2
require (
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
)
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/fatih/color v1.14.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/PuerkitoBio/goquery v1.10.0
github.com/PuerkitoBio/goquery v1.11.0
github.com/bogem/id3v2/v2 v2.1.4
github.com/briandowns/spinner v1.23.2
github.com/flytam/filenamify v1.2.0
github.com/go-flac/flacpicture/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/spf13/pflag v1.0.6 // indirect
go.etcd.io/bbolt v1.4.0
golang.org/x/crypto v0.28.0
github.com/spf13/pflag v1.0.10 // indirect
go.etcd.io/bbolt v1.4.3
golang.org/x/crypto v0.48.0
)
+71 -97
View File
@@ -1,148 +1,122 @@
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.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/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac/v2 v2.0.1 h1:1zilNkbmmpK9DLsz2NbjLHG8avOmthYqUfVc9YKB/Ps=
github.com/go-flac/go-flac/v2 v2.0.1/go.mod h1:hvgeR2hElLbwk0Q1/vMazIDmIc2LAFSd9Bx/Fk6ViKo=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/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.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/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
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/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-176
View File
@@ -1,176 +0,0 @@
package bpm
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
)
type Metrics struct {
BPM string
Key string
}
func FetchMetrics(ctx context.Context, httpClient *http.Client, artist, title, duration string) (*Metrics, error) {
url, err := findSongURL(ctx, httpClient, artist, title, duration)
if err != nil {
return nil, err
}
html, err := fetchPage(ctx, httpClient, url)
if err != nil {
return nil, err
}
return parseMetrics(html)
}
func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
rootUrl := "https://songbpm.com"
reqUrl := rootUrl + "/searches"
values := url.Values{}
values.Add("query", fmt.Sprintf("%s %s", artist, title))
req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBufferString(values.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", "https://songbpm.com")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return "", err
}
var (
found bool
url string
)
doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, selection *goquery.Selection) bool {
lowerSelection := strings.ToLower(selection.Text())
lowerTitle := strings.ToLower(title)
lowerArtist := strings.ToLower(artist)
if !strings.Contains(lowerSelection, lowerTitle) || !strings.Contains(lowerSelection, lowerArtist) {
return true
}
durationStr := strings.TrimSpace(selection.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text())
parts := strings.Split(durationStr, ":")
if len(parts) != 2 {
return true
}
minutes, err := strconv.Atoi(parts[0])
if err != nil {
return true
}
seconds, err := strconv.Atoi(parts[1])
if err != nil {
return true
}
foundDuration := minutes*60 + seconds
duration, err := strconv.Atoi(duration)
if err != nil {
return true
}
if foundDuration <= (duration-2) || foundDuration >= (duration+2) {
return true
}
url = selection.AttrOr("href", "")
found = true
return false
})
if !found {
return "", fmt.Errorf("no data found")
}
return rootUrl + url, nil
}
func fetchPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func parseMetrics(html string) (*Metrics, error) {
bpmRegex := regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
bpmMatch := bpmRegex.FindStringSubmatch(html)
keyRegex := regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
keyMatch := keyRegex.FindStringSubmatch(html)
modeRegex := regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
modeMatch := modeRegex.FindStringSubmatch(html)
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
return nil, fmt.Errorf("no data found")
}
isMinor := false
bpm := bpmMatch[1]
key := keyMatch[1]
if modeMatch[1] == "minor" {
isMinor = true
}
if strings.Contains(key, "/") {
parts := strings.Split(key, "/")
key = parts[0]
}
key = strings.ReplaceAll(key, "♯", "#")
key = strings.ReplaceAll(key, "♭", "b")
if isMinor && !strings.HasSuffix(key, "m") {
key += "m"
}
return &Metrics{
BPM: bpm,
Key: key,
}, nil
}
+28 -53
View File
@@ -3,80 +3,55 @@ package config
import (
"fmt"
"os"
"path"
"path/filepath"
"github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/viper"
)
type Config struct {
ArlCookie string `mapstructure:"arl_cookie"`
SecretKey string `mapstructure:"secret_key"`
OutputDir string `mapstructure:"output_dir"`
ArlCookie string
OutputDir string
HomeDir string
}
func New(cfgPath string) (*Config, error) {
func New() (*Config, error) {
arl := os.Getenv("DEEZER_ARL")
if arl == "" {
return nil, fmt.Errorf("DEEZER_ARL is not set. Export it in your shell: export DEEZER_ARL='your_cookie'")
}
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
cfgDir := filepath.Join(homeDir, ".godeez")
if err := fileutil.EnsureDir(cfgDir); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
outputDir := filepath.Join(homeDir, "Music", "GoDeez")
if err := fileutil.EnsureDir(outputDir); err != nil {
return nil, fmt.Errorf("failed to create output directory: %w", err)
}
if cfgPath == "" {
cfgPath = path.Join(cfgDir, "config.toml")
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
fmt.Printf("Config file not found, creating one at %s\n", cfgPath)
content := []byte("arl_cookie = ''\nsecret_key = ''\noutput_dir = ''\n")
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
return nil, fmt.Errorf("failed to create config file: %w", err)
}
os.Exit(0)
// Migrate tracks.db from ~/.godeez/ to output dir
oldDB := filepath.Join(homeDir, ".godeez", "tracks.db")
newDB := filepath.Join(outputDir, ".tracks.db")
if _, err := os.Stat(oldDB); err == nil {
if _, err := os.Stat(newDB); os.IsNotExist(err) {
os.Rename(oldDB, newDB)
}
}
viper.SetConfigFile(cfgPath)
viper.SetConfigType("toml")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Clean up old config directory
oldDir := filepath.Join(homeDir, ".godeez")
os.Remove(filepath.Join(oldDir, "config.toml"))
os.Remove(oldDir) // fails silently if not empty
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := cfg.Validate(homeDir); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
if err := store.OpenDB(cfgDir); err != nil {
if err := store.OpenDB(outputDir); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *Config) Validate(homeDir string) error {
if c.ArlCookie == "" {
return fmt.Errorf("arl_cookie is not set")
}
if c.SecretKey == "" {
return fmt.Errorf("secret_key is not set")
}
if len(c.SecretKey) != 16 {
return fmt.Errorf("secret_key must be 16 bytes long")
}
if c.OutputDir == "" {
c.OutputDir = filepath.Join(homeDir, "Music", "GoDeez")
}
return nil
return &Config{
ArlCookie: arl,
OutputDir: outputDir,
HomeDir: homeDir,
}, nil
}
+10 -7
View File
@@ -3,18 +3,22 @@ package crypto
import (
"crypto/cipher"
"crypto/md5"
"fmt"
"encoding/hex"
"golang.org/x/crypto/blowfish"
)
var iv = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
var (
iv = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
secretKey = []byte("g4el58wc0zvf9na1")
)
func GetKey(secretKey, songID string) []byte {
func GetKey(songID string) []byte {
hash := md5.Sum([]byte(songID))
hashHex := fmt.Sprintf("%x", hash)
hashHex := hex.EncodeToString(hash[:])
key := []byte(secretKey)
key := make([]byte, len(secretKey))
copy(key, secretKey)
for i := 0; i < len(hash); i++ {
key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16]
}
@@ -28,9 +32,8 @@ func Decrypt(data, key []byte) ([]byte, error) {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data)
cipher.NewCBCDecrypter(block, iv).CryptBlocks(decrypted, data)
return decrypted, nil
}
+6 -3
View File
@@ -19,6 +19,7 @@ type Album struct {
PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"`
Label string `json:"LABEL_NAME"`
ProducerLine string `json:"PRODUCER_LINE"`
Copyright string `json:"COPYRIGHT"`
Duration string `json:"DURATION"`
} `json:"DATA"`
Songs struct {
@@ -59,12 +60,14 @@ func (a *Album) GetSongs() []*Song {
return a.Results.Songs.Data
}
func (a *Album) SetSongs(s []*Song) {
a.Results.Songs.Data = s
}
func (a *Album) GetOutputDir(outputDir string) string {
base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{})
outputDir = path.Join(outputDir, base)
return outputDir
return path.Join(outputDir, base)
}
func (a *Album) Unmarshal(data []byte) error {
+77
View File
@@ -0,0 +1,77 @@
package deezer
import (
"encoding/json"
"fmt"
"path"
"strconv"
"strings"
"time"
"github.com/flytam/filenamify"
)
type Artist struct {
Results struct {
Data struct {
Name string `json:"ART_NAME"`
} `json:"DATA"`
Songs struct {
Data []*Song `json:"data"`
} `json:"TOP"`
} `json:"results"`
}
func (a *Artist) String() string {
songs := a.Results.Songs.Data
count := len(songs)
totalSec := 0
for _, s := range songs {
if d, err := strconv.Atoi(s.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++ {
s := songs[i]
fmt.Fprintf(&b, " %2d. %s %s\n", i+1, s.Artist, s.GetTitle())
}
fmt.Fprintf(&b, "===========================================\n")
return b.String()
}
func (a *Artist) GetType() string {
return "Artist"
}
func (a *Artist) GetTitle() string {
return a.Results.Data.Name
}
func (a *Artist) GetSongs() []*Song {
return a.Results.Songs.Data
}
func (a *Artist) SetSongs(s []*Song) {
a.Results.Songs.Data = s
}
func (a *Artist) GetOutputDir(outputDir string) string {
base, _ := filenamify.Filenamify(a.Results.Data.Name, filenamify.Options{})
return path.Join(outputDir, base)
}
func (a *Artist) Unmarshal(data []byte) error {
return json.Unmarshal(data, a)
}
+53 -40
View File
@@ -29,23 +29,37 @@ func NewClient(ctx context.Context, appConfig *config.Config) (*Client, error) {
}, nil
}
func (c *Client) FetchResource(ctx context.Context, ressource Resource, id string) error {
func (c *Client) FetchResource(ctx context.Context, resource Resource, id string) error {
payload := map[string]interface{}{
"nb": 10000,
"start": 0,
"playlist_id": id,
"alb_id": id,
"lang": "en",
"tab": 0,
"tags": true,
"header": true,
"nb": 10000,
"start": 0,
"lang": "en",
"tab": 0,
"tags": true,
"header": true,
}
var idKey string
switch resource.(type) {
case *Playlist:
idKey = "playlist_id"
case *Album:
idKey = "alb_id"
case *Artist:
idKey = "art_id"
case *Track:
idKey = "sng_id"
default:
return fmt.Errorf("unsupported resource type: %T", resource)
}
payload[idKey] = id
jsonData, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", ressource.GetType(), c.Session.APIToken)
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", resource.GetType(), c.Session.APIToken)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
@@ -66,34 +80,36 @@ func (c *Client) FetchResource(ctx context.Context, ressource Resource, id strin
return err
}
if strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`) {
return fmt.Errorf("invalid playlist ID")
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 fmt.Errorf("%s", check.errMsg)
}
}
if strings.Contains(string(body), `"DATA_ERROR":"album::getData"`) {
return fmt.Errorf("invalid album ID")
}
if strings.Contains(string(body), `"results":{}`) {
if strings.Contains(bodyStr, `"results":{}`) {
return fmt.Errorf("unexpected response")
}
return ressource.Unmarshal(body)
return resource.Unmarshal(body)
}
func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*Media, error) {
var formats string
switch quality {
case "mp3_128":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "mp3_320":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"}]`
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"}]`
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, formats, song.TrackToken)
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, qualityFormats[quality], song.TrackToken)
req, err := http.NewRequestWithContext(ctx, "POST", "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
if err != nil {
return nil, err
@@ -115,8 +131,7 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
}
var media Media
err = json.Unmarshal(body, &media)
if err != nil {
if err := json.Unmarshal(body, &media); err != nil {
return nil, err
}
@@ -124,17 +139,20 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
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)
}
if len(media.Data) == 0 || len(media.Data[0].Media) == 0 || len(media.Data[0].Media[0].Sources) == 0 {
return nil, fmt.Errorf("no sources found")
}
return &media, nil
}
@@ -158,13 +176,8 @@ func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error
return io.ReadAll(resp.Body)
}
func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) {
url, err := media.GetURL()
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
func (c *Client) GetMediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "GET", media.GetURL(), nil)
if err != nil {
return nil, err
}
+11 -40
View File
@@ -1,56 +1,27 @@
package deezer
import (
"fmt"
)
type Media struct {
Errors []MediaError `json:"errors"`
Errors []mediaError `json:"errors"`
Data []struct {
Media []struct {
Type string `json:"media_type"`
Cipher Cipher `json:"cipher"`
Format string `json:"format"`
Sources []Source `json:"sources"`
Format string `json:"format"`
Sources []struct {
URL string `json:"url"`
} `json:"sources"`
}
Errors []MediaError `json:"errors"`
Errors []mediaError `json:"errors"`
} `json:"data"`
}
type MediaError struct {
type mediaError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type Cipher struct {
Type string `json:"type"`
func (m *Media) GetURL() string {
return m.Data[0].Media[0].Sources[0].URL
}
type Source struct {
URL string `json:"url"`
Provider string `json:"provider"`
}
func (m *Media) GetURL() (string, error) {
if len(m.Data) == 0 || len(m.Data[0].Media) == 0 || len(m.Data[0].Media[0].Sources) == 0 {
return "", fmt.Errorf("no media sources found")
}
url := m.Data[0].Media[0].Sources[0].URL
for _, source := range m.Data[0].Media[0].Sources {
if source.Provider == "ak" {
url = source.URL
break
}
}
return url, nil
}
func (m *Media) GetFormat() (string, error) {
if len(m.Data) == 0 || len(m.Data[0].Media) == 0 {
return "", fmt.Errorf("no media format found")
}
return m.Data[0].Media[0].Format, nil
func (m *Media) GetFormat() string {
return m.Data[0].Media[0].Format
}
+6 -5
View File
@@ -13,7 +13,6 @@ type Playlist struct {
Results struct {
Data struct {
Title string `json:"TITLE"`
Status int `json:"STATUS"`
Creator string `json:"PARENT_USERNAME"`
Duration int `json:"DURATION"`
} `json:"DATA"`
@@ -50,11 +49,13 @@ func (p *Playlist) GetSongs() []*Song {
return p.Results.Songs.Data
}
func (p *Playlist) GetOutputDir(outputDir string) string {
p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
outputDir = path.Join(outputDir, p.Results.Data.Title)
func (p *Playlist) SetSongs(s []*Song) {
p.Results.Songs.Data = s
}
return outputDir
func (p *Playlist) GetOutputDir(outputDir string) string {
base, _ := filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
return path.Join(outputDir, base)
}
func (p *Playlist) Unmarshal(data []byte) error {
+1
View File
@@ -4,6 +4,7 @@ type Resource interface {
GetTitle() string
GetType() string
GetSongs() []*Song
SetSongs(songs []*Song)
GetOutputDir(outputDir string) string
Unmarshal(data []byte) error
}
+18 -22
View File
@@ -10,25 +10,11 @@ import (
"time"
)
type UserDataResponse struct {
Results struct {
APIToken string `json:"checkForm"`
User struct {
Id int `json:"USER_ID"`
Options struct {
LicenseToken string `json:"license_token"`
MobileOffline bool `json:"mobile_offline"`
WebOffline bool `json:"web_offline"`
} `json:"OPTIONS"`
} `json:"USER"`
} `json:"results"`
}
type Session struct {
ArlCookie string
APIToken string
LicenseToken string
HttpClient *http.Client
Premium bool
}
func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
@@ -67,22 +53,32 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
return nil, err
}
var res UserDataResponse
var res struct {
Results struct {
APIToken string `json:"checkForm"`
User struct {
ID int `json:"USER_ID"`
Options struct {
LicenseToken string `json:"license_token"`
MobileOffline bool `json:"mobile_offline"`
WebOffline bool `json:"web_offline"`
} `json:"OPTIONS"`
} `json:"USER"`
} `json:"results"`
}
if err := json.Unmarshal(body, &res); err != nil {
return nil, err
}
if res.Results.User.Id == 0 {
if res.Results.User.ID == 0 {
return nil, fmt.Errorf("invalid arl cookie")
}
if !res.Results.User.Options.MobileOffline && !res.Results.User.Options.WebOffline {
return nil, fmt.Errorf("premium account required")
}
opts := res.Results.User.Options
return &Session{
ArlCookie: arlCookie,
APIToken: res.Results.APIToken,
LicenseToken: res.Results.User.Options.LicenseToken,
LicenseToken: opts.LicenseToken,
HttpClient: client,
Premium: opts.MobileOffline || opts.WebOffline,
}, nil
}
+44 -26
View File
@@ -1,50 +1,68 @@
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 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"`
ID string `json:"SNG_ID"`
Artist string `json:"ART_NAME"`
Title string `json:"SNG_TITLE"`
Version string `json:"VERSION"`
Cover string `json:"ALB_PICTURE"`
Contributors Contributors `json:"SNG_CONTRIBUTORS"`
Duration string `json:"DURATION"`
Gain string `json:"GAIN"`
ISRC string `json:"ISRC"`
TrackNumber string `json:"TRACK_NUMBER"`
TrackToken string `json:"TRACK_TOKEN"`
}
func (s *Song) GetTitle() string {
songTitle := s.Title
if s.Version != "" {
songTitle = fmt.Sprintf("%s %s", s.Title, s.Version)
return s.Title + " " + s.Version
}
return songTitle
return s.Title
}
func (s *Song) GetFileName(resourceType string, song *Song, media *Media) string {
func (s *Song) GetFileName(resourceType, mediaFormat string) string {
ext := "mp3"
if media.Data[0].Media[0].Format == "FLAC" {
if mediaFormat == "FLAC" {
ext = "flac"
}
trackNumber := ""
prefix := ""
if resourceType == "album" {
trackNumber = song.TrackNumber + ". "
if n, err := strconv.Atoi(s.TrackNumber); err == nil {
prefix = fmt.Sprintf("%02d. ", n)
} else {
prefix = s.TrackNumber + ". "
}
}
fileName := fmt.Sprintf("%s%s - %s.%s", trackNumber, s.Artist, s.GetTitle(), ext)
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
fileName := fmt.Sprintf("%s%s - %s.%s", prefix, s.Artist, s.GetTitle(), ext)
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{MaxLength: 255})
return fileName
}
+65
View File
@@ -0,0 +1,65 @@
package deezer
import (
"encoding/json"
"fmt"
"path"
"strconv"
"time"
)
type Track struct {
Results struct {
Data *Song `json:"DATA"`
} `json:"results"`
}
func (t *Track) String() string {
if t.Results.Data == nil {
return "Track: No data available"
}
duration, err := strconv.Atoi(t.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Track Info ] =================
Title: %s
Artist: %s
Duration: %s
==================================================`,
t.Results.Data.GetTitle(),
t.Results.Data.Artist,
time.Duration(duration)*time.Second,
)
}
func (t *Track) GetType() string {
return "Track"
}
func (t *Track) GetTitle() string {
if t.Results.Data == nil {
return ""
}
return t.Results.Data.GetTitle()
}
func (t *Track) GetSongs() []*Song {
if t.Results.Data == nil {
return nil
}
return []*Song{t.Results.Data}
}
func (t *Track) SetSongs(songs []*Song) {}
func (t *Track) GetOutputDir(outputDir string) string {
return path.Join(outputDir, "Singles")
}
func (t *Track) Unmarshal(data []byte) error {
return json.Unmarshal(data, t)
}
+105 -125
View File
@@ -7,15 +7,15 @@ import (
"io"
"os"
"path"
"strings"
"sync"
"time"
"github.com/briandowns/spinner"
"github.com/mathismqn/godeez/internal/bpm"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/crypto"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/logger"
"github.com/mathismqn/godeez/internal/store"
"github.com/mathismqn/godeez/internal/tags"
)
@@ -26,6 +26,7 @@ type Client struct {
appConfig *config.Config
resourceType string
deezerClient *deezer.Client
Logger *logger.Logger
hashIndexOnce sync.Once
hashIndex *fileutil.HashIndex
@@ -36,172 +37,154 @@ func New(appConfig *config.Config, resourceType string) *Client {
return &Client{
appConfig: appConfig,
resourceType: resourceType,
deezerClient: nil,
Logger: logger.New(nil),
}
}
func (c *Client) Run(ctx context.Context, opts Options, id string) error {
if err := c.initDeezerClient(ctx, opts); err != nil {
return err
}
resource, outputDir, err := c.prepareResource(ctx, id, opts)
if err != nil {
return err
}
return c.downloadAllSongs(ctx, resource, id, opts, outputDir)
}
func (c *Client) initDeezerClient(ctx context.Context, opts Options) error {
var err error
c.deezerClient, err = deezer.NewClient(ctx, c.appConfig)
if err != nil {
return err
}
var resource deezer.Resource
switch c.resourceType {
case "album":
resource = &deezer.Album{}
case "playlist":
resource = &deezer.Playlist{}
default:
return fmt.Errorf("unsupported resource type: %s", c.resourceType)
if !c.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") {
return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
}
return nil
}
func (c *Client) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) {
resource, err := c.createResource()
if err != nil {
return nil, "", err
}
if err := c.deezerClient.FetchResource(ctx, resource, id); err != nil {
return fmt.Errorf("failed to fetch resource: %w", err)
return nil, "", fmt.Errorf("failed to fetch resource: %w", err)
}
songs := resource.GetSongs()
if len(songs) == 0 {
return fmt.Errorf("%s has no songs", c.resourceType)
if c.resourceType == "track" {
return nil, "", fmt.Errorf("track with ID %s not found", id)
}
return nil, "", fmt.Errorf("%s has no songs", c.resourceType)
}
rootOutputDir := c.appConfig.OutputDir
resourceOutputDir := resource.GetOutputDir(rootOutputDir)
if err := fileutil.EnsureDir(resourceOutputDir); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
if c.resourceType == "artist" && len(songs) > opts.Limit {
resource.SetSongs(songs[:opts.Limit])
}
outputDir := resource.GetOutputDir(c.appConfig.OutputDir)
if err := fileutil.EnsureDir(outputDir); err != nil {
return nil, "", fmt.Errorf("failed to create output directory: %w", err)
}
return resource, outputDir, nil
}
func (c *Client) createResource() (deezer.Resource, error) {
switch c.resourceType {
case "album":
return &deezer.Album{}, nil
case "playlist":
return &deezer.Playlist{}, nil
case "artist":
return &deezer.Artist{}, nil
case "track":
return &deezer.Track{}, nil
default:
return nil, fmt.Errorf("unsupported resource type: %s", c.resourceType)
}
}
func (c *Client) downloadAllSongs(ctx context.Context, resource deezer.Resource, resourceID string, opts Options, outputDir string) error {
songs := resource.GetSongs()
startTime := time.Now()
fmt.Printf("%s\n\nStarting download...\n\n", resource)
downloaded := 0
skipped := 0
failed := 0
if c.resourceType != "track" {
fmt.Printf("%s\n\nStarting download...\n\n", resource)
}
progress := newProgressTracker(c.Logger, len(songs), c.resourceType)
for i, song := range songs {
if ctx.Err() != nil {
return ctx.Err()
}
trackProgress := fmt.Sprintf("[%d/%d]", i+1, len(songs))
sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
sp.Writer = os.Stdout
sp.Prefix = trackProgress + " "
sp.Suffix = fmt.Sprintf(" Downloading: %s - %s", song.Artist, song.Title)
sp.Start()
warnings, err := c.downloadSong(ctx, resource, song, opts, resourceOutputDir)
sp := progress.startDownload(i, song)
result := c.downloadSong(ctx, resource, song, opts, outputDir)
sp.Stop()
if err != nil {
if errors.Is(err, context.Canceled) {
return err
}
if path, ok := IsSkipError(err); ok {
skipped++
fmt.Printf("%s ↷ Skipped: %s - %s\n Already exists at: %s\n", trackProgress, song.Artist, song.Title, path)
continue
}
failed++
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n", trackProgress, song.Artist, song.Title, err)
continue
if result.err != nil && errors.Is(result.err, context.Canceled) {
return result.err
}
symbol := "✔"
if len(warnings) > 0 {
symbol = "⚠"
}
downloaded++
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, song.Title)
for _, w := range warnings {
fmt.Printf(" Warning: %s\n", w)
}
progress.handleResult(i, song, result)
}
fmt.Printf(`
================== [ Summary ] ==================
Downloaded: %d
Skipped: %d
Failed: %d
Elapsed time: %s
Files saved to: %s
=================================================
`,
downloaded,
skipped,
failed,
time.Since(startTime).Round(time.Second),
resourceOutputDir,
)
progress.printSummary(resource.GetTitle(), resourceID, outputDir, time.Since(startTime))
return nil
}
func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, song *deezer.Song, opts Options, outputDir string) ([]string, error) {
var warnings []string
func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, song *deezer.Song, opts Options, outputDir string) downloadResult {
media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality)
if err != nil {
return warnings, fmt.Errorf("failed to fetch media: %w", err)
return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)}
}
fileName := song.GetFileName(c.resourceType, song, media)
outputPath := path.Join(outputDir, fileName)
mediaFormat := media.GetFormat()
if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality {
return downloadResult{err: fmt.Errorf("requested quality '%s' not available", opts.Quality)}
}
mediaFormat, err := media.GetFormat()
if skipPath, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
return downloadResult{skipped: true, path: skipPath}
}
metadataChan := make(chan metadataResult, 1)
go func() {
metadataChan <- fetchMetadata(c.deezerClient.Session.HttpClient, ctx, song, opts)
}()
stream, err := c.deezerClient.GetMediaStream(ctx, media)
if err != nil {
return warnings, fmt.Errorf("failed to get media format: %w", err)
}
if path, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
return warnings, SkipError{Path: path}
}
var metricsChan chan *bpm.Metrics
var errChan chan error
if opts.BPM {
metricsChan = make(chan *bpm.Metrics, 1)
errChan = make(chan error, 1)
go func() {
metrics, err := bpm.FetchMetrics(ctx, c.deezerClient.Session.HttpClient, song.Artist, song.Title, song.Duration)
if err != nil {
errChan <- err
return
}
metricsChan <- metrics
}()
}
stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID)
if err != nil {
return warnings, fmt.Errorf("failed to get media stream: %w", err)
return downloadResult{err: fmt.Errorf("failed to get media stream: %w", err)}
}
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
key := crypto.GetKey(c.appConfig.SecretKey, song.ID)
fileName := song.GetFileName(c.resourceType, mediaFormat)
outputPath := path.Join(outputDir, fileName)
key := crypto.GetKey(song.ID)
if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil {
fileutil.DeleteFile(outputPath)
return warnings, fmt.Errorf("failed to stream to file: %w", err)
return downloadResult{err: fmt.Errorf("failed to stream to file: %w", err)}
}
metrics := &bpm.Metrics{}
if opts.BPM {
select {
case metrics = <-metricsChan:
case err := <-errChan:
if !errors.Is(err, context.Canceled) {
warnings = append(warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err))
}
}
var warnings []string
if opts.Quality != strings.ToLower(mediaFormat) {
warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat)))
}
cover, err := c.deezerClient.FetchCoverImage(ctx, song)
@@ -209,9 +192,11 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
}
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, cover, metrics)...)
metadata := <-metadataChan
warnings = append(warnings, metadata.warnings...)
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, metadata.genre, cover, metadata.bpmKey)...)
return warnings, nil
return downloadResult{warnings: warnings}
}
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
@@ -229,22 +214,18 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP
case <-ctx.Done():
return ctx.Err()
default:
// continue
}
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 n > 0 {
totalRead += n
}
}
if totalRead == 0 {
@@ -258,8 +239,7 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP
}
}
_, err = file.Write(buffer[:totalRead])
if err != nil {
if _, err = file.Write(buffer[:totalRead]); err != nil {
return err
}
@@ -271,10 +251,10 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP
return nil
}
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat string, cover []byte, metrics *bpm.Metrics) []string {
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat, genre string, cover []byte, bpmKey bpmKey) []string {
var warnings []string
if err := tags.AddTags(resource, song, cover, outputPath, metrics.BPM, metrics.Key); err != nil {
if err := tags.AddTags(resource, song, cover, outputPath, bpmKey.BPM, bpmKey.Key, genre); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err))
}
+80
View File
@@ -0,0 +1,80 @@
package downloader
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/provider"
)
type bpmKey struct {
BPM string
Key string
}
type metadataResult struct {
bpmKey bpmKey
genre string
warnings []string
}
func fetchMetadata(httpClient *http.Client, ctx context.Context, song *deezer.Song, 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 := provider.FetchBPM(ctx, httpClient, song.Artist, song.Title, song.Duration)
bpmChan <- bpmResult{value: bpmKey{BPM: result.BPM, Key: result.Key}, err: err}
}()
}
if opts.Genre {
go func() {
genre, err := provider.FetchGenre(ctx, httpClient, song.Artist, song.GetTitle())
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
}
+12 -1
View File
@@ -9,19 +9,30 @@ var validQualities = map[string]bool{
"mp3_128": true,
"mp3_320": true,
"flac": true,
"best": true,
}
type Options struct {
Quality string
Timeout time.Duration
Limit int
BPM bool
Genre bool
Strict bool
}
func (o *Options) Validate() error {
if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality)
}
if o.Timeout <= 0 {
return fmt.Errorf("timeout must be a positive duration")
}
if o.Limit <= 0 {
return fmt.Errorf("limit must be a positive integer")
}
if o.Limit > 100 {
return fmt.Errorf("limit must not exceed 100")
}
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package downloader
import (
"fmt"
"math/rand"
"os"
"time"
"github.com/briandowns/spinner"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/logger"
)
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 {
logger *logger.Logger
stats downloadStats
totalSongs int
resourceType string
}
func newProgressTracker(logger *logger.Logger, totalSongs int, resourceType string) *progressTracker {
return &progressTracker{
logger: logger,
totalSongs: totalSongs,
resourceType: resourceType,
}
}
func (pt *progressTracker) startDownload(index int, song *deezer.Song) *spinner.Spinner {
trackProgress := fmt.Sprintf("[%d/%d]", index+1, pt.totalSongs)
sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
sp.Writer = os.Stdout
sp.Prefix = trackProgress + " "
sp.Suffix = fmt.Sprintf(" Downloading: %s - %s", song.Artist, song.GetTitle())
sp.Start()
return sp
}
func (pt *progressTracker) handleResult(index int, song *deezer.Song, result downloadResult) {
trackProgress := fmt.Sprintf("[%d/%d]", index+1, pt.totalSongs)
songTitle := song.GetTitle()
if result.skipped {
pt.stats.skipped++
fmt.Printf("%s ↷ Skipped: %s - %s\n Already exists at: %s\n",
trackProgress, song.Artist, songTitle, result.path)
return
}
if result.err != nil {
pt.stats.failed++
pt.logger.Errorf("Failed to download %s - %s: %v\n", song.Artist, songTitle, result.err)
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n",
trackProgress, song.Artist, songTitle, result.err)
return
}
pt.stats.downloaded++
if len(result.warnings) > 0 {
pt.stats.warnings++
}
pt.logger.Infof("Downloaded %s - %s\n", song.Artist, songTitle)
symbol := "✔"
if len(result.warnings) > 0 {
symbol = "⚠"
}
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, songTitle)
for _, w := range result.warnings {
pt.logger.Warnf("Warning: %s\n", w)
fmt.Printf(" Warning: %s\n", w)
}
}
func (pt *progressTracker) printSummary(resourceTitle, resourceID, outputDir string, elapsed time.Duration) {
if pt.stats.downloaded > 0 || pt.stats.failed > 0 {
pt.logger.Infof("Resource %s (%s): %d downloaded, %d skipped, %d failed\n",
resourceTitle, resourceID, pt.stats.downloaded, pt.stats.skipped, pt.stats.failed)
}
if pt.resourceType != "track" {
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 (pt *progressTracker) showSupportMessage() {
if rand.Float64() < 0.1 {
fmt.Printf("\n💖 Enjoying GoDeez? Give us a ⭐ on GitHub: https://github.com/mathismqn/godeez\n")
}
}
+24 -30
View File
@@ -7,37 +7,31 @@ import (
"github.com/mathismqn/godeez/internal/store"
)
type SkipError struct {
Path string
}
func (e SkipError) Error() string {
return e.Path
}
func IsSkipError(err error) (string, bool) {
if skipErr, ok := err.(SkipError); ok {
return skipErr.Path, true
}
return "", false
}
func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) {
if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat {
if fileutil.FileExists(existing.Path) {
return existing.Path, true
}
if existing.Hash != "" {
if err := c.initHashIndex(ctx); err == nil {
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok {
existing.Path = foundPath
_ = existing.Save()
return foundPath, true
}
}
}
existing, err := store.GetDownloadInfo(songID)
if err != nil || existing.Quality != mediaFormat {
return "", false
}
return "", false
if fileutil.FileExists(existing.Path) {
return existing.Path, true
}
if existing.Hash == "" {
return "", false
}
if err := c.initHashIndex(ctx); err != nil {
return "", false
}
foundPath, ok := c.hashIndex.Find(existing.Hash)
if !ok {
return "", false
}
existing.Path = foundPath
_ = existing.Save()
return foundPath, true
}
+4 -6
View File
@@ -2,6 +2,7 @@ package fileutil
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
@@ -18,13 +19,11 @@ func EnsureDir(path string) error {
if !info.IsDir() {
return fmt.Errorf("file already exists at %s", path)
}
return nil
}
func FileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
@@ -32,7 +31,6 @@ func DeleteFile(path string) error {
if !FileExists(path) {
return nil
}
return os.Remove(path)
}
@@ -43,10 +41,10 @@ func GetFileHash(path string) (string, error) {
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
return hex.EncodeToString(h.Sum(nil)), nil
}
+4 -8
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"io"
"io/fs"
"os"
"path/filepath"
)
@@ -16,12 +17,11 @@ type HashIndex struct {
func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
index := &HashIndex{files: make(map[string]string)}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil || info.IsDir() {
if err != nil || d.IsDir() {
return nil
}
@@ -36,12 +36,9 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
return nil
}
sum := hex.EncodeToString(h.Sum(nil))
index.files[sum] = path
index.files[hex.EncodeToString(h.Sum(nil))] = path
return nil
})
if err != nil {
return nil, err
}
@@ -51,6 +48,5 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
func (h *HashIndex) Find(hash string) (string, bool) {
path, ok := h.files[hash]
return path, ok
}
+21
View File
@@ -0,0 +1,21 @@
package logger
import "log"
type Logger struct {
l *log.Logger
}
func New(l *log.Logger) *Logger {
return &Logger{l: l}
}
func (l *Logger) logf(level, format string, args ...any) {
if l.l != nil {
l.l.Printf("["+level+"] "+format, args...)
}
}
func (l *Logger) Infof(format string, args ...any) { l.logf("INFO", format, args...) }
func (l *Logger) Warnf(format string, args ...any) { l.logf("WARN", format, args...) }
func (l *Logger) Errorf(format string, args ...any) { l.logf("ERROR", format, args...) }
+159
View File
@@ -0,0 +1,159 @@
package provider
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
neturl "net/url"
"regexp"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
)
type BPMKey struct {
BPM string
Key string
}
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) {
songURL, err := findSongURL(ctx, httpClient, artist, title, duration)
if err != nil {
return BPMKey{}, err
}
html, err := fetchBPMPage(ctx, httpClient, songURL)
if err != nil {
return BPMKey{}, err
}
return parseBPM(html)
}
func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
const rootURL = "https://songbpm.com"
values := neturl.Values{}
values.Add("query", fmt.Sprintf("%s %s", artist, title))
req, err := http.NewRequestWithContext(ctx, "POST", 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 "", fmt.Errorf("no data found")
}
return rootURL + matchURL, nil
}
func fetchBPMPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
func 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{}, fmt.Errorf("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
}
+124
View File
@@ -0,0 +1,124 @@
package provider
import (
"context"
"fmt"
"net/http"
"strings"
"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) {
reqURL := fmt.Sprintf("https://www.last.fm/music/%s/%s/+tags", artist, 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 "", fmt.Errorf("no data found")
}
return formatTags(filtered), nil
}
func fetchGenrePage(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, 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 {
words[i] = strings.ToUpper(w[:1]) + strings.ToLower(w[1:])
}
formatted = append(formatted, strings.Join(words, " "))
}
return strings.Join(formatted, " / ")
}
+6 -9
View File
@@ -16,6 +16,8 @@ type DownloadInfo struct {
Downloaded time.Time `json:"downloaded_at"`
}
var trackBucket = []byte("tracks")
func GetDownloadInfo(songID string) (*DownloadInfo, error) {
var info DownloadInfo
@@ -39,9 +41,9 @@ func GetDownloadInfo(songID string) (*DownloadInfo, error) {
func (d *DownloadInfo) Save() error {
return db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(trackBucket)
if b == nil {
return fmt.Errorf("bucket not found")
b, err := tx.CreateBucketIfNotExists(trackBucket)
if err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
data, err := json.Marshal(d)
@@ -49,11 +51,6 @@ func (d *DownloadInfo) Save() error {
return err
}
err = b.Put([]byte(d.SongID), data)
if err != nil {
return err
}
return nil
return b.Put([]byte(d.SongID), data)
})
}
+2 -15
View File
@@ -7,26 +7,13 @@ import (
bolt "go.etcd.io/bbolt"
)
var (
db *bolt.DB
trackBucket = []byte("tracks")
)
var db *bolt.DB
func OpenDB(cfgDir string) error {
var err error
dbPath := path.Join(cfgDir, "tracks.db")
db, err = bolt.Open(dbPath, 0600, nil)
db, err = bolt.Open(path.Join(cfgDir, ".tracks.db"), 0600, nil)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(trackBucket)
return err
}); err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
return nil
}
+23 -31
View File
@@ -16,48 +16,52 @@ type flacTagger struct {
index int
}
func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error {
func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error {
if album, ok := resource.(*deezer.Album); ok {
dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-")
if len(dateParts) == 3 {
album.Results.Data.PhysicalReleaseDate = dateParts[0]
if parts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-"); len(parts) == 3 {
album.Results.Data.PhysicalReleaseDate = parts[0]
}
t.addTag("ALBUM", album.Results.Data.Title)
t.addTag("TRACKNUMBER", song.TrackNumber)
t.addTag("ALBUMARTIST", album.Results.Data.Artist)
t.addTag("ALBUM", album.Results.Data.Title)
t.addTag("PUBLISHER", album.Results.Data.Label)
t.addTag("ORIGINALDATE", album.Results.Data.OriginalReleaseDate)
t.addTag("DATE", album.Results.Data.PhysicalReleaseDate)
t.addTag("COMMENT", album.Results.Data.ProducerLine)
t.addTag("TRACKNUMBER", song.TrackNumber)
t.addTag("COPYRIGHT", album.Results.Data.Copyright)
}
t.addTag("TITLE", song.Title)
t.addTag("ARTIST", strings.Join(song.Contributors.MainArtists, ", "))
t.addTag("TITLE", song.GetTitle())
t.addTag("COMPOSER", strings.Join(song.Contributors.Composers, ", "))
t.addTag("LYRICIST", strings.Join(song.Contributors.Authors, ", "))
t.addTag("GENRE", genre)
t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain)
t.addTag("ISRC", song.ISRC)
t.addTag("BPM", tempo)
t.addTag("KEY", key)
t.addTag("INITIALKEY", key)
cmtsmeta := t.cmts.Marshal()
cmtsMeta := t.cmts.Marshal()
if t.index > 0 {
t.file.Meta[t.index] = &cmtsmeta
t.file.Meta[t.index] = &cmtsMeta
} else {
t.file.Meta = append(t.file.Meta, &cmtsmeta)
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)
pictureMeta := picture.Marshal()
t.file.Meta = append(t.file.Meta, &pictureMeta)
return t.saveTags(path)
tmpPath := path + ".tmp"
if err := t.file.Save(tmpPath); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func (t *flacTagger) addTag(name, value string) {
@@ -66,26 +70,14 @@ func (t *flacTagger) addTag(name, value string) {
}
}
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
func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int) {
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
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err == nil {
return cmt, idx
}
}
}
return cmt, cmtIdx, nil
return nil, 0
}
+13 -14
View File
@@ -13,7 +13,7 @@ type id3v2Tagger struct {
tag *id3v2.Tag
}
func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error {
func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error {
defer t.tag.Close()
duration, err := strconv.Atoi(song.Duration)
@@ -23,34 +23,34 @@ func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover
song.Duration = fmt.Sprintf("%d", duration*1000)
if album, ok := resource.(*deezer.Album); ok {
t.addTag("TALB", album.Results.Data.Title)
t.addTag("TRCK", song.TrackNumber)
t.addTag("TPE2", album.Results.Data.Artist)
t.addTag("TALB", album.Results.Data.Title)
t.addTag("TPUB", album.Results.Data.Label)
t.addTag("TDOR", album.Results.Data.OriginalReleaseDate)
t.addTag("TYER", album.Results.Data.PhysicalReleaseDate)
t.addTag("COMM", album.Results.Data.ProducerLine)
t.addTag("TRCK", song.TrackNumber)
t.addTag("TCOP", album.Results.Data.Copyright)
}
t.addTag("TIT2", song.Title)
t.addTag("TPE1", strings.Join(song.Contributors.MainArtists, ", "))
t.addTag("TIT2", song.GetTitle())
t.addTag("TCOM", strings.Join(song.Contributors.Composers, ", "))
t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", "))
t.addTag("TCON", genre)
t.addTag("TLEN", song.Duration)
t.addTXXXTag("GAIN", song.Gain)
t.addTXXXTag("ISRC", song.ISRC)
t.addTag("TBPM", tempo)
t.addTag("TKEY", key)
t.addTXXX("GAIN", song.Gain)
t.addTXXX("ISRC", song.ISRC)
frame := id3v2.PictureFrame{
t.tag.AddAttachedPicture(id3v2.PictureFrame{
Encoding: t.tag.DefaultEncoding(),
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Cover",
Picture: cover,
}
t.tag.AddAttachedPicture(frame)
})
return t.tag.Save()
}
@@ -61,13 +61,12 @@ func (t *id3v2Tagger) addTag(name, value string) {
}
}
func (t *id3v2Tagger) addTXXXTag(description, value string) {
func (t *id3v2Tagger) addTXXX(description, value string) {
if value != "" {
udf := id3v2.UserDefinedTextFrame{
t.tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
Encoding: t.tag.DefaultEncoding(),
Description: description,
Value: value,
}
t.tag.AddUserDefinedTextFrame(udf)
})
}
}
+8 -13
View File
@@ -10,17 +10,15 @@ import (
)
type tagger interface {
addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error
addTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error
}
func newTagger(filePath string) (tagger, error) {
ext := path.Ext(filePath)
if ext == ".mp3" {
if path.Ext(filePath) == ".mp3" {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return nil, err
}
return &id3v2Tagger{tag: tag}, nil
}
@@ -28,22 +26,19 @@ func newTagger(filePath string) (tagger, error) {
if err != nil {
return nil, err
}
cmts, idx, err := extractFLACComment(file)
if err != nil {
return nil, err
}
if cmts == nil && idx > 0 {
cmts, idx := extractFLACComment(file)
if cmts == nil {
cmts = flacvorbis.New()
}
return &flacTagger{file: file, cmts: cmts, index: idx}, nil
}
func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key string) error {
tagger, err := newTagger(filePath)
func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error {
t, err := newTagger(filePath)
if err != nil {
return err
}
return tagger.addTags(resource, song, cover, filePath, tempo, key)
return t.addTags(resource, song, cover, filePath, tempo, key, genre)
}