Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ef1ba42a8 | ||
|
|
e4c421ff00 | ||
|
|
9d87f635d0 | ||
|
|
8c9b327b2c | ||
|
|
0493866141 | ||
|
|
523b562ed0 | ||
|
|
c2b4fcfa11 | ||
|
|
9b23e3a8d9 | ||
|
|
cf7f76948b | ||
|
|
4b2c9c92b8 | ||
|
|
22ddb2408b |
+23
-15
@@ -5,18 +5,27 @@ 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.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
|
||||
- New `artist` command to download an artist’s top tracks.
|
||||
- `--limit` flag for the `artist` command to restrict the number of tracks.
|
||||
- `--strict` flag for downloads: fail if the requested quality is unavailable.
|
||||
- Add new `artist` command to download an artist’s top tracks.
|
||||
- Add `--limit` flag for the `artist` command to restrict the number of tracks.
|
||||
- Add `--strict` flag for downloads: fail if the requested quality is unavailable.
|
||||
|
||||
### Changed
|
||||
- Default download quality is now **MP3 320kbps**.
|
||||
- Set default download quality to **MP3 320kbps**.
|
||||
|
||||
### Removed
|
||||
- The `--quality=best` option. Fallback to lower quality is now the **default behavior**; use the new `--strict` flag if you want to prevent fallback.
|
||||
- Remove `--quality=best` option. Fallback to lower quality is now the default behavior; use the `--strict` flag to prevent fallback.
|
||||
|
||||
### Fixed
|
||||
- Handle error when `SNG_CONTRIBUTORS` metadata is empty.
|
||||
@@ -24,22 +33,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [1.1.1] - 2025-06-16
|
||||
|
||||
### Fixed
|
||||
- Restored ability to download tracks **without a Deezer Premium account** (limited to **MP3 128kbps** for free accounts).
|
||||
- Restore ability to download tracks without a Deezer Premium account (limited to **MP3 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 user’s home directory has been replaced by a `.godeez/` directory.
|
||||
It now stores both `config.toml` and the internal `tracks.db`.
|
||||
If you're upgrading from an older version, move your existing config into `.godeez/config.toml`.
|
||||
- Simplified `config.toml`: `iv` and `license_token` are no longer required.
|
||||
- Cleanup logic: corrupted or incomplete files are now automatically deleted on download failure.
|
||||
- Replace the `godeez` file in the user’s home directory with a `.godeez/` directory, which now stores both `config.toml` and `tracks.db`.
|
||||
👉 If upgrading, move your existing config into `.godeez/config.toml`.
|
||||
- Simplify `config.toml`: remove the need for `iv` and `license_token`.
|
||||
- Automatically delete corrupted or incomplete files on download failure.
|
||||
|
||||
## [1.0.0] - 2024-10-15
|
||||
|
||||
|
||||
@@ -13,32 +13,33 @@ 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, albums, and artists' top tracks from Deezer
|
||||
* Select audio quality: MP3 128kbps, MP3 320kbps (default), or FLAC (⚠️ non-premium accounts are limited to 128kbps)
|
||||
* Automatically adds metadata tags to downloaded files
|
||||
* Fetch and tag songs with BPM and musical key
|
||||
* Smart skip system: avoids re-downloading already existing files using hashes and metadata
|
||||
* Cross-platform support (works on Windows, macOS, and Linux)
|
||||
* Simple and easy-to-use CLI
|
||||
- Download playlists, albums, artists’ top tracks, and individual tracks
|
||||
- Choose audio quality: **MP3 128kbps**, **MP3 320kbps** (default), or **FLAC** (⚠️ non‑premium 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.2.0-linux-amd64 /usr/local/bin/godeez
|
||||
mv godeez-1.3.0-linux-amd64 /usr/local/bin/godeez
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -46,12 +47,12 @@ mv godeez-1.2.0-linux-amd64 /usr/local/bin/godeez
|
||||
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).
|
||||
|
||||
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
|
||||
- `config.toml`: main configuration file you need to edit manually
|
||||
- `tracks.db`: internal database used to track downloads and avoid duplicates
|
||||
|
||||
### Steps to configure
|
||||
|
||||
1. Run the application for the first time: this creates the `.godeez` directory and the `config.toml` file inside it.
|
||||
1. Run the application once: this creates the `.godeez` directory and the `config.toml` file.
|
||||
2. Edit the `config.toml` file with a text editor to set the required values.
|
||||
|
||||
### Variables to configure
|
||||
@@ -78,7 +79,6 @@ Here are the key variables you need to set in `config.toml`:
|
||||
|
||||
### Example
|
||||
|
||||
Here's an example of a minimal `config.toml` you can customize:
|
||||
```toml
|
||||
# ~/.godeez/config.toml
|
||||
|
||||
@@ -91,7 +91,8 @@ output_dir = '' # optional
|
||||
|
||||
### CLI Overview
|
||||
|
||||
When you run `godeez` without any additional commands, you’ll see a general help menu:
|
||||
Running `godeez` without arguments shows the help menu:
|
||||
|
||||
```bash
|
||||
GoDeez is a tool to download music from Deezer
|
||||
|
||||
@@ -109,9 +110,9 @@ Flags:
|
||||
|
||||
Use "godeez [command] --help" for more information about a command.
|
||||
```
|
||||
This provides an overview of the available commands and flags.
|
||||
|
||||
To download music, you need to use the download command. Here’s how the CLI looks when you run `godeez download`:
|
||||
### Download commands
|
||||
|
||||
```bash
|
||||
Download songs from Deezer
|
||||
|
||||
@@ -122,10 +123,12 @@ Available Commands:
|
||||
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] (default "mp3_320")
|
||||
--strict fail the song download if the quality is not available
|
||||
@@ -134,13 +137,40 @@ Flags:
|
||||
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 it’s a bug fix, a new feature, or improving documentation, your input is valuable.
|
||||
|
||||
If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion.
|
||||
Every bit of support counts, so don’t forget to give the project a star if you enjoy using it. Thank you for helping make this project better!
|
||||
|
||||
## ⭐ Support the Project
|
||||
|
||||
If **GoDeez** helps you enjoy your music collection, please consider giving it a star!
|
||||
|
||||
**Why star 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
|
||||
|
||||
@@ -148,4 +178,4 @@ This project is licensed under the MIT License. See the [LICENSE](https://github
|
||||
|
||||
---
|
||||
|
||||
> ⚠️ This tool is provided for educational and personal use only. Please ensure your usage complies with Deezer’s Terms of Service.
|
||||
> ⚠️ This tool is provided for educational and personal use only. Please ensure your usage complies with Deezer’s Terms of Service.
|
||||
|
||||
+7
-2
@@ -29,12 +29,14 @@ func init() {
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,9 +79,12 @@ func newDownloadCmd(resourceType string) *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
if resourceType == "artist" {
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download")
|
||||
switch resourceType {
|
||||
case "artist":
|
||||
cmd.Short = "Download top songs from an artist"
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download")
|
||||
case "track":
|
||||
cmd.Short = "Download a single track"
|
||||
}
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+15
-10
@@ -45,6 +45,8 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
|
||||
payload["alb_id"] = id
|
||||
case *Artist:
|
||||
payload["art_id"] = id
|
||||
case *Track:
|
||||
payload["sng_id"] = id
|
||||
default:
|
||||
return fmt.Errorf("unsupported resource type: %T", r)
|
||||
}
|
||||
@@ -75,15 +77,17 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`) {
|
||||
switch {
|
||||
case strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`):
|
||||
return fmt.Errorf("invalid playlist ID")
|
||||
}
|
||||
if strings.Contains(string(body), `"DATA_ERROR":"album::getData"`) {
|
||||
case strings.Contains(string(body), `"DATA_ERROR":"album::getData"`):
|
||||
return fmt.Errorf("invalid album ID")
|
||||
}
|
||||
if strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`) {
|
||||
case strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`):
|
||||
return fmt.Errorf("invalid artist ID")
|
||||
case strings.Contains(string(body), `"DATA_ERROR":"song::getData"`):
|
||||
return fmt.Errorf("invalid track ID")
|
||||
}
|
||||
|
||||
if strings.Contains(string(body), `"results":{}`) {
|
||||
return fmt.Errorf("unexpected response")
|
||||
}
|
||||
@@ -137,6 +141,7 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
|
||||
|
||||
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")
|
||||
@@ -145,6 +150,10 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
|
||||
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
|
||||
}
|
||||
|
||||
@@ -169,11 +178,7 @@ func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error
|
||||
}
|
||||
|
||||
func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) {
|
||||
url, err := media.GetURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := media.GetURL()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Media struct {
|
||||
Errors []MediaError `json:"errors"`
|
||||
Data []struct {
|
||||
@@ -31,18 +27,10 @@ type Source struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
func (m *Media) GetURL() (string, error) {
|
||||
if len(m.Data) == 0 || len(m.Data[0].Media) == 0 || len(m.Data[0].Media[0].Sources) == 0 {
|
||||
return "", fmt.Errorf("no media sources found")
|
||||
}
|
||||
|
||||
return m.Data[0].Media[0].Sources[0].URL, nil
|
||||
func (m *Media) GetURL() string {
|
||||
return m.Data[0].Media[0].Sources[0].URL
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -48,9 +48,9 @@ func (s *Song) GetTitle() string {
|
||||
return songTitle
|
||||
}
|
||||
|
||||
func (s *Song) GetFileName(resourceType string, song *Song, media *Media) string {
|
||||
func (s *Song) GetFileName(resourceType, mediaFormat string, song *Song) string {
|
||||
ext := "mp3"
|
||||
if media.Data[0].Media[0].Format == "FLAC" {
|
||||
if mediaFormat == "FLAC" {
|
||||
ext = "flac"
|
||||
}
|
||||
trackNumber := ""
|
||||
@@ -59,7 +59,7 @@ func (s *Song) GetFileName(resourceType string, song *Song, media *Media) string
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("%s%s - %s.%s", trackNumber, s.Artist, s.GetTitle(), ext)
|
||||
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
|
||||
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{MaxLength: 255})
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/flytam/filenamify"
|
||||
)
|
||||
|
||||
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 := "Unknown"
|
||||
if t.Results.Data.Duration != "" {
|
||||
if d, err := time.ParseDuration(t.Results.Data.Duration + "s"); err == nil {
|
||||
duration = d.String()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
`================= [ Track Info ] =================
|
||||
Title: %s
|
||||
Artist: %s
|
||||
Duration: %s
|
||||
==================================================`,
|
||||
t.Results.Data.GetTitle(),
|
||||
t.Results.Data.Artist,
|
||||
duration,
|
||||
)
|
||||
}
|
||||
|
||||
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 []*Song{}
|
||||
}
|
||||
return []*Song{t.Results.Data}
|
||||
}
|
||||
|
||||
func (t *Track) SetSongs(songs []*Song) {}
|
||||
|
||||
func (t *Track) GetOutputDir(outputDir string) string {
|
||||
if t.Results.Data == nil {
|
||||
return outputDir
|
||||
}
|
||||
|
||||
// For single tracks, create a simple "Singles" folder
|
||||
base := "Singles"
|
||||
base, _ = filenamify.Filenamify(base, filenamify.Options{})
|
||||
outputDir = path.Join(outputDir, base)
|
||||
|
||||
return outputDir
|
||||
}
|
||||
|
||||
func (t *Track) Unmarshal(data []byte) error {
|
||||
return json.Unmarshal(data, t)
|
||||
}
|
||||
+83
-121
@@ -11,13 +11,12 @@ import (
|
||||
"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/provider"
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
"github.com/mathismqn/godeez/internal/tags"
|
||||
)
|
||||
@@ -45,6 +44,19 @@ func New(appConfig *config.Config, resourceType string) *Client {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -55,192 +67,142 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error {
|
||||
return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
|
||||
}
|
||||
|
||||
var resource deezer.Resource
|
||||
switch c.resourceType {
|
||||
case "album":
|
||||
resource = &deezer.Album{}
|
||||
case "playlist":
|
||||
resource = &deezer.Playlist{}
|
||||
case "artist":
|
||||
resource = &deezer.Artist{}
|
||||
default:
|
||||
return fmt.Errorf("unsupported resource type: %s", c.resourceType)
|
||||
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)
|
||||
}
|
||||
|
||||
if c.resourceType == "artist" && len(songs) > opts.Limit {
|
||||
songs = songs[:opts.Limit]
|
||||
resource.SetSongs(songs)
|
||||
}
|
||||
|
||||
rootOutputDir := c.appConfig.OutputDir
|
||||
resourceOutputDir := resource.GetOutputDir(rootOutputDir)
|
||||
resourceOutputDir := resource.GetOutputDir(c.appConfig.OutputDir)
|
||||
if err := fileutil.EnsureDir(resourceOutputDir); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
fmt.Printf("%s\n\nStarting download...\n\n", resource)
|
||||
return resource, resourceOutputDir, nil
|
||||
}
|
||||
|
||||
downloaded := 0
|
||||
skipped := 0
|
||||
failed := 0
|
||||
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()
|
||||
|
||||
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 result.err != nil {
|
||||
if errors.Is(result.err, context.Canceled) {
|
||||
return result.err
|
||||
}
|
||||
|
||||
if path, ok := IsSkipError(err); ok {
|
||||
skipped++
|
||||
fmt.Printf("%s ↷ Skipped: %s - %s\n Already exists at: %s\n", trackProgress, song.Artist, song.Title, path)
|
||||
continue
|
||||
}
|
||||
|
||||
failed++
|
||||
c.Logger.Errorf("Failed to download %s - %s: %v\n", song.Artist, song.Title, err)
|
||||
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n", trackProgress, song.Artist, song.Title, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
symbol := "✔"
|
||||
if len(warnings) > 0 {
|
||||
symbol = "⚠"
|
||||
}
|
||||
|
||||
downloaded++
|
||||
c.Logger.Infof("Downloaded %s - %s\n", song.Artist, song.Title)
|
||||
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, song.Title)
|
||||
|
||||
for _, w := range warnings {
|
||||
c.Logger.Warnf("Warning: %s\n", w)
|
||||
fmt.Printf(" Warning: %s\n", w)
|
||||
}
|
||||
progress.handleResult(i, song, result)
|
||||
}
|
||||
|
||||
if downloaded > 0 || failed > 0 {
|
||||
c.Logger.Infof("Playlist %s (%s): %d downloaded, %d skipped, %d failed\n", resource.GetTitle(), id, downloaded, skipped, failed)
|
||||
}
|
||||
fmt.Printf(`
|
||||
================== [ Summary ] ==================
|
||||
Downloaded: %d
|
||||
Skipped: %d
|
||||
Failed: %d
|
||||
Elapsed time: %s
|
||||
Files saved to: %s
|
||||
=================================================
|
||||
`,
|
||||
downloaded,
|
||||
skipped,
|
||||
failed,
|
||||
time.Since(startTime).Round(time.Second),
|
||||
resourceOutputDir,
|
||||
)
|
||||
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) {
|
||||
func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, song *deezer.Song, opts Options, outputDir string) downloadResult {
|
||||
var warnings []string
|
||||
|
||||
media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch media: %w", err)
|
||||
return handleError(fmt.Errorf("failed to fetch media: %w", err))
|
||||
}
|
||||
|
||||
fileName := song.GetFileName(c.resourceType, song, media)
|
||||
outputPath := path.Join(outputDir, fileName)
|
||||
|
||||
mediaFormat, err := media.GetFormat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get media format: %w", err)
|
||||
}
|
||||
mediaFormat := media.GetFormat()
|
||||
if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality {
|
||||
return nil, fmt.Errorf("requested quality '%s' not available", opts.Quality)
|
||||
return handleError(fmt.Errorf("requested quality '%s' not available", opts.Quality))
|
||||
}
|
||||
|
||||
if path, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
|
||||
return nil, SkipError{Path: path}
|
||||
return handleError(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
|
||||
}()
|
||||
}
|
||||
metadataFetcher := newMetadataFetcher(c.deezerClient.Session.HttpClient)
|
||||
metadataResult := metadataFetcher.fetch(ctx, song, opts)
|
||||
warnings = append(warnings, metadataResult.warnings...)
|
||||
|
||||
stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get media stream: %w", err)
|
||||
return handleError(fmt.Errorf("failed to get media stream: %w", err))
|
||||
}
|
||||
|
||||
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
|
||||
fileName := song.GetFileName(c.resourceType, mediaFormat, song)
|
||||
outputPath := path.Join(outputDir, fileName)
|
||||
|
||||
key := crypto.GetKey(c.appConfig.SecretKey, song.ID)
|
||||
if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil {
|
||||
fileutil.DeleteFile(outputPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to stream to file: %w", err)
|
||||
return handleError(fmt.Errorf("failed to stream to file: %w", err))
|
||||
}
|
||||
|
||||
if opts.Quality != strings.ToLower(mediaFormat) {
|
||||
warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat)))
|
||||
}
|
||||
|
||||
metrics := &bpm.Metrics{}
|
||||
if opts.BPM {
|
||||
select {
|
||||
case metrics = <-metricsChan:
|
||||
case err := <-errChan:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
warnings = append(warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cover, err := c.deezerClient.FetchCoverImage(ctx, song)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
|
||||
}
|
||||
|
||||
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, cover, metrics)...)
|
||||
finalizeWarnings := c.finalizeDownload(resource, song, outputPath, mediaFormat, metadataResult.genre, cover, metadataResult.bpmKey)
|
||||
warnings = append(warnings, finalizeWarnings...)
|
||||
|
||||
return warnings, nil
|
||||
return downloadResult{
|
||||
success: true,
|
||||
warnings: warnings,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
|
||||
@@ -300,10 +262,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 provider.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))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package downloader
|
||||
|
||||
func handleError(err error) downloadResult {
|
||||
if path, ok := IsSkipError(err); ok {
|
||||
return downloadResult{
|
||||
skipped: true,
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
return downloadResult{
|
||||
success: false,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package downloader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/deezer"
|
||||
"github.com/mathismqn/godeez/internal/provider"
|
||||
)
|
||||
|
||||
type metadataResult struct {
|
||||
bpmKey provider.BPMKey
|
||||
genre string
|
||||
warnings []string
|
||||
}
|
||||
|
||||
type metadataFetcher struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newMetadataFetcher(httpClient *http.Client) *metadataFetcher {
|
||||
return &metadataFetcher{
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (mf *metadataFetcher) fetch(ctx context.Context, song *deezer.Song, opts Options) metadataResult {
|
||||
result := metadataResult{
|
||||
bpmKey: provider.BPMKey{},
|
||||
genre: "",
|
||||
warnings: []string{},
|
||||
}
|
||||
|
||||
if !opts.BPM && !opts.Genre {
|
||||
return result
|
||||
}
|
||||
|
||||
bmpChan := make(chan provider.BPMKey, 1)
|
||||
bmpErrChan := make(chan error, 1)
|
||||
genreChan := make(chan string, 1)
|
||||
genreErrChan := make(chan error, 1)
|
||||
|
||||
if opts.BPM {
|
||||
go func() {
|
||||
p := provider.BPMProvider{}
|
||||
bmpKey, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.Title, song.Duration)
|
||||
if err != nil {
|
||||
bmpErrChan <- err
|
||||
} else {
|
||||
bmpChan <- bmpKey
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Genre {
|
||||
go func() {
|
||||
p := provider.GenreProvider{}
|
||||
genre, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.GetTitle())
|
||||
if err != nil {
|
||||
genreErrChan <- err
|
||||
} else {
|
||||
genreChan <- genre
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.BPM {
|
||||
select {
|
||||
case bmpKey := <-bmpChan:
|
||||
result.bpmKey = bmpKey
|
||||
case err := <-bmpErrChan:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Genre {
|
||||
select {
|
||||
case genre := <-genreChan:
|
||||
result.genre = genre
|
||||
case err := <-genreErrChan:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type Options struct {
|
||||
Timeout time.Duration
|
||||
Limit int
|
||||
BPM bool
|
||||
Genre bool
|
||||
Strict bool
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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 downloadStats struct {
|
||||
downloaded int
|
||||
skipped int
|
||||
failed int
|
||||
}
|
||||
|
||||
type downloadResult struct {
|
||||
success bool
|
||||
skipped bool
|
||||
path string
|
||||
warnings []string
|
||||
err error
|
||||
}
|
||||
|
||||
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,
|
||||
stats: &downloadStats{},
|
||||
totalSongs: totalSongs,
|
||||
resourceType: resourceType,
|
||||
}
|
||||
}
|
||||
|
||||
func (pt *progressTracker) startDownload(index int, song *deezer.Song) *spinner.Spinner {
|
||||
songTitle := song.GetTitle()
|
||||
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, songTitle)
|
||||
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
|
||||
}
|
||||
|
||||
symbol := "✔"
|
||||
if len(result.warnings) > 0 {
|
||||
symbol = "⚠"
|
||||
}
|
||||
|
||||
pt.stats.downloaded++
|
||||
pt.logger.Infof("Downloaded %s - %s\n", song.Artist, songTitle)
|
||||
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" {
|
||||
fmt.Printf(`
|
||||
================== [ Summary ] ==================
|
||||
Downloaded: %d
|
||||
Skipped: %d
|
||||
Failed: %d
|
||||
Elapsed time: %s
|
||||
Files saved to: %s
|
||||
=================================================
|
||||
`,
|
||||
pt.stats.downloaded,
|
||||
pt.stats.skipped,
|
||||
pt.stats.failed,
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package bpm
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
neturl "net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -14,30 +14,32 @@ import (
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
type BPMProvider struct{}
|
||||
|
||||
type BPMKey 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)
|
||||
func (p BPMProvider) Fetch(ctx context.Context, httpClient *http.Client, artist, title, duration string) (BPMKey, error) {
|
||||
url, err := p.findSongURL(ctx, httpClient, artist, title, duration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return BPMKey{}, err
|
||||
}
|
||||
|
||||
html, err := fetchPage(ctx, httpClient, url)
|
||||
html, err := p.fetchPage(ctx, httpClient, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return BPMKey{}, err
|
||||
}
|
||||
|
||||
return parseMetrics(html)
|
||||
return p.parse(html)
|
||||
}
|
||||
|
||||
func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
|
||||
func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
|
||||
rootUrl := "https://songbpm.com"
|
||||
reqUrl := rootUrl + "/searches"
|
||||
|
||||
values := url.Values{}
|
||||
values := neturl.Values{}
|
||||
values.Add("query", fmt.Sprintf("%s %s", artist, title))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBufferString(values.Encode()))
|
||||
@@ -90,12 +92,13 @@ func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, du
|
||||
}
|
||||
|
||||
foundDuration := minutes*60 + seconds
|
||||
duration, err := strconv.Atoi(duration)
|
||||
wantDuration, err := strconv.Atoi(duration)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if foundDuration <= (duration-2) || foundDuration >= (duration+2) {
|
||||
const durationToleranceSec = 2
|
||||
if foundDuration <= (wantDuration-durationToleranceSec) || foundDuration >= (wantDuration+durationToleranceSec) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -112,7 +115,7 @@ func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, du
|
||||
return rootUrl + url, nil
|
||||
}
|
||||
|
||||
func fetchPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
|
||||
func (p BPMProvider) fetchPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -136,7 +139,7 @@ func fetchPage(ctx context.Context, httpClient *http.Client, url string) (string
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func parseMetrics(html string) (*Metrics, error) {
|
||||
func (p BPMProvider) parse(html string) (BPMKey, error) {
|
||||
bpmRegex := regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
|
||||
bpmMatch := bpmRegex.FindStringSubmatch(html)
|
||||
|
||||
@@ -147,7 +150,7 @@ func parseMetrics(html string) (*Metrics, error) {
|
||||
modeMatch := modeRegex.FindStringSubmatch(html)
|
||||
|
||||
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
|
||||
return nil, fmt.Errorf("no data found")
|
||||
return BPMKey{}, fmt.Errorf("no data found")
|
||||
}
|
||||
|
||||
isMinor := false
|
||||
@@ -169,8 +172,9 @@ func parseMetrics(html string) (*Metrics, error) {
|
||||
key += "m"
|
||||
}
|
||||
|
||||
return &Metrics{
|
||||
return BPMKey{
|
||||
BPM: bpm,
|
||||
Key: key,
|
||||
}, nil
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
type GenreProvider struct{}
|
||||
|
||||
func (p GenreProvider) Fetch(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 := p.fetchPage(ctx, httpClient, reqUrl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tags := p.parse(doc)
|
||||
if len(tags) == 0 {
|
||||
return "", fmt.Errorf("no data found")
|
||||
}
|
||||
|
||||
if len(tags) > 1 {
|
||||
tags = tags[:2]
|
||||
}
|
||||
genre := p.formatTags(tags)
|
||||
|
||||
return genre, nil
|
||||
}
|
||||
|
||||
func (p GenreProvider) fetchPage(ctx context.Context, httpClient *http.Client, reqUrl string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqUrl, 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)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (p GenreProvider) parse(doc *goquery.Document) []string {
|
||||
var tags []string
|
||||
doc.Find("ol.big-tags .big-tags-item-name a").Each(func(_ int, s *goquery.Selection) {
|
||||
tag := strings.TrimSpace(s.Text())
|
||||
if tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
})
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
func (p GenreProvider) formatTags(tags []string) string {
|
||||
var formatted []string
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
words := strings.Fields(tag)
|
||||
for i, w := range words {
|
||||
words[i] = strings.Title(w)
|
||||
}
|
||||
formatted = append(formatted, strings.Join(words, " "))
|
||||
}
|
||||
|
||||
return strings.Join(formatted, "/")
|
||||
}
|
||||
@@ -16,26 +16,28 @@ 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]
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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,19 +23,21 @@ 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)
|
||||
|
||||
@@ -10,7 +10,7 @@ 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, path, tempo, key, genre string) error
|
||||
}
|
||||
|
||||
func newTagger(filePath string) (tagger, error) {
|
||||
@@ -39,11 +39,11 @@ func newTagger(filePath string) (tagger, error) {
|
||||
return &flacTagger{file: file, cmts: cmts, index: idx}, nil
|
||||
}
|
||||
|
||||
func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key string) error {
|
||||
func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error {
|
||||
tagger, err := newTagger(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tagger.addTags(resource, song, cover, filePath, tempo, key)
|
||||
return tagger.addTags(resource, song, cover, filePath, tempo, key, genre)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user