24 Commits
Author SHA1 Message Date
Mathis Maquenne 1fdd0e8c19 docs(readme): clarify quality limitation for free accounts 2025-06-16 16:47:00 -04:00
Mathis Maquenne b4e00e9306 docs(changelog): update for v1.1.1 2025-06-16 16:44:42 -04:00
Mathis Maquenne 327d55cff5 fix: allow downloads without premium account 2025-06-16 16:36:14 -04:00
Mathis Maquenne ce1690137e fix: adjust stream timeout and filename spacing 2025-05-19 22:53:11 +02:00
Mathis Maquenne 7daee4dab6 docs: add changelog 2025-05-19 17:12:22 +02:00
Mathis Maquenne 5951e13abe chore(cli): update descriptions 2025-05-19 16:41:08 +02:00
Mathis Maquenne 56e3e894ab docs: update README 2025-05-19 16:38:36 +02:00
Mathis Maquenne 68f22ce571 refactor: rework output directory handling 2025-05-19 15:38:18 +02:00
Mathis Maquenne 674eb99ffd feat(cli): add bpm flag to enable BPM and key tagging 2025-05-19 14:24:22 +02:00
Mathis Maquenne adfd71204d refactor(cli): remove multi-download support for albums/playlists 2025-05-19 13:59:01 +02:00
Mathis Maquenne e50395ae1d refactor: change output file name 2025-05-19 13:41:25 +02:00
Mathis Maquenne 0a9e6c9a4d refactor(cli): improve output display 2025-05-19 13:35:06 +02:00
Mathis Maquenne 5d18ab2088 fix(context): ignore cancellation errors triggered by user 2025-05-18 22:14:18 +02:00
Mathis Maquenne ebfb312718 refactor(crypto): use IV as a constant 2025-05-07 20:57:12 +02:00
Mathis Maquenne 67af80403b feat(cli): add timeout flag 2025-05-01 20:45:27 +02:00
Mathis Maquenne 0bae78a1ff refactor: major project restructure 2025-05-01 20:30:56 +02:00
Mathis Maquenne c74531b36c perf: download music and retrieve data in parallel 2025-04-26 18:30:48 +02:00
Mathis Maquenne 917ac5f6c2 feat: retrieve modified file paths using hashes 2025-04-26 14:10:27 +02:00
Mathis Maquenne 6d3935c744 feat: delete file if download fails 2025-04-26 13:17:03 +02:00
Mathis Maquenne f931cad746 feat: add local DB to save downloads 2025-04-26 13:09:59 +02:00
Mathis Maquenne 6675834842 refactor: change default output directory 2025-04-26 00:49:56 +02:00
Mathis Maquenne 7302685e34 feat(tags): differentiate between minor and major keys 2025-04-26 00:01:35 +02:00
Mathis Maquenne ddc8240da8 refactor: use a better API to download playlists with more than 40 tracks 2025-04-26 00:01:27 +02:00
Mathis Maquenne d0599f654e feat(tags): add tempo and key 2025-04-26 00:01:19 +02:00
31 changed files with 1494 additions and 567 deletions
-4
View File
@@ -1,4 +0,0 @@
arl_cookie = '1d9e90abb452a61b1b7463f0953e1b303c4e2e7e5fd404fde9b385f4de01c340ac0f62f1c8c1550405b0b9beded0e28c481e96a6148e8f4548351add5d7db746b2785ecf83b1768e5dd8cc73b1ad30c18d07c9cb37f5c6b9cd7a78a4de2aff11'
license_token = 'AAAAA1bcDFgHJKLmnOPqrsTUvwXYZ12o3lmNOjR2xWiQRT3v5zxL4mKoWjxL82jD7sWx93KfYGsN2IkdPxsUw9PTas7oFbgR3nY9qpZmVyHFJKLPxT7sWmqErXyTn5iKoNpRtV7bPdQZ8qLmWoJpU2nKrXz6vW'
secret_key = 'hTv1IAw19qWy9i3f'
iv = '0001020304050607'
+3
View File
@@ -0,0 +1,3 @@
.DS_Store
build.sh
builds/
+32
View File
@@ -0,0 +1,32 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.1] - 2025-06-16
### Fixed
- Restored 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.
### 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.
## [1.0.0] - 2024-10-15
### Added
- Initial release of **GoDeez** with basic Deezer album and playlist downloading capabilities.
+46 -30
View File
@@ -2,6 +2,10 @@
# GoDeez # GoDeez
[![GitHub release](https://img.shields.io/github/v/release/mathismqn/godeez)](https://github.com/mathismqn/godeez/releases)
[![License](https://img.shields.io/github/license/mathismqn/godeez)](https://github.com/mathismqn/godeez/blob/main/LICENSE)
[![Last commit](https://img.shields.io/github/last-commit/mathismqn/godeez)](https://github.com/mathismqn/godeez/commits/main)
A simple Go tool for downloading music from [Deezer](https://www.deezer.com). A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
[Features](#features) • [Features](#features) •
@@ -16,8 +20,10 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
## Features ## Features
* Download playlists and albums from Deezer * Download playlists and albums from Deezer
* Select audio quality: MP3 128kbps, MP3 320kbps, or FLAC * Select audio quality: MP3 128kbps, MP3 320kbps, or FLAC (⚠️ non-premium accounts are limited to 128kbps)
* Automatically adds metadata tags to downloaded files * 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) * Cross-platform support (works on Windows, macOS, and Linux)
* Simple and easy-to-use CLI * Simple and easy-to-use CLI
@@ -32,26 +38,28 @@ To install **GoDeez**, simply download the latest binary for your platform from
Example (Linux/macOS): Example (Linux/macOS):
```bash ```bash
# Move the downloaded binary to /usr/local/bin for easy access from anywhere # Move the downloaded binary to /usr/local/bin for easy access from anywhere
mv godeez-1.0.0-linux-amd64 /usr/local/bin/godeez mv godeez-1.1.0-linux-amd64 /usr/local/bin/godeez
``` ```
## Configuration ## Configuration
The first time you run **GoDeez**, a configuration file named .godeez will be automatically generated in your home directory ($HOME on Linux/macOS, %USERPROFILE% on Windows). 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).
This configuration file contains several important variables that you need to fill out manually. Below are the steps for retrieving and setting each 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 ### Steps to configure
1. Run the application for the first time: This generates the .godeez configuration file in your home directory. 1. Run the application for the first time: this creates the `.godeez` directory and the `config.toml` file inside it.
2. Edit the configuration file: Open the .godeez file with a text editor to set the required variables. 2. Edit the `config.toml` file with a text editor to set the required values.
### Variables to configure ### Variables to configure
Here are the key variables you need to set in the .godeez file: Here are the key variables you need to set in `config.toml`:
1. `arl_cookie` 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. * **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**: * **How to retrieve it**:
1. Open your browser and log in to your Deezer account. 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). 2. Open the Developer Tools (right-click on the page and select “Inspect” or press F12).
@@ -59,28 +67,31 @@ Here are the key variables you need to set in the .godeez file:
4. In the left panel, look for Cookies and select `https://www.deezer.com`. 4. In the left panel, look for Cookies and select `https://www.deezer.com`.
5. Find the arl cookie and copy its value. 5. Find the arl cookie and copy its value.
2. `license_token` 2. `secret_key`
* **What is it?**: The license_token is required to access Deezers media URLs for downloading songs. This token is found in the network requests your browser makes when playing a song. * **What is it?**: The `secret_key` is a cryptographic value used to decrypt Deezers media files.
* **How to retrieve it**:
1. Open Developer Tools in your browser (right-click on the page and select “Inspect” or press F12).
2. Go to the Network tab.
3. Start playing a song on Deezer and look for a request to `https://media.deezer.com/v1/get_url`.
4. Select the request and in the Request Data section, find the license_token.
5. Copy the license_token value.
3. `secret_key`
* **What is it?**: The secret_key is a cryptographic value used alongside the iv to decrypt Deezers media files.
* **How to retrieve it?**: While we cannot provide the specific secret_key in this documentation, it can be found online through various sources or developer communities that focus on Deezer. * **How to retrieve it?**: While we cannot provide the specific secret_key in this documentation, it can be found online through various sources or developer communities that focus on Deezer.
4. `iv` 3. `output_dir` (optional)
* **What is it?**: This is another cryptographic variable needed to decrypt media streams from Deezer. * **What is it?**: The `output_dir` is the path where downloaded music files will be saved.
* **How to retrieve it?**: The iv can be found in the [.example-config](https://github.com/mathismqn/godeez/blob/main/.example-config) file included with this project. * **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
```
## Usage ## Usage
### CLI Overview ### CLI Overview
When you run **godeez** without any additional commands, youll see a general help menu: When you run `godeez` without any additional commands, youll see a general help menu:
```bash ```bash
GoDeez is a tool to download music from Deezer GoDeez is a tool to download music from Deezer
@@ -93,7 +104,7 @@ Available Commands:
help Help about any command help Help about any command
Flags: Flags:
--config string config file (default is $HOME/.godeez) --config string config file (default ~/.godeez/config.toml)
-h, --help help for godeez -h, --help help for godeez
Use "godeez [command] --help" for more information about a command. Use "godeez [command] --help" for more information about a command.
@@ -108,16 +119,17 @@ Usage:
godeez download [command] godeez download [command]
Available Commands: Available Commands:
album Download songs from one or more albums album Download songs from album
playlist Download songs from one or more playlists playlist Download songs from playlist
Flags: Flags:
-h, --help help for download --bpm fetch BPM/key and add to file tags
-o, --output string output directory (default is current directory) -h, --help help for download
-q, --quality string download quality [mp3_128, mp3_320, flac, best] (default is best) -q, --quality string download quality [mp3_128, mp3_320, flac, best] (default "best")
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
Global Flags: Global Flags:
--config string config file (default is $HOME/.godeez) --config string config file (default ~/.godeez/config.toml)
Use "godeez download [command] --help" for more information about a command. Use "godeez download [command] --help" for more information about a command.
``` ```
@@ -133,3 +145,7 @@ Every bit of support counts, so dont forget to give the project a star if you
## License ## License
This project is licensed under the MIT License. See the [LICENSE](https://github.com/mathismqn/godeez/blob/main/LICENSE) file for details. This project is licensed under the MIT License. See the [LICENSE](https://github.com/mathismqn/godeez/blob/main/LICENSE) file for details.
---
> ⚠️ This tool is provided for educational and personal use only. Please ensure your usage complies with Deezers Terms of Service.
+33 -133
View File
@@ -1,19 +1,16 @@
package cmd package cmd
import ( import (
"context"
"errors"
"fmt" "fmt"
"os" "time"
"path"
"strings"
"github.com/flytam/filenamify" "github.com/mathismqn/godeez/internal/downloader"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/tags"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var outputDir string var opts downloader.Options
var quality string
var downloadCmd = &cobra.Command{ var downloadCmd = &cobra.Command{
Use: "download", Use: "download",
@@ -22,137 +19,40 @@ var downloadCmd = &cobra.Command{
func init() { func init() {
RootCmd.AddCommand(downloadCmd) RootCmd.AddCommand(downloadCmd)
downloadCmd.PersistentFlags().StringVarP(&outputDir, "output", "o", "", "output directory (default is current directory)")
downloadCmd.PersistentFlags().StringVarP(&quality, "quality", "q", "", "download quality [mp3_128, mp3_320, flac, best] (default is best)") downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "best", "download quality [mp3_128, mp3_320, flac, best]")
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.AddCommand(
newDownloadCmd("album"),
newDownloadCmd("playlist"),
)
} }
func validateInput() { func newDownloadCmd(resourceType string) *cobra.Command {
if quality == "" { cmd := &cobra.Command{
quality = "best" Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType),
} Short: fmt.Sprintf("Download songs from %s", resourceType),
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
return opts.Validate()
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dl := downloader.New(appConfig, resourceType)
validQualities := map[string]bool{ if err := dl.Run(ctx, opts, args[0]); err != nil {
"mp3_128": true, if errors.Is(err, context.Canceled) {
"mp3_320": true, return nil
"flac": true,
"best": true,
}
if !validQualities[quality] {
fmt.Fprintf(os.Stderr, "Error: invalid quality option: %s\n", quality)
os.Exit(1)
}
}
func downloadContent(contentType string, args []string) {
nArgs := len(args)
separator := "--------------------------------------------------"
for i, id := range args {
fmt.Println(separator)
fmt.Printf("[%d/%d] Getting data for %s %s...", i+1, nArgs, contentType, id)
var resource deezer.Resource
var songs []*deezer.Song
switch contentType {
case "album":
album := &deezer.Album{}
if err := deezer.GetData(album, id); err != nil {
fmt.Printf("\r[%d/%d] Getting data for album %s... FAILED\n", i+1, nArgs, id)
fmt.Fprintf(os.Stderr, "Error: could not get album data: %v\n", err)
continue
}
resource = album
songs = album.GetSongs()
case "playlist":
playlist := &deezer.Playlist{}
if err := deezer.GetData(playlist, id); err != nil {
fmt.Printf("\r[%d/%d] Getting data for playlist %s... FAILED\n", i+1, nArgs, id)
fmt.Fprintf(os.Stderr, "Error: could not get playlist data: %v\n", err)
continue
}
if playlist.Data.Status == 1 && playlist.Data.CollabKey == "" {
fmt.Printf("\r[%d/%d] Getting data for playlist %s... FAILED\n", i+1, nArgs, id)
fmt.Fprintf(os.Stderr, "Error: playlist is private and no valid arl cookie was provided\n")
continue
}
resource = playlist
songs = playlist.GetSongs()
}
fmt.Printf("\r[%d/%d] Getting data for %s %s... DONE\n", i+1, nArgs, contentType, id)
output := resource.GetOutputPath(outputDir)
if _, err := os.Stat(output); os.IsNotExist(err) {
if err := os.MkdirAll(output, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not create output directory: %v\n", err)
continue
}
}
title := resource.GetTitle()
fmt.Printf("Starting download of %s: %s\n", contentType, title)
for _, song := range songs {
songTitle := song.Title
if song.Version != "" {
songTitle = fmt.Sprintf("%s %s", song.Title, song.Version)
}
fmt.Printf(" Downloading %s...", songTitle)
media, err := song.GetMediaData(quality)
if err != nil {
fmt.Printf("\r Downloading %s... FAILED\n", songTitle)
fmt.Fprintf(os.Stderr, "Error: could not get media data: %v\n", err)
if err.Error() == "invalid license token" {
os.Exit(1)
} }
continue
return err
} }
if len(media.Data) == 0 || len(media.Data[0].Media) == 0 || len(media.Data[0].Media[0].Sources) == 0 { return nil
fmt.Printf("\r Downloading %s... FAILED\n", songTitle) },
fmt.Fprintf(os.Stderr, "Error: could not get media sources\n")
continue
}
url := media.Data[0].Media[0].Sources[0].URL
for _, source := range media.Data[0].Media[0].Sources {
if source.Provider == "ak" {
url = source.URL
break
}
}
ext := "mp3"
if media.Data[0].Media[0].Format == "FLAC" {
ext = "flac"
}
trackNumber := ""
if contentType == "album" {
trackNumber = song.TrackNumber + "."
}
fileName := fmt.Sprintf("%s %s - %s.%s", trackNumber, songTitle, strings.Join(song.Contributors.MainArtists, ", "), ext)
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
filePath := path.Join(output, fileName)
err = media.Download(url, filePath, song.ID)
if err != nil {
fmt.Printf("\r Downloading %s... FAILED\n", songTitle)
fmt.Fprintf(os.Stderr, "Error: could not download song: %v\n", err)
continue
}
fmt.Printf("\r Downloading %s... DONE\n", songTitle)
if err := tags.AddTags(resource, song, filePath); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not add tags to song: %v\n", err)
}
}
} }
fmt.Println(separator) return cmd
fmt.Println("All downloads completed")
} }
-17
View File
@@ -1,17 +0,0 @@
package cmd
import "github.com/spf13/cobra"
var albumCmd = &cobra.Command{
Use: "album [album_id...]",
Short: "Download songs from one or more albums",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
validateInput()
downloadContent("album", args)
},
}
func init() {
downloadCmd.AddCommand(albumCmd)
}
-17
View File
@@ -1,17 +0,0 @@
package cmd
import "github.com/spf13/cobra"
var playlistCmd = &cobra.Command{
Use: "playlist [playlist_id...]",
Short: "Download songs from one or more playlists",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
validateInput()
downloadContent("playlist", args)
},
}
func init() {
downloadCmd.AddCommand(playlistCmd)
}
+13 -64
View File
@@ -1,78 +1,27 @@
package cmd package cmd
import ( import (
"fmt"
"os"
"path"
"github.com/mathismqn/godeez/internal/config" "github.com/mathismqn/godeez/internal/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
var cfgFile string var (
cfgPath string
appConfig *config.Config
)
var RootCmd = &cobra.Command{ var RootCmd = &cobra.Command{
Use: "godeez", Use: "godeez",
Short: "GoDeez is a tool to download music from Deezer", Short: "GoDeez is a tool to download music from Deezer",
Run: func(cmd *cobra.Command, args []string) { SilenceUsage: true,
cmd.Help() PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
var err error
appConfig, err = config.New(cfgPath)
return err
}, },
} }
func init() { func init() {
RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.godeez)") RootCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "config file (default ~/.godeez/config.toml)")
cobra.OnInitialize(initConfig)
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
homedir, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: could not get home directory: %v\n", err)
os.Exit(1)
}
path := path.Join(homedir, ".godeez")
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("Config file not found, creating one at %s\n", path)
content := []byte("arl_cookie = ''\nlicense_token = ''\nsecret_key = ''\niv = '0001020304050607'\n")
if err := os.WriteFile(path, content, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not create config file: %v\n", err)
os.Exit(1)
}
}
viper.AddConfigPath(homedir)
viper.SetConfigName(".godeez")
}
viper.SetConfigType("toml")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not read config file: %v\n", err)
os.Exit(1)
}
cfg := &config.Cfg
if err := viper.Unmarshal(&cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not unmarshal config file: %v\n", err)
os.Exit(1)
}
if cfg.LicenseToken == "" {
fmt.Fprintln(os.Stderr, "Error: license_token is not set in config file")
os.Exit(1)
}
if cfg.SecretKey == "" {
fmt.Fprintln(os.Stderr, "Error: secret_key is not set in config file")
os.Exit(1)
}
if cfg.IV == "" {
fmt.Fprintln(os.Stderr, "Error: iv is not set in config file")
os.Exit(1)
}
} }
+11 -2
View File
@@ -8,9 +8,13 @@ require (
) )
require ( 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/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // 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/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -22,19 +26,24 @@ require (
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr 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/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.26.0 // 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 golang.org/x/text v0.19.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
require ( require (
github.com/PuerkitoBio/goquery v1.10.0
github.com/bogem/id3v2/v2 v2.1.4 github.com/bogem/id3v2/v2 v2.1.4
github.com/briandowns/spinner v1.23.2
github.com/flytam/filenamify v1.2.0 github.com/flytam/filenamify v1.2.0
github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.1 github.com/go-flac/go-flac/v2 v2.0.1
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.6 // indirect
go.etcd.io/bbolt v1.4.0
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.28.0
) )
+39 -4
View File
@@ -1,10 +1,18 @@
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/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI= github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8= github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -29,6 +37,11 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@@ -51,8 +64,9 @@ 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/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 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/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 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -63,11 +77,14 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.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 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 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 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
@@ -79,29 +96,47 @@ golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 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/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.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 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+176
View File
@@ -0,0 +1,176 @@
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
}
+77 -5
View File
@@ -1,10 +1,82 @@
package config 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 { type Config struct {
LicenseToken string `mapstructure:"license_token"` ArlCookie string `mapstructure:"arl_cookie"`
ArlCookie string `mapstructure:"arl_cookie"` SecretKey string `mapstructure:"secret_key"`
SecretKey string `mapstructure:"secret_key"` OutputDir string `mapstructure:"output_dir"`
IV string `mapstructure:"iv"`
} }
var Cfg Config func New(cfgPath string) (*Config, error) {
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)
}
if cfgPath == "" {
cfgPath = path.Join(cfgDir, "config.toml")
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
fmt.Printf("Config file not found, creating one at %s\n", cfgPath)
content := []byte("arl_cookie = ''\nsecret_key = ''\noutput_dir = ''\n")
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
return nil, fmt.Errorf("failed to create config file: %w", err)
}
os.Exit(0)
}
}
viper.SetConfigFile(cfgPath)
viper.SetConfigType("toml")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
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 {
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
}
@@ -3,18 +3,18 @@ package crypto
import ( import (
"crypto/cipher" "crypto/cipher"
"crypto/md5" "crypto/md5"
"encoding/hex"
"fmt" "fmt"
"github.com/mathismqn/godeez/internal/config"
"golang.org/x/crypto/blowfish" "golang.org/x/crypto/blowfish"
) )
func GetBlowfishKey(songID string) []byte { var iv = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
func GetKey(secretKey, songID string) []byte {
hash := md5.Sum([]byte(songID)) hash := md5.Sum([]byte(songID))
hashHex := fmt.Sprintf("%x", hash) hashHex := fmt.Sprintf("%x", hash)
key := []byte(config.Cfg.SecretKey) key := []byte(secretKey)
for i := 0; i < len(hash); i++ { for i := 0; i < len(hash); i++ {
key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16] key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16]
} }
@@ -22,17 +22,12 @@ func GetBlowfishKey(songID string) []byte {
return key return key
} }
func DecryptBlowfish(data, key []byte) ([]byte, error) { func Decrypt(data, key []byte) ([]byte, error) {
block, err := blowfish.NewCipher(key) block, err := blowfish.NewCipher(key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
iv, err := hex.DecodeString(config.Cfg.IV)
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv) mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data)) decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data) mode.CryptBlocks(decrypted, data)
+53 -29
View File
@@ -4,45 +4,69 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path" "path"
"strconv"
"time"
"github.com/flytam/filenamify" "github.com/flytam/filenamify"
) )
type Album struct { type Album struct {
Data struct { Results struct {
Title string `json:"ALB_TITLE"` Data struct {
Artist string `json:"ART_NAME"` Title string `json:"ALB_TITLE"`
OriginalReleaseDate string `json:"ORIGINAL_RELEASE_DATE"` Artist string `json:"ART_NAME"`
PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"` OriginalReleaseDate string `json:"ORIGINAL_RELEASE_DATE"`
Label string `json:"LABEL_NAME"` PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"`
ProducerLine string `json:"PRODUCER_LINE"` Label string `json:"LABEL_NAME"`
} `json:"DATA"` ProducerLine string `json:"PRODUCER_LINE"`
Songs struct { Duration string `json:"DURATION"`
Data []*Song `json:"data"` } `json:"DATA"`
} `json:"SONGS"` Songs struct {
Data []*Song `json:"data"`
} `json:"SONGS"`
} `json:"results"`
} }
func (a *Album) GetURL(id string) string { func (a *Album) String() string {
return "https://www.deezer.com/en/album/" + id duration, err := strconv.Atoi(a.Results.Data.Duration)
if err != nil {
duration = 0
}
return fmt.Sprintf(
`================= [ Album Info ] =================
Title: %s
Artist: %s
Tracks: %d
Duration: %s
==================================================`,
a.Results.Data.Title,
a.Results.Data.Artist,
len(a.Results.Songs.Data),
time.Duration(duration)*time.Second,
)
} }
func (a *Album) UnmarshalData(data []byte) error { func (a *Album) GetType() string {
return json.Unmarshal(data, a) return "Album"
}
func (a *Album) GetSongs() []*Song {
return a.Songs.Data
}
func (a *Album) GetOutputPath(outputDir string) string {
base := fmt.Sprintf("%s - %s", a.Data.Artist, a.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{})
outputPath := path.Join(outputDir, base)
outputPath, _ = filenamify.Filenamify(outputPath, filenamify.Options{})
return outputPath
} }
func (a *Album) GetTitle() string { func (a *Album) GetTitle() string {
return a.Data.Title return a.Results.Data.Title
}
func (a *Album) GetSongs() []*Song {
return a.Results.Songs.Data
}
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
}
func (a *Album) Unmarshal(data []byte) error {
return json.Unmarshal(data, a)
} }
+185
View File
@@ -0,0 +1,185 @@
package deezer
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/mathismqn/godeez/internal/config"
)
type Client struct {
AppConfig *config.Config
Session *Session
}
func NewClient(ctx context.Context, appConfig *config.Config) (*Client, error) {
session, err := Authenticate(ctx, appConfig.ArlCookie)
if err != nil {
return nil, fmt.Errorf("failed to authenticate: %w", err)
}
return &Client{
AppConfig: appConfig,
Session: session,
}, nil
}
func (c *Client) FetchResource(ctx context.Context, ressource 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,
}
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)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
resp, err := c.Session.HttpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`) {
return fmt.Errorf("invalid playlist ID")
}
if strings.Contains(string(body), `"DATA_ERROR":"album::getData"`) {
return fmt.Errorf("invalid album ID")
}
if strings.Contains(string(body), `"results":{}`) {
return fmt.Errorf("unexpected response")
}
return ressource.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"}]`
}
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, formats, song.TrackToken)
req, err := http.NewRequestWithContext(ctx, "POST", "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
if err != nil {
return nil, err
}
resp, err := c.Session.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var media Media
err = json.Unmarshal(body, &media)
if err != nil {
return nil, err
}
if len(media.Errors) > 0 {
if media.Errors[0].Code == 1000 {
return nil, fmt.Errorf("invalid license token")
}
return nil, fmt.Errorf("%s", media.Errors[0].Message)
}
if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 {
if media.Data[0].Errors[0].Code == 2002 {
return nil, fmt.Errorf("invalid track token")
}
return nil, fmt.Errorf("%s", media.Data[0].Errors[0].Message)
}
return &media, nil
}
func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error) {
url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", song.Cover)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := c.Session.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func (c *Client) 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)
if err != nil {
return nil, err
}
streamingClient := *c.Session.HttpClient
streamingClient.Timeout = 0
resp, err := streamingClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return resp.Body, nil
}
+16 -58
View File
@@ -2,10 +2,6 @@ package deezer
import ( import (
"fmt" "fmt"
"net/http"
"os"
"github.com/mathismqn/godeez/internal/crypto"
) )
type Media struct { type Media struct {
@@ -35,64 +31,26 @@ type Source struct {
Provider string `json:"provider"` Provider string `json:"provider"`
} }
const ChunkSize = 2048 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 {
func (m *Media) Download(url, path, songID string) error { return "", fmt.Errorf("no media sources found")
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
} }
file, err := os.Create(path) url := m.Data[0].Media[0].Sources[0].URL
if err != nil { for _, source := range m.Data[0].Media[0].Sources {
return err if source.Provider == "ak" {
} url = source.URL
defer file.Close()
key := crypto.GetBlowfishKey(songID)
buffer := make([]byte, ChunkSize)
for chunk := 0; ; chunk++ {
totalRead := 0
for totalRead < ChunkSize {
n, err := resp.Body.Read(buffer[totalRead:])
if err != nil {
if err.Error() == "EOF" {
break
}
return err
}
if n > 0 {
totalRead += n
}
}
if totalRead == 0 {
break
}
if chunk%3 == 0 && totalRead == ChunkSize {
buffer, err = crypto.DecryptBlowfish(buffer, key)
if err != nil {
return err
}
}
_, err = file.Write(buffer[:totalRead])
if err != nil {
return err
}
if totalRead < ChunkSize {
break break
} }
} }
return nil 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
} }
+44 -25
View File
@@ -2,42 +2,61 @@ package deezer
import ( import (
"encoding/json" "encoding/json"
"fmt"
"path" "path"
"time"
"github.com/flytam/filenamify" "github.com/flytam/filenamify"
) )
type Playlist struct { type Playlist struct {
Data struct { Results struct {
Title string `json:"TITLE"` Data struct {
Status int `json:"STATUS"` Title string `json:"TITLE"`
CollabKey string `json:"COLLAB_KEY"` Status int `json:"STATUS"`
} `json:"DATA"` Creator string `json:"PARENT_USERNAME"`
Songs struct { Duration int `json:"DURATION"`
Data []*Song `json:"data"` } `json:"DATA"`
} `json:"SONGS"` Songs struct {
Data []*Song `json:"data"`
} `json:"SONGS"`
} `json:"results"`
} }
func (p *Playlist) GetURL(id string) string { func (p *Playlist) String() string {
return "https://www.deezer.com/en/playlist/" + id return fmt.Sprintf(
`=============== [ Playlist Info ] ===============
Title: %s
Creator: %s
Tracks: %d
Duration: %s
=================================================`,
p.Results.Data.Title,
p.Results.Data.Creator,
len(p.Results.Songs.Data),
time.Duration(p.Results.Data.Duration)*time.Second,
)
} }
func (p *Playlist) UnmarshalData(data []byte) error { func (p *Playlist) GetType() string {
return json.Unmarshal(data, p) return "Playlist"
}
func (p *Playlist) GetSongs() []*Song {
return p.Songs.Data
}
func (p *Playlist) GetOutputPath(outputDir string) string {
p.Data.Title, _ = filenamify.Filenamify(p.Data.Title, filenamify.Options{})
outputPath := path.Join(outputDir, p.Data.Title)
outputPath, _ = filenamify.Filenamify(outputPath, filenamify.Options{})
return outputPath
} }
func (p *Playlist) GetTitle() string { func (p *Playlist) GetTitle() string {
return p.Data.Title return p.Results.Data.Title
}
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)
return outputDir
}
func (p *Playlist) Unmarshal(data []byte) error {
return json.Unmarshal(data, p)
} }
+4 -52
View File
@@ -1,57 +1,9 @@
package deezer package deezer
import (
"fmt"
"io"
"net/http"
"regexp"
"github.com/mathismqn/godeez/internal/config"
)
type Resource interface { type Resource interface {
GetURL(id string) string
UnmarshalData(data []byte) error
GetSongs() []*Song
GetOutputPath(outputDir string) string
GetTitle() string GetTitle() string
} GetType() string
GetSongs() []*Song
func GetData(r Resource, id string) error { GetOutputDir(outputDir string) string
url := r.GetURL(id) Unmarshal(data []byte) error
client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
if config.Cfg.ArlCookie != "" {
req.AddCookie(&http.Cookie{
Name: "arl",
Value: config.Cfg.ArlCookie,
})
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("resource not found")
}
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
re := regexp.MustCompile(`window\.__DZR_APP_STATE__ = (\{.*\})`)
matches := re.FindStringSubmatch(string(body))
if len(matches) != 2 {
return fmt.Errorf("error parsing response")
}
return r.UnmarshalData([]byte(matches[1]))
} }
+89
View File
@@ -0,0 +1,89 @@
package deezer
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"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) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
client := &http.Client{
Timeout: 20 * time.Second,
Jar: jar,
}
url := "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token="
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.AddCookie(&http.Cookie{
Name: "arl",
Value: arlCookie,
})
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var res UserDataResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, err
}
if res.Results.User.Id == 0 {
return nil, fmt.Errorf("invalid arl cookie")
}
isPremium := res.Results.User.Options.MobileOffline || res.Results.User.Options.WebOffline
return &Session{
ArlCookie: arlCookie,
APIToken: res.Results.APIToken,
LicenseToken: res.Results.User.Options.LicenseToken,
HttpClient: client,
Premium: isPremium,
}, nil
}
+17 -62
View File
@@ -1,13 +1,9 @@
package deezer package deezer
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"github.com/mathismqn/godeez/internal/config" "github.com/flytam/filenamify"
) )
type Song struct { type Song struct {
@@ -28,68 +24,27 @@ type Song struct {
TrackToken string `json:"TRACK_TOKEN"` TrackToken string `json:"TRACK_TOKEN"`
} }
func (s *Song) GetMediaData(quality string) (*Media, error) { func (s *Song) GetTitle() string {
var formats string songTitle := s.Title
if s.Version != "" {
switch quality { songTitle = fmt.Sprintf("%s %s", s.Title, s.Version)
case "mp3_128":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "mp3_320":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"}]`
case "flac":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"}]`
case "best":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
} }
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, config.Cfg.LicenseToken, formats, s.TrackToken) return songTitle
resp, err := http.Post("https://media.deezer.com/v1/get_url", "application/json", bytes.NewBuffer([]byte(reqBody)))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var media Media
err = json.Unmarshal(body, &media)
if err != nil {
return nil, err
}
if len(media.Errors) > 0 {
if media.Errors[0].Code == 1000 {
return nil, fmt.Errorf("invalid license token")
}
return nil, fmt.Errorf("%s", media.Errors[0].Message)
}
if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 {
if media.Data[0].Errors[0].Code == 2002 {
return nil, fmt.Errorf("invalid track token")
}
return nil, fmt.Errorf("%s", media.Data[0].Errors[0].Message)
}
return &media, nil
} }
func (s *Song) GetCoverImage() ([]byte, error) { func (s *Song) GetFileName(resourceType string, song *Song, media *Media) string {
url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", s.Cover) ext := "mp3"
resp, err := http.Get(url) if media.Data[0].Media[0].Format == "FLAC" {
if err != nil { ext = "flac"
return nil, err
} }
defer resp.Body.Close() trackNumber := ""
if resourceType == "album" {
if resp.StatusCode != http.StatusOK { trackNumber = song.TrackNumber + ". "
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
} }
return io.ReadAll(resp.Body) fileName := fmt.Sprintf("%s%s - %s.%s", trackNumber, s.Artist, s.GetTitle(), ext)
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
return fileName
} }
+311
View File
@@ -0,0 +1,311 @@
package downloader
import (
"context"
"errors"
"fmt"
"io"
"os"
"path"
"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/store"
"github.com/mathismqn/godeez/internal/tags"
)
const chunkSize = 2048
type Client struct {
appConfig *config.Config
resourceType string
deezerClient *deezer.Client
hashIndexOnce sync.Once
hashIndex *fileutil.HashIndex
hashIndexErr error
}
func New(appConfig *config.Config, resourceType string) *Client {
return &Client{
appConfig: appConfig,
resourceType: resourceType,
deezerClient: nil,
}
}
func (c *Client) Run(ctx context.Context, opts Options, id string) error {
var err error
c.deezerClient, err = deezer.NewClient(ctx, c.appConfig)
if err != nil {
return err
}
if !c.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") {
return fmt.Errorf("premium account required for %s quality", opts.Quality)
}
var resource deezer.Resource
switch c.resourceType {
case "album":
resource = &deezer.Album{}
case "playlist":
resource = &deezer.Playlist{}
default:
return fmt.Errorf("unsupported resource type: %s", c.resourceType)
}
if err := c.deezerClient.FetchResource(ctx, resource, id); err != nil {
return fmt.Errorf("failed to fetch resource: %w", err)
}
songs := resource.GetSongs()
if len(songs) == 0 {
return fmt.Errorf("%s has no songs", c.resourceType)
}
rootOutputDir := c.appConfig.OutputDir
resourceOutputDir := resource.GetOutputDir(rootOutputDir)
if err := fileutil.EnsureDir(resourceOutputDir); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
startTime := time.Now()
fmt.Printf("%s\n\nStarting download...\n\n", resource)
downloaded := 0
skipped := 0
failed := 0
for i, song := range songs {
if ctx.Err() != nil {
return ctx.Err()
}
trackProgress := fmt.Sprintf("[%d/%d]", i+1, len(songs))
sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
sp.Writer = os.Stdout
sp.Prefix = trackProgress + " "
sp.Suffix = fmt.Sprintf(" Downloading: %s - %s", song.Artist, song.Title)
sp.Start()
warnings, err := c.downloadSong(ctx, resource, song, opts, resourceOutputDir)
sp.Stop()
if err != nil {
if errors.Is(err, context.Canceled) {
return err
}
if path, ok := IsSkipError(err); ok {
skipped++
fmt.Printf("%s ↷ Skipped: %s - %s\n Already exists at: %s\n", trackProgress, song.Artist, song.Title, path)
continue
}
failed++
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n", trackProgress, song.Artist, song.Title, err)
continue
}
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)
}
}
fmt.Printf(`
================== [ Summary ] ==================
Downloaded: %d
Skipped: %d
Failed: %d
Elapsed time: %s
Files saved to: %s
=================================================
`,
downloaded,
skipped,
failed,
time.Since(startTime).Round(time.Second),
resourceOutputDir,
)
return nil
}
func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, song *deezer.Song, opts Options, outputDir string) ([]string, error) {
var warnings []string
media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality)
if err != nil {
return warnings, 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 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)
}
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
key := crypto.GetKey(c.appConfig.SecretKey, song.ID)
if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil {
fileutil.DeleteFile(outputPath)
return warnings, 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))
}
}
}
cover, err := c.deezerClient.FetchCoverImage(ctx, song)
if err != nil && !errors.Is(err, context.Canceled) {
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
}
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, cover, metrics)...)
return warnings, nil
}
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
defer stream.Close()
file, err := os.Create(outputPath)
if err != nil {
return err
}
defer file.Close()
buffer := make([]byte, chunkSize)
for chunk := 0; ; chunk++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
// continue
}
totalRead := 0
for totalRead < chunkSize {
n, err := stream.Read(buffer[totalRead:])
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return err
}
if n > 0 {
totalRead += n
}
}
if totalRead == 0 {
break
}
if chunk%3 == 0 && totalRead == chunkSize {
buffer, err = crypto.Decrypt(buffer, key)
if err != nil {
return err
}
}
_, err = file.Write(buffer[:totalRead])
if err != nil {
return err
}
if totalRead < chunkSize {
break
}
}
return nil
}
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat string, cover []byte, metrics *bpm.Metrics) []string {
var warnings []string
if err := tags.AddTags(resource, song, cover, outputPath, metrics.BPM, metrics.Key); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err))
}
hash, err := fileutil.GetFileHash(outputPath)
if err != nil {
warnings = append(warnings, fmt.Sprintf("failed to get file hash: %v", err))
}
info := &store.DownloadInfo{
SongID: song.ID,
Quality: mediaFormat,
Path: outputPath,
Hash: hash,
Downloaded: time.Now(),
}
if err := info.Save(); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to save download info: %v", err))
}
return warnings
}
func (c *Client) initHashIndex(ctx context.Context) error {
c.hashIndexOnce.Do(func() {
c.hashIndex, c.hashIndexErr = fileutil.NewHashIndex(ctx, c.appConfig.OutputDir)
})
return c.hashIndexErr
}
+27
View File
@@ -0,0 +1,27 @@
package downloader
import (
"fmt"
"time"
)
var validQualities = map[string]bool{
"mp3_128": true,
"mp3_320": true,
"flac": true,
"best": true,
}
type Options struct {
Quality string
Timeout time.Duration
BPM bool
}
func (o *Options) Validate() error {
if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality)
}
return nil
}
+43
View File
@@ -0,0 +1,43 @@
package downloader
import (
"context"
"github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/store"
)
type SkipError struct {
Path string
}
func (e SkipError) Error() string {
return e.Path
}
func IsSkipError(err error) (string, bool) {
if skipErr, ok := err.(SkipError); ok {
return skipErr.Path, true
}
return "", false
}
func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) {
if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat {
if fileutil.FileExists(existing.Path) {
return existing.Path, true
}
if existing.Hash != "" {
if err := c.initHashIndex(ctx); err == nil {
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok {
existing.Path = foundPath
_ = existing.Save()
return foundPath, true
}
}
}
}
return "", false
}
+52
View File
@@ -0,0 +1,52 @@
package fileutil
import (
"crypto/sha256"
"fmt"
"io"
"os"
)
func EnsureDir(path string) error {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return os.MkdirAll(path, 0755)
}
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("file already exists at %s", path)
}
return nil
}
func FileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func DeleteFile(path string) error {
if !FileExists(path) {
return nil
}
return os.Remove(path)
}
func GetFileHash(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
+56
View File
@@ -0,0 +1,56 @@
package fileutil
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
)
type HashIndex struct {
files map[string]string
}
func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
index := &HashIndex{files: make(map[string]string)}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil || info.IsDir() {
return nil
}
file, err := os.Open(path)
if err != nil {
return nil
}
defer file.Close()
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return nil
}
sum := hex.EncodeToString(h.Sum(nil))
index.files[sum] = path
return nil
})
if err != nil {
return nil, err
}
return index, nil
}
func (h *HashIndex) Find(hash string) (string, bool) {
path, ok := h.files[hash]
return path, ok
}
+59
View File
@@ -0,0 +1,59 @@
package store
import (
"encoding/json"
"fmt"
"time"
"go.etcd.io/bbolt"
)
type DownloadInfo struct {
SongID string `json:"song_id"`
Quality string `json:"quality"`
Path string `json:"path"`
Hash string `json:"hash"`
Downloaded time.Time `json:"downloaded_at"`
}
func GetDownloadInfo(songID string) (*DownloadInfo, error) {
var info DownloadInfo
if err := db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(trackBucket)
if b == nil {
return fmt.Errorf("bucket not found")
}
data := b.Get([]byte(songID))
if data == nil {
return fmt.Errorf("not found")
}
return json.Unmarshal(data, &info)
}); err != nil {
return nil, err
}
return &info, nil
}
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")
}
data, err := json.Marshal(d)
if err != nil {
return err
}
err = b.Put([]byte(d.SongID), data)
if err != nil {
return err
}
return nil
})
}
+32
View File
@@ -0,0 +1,32 @@
package store
import (
"fmt"
"path"
bolt "go.etcd.io/bbolt"
)
var (
db *bolt.DB
trackBucket = []byte("tracks")
)
func OpenDB(cfgDir string) error {
var err error
dbPath := path.Join(cfgDir, "tracks.db")
db, err = bolt.Open(dbPath, 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
}
+26 -22
View File
@@ -10,25 +10,25 @@ import (
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
) )
type FLACTagger struct { type flacTagger struct {
File *flac.File file *flac.File
Cmts *flacvorbis.MetaDataBlockVorbisComment cmts *flacvorbis.MetaDataBlockVorbisComment
Index int index int
} }
func (t *FLACTagger) AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, path string) error { func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error {
if album, ok := resource.(*deezer.Album); ok { if album, ok := resource.(*deezer.Album); ok {
dateParts := strings.Split(album.Data.PhysicalReleaseDate, "-") dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-")
if len(dateParts) == 3 { if len(dateParts) == 3 {
album.Data.PhysicalReleaseDate = dateParts[0] album.Results.Data.PhysicalReleaseDate = dateParts[0]
} }
t.addTag("ALBUM", album.Data.Title) t.addTag("ALBUM", album.Results.Data.Title)
t.addTag("ALBUMARTIST", album.Data.Artist) t.addTag("ALBUMARTIST", album.Results.Data.Artist)
t.addTag("PUBLISHER", album.Data.Label) t.addTag("PUBLISHER", album.Results.Data.Label)
t.addTag("ORIGINALDATE", album.Data.OriginalReleaseDate) t.addTag("ORIGINALDATE", album.Results.Data.OriginalReleaseDate)
t.addTag("DATE", album.Data.PhysicalReleaseDate) t.addTag("DATE", album.Results.Data.PhysicalReleaseDate)
t.addTag("COMMENT", album.Data.ProducerLine) t.addTag("COMMENT", album.Results.Data.ProducerLine)
t.addTag("TRACKNUMBER", song.TrackNumber) t.addTag("TRACKNUMBER", song.TrackNumber)
} }
@@ -39,11 +39,15 @@ func (t *FLACTagger) AddTags(resource deezer.Resource, song *deezer.Song, cover
t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain) t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain)
t.addTag("ISRC", song.ISRC) t.addTag("ISRC", song.ISRC)
cmtsmeta := t.Cmts.Marshal() t.addTag("BPM", tempo)
if t.Index > 0 { t.addTag("KEY", key)
t.File.Meta[t.Index] = &cmtsmeta t.addTag("INITIALKEY", key)
cmtsmeta := t.cmts.Marshal()
if t.index > 0 {
t.file.Meta[t.index] = &cmtsmeta
} else { } 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") picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg")
@@ -51,20 +55,20 @@ func (t *FLACTagger) AddTags(resource deezer.Resource, song *deezer.Song, cover
return err return err
} }
picturemeta := picture.Marshal() picturemeta := picture.Marshal()
t.File.Meta = append(t.File.Meta, &picturemeta) t.file.Meta = append(t.file.Meta, &picturemeta)
return t.saveTags(path) return t.saveTags(path)
} }
func (t *FLACTagger) addTag(name, value string) { func (t *flacTagger) addTag(name, value string) {
if value != "" { if value != "" {
t.Cmts.Add(name, value) t.cmts.Add(name, value)
} }
} }
func (t *FLACTagger) saveTags(path string) error { func (t *flacTagger) saveTags(path string) error {
tempPath := path + ".tmp" tempPath := path + ".tmp"
t.File.Save(tempPath) t.file.Save(tempPath)
return os.Rename(tempPath, path) return os.Rename(tempPath, path)
} }
+25 -19
View File
@@ -9,23 +9,26 @@ import (
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
) )
type ID3v2Tagger struct { type id3v2Tagger struct {
Tag *id3v2.Tag tag *id3v2.Tag
} }
func (t *ID3v2Tagger) AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, path string) error { func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error {
defer t.Tag.Close() defer t.tag.Close()
duration, _ := strconv.Atoi(song.Duration) duration, err := strconv.Atoi(song.Duration)
if err != nil {
return err
}
song.Duration = fmt.Sprintf("%d", duration*1000) song.Duration = fmt.Sprintf("%d", duration*1000)
if album, ok := resource.(*deezer.Album); ok { if album, ok := resource.(*deezer.Album); ok {
t.addTag("TALB", album.Data.Title) t.addTag("TALB", album.Results.Data.Title)
t.addTag("TPE2", album.Data.Artist) t.addTag("TPE2", album.Results.Data.Artist)
t.addTag("TPUB", album.Data.Label) t.addTag("TPUB", album.Results.Data.Label)
t.addTag("TDOR", album.Data.OriginalReleaseDate) t.addTag("TDOR", album.Results.Data.OriginalReleaseDate)
t.addTag("TYER", album.Data.PhysicalReleaseDate) t.addTag("TYER", album.Results.Data.PhysicalReleaseDate)
t.addTag("COMM", album.Data.ProducerLine) t.addTag("COMM", album.Results.Data.ProducerLine)
t.addTag("TRCK", song.TrackNumber) t.addTag("TRCK", song.TrackNumber)
} }
@@ -37,31 +40,34 @@ func (t *ID3v2Tagger) AddTags(resource deezer.Resource, song *deezer.Song, cover
t.addTXXXTag("GAIN", song.Gain) t.addTXXXTag("GAIN", song.Gain)
t.addTXXXTag("ISRC", song.ISRC) t.addTXXXTag("ISRC", song.ISRC)
t.addTag("TBPM", tempo)
t.addTag("TKEY", key)
frame := id3v2.PictureFrame{ frame := id3v2.PictureFrame{
Encoding: t.Tag.DefaultEncoding(), Encoding: t.tag.DefaultEncoding(),
MimeType: "image/jpeg", MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover, PictureType: id3v2.PTFrontCover,
Description: "Cover", Description: "Cover",
Picture: cover, Picture: cover,
} }
t.Tag.AddAttachedPicture(frame) t.tag.AddAttachedPicture(frame)
return t.Tag.Save() return t.tag.Save()
} }
func (t *ID3v2Tagger) addTag(name, value string) { func (t *id3v2Tagger) addTag(name, value string) {
if value != "" { if value != "" {
t.Tag.AddTextFrame(name, t.Tag.DefaultEncoding(), value) t.tag.AddTextFrame(name, t.tag.DefaultEncoding(), value)
} }
} }
func (t *ID3v2Tagger) addTXXXTag(description, value string) { func (t *id3v2Tagger) addTXXXTag(description, value string) {
if value != "" { if value != "" {
udf := id3v2.UserDefinedTextFrame{ udf := id3v2.UserDefinedTextFrame{
Encoding: t.Tag.DefaultEncoding(), Encoding: t.tag.DefaultEncoding(),
Description: description, Description: description,
Value: value, Value: value,
} }
t.Tag.AddUserDefinedTextFrame(udf) t.tag.AddUserDefinedTextFrame(udf)
} }
} }
+9 -12
View File
@@ -9,18 +9,19 @@ import (
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
) )
type Tagger interface { type tagger interface {
AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, path string) error addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error
} }
func NewTagger(filePath string) (Tagger, error) { func newTagger(filePath string) (tagger, error) {
ext := path.Ext(filePath) ext := path.Ext(filePath)
if ext == ".mp3" { if ext == ".mp3" {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &ID3v2Tagger{Tag: tag}, nil
return &id3v2Tagger{tag: tag}, nil
} }
file, err := flac.ParseFile(filePath) file, err := flac.ParseFile(filePath)
@@ -35,18 +36,14 @@ func NewTagger(filePath string) (Tagger, error) {
cmts = flacvorbis.New() cmts = flacvorbis.New()
} }
return &FLACTagger{File: file, Cmts: cmts, Index: idx}, nil return &flacTagger{file: file, cmts: cmts, index: idx}, nil
} }
func AddTags(resource deezer.Resource, song *deezer.Song, filePath string) error { func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key string) error {
tagger, err := NewTagger(filePath) tagger, err := newTagger(filePath)
if err != nil {
return err
}
cover, err := song.GetCoverImage()
if err != nil { if err != nil {
return err return err
} }
return tagger.AddTags(resource, song, cover, filePath) return tagger.addTags(resource, song, cover, filePath, tempo, key)
} }
+11 -2
View File
@@ -1,7 +1,16 @@
package main package main
import "github.com/mathismqn/godeez/cmd" import (
"context"
"os"
"os/signal"
"github.com/mathismqn/godeez/cmd"
)
func main() { func main() {
cmd.RootCmd.Execute() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
cmd.RootCmd.ExecuteContext(ctx)
} }