54 Commits
Author SHA1 Message Date
Mathis Maquenne 2ef1ba42a8 docs: update for v1.3.0 2025-09-11 01:18:14 +02:00
Mathis Maquenne e4c421ff00 feat: add CTA after download and mention it in README 2025-09-11 00:32:42 +02:00
Mathis Maquenne 9d87f635d0 refactor(downloader): improve structure and readability 2025-09-11 00:05:17 +02:00
Mathis Maquenne 8c9b327b2c feat(tags): fetch genre and add it to file tags with --genre 2025-09-10 16:05:53 +02:00
Mathis Maquenne 0493866141 refactor: move BPM fetching logic into provider package 2025-09-10 15:09:24 +02:00
Mathis Maquenne 523b562ed0 Merge branch 'feature/download-track' into dev 2025-09-10 11:11:40 +02:00
Mathis Maquenne c2b4fcfa11 feat(cli): simplify output for individual songs 2025-09-10 11:10:52 +02:00
Felipe Marinho 9b23e3a8d9 feat: download single tracks (#5)
Introduces the `godeez download track <ID>` command, allowing users to download individual songs directly.

Single tracks are saved into a "Singles" folder by default to keep them organized. The README has been updated with documentation and examples for this new functionality.
2025-09-10 10:26:58 +02:00
Mathis Maquenne cf7f76948b chore(tags): add the version of a track to the title tag 2025-09-09 23:47:33 +02:00
Mathis Maquenne 4b2c9c92b8 chore(tags): add copyright tag 2025-09-09 10:37:56 +02:00
Mathis Maquenne 22ddb2408b fix(media): handle empty media 2025-09-09 10:05:42 +02:00
Mathis Maquenne 3022e226ce fix(build): comment out unfinished autostart implementation 2025-08-18 11:29:02 +02:00
Mathis Maquenne c4329431b5 docs: update for v1.2.0 2025-08-18 11:20:29 +02:00
Mathis Maquenne be40910480 chore(deps): update all dependencies 2025-08-18 10:49:42 +02:00
Mathis Maquenne 1813a16bb7 feat(cli): change default download quality from flac to mp3_320 2025-08-09 19:40:06 +02:00
Mathis Maquenne 283c5d76e9 feat(cli): add --strict flag to fail track download if quality is unavailable 2025-08-09 19:17:29 +02:00
Mathis Maquenne 7154c15cc3 refactor(cli): remove --quality=best flag since fallback is now automatic 2025-08-06 22:28:58 +02:00
Mathis Maquenne 50fde9dfa9 feat(media): fallback to lower quality if requested format is unavailable 2025-08-06 22:09:06 +02:00
Mathis Maquenne b4f69396af fix(song): handle empty SNG_CONTRIBUTORS array during unmarshal 2025-08-06 20:45:27 +02:00
Mathis Maquenne fe3491e16d chore(watcher): disable feature due to known DB concurrency issues 2025-08-06 18:18:17 +02:00
Mathis Maquenne ef018b38ad fix(logger): initialize downloader with nil logger to avoid error 2025-06-23 21:03:27 -04:00
Mathis Maquenne 33e774d078 refactor: simplify homeDir and appConfig usage 2025-06-23 20:58:08 -04:00
Mathis Maquenne a619b37922 feat(watcher): add macOS autostart support via launchd 2025-06-23 20:03:56 -04:00
Mathis Maquenne 31d8a3b04f feat(watch): add command to remove a playlist from the watch list 2025-06-22 17:51:17 -04:00
Mathis Maquenne 95c81a2a67 feat(watch): add command to list watched playlists 2025-06-22 17:47:54 -04:00
Mathis Maquenne 498f7c8d3f feat(watch): add command to register a playlist to the watch list 2025-06-22 17:47:05 -04:00
Mathis Maquenne b19c92f227 feat: watch playlists and download newly added tracks 2025-06-22 17:35:16 -04:00
Mathis Maquenne 99bdc22a25 fix(cli): restrict --limit option to download artist command 2025-06-21 16:22:16 -04:00
Mathis Maquenne 0680d8c8e7 chore(deps): update golang.org/x/crypto and golang.org/x/net to patch security vulnerabilities 2025-06-20 20:07:51 -04:00
Mathis Maquenne 67b4fe404e feat: download artist's top tracks with --limit flag 2025-06-20 20:00:59 -04:00
Mathis Maquenne 1fdd0e8c19 docs(readme): clarify quality limitation for free accounts 2025-06-16 16:47:00 -04:00
Mathis Maquenne b4e00e9306 docs(changelog): update for v1.1.1 2025-06-16 16:44:42 -04:00
Mathis Maquenne 327d55cff5 fix: allow downloads without premium account 2025-06-16 16:36:14 -04:00
Mathis Maquenne ce1690137e fix: adjust stream timeout and filename spacing 2025-05-19 22:53:11 +02:00
Mathis Maquenne 7daee4dab6 docs: add changelog 2025-05-19 17:12:22 +02:00
Mathis Maquenne 5951e13abe chore(cli): update descriptions 2025-05-19 16:41:08 +02:00
Mathis Maquenne 56e3e894ab docs: update README 2025-05-19 16:38:36 +02:00
Mathis Maquenne 68f22ce571 refactor: rework output directory handling 2025-05-19 15:38:18 +02:00
Mathis Maquenne 674eb99ffd feat(cli): add bpm flag to enable BPM and key tagging 2025-05-19 14:24:22 +02:00
Mathis Maquenne adfd71204d refactor(cli): remove multi-download support for albums/playlists 2025-05-19 13:59:01 +02:00
Mathis Maquenne e50395ae1d refactor: change output file name 2025-05-19 13:41:25 +02:00
Mathis Maquenne 0a9e6c9a4d refactor(cli): improve output display 2025-05-19 13:35:06 +02:00
Mathis Maquenne 5d18ab2088 fix(context): ignore cancellation errors triggered by user 2025-05-18 22:14:18 +02:00
Mathis Maquenne ebfb312718 refactor(crypto): use IV as a constant 2025-05-07 20:57:12 +02:00
Mathis Maquenne 67af80403b feat(cli): add timeout flag 2025-05-01 20:45:27 +02:00
Mathis Maquenne 0bae78a1ff refactor: major project restructure 2025-05-01 20:30:56 +02:00
Mathis Maquenne c74531b36c perf: download music and retrieve data in parallel 2025-04-26 18:30:48 +02:00
Mathis Maquenne 917ac5f6c2 feat: retrieve modified file paths using hashes 2025-04-26 14:10:27 +02:00
Mathis Maquenne 6d3935c744 feat: delete file if download fails 2025-04-26 13:17:03 +02:00
Mathis Maquenne f931cad746 feat: add local DB to save downloads 2025-04-26 13:09:59 +02:00
Mathis Maquenne 6675834842 refactor: change default output directory 2025-04-26 00:49:56 +02:00
Mathis Maquenne 7302685e34 feat(tags): differentiate between minor and major keys 2025-04-26 00:01:35 +02:00
Mathis Maquenne ddc8240da8 refactor: use a better API to download playlists with more than 40 tracks 2025-04-26 00:01:27 +02:00
Mathis Maquenne d0599f654e feat(tags): add tempo and key 2025-04-26 00:01:19 +02:00
47 changed files with 2634 additions and 682 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/
+56
View File
@@ -0,0 +1,56 @@
# 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.3.0] - 2025-09-11
### Added
- Add new `track` command to download individual songs.
- Add `--genre` flag to fetch and embed genre information into file metadata tags.
### Fixed
- Handle empty media resources gracefully to prevent crashes.
## [1.2.0] - 2025-08-18
### Added
- Add new `artist` command to download an artists top tracks.
- Add `--limit` flag for the `artist` command to restrict the number of tracks.
- Add `--strict` flag for downloads: fail if the requested quality is unavailable.
### Changed
- Set default download quality to **MP3 320kbps**.
### Removed
- Remove `--quality=best` option. Fallback to lower quality is now the default behavior; use the `--strict` flag to prevent fallback.
### Fixed
- Handle error when `SNG_CONTRIBUTORS` metadata is empty.
## [1.1.1] - 2025-06-16
### Fixed
- Restore ability to download tracks without a Deezer Premium account (limited to **MP3 128kbps** for free accounts).
## [1.1.0] - 2025-05-19
### Added
- Support downloading full albums and playlists with more than 40 tracks (previous limit removed).
- Fetch and embed **BPM** and **musical key** into metadata tags.
- Add local **database system** (`tracks.db`) to track downloaded files and avoid re-downloading, even if files are renamed or moved.
- Improve CLI **output formatting** for a cleaner and more informative user experience.
### Changed
- Replace the `godeez` file in the users home directory with a `.godeez/` directory, which now stores both `config.toml` and `tracks.db`.
👉 If upgrading, move your existing config into `.godeez/config.toml`.
- Simplify `config.toml`: remove the need for `iv` and `license_token`.
- Automatically delete corrupted or incomplete files on download failure.
## [1.0.0] - 2024-10-15
### Added
- Initial release of **GoDeez** with basic Deezer album and playlist downloading capabilities.
+86 -40
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) •
@@ -9,49 +13,54 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
[Configuration](#configuration) • [Configuration](#configuration) •
[Usage](#usage) • [Usage](#usage) •
[Contributing](#contributing) • [Contributing](#contributing) •
[Support](#⭐-support-the-project) •
[License](#license) [License](#license)
</div> </div>
## Features ## Features
* Download playlists and albums from Deezer - Download playlists, albums, artists top tracks, and individual tracks
* Select audio quality: MP3 128kbps, MP3 320kbps, or FLAC - Choose audio quality: **MP3 128kbps**, **MP3 320kbps** (default), or **FLAC** (⚠️ nonpremium accounts are limited to 128kbps)
* Automatically adds metadata tags to downloaded files - Automatically embed metadata tags (artist, album, title, artwork, etc.)
* Cross-platform support (works on Windows, macOS, and Linux) - Fetch and tag songs with **BPM**, **musical key**, and **genre**
* Simple and easy-to-use CLI - Skip already-downloaded files using hashes and metadata
- Support Windows, macOS, and Linux
- Provide a simple, easy-to-use CLI
## Installation ## 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. 1. Go to the [Releases](https://github.com/mathismqn/godeez/releases) page.
2. Download the appropriate binary for your operating system (Windows, macOS, or Linux). 2. Download the appropriate binary for your operating system (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): 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.3.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 you need to edit manually
- `tracks.db`: internal database used to track downloads and avoid 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 once: this creates the `.godeez` directory and the `config.toml` file.
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 +68,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
```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: Running `godeez` without arguments shows the help menu:
```bash ```bash
GoDeez is a tool to download music from Deezer GoDeez is a tool to download music from Deezer
@@ -93,14 +105,14 @@ 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.
``` ```
This provides an overview of the available commands and flags.
To download music, you need to use the download command. Heres how the CLI looks when you run `godeez download`: ### Download commands
```bash ```bash
Download songs from Deezer Download songs from Deezer
@@ -108,28 +120,62 @@ Usage:
godeez download [command] godeez download [command]
Available Commands: Available Commands:
album Download songs from one or more albums album Download songs from an album
playlist Download songs from one or more playlists artist Download top songs from an artist
playlist Download songs from a playlist
track Download a single track
Flags: 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 -h, --help help for download
-o, --output string output directory (default is current directory) -q, --quality string download quality [mp3_128, mp3_320, flac] (default "mp3_320")
-q, --quality string download quality [mp3_128, mp3_320, flac, best] (default is best) --strict fail the song download if the quality is not available
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
Global Flags:
--config string config file (default is $HOME/.godeez)
Use "godeez download [command] --help" for more information about a command. 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 ## Contributing
Contributions help make **GoDeez** a better tool for everyone, and any help is greatly appreciated. Contributions help make **GoDeez** a better tool for everyone, and any help is greatly appreciated.
Whether its a bug fix, a new feature, or improving documentation, your input is valuable. Whether its a bug fix, a new feature, or improving documentation, your input is valuable.
If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion. If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion.
Every bit of support counts, so dont forget to give the project a star if you enjoy using it. Thank you for helping make this project better!
## ⭐ Support the Project
If **GoDeez** helps you enjoy your music collection, please consider giving it a star!
**Why star us?**
- Helps more music lovers discover the project
- Shows appreciation for the work and motivates development
- Takes just one click but means the world to us!
## License ## 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.
+59 -126
View File
@@ -1,19 +1,21 @@
package cmd package cmd
import ( import (
"context"
"errors"
"fmt" "fmt"
"os"
"path"
"strings" "strings"
"time"
"github.com/flytam/filenamify" "github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/downloader"
"github.com/mathismqn/godeez/internal/tags"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var outputDir string var (
var quality string opts downloader.Options
cfgPath string
)
var downloadCmd = &cobra.Command{ var downloadCmd = &cobra.Command{
Use: "download", Use: "download",
@@ -22,137 +24,68 @@ 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().StringVar(&cfgPath, "config", "", "config file (default ~/.godeez/config.toml)")
downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]")
downloadCmd.PersistentFlags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
downloadCmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
downloadCmd.PersistentFlags().BoolVar(&opts.Genre, "genre", false, "fetch genre and add to file tags")
downloadCmd.PersistentFlags().BoolVar(&opts.Strict, "strict", false, "fail the song download if the quality is not available")
downloadCmd.AddCommand(
newDownloadCmd("album"),
newDownloadCmd("playlist"),
newDownloadCmd("artist"),
newDownloadCmd("track"),
)
} }
func validateInput() { func newDownloadCmd(resourceType string) *cobra.Command {
if quality == "" { article := "a"
quality = "best" if resourceType == "album" {
article = "an"
} }
validQualities := map[string]bool{ cmd := &cobra.Command{
"mp3_128": true, Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType),
"mp3_320": true, Short: fmt.Sprintf("Download songs from %s %s", article, resourceType),
"flac": true, Args: cobra.ExactArgs(1),
"best": true, PreRunE: func(cmd *cobra.Command, args []string) error {
} appConfig, err := config.New(cfgPath)
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 { if err != nil {
fmt.Printf("\r Downloading %s... FAILED\n", songTitle) return err
fmt.Fprintf(os.Stderr, "Error: could not get media data: %v\n", err)
if err.Error() == "invalid license token" {
os.Exit(1)
} }
continue cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig))
opts.Quality = strings.ToLower(opts.Quality)
return opts.Validate()
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appConfigVal := ctx.Value("appConfig")
appConfig, _ := appConfigVal.(*config.Config)
dl := downloader.New(appConfig, resourceType)
if err := dl.Run(ctx, opts, args[0]); err != nil {
if errors.Is(err, context.Canceled) {
return nil
} }
if len(media.Data) == 0 || len(media.Data[0].Media) == 0 || len(media.Data[0].Media[0].Sources) == 0 { return err
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 return nil
for _, source := range media.Data[0].Media[0].Sources { },
if source.Provider == "ak" {
url = source.URL
break
}
} }
ext := "mp3" switch resourceType {
if media.Data[0].Media[0].Format == "FLAC" { case "artist":
ext = "flac" cmd.Short = "Download top songs from an artist"
} cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download")
trackNumber := "" case "track":
if contentType == "album" { cmd.Short = "Download a single track"
trackNumber = song.TrackNumber + "."
} }
fileName := fmt.Sprintf("%s %s - %s.%s", trackNumber, songTitle, strings.Join(song.Contributors.MainArtists, ", "), ext) return cmd
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)
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)
}
+19 -67
View File
@@ -1,78 +1,30 @@
package cmd package cmd
import ( import (
"fmt"
"os"
"path"
"github.com/mathismqn/godeez/internal/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
var cfgFile string
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 {
// TEMPORARILY DISABLED:
// Watcher autostart (EnsureAutostart) has been disabled due to
// concurrency issues with database access (e.g., when using `download`).
// To re-enable, uncomment the line below.
/*
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
if err := watcher.EnsureAutostart(homeDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to install autostart for watcher: %v\n", err)
}
*/
return nil
}, },
} }
func init() {
RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.godeez)")
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)
}
}
+18
View File
@@ -0,0 +1,18 @@
package cmd
import (
"github.com/spf13/cobra"
)
var watchCmd = &cobra.Command{
Use: "watch",
Short: "Watch playlists and auto-download new tracks",
}
// TEMPORARILY DISABLED:
// The `watch` command and all its subcommands are currently disabled
// due to known issues (e.g., database access conflicts with `download`).
// To re-enable, uncomment the line below.
func init() {
// RootCmd.AddCommand(watchCmd)
}
+49
View File
@@ -0,0 +1,49 @@
package cmd
import (
"fmt"
"strings"
"time"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/cobra"
)
var watchAddCmd = &cobra.Command{
Use: "add <playlist_id>",
Short: "Add a playlist to the watch list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := args[0]
ok, err := store.IsWatched(id)
if err != nil {
return err
}
if ok {
fmt.Printf("Playlist %s is already being watched\n", id)
return nil
}
playlist := &store.WatchedPlaylist{
ID: id,
Quality: strings.ToLower(opts.Quality),
BPM: opts.BPM,
Timeout: opts.Timeout,
}
if err := playlist.Save(); err != nil {
return fmt.Errorf("failed to add playlist %s to watch list: %w", id, err)
}
fmt.Printf("Playlist %s added to watch list\n", id)
return nil
},
}
func init() {
watchCmd.AddCommand(watchAddCmd)
watchAddCmd.Flags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]")
watchAddCmd.Flags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
watchAddCmd.Flags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
}
+41
View File
@@ -0,0 +1,41 @@
package cmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/cobra"
)
var watchListCmd = &cobra.Command{
Use: "list",
Short: "List watched playlists",
RunE: func(cmd *cobra.Command, args []string) error {
playlists, err := store.ListWatchedPlaylists()
if err != nil {
return fmt.Errorf("failed to list watched playlists: %w", err)
}
if len(playlists) == 0 {
fmt.Println("No watched playlists.")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tQuality\tFetch BPM\tTimeout")
fmt.Fprintln(w, "---\t-------\t----------\t-------")
for _, playlist := range playlists {
fmt.Fprintf(w, "%s\t%s\t%t\t%s\n", playlist.ID, playlist.Quality, playlist.BPM, playlist.Timeout)
}
w.Flush()
return nil
},
}
func init() {
watchCmd.AddCommand(watchListCmd)
}
+35
View File
@@ -0,0 +1,35 @@
package cmd
import (
"fmt"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/cobra"
)
var watchRemoveCmd = &cobra.Command{
Use: "remove <playlist_id>",
Short: "Remove a playlist from the watch list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := args[0]
ok, err := store.IsWatched(id)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("playlist %s is not being watched", id)
}
if err := store.RemoveWatchedPlaylist(id); err != nil {
return fmt.Errorf("failed to remove playlist %s from watch list: %w", id, err)
}
fmt.Printf("Playlist %s removed from watch list\n", id)
return nil
},
}
func init() {
watchCmd.AddCommand(watchRemoveCmd)
}
+34
View File
@@ -0,0 +1,34 @@
package cmd
import (
"context"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/watcher"
"github.com/spf13/cobra"
)
var watchRunCmd = &cobra.Command{
Use: "run",
Short: "Start the background playlist watcher",
Hidden: true,
PreRun: func(cmd *cobra.Command, args []string) {
appConfig, err := config.New("")
if err != nil {
return
}
cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig))
},
Run: func(cmd *cobra.Command, args []string) {
ctx := cmd.Context()
appConfigVal := ctx.Value("appConfig")
appConfig, _ := appConfigVal.(*config.Config)
w := watcher.New(appConfig)
w.Run(ctx, opts)
},
}
func init() {
watchCmd.AddCommand(watchRunCmd)
}
+26 -22
View File
@@ -1,40 +1,44 @@
module github.com/mathismqn/godeez module github.com/mathismqn/godeez
go 1.23.1 go 1.24.0
toolchain go1.24.4
require ( require (
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.20.1
) )
require ( require (
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/fatih/color v1.18.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sagikazarmark/locafero v0.10.0 // indirect
github.com/spf13/afero v1.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect golang.org/x/net v0.43.0 // indirect
go.uber.org/multierr v1.9.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/term v0.34.0 // indirect
golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.28.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
require ( require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/bogem/id3v2/v2 v2.1.4 github.com/bogem/id3v2/v2 v2.1.4
github.com/briandowns/spinner v1.23.2
github.com/flytam/filenamify v1.2.0 github.com/flytam/filenamify v1.2.0
github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.1 github.com/go-flac/go-flac/v2 v2.0.4
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.7 // indirect
golang.org/x/crypto v0.28.0 go.etcd.io/bbolt v1.4.2
golang.org/x/crypto v0.41.0
) )
+98 -61
View File
@@ -1,113 +1,150 @@
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI= github.com/flytam/filenamify v1.2.0 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=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac/v2 v2.0.1 h1:1zilNkbmmpK9DLsz2NbjLHG8avOmthYqUfVc9YKB/Ps= github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac/v2 v2.0.1/go.mod h1:hvgeR2hElLbwk0Q1/vMazIDmIc2LAFSd9Bx/Fk6ViKo= github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 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/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 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.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+76 -3
View File
@@ -1,10 +1,83 @@
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"`
IV string `mapstructure:"iv"` OutputDir string `mapstructure:"output_dir"`
HomeDir string
} }
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)
}
cfg := &Config{HomeDir: homeDir}
if err := viper.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
if err := store.OpenDB(cfgDir); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) Validate() error {
if c.ArlCookie == "" {
return fmt.Errorf("arl_cookie is not set")
}
if c.SecretKey == "" {
return fmt.Errorf("secret_key is not set")
}
if len(c.SecretKey) != 16 {
return fmt.Errorf("secret_key must be 16 bytes long")
}
if c.OutputDir == "" {
c.OutputDir = filepath.Join(c.HomeDir, "Music", "GoDeez")
}
return nil
}
@@ -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)
+47 -18
View File
@@ -4,11 +4,14 @@ 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 {
Results struct {
Data struct { Data struct {
Title string `json:"ALB_TITLE"` Title string `json:"ALB_TITLE"`
Artist string `json:"ART_NAME"` Artist string `json:"ART_NAME"`
@@ -16,33 +19,59 @@ type Album struct {
PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"` PhysicalReleaseDate string `json:"PHYSICAL_RELEASE_DATE"`
Label string `json:"LABEL_NAME"` Label string `json:"LABEL_NAME"`
ProducerLine string `json:"PRODUCER_LINE"` ProducerLine string `json:"PRODUCER_LINE"`
Copyright string `json:"COPYRIGHT"`
Duration string `json:"DURATION"`
} `json:"DATA"` } `json:"DATA"`
Songs struct { Songs struct {
Data []*Song `json:"data"` Data []*Song `json:"data"`
} `json:"SONGS"` } `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) SetSongs(s []*Song) {
a.Results.Songs.Data = s
}
func (a *Album) GetOutputDir(outputDir string) string {
base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{})
outputDir = path.Join(outputDir, base)
return outputDir
}
func (a *Album) Unmarshal(data []byte) error {
return json.Unmarshal(data, a)
} }
+82
View File
@@ -0,0 +1,82 @@
package deezer
import (
"encoding/json"
"fmt"
"path"
"strconv"
"strings"
"time"
"github.com/flytam/filenamify"
)
type Artist struct {
Results struct {
Data struct {
Name string `json:"ART_NAME"`
} `json:"DATA"`
Songs struct {
Data []*Song `json:"data"`
} `json:"TOP"`
} `json:"results"`
}
func (a *Artist) GetType() string {
return "Artist"
}
func (a *Artist) GetTitle() string {
return a.Results.Data.Name
}
func (a *Artist) GetSongs() []*Song {
return a.Results.Songs.Data
}
func (a *Artist) SetSongs(s []*Song) {
a.Results.Songs.Data = s
}
func (a *Artist) GetOutputDir(outputDir string) string {
base, _ := filenamify.Filenamify(a.GetTitle(), filenamify.Options{})
return path.Join(outputDir, base)
}
func (a *Artist) Unmarshal(data []byte) error {
return json.Unmarshal(data, a)
}
func (a *Artist) String() string {
tracks := a.GetSongs()
count := len(tracks)
limit := 3
if count < limit {
limit = count
}
totalSec := 0
for _, s := range tracks {
if d, err := strconv.Atoi(s.Duration); err == nil {
totalSec += d
}
}
totalDuration := time.Duration(totalSec) * time.Second
var b strings.Builder
fmt.Fprintf(&b, "============= [ Artist Info ] =============\n")
fmt.Fprintf(&b, "Artist: %s\n", a.GetTitle())
fmt.Fprintf(&b, "Tracks: %d\n", count)
fmt.Fprintf(&b, "Playtime: %s\n", totalDuration)
fmt.Fprintf(&b, "-------------------------------------------\n")
fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit)
for i := 0; i < limit; i++ {
s := tracks[i]
title := s.GetTitle()
fmt.Fprintf(&b, " %2d. %s %s\n", i+1, s.Artist, title)
}
fmt.Fprintf(&b, "===========================================\n")
return b.String()
}
+200
View File
@@ -0,0 +1,200 @@
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, resource Resource, id string) error {
payload := map[string]interface{}{
"nb": 10000,
"start": 0,
"lang": "en",
"tab": 0,
"tags": true,
"header": true,
}
switch r := resource.(type) {
case *Playlist:
payload["playlist_id"] = id
case *Album:
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)
}
jsonData, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", resource.GetType(), c.Session.APIToken)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
resp, err := c.Session.HttpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
switch {
case strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`):
return fmt.Errorf("invalid playlist ID")
case strings.Contains(string(body), `"DATA_ERROR":"album::getData"`):
return fmt.Errorf("invalid album ID")
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")
}
return resource.Unmarshal(body)
}
func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*Media, error) {
var formats string
switch quality {
case "mp3_128":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "mp3_320":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "flac":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
}
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, formats, song.TrackToken)
req, err := http.NewRequestWithContext(ctx, "POST", "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
if err != nil {
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)
}
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
}
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 := media.GetURL()
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
}
+5 -67
View File
@@ -1,13 +1,5 @@
package deezer package deezer
import (
"fmt"
"net/http"
"os"
"github.com/mathismqn/godeez/internal/crypto"
)
type Media struct { type Media struct {
Errors []MediaError `json:"errors"` Errors []MediaError `json:"errors"`
Data []struct { Data []struct {
@@ -35,64 +27,10 @@ type Source struct {
Provider string `json:"provider"` Provider string `json:"provider"`
} }
const ChunkSize = 2048 func (m *Media) GetURL() string {
return m.Data[0].Media[0].Sources[0].URL
}
func (m *Media) Download(url, path, songID string) error { func (m *Media) GetFormat() string {
resp, err := http.Get(url) return m.Data[0].Media[0].Format
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
key := crypto.GetBlowfishKey(songID)
buffer := make([]byte, ChunkSize)
for chunk := 0; ; chunk++ {
totalRead := 0
for totalRead < ChunkSize {
n, err := resp.Body.Read(buffer[totalRead:])
if err != nil {
if err.Error() == "EOF" {
break
}
return err
}
if n > 0 {
totalRead += n
}
}
if totalRead == 0 {
break
}
if chunk%3 == 0 && totalRead == ChunkSize {
buffer, err = crypto.DecryptBlowfish(buffer, key)
if err != nil {
return err
}
}
_, err = file.Write(buffer[:totalRead])
if err != nil {
return err
}
if totalRead < ChunkSize {
break
}
}
return nil
} }
+41 -18
View File
@@ -2,42 +2,65 @@ 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 {
Results struct {
Data struct { Data struct {
Title string `json:"TITLE"` Title string `json:"TITLE"`
Status int `json:"STATUS"` Status int `json:"STATUS"`
CollabKey string `json:"COLLAB_KEY"` Creator string `json:"PARENT_USERNAME"`
Duration int `json:"DURATION"`
} `json:"DATA"` } `json:"DATA"`
Songs struct { Songs struct {
Data []*Song `json:"data"` Data []*Song `json:"data"`
} `json:"SONGS"` } `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) SetSongs(s []*Song) {
p.Results.Songs.Data = s
}
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)
} }
+5 -52
View File
@@ -1,57 +1,10 @@
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 { SetSongs(songs []*Song)
url := r.GetURL(id) GetOutputDir(outputDir string) string
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
}
+36 -66
View File
@@ -1,26 +1,37 @@
package deezer package deezer
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"github.com/mathismqn/godeez/internal/config" "github.com/flytam/filenamify"
) )
type Contributors struct {
MainArtists []string `json:"main_artist"`
Composers []string `json:"composer"`
Authors []string `json:"author"`
}
func (c *Contributors) UnmarshalJSON(data []byte) error {
if string(data) == "[]" {
*c = Contributors{}
return nil
}
type Alias Contributors
aux := (*Alias)(c)
return json.Unmarshal(data, aux)
}
type Song struct { type Song struct {
ID string `json:"SNG_ID"` ID string `json:"SNG_ID"`
Artist string `json:"ART_NAME"` Artist string `json:"ART_NAME"`
Title string `json:"SNG_TITLE"` Title string `json:"SNG_TITLE"`
Version string `json:"VERSION"` Version string `json:"VERSION"`
Cover string `json:"ALB_PICTURE"` Cover string `json:"ALB_PICTURE"`
Contributors struct { Contributors Contributors `json:"SNG_CONTRIBUTORS"`
MainArtists []string `json:"main_artist"`
Composers []string `json:"composer"`
Authors []string `json:"author"`
} `json:"SNG_CONTRIBUTORS"`
Duration string `json:"DURATION"` Duration string `json:"DURATION"`
Gain string `json:"GAIN"` Gain string `json:"GAIN"`
ISRC string `json:"ISRC"` ISRC string `json:"ISRC"`
@@ -28,68 +39,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, mediaFormat string, song *Song) 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 mediaFormat == "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{MaxLength: 255})
return fileName
} }
+77
View File
@@ -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)
}
+298
View File
@@ -0,0 +1,298 @@
package downloader
import (
"context"
"errors"
"fmt"
"io"
"os"
"path"
"strings"
"sync"
"time"
"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"
)
const chunkSize = 2048
type Client struct {
appConfig *config.Config
resourceType string
deezerClient *deezer.Client
Logger *logger.Logger
hashIndexOnce sync.Once
hashIndex *fileutil.HashIndex
hashIndexErr error
}
func New(appConfig *config.Config, resourceType string) *Client {
return &Client{
appConfig: appConfig,
resourceType: resourceType,
deezerClient: nil,
Logger: logger.New(nil), // Initialize with a nil logger, can be set later
}
}
func (c *Client) Run(ctx context.Context, opts Options, id string) error {
if err := c.initDeezerClient(ctx, opts); err != nil {
return err
}
resource, outputDir, err := c.prepareResource(ctx, id, opts)
if err != nil {
return err
}
return c.downloadAllSongs(ctx, resource, id, opts, outputDir)
}
func (c *Client) initDeezerClient(ctx context.Context, opts Options) error {
var err error
c.deezerClient, err = deezer.NewClient(ctx, c.appConfig)
if err != nil {
return err
}
if !c.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") {
return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
}
return nil
}
func (c *Client) prepareResource(ctx context.Context, id string, opts Options) (deezer.Resource, string, error) {
resource, err := c.createResource()
if err != nil {
return nil, "", err
}
if err := c.deezerClient.FetchResource(ctx, resource, id); err != nil {
return nil, "", fmt.Errorf("failed to fetch resource: %w", err)
}
songs := resource.GetSongs()
if len(songs) == 0 {
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)
}
resourceOutputDir := resource.GetOutputDir(c.appConfig.OutputDir)
if err := fileutil.EnsureDir(resourceOutputDir); err != nil {
return nil, "", fmt.Errorf("failed to create output directory: %w", err)
}
return resource, resourceOutputDir, nil
}
func (c *Client) createResource() (deezer.Resource, error) {
switch c.resourceType {
case "album":
return &deezer.Album{}, nil
case "playlist":
return &deezer.Playlist{}, nil
case "artist":
return &deezer.Artist{}, nil
case "track":
return &deezer.Track{}, nil
default:
return nil, fmt.Errorf("unsupported resource type: %s", c.resourceType)
}
}
func (c *Client) downloadAllSongs(ctx context.Context, resource deezer.Resource, resourceID string, opts Options, outputDir string) error {
songs := resource.GetSongs()
startTime := time.Now()
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()
}
sp := progress.startDownload(i, song)
result := c.downloadSong(ctx, resource, song, opts, outputDir)
sp.Stop()
if result.err != nil {
if errors.Is(result.err, context.Canceled) {
return result.err
}
}
progress.handleResult(i, song, result)
}
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) downloadResult {
var warnings []string
media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality)
if err != nil {
return handleError(fmt.Errorf("failed to fetch media: %w", err))
}
mediaFormat := media.GetFormat()
if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality {
return handleError(fmt.Errorf("requested quality '%s' not available", opts.Quality))
}
if path, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
return handleError(SkipError{Path: path})
}
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 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 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)))
}
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))
}
finalizeWarnings := c.finalizeDownload(resource, song, outputPath, mediaFormat, metadataResult.genre, cover, metadataResult.bpmKey)
warnings = append(warnings, finalizeWarnings...)
return downloadResult{
success: true,
warnings: warnings,
}
}
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, genre string, cover []byte, bpmKey provider.BPMKey) []string {
var warnings []string
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))
}
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
}
+15
View File
@@ -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,
}
}
+92
View File
@@ -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
}
+38
View File
@@ -0,0 +1,38 @@
package downloader
import (
"fmt"
"time"
)
var validQualities = map[string]bool{
"mp3_128": true,
"mp3_320": true,
"flac": true,
}
type Options struct {
Quality string
Timeout time.Duration
Limit int
BPM bool
Genre bool
Strict bool
}
func (o *Options) Validate() error {
if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality)
}
if o.Timeout <= 0 {
return fmt.Errorf("timeout must be a positive duration")
}
if o.Limit <= 0 {
return fmt.Errorf("limit must be a positive integer")
}
if o.Limit > 100 {
return fmt.Errorf("limit must not exceed 100")
}
return nil
}
+124
View File
@@ -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")
}
}
+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
}
+29
View File
@@ -0,0 +1,29 @@
package logger
import "log"
type Logger struct {
l *log.Logger
}
func New(l *log.Logger) *Logger {
return &Logger{l: l}
}
func (l *Logger) Infof(format string, args ...any) {
if l.l != nil {
l.l.Printf("[INFO] "+format, args...)
}
}
func (l *Logger) Warnf(format string, args ...any) {
if l.l != nil {
l.l.Printf("[WARN] "+format, args...)
}
}
func (l *Logger) Errorf(format string, args ...any) {
if l.l != nil {
l.l.Printf("[ERROR] "+format, args...)
}
}
+180
View File
@@ -0,0 +1,180 @@
package provider
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
neturl "net/url"
"regexp"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
)
type BPMProvider struct{}
type BPMKey struct {
BPM string
Key string
}
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 BPMKey{}, err
}
html, err := p.fetchPage(ctx, httpClient, url)
if err != nil {
return BPMKey{}, err
}
return p.parse(html)
}
func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
rootUrl := "https://songbpm.com"
reqUrl := rootUrl + "/searches"
values := neturl.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
wantDuration, err := strconv.Atoi(duration)
if err != nil {
return true
}
const durationToleranceSec = 2
if foundDuration <= (wantDuration-durationToleranceSec) || foundDuration >= (wantDuration+durationToleranceSec) {
return true
}
url = selection.AttrOr("href", "")
found = true
return false
})
if !found {
return "", fmt.Errorf("no data found")
}
return rootUrl + url, nil
}
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
}
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 (p BPMProvider) parse(html string) (BPMKey, 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 BPMKey{}, 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 BPMKey{
BPM: bpm,
Key: key,
}, nil
}
+86
View File
@@ -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, "/")
}
+56
View File
@@ -0,0 +1,56 @@
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"`
}
var trackBucket = []byte("tracks")
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, err := tx.CreateBucketIfNotExists(trackBucket)
if err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
data, err := json.Marshal(d)
if err != nil {
return err
}
return b.Put([]byte(d.SongID), data)
})
}
+22
View File
@@ -0,0 +1,22 @@
package store
import (
"fmt"
"path"
bolt "go.etcd.io/bbolt"
)
var db *bolt.DB
func OpenDB(cfgDir string) error {
var err error
dbPath := path.Join(cfgDir, "tracks.db")
db, err = bolt.Open(dbPath, 0600, nil)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package store
import (
"encoding/json"
"fmt"
"time"
bolt "go.etcd.io/bbolt"
)
type WatchedPlaylist struct {
ID string `json:"id"`
Quality string `json:"quality"`
BPM bool `json:"bpm"`
Timeout time.Duration `json:"timeout"`
}
var watchedBucket = []byte("watched")
func ListWatchedPlaylists() ([]*WatchedPlaylist, error) {
var playlists []*WatchedPlaylist
if err := db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(watchedBucket)
if b == nil {
return nil
}
return b.ForEach(func(k, v []byte) error {
var p WatchedPlaylist
if err := json.Unmarshal(v, &p); err != nil {
return err
}
playlists = append(playlists, &p)
return nil
})
}); err != nil {
return nil, err
}
return playlists, nil
}
func (p *WatchedPlaylist) Save() error {
return db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(watchedBucket)
if err != nil {
return err
}
data, err := json.Marshal(p)
if err != nil {
return err
}
return b.Put([]byte(p.ID), data)
})
}
func RemoveWatchedPlaylist(playlistID string) error {
return db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(watchedBucket)
if b == nil {
return fmt.Errorf("bucket not found")
}
return b.Delete([]byte(playlistID))
})
}
func IsWatched(playlistID string) (bool, error) {
var found bool
err := db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(watchedBucket)
if b == nil {
return nil
}
found = b.Get([]byte(playlistID)) != nil
return nil
})
return found, err
}
+29 -23
View File
@@ -10,40 +10,46 @@ 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, genre 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("ALBUMARTIST", album.Data.Artist)
t.addTag("PUBLISHER", album.Data.Label)
t.addTag("ORIGINALDATE", album.Data.OriginalReleaseDate)
t.addTag("DATE", album.Data.PhysicalReleaseDate)
t.addTag("COMMENT", album.Data.ProducerLine)
t.addTag("TRACKNUMBER", song.TrackNumber) t.addTag("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("COPYRIGHT", album.Results.Data.Copyright)
} }
t.addTag("TITLE", song.Title)
t.addTag("ARTIST", strings.Join(song.Contributors.MainArtists, ", ")) t.addTag("ARTIST", strings.Join(song.Contributors.MainArtists, ", "))
t.addTag("TITLE", song.GetTitle())
t.addTag("COMPOSER", strings.Join(song.Contributors.Composers, ", ")) t.addTag("COMPOSER", strings.Join(song.Contributors.Composers, ", "))
t.addTag("LYRICIST", strings.Join(song.Contributors.Authors, ", ")) t.addTag("LYRICIST", strings.Join(song.Contributors.Authors, ", "))
t.addTag("GENRE", genre)
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 +57,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)
} }
+28 -20
View File
@@ -9,59 +9,67 @@ 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, genre 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("TPE2", album.Data.Artist)
t.addTag("TPUB", album.Data.Label)
t.addTag("TDOR", album.Data.OriginalReleaseDate)
t.addTag("TYER", album.Data.PhysicalReleaseDate)
t.addTag("COMM", album.Data.ProducerLine)
t.addTag("TRCK", song.TrackNumber) t.addTag("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("TCOP", album.Results.Data.Copyright)
} }
t.addTag("TIT2", song.Title)
t.addTag("TPE1", strings.Join(song.Contributors.MainArtists, ", ")) t.addTag("TPE1", strings.Join(song.Contributors.MainArtists, ", "))
t.addTag("TIT2", song.GetTitle())
t.addTag("TCOM", strings.Join(song.Contributors.Composers, ", ")) t.addTag("TCOM", strings.Join(song.Contributors.Composers, ", "))
t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", ")) t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", "))
t.addTag("TCON", genre)
t.addTag("TLEN", song.Duration) t.addTag("TLEN", song.Duration)
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, genre 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, genre 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, genre)
} }
+39
View File
@@ -0,0 +1,39 @@
package watcher
import (
"os"
"path/filepath"
"runtime"
"strings"
)
func EnsureAutostart(homeDir string) error {
if isAutostartInstalled(homeDir) || isTemporaryExecutable() {
return nil
}
// return installAutostart(homeDir)
return nil
}
func isAutostartInstalled(homeDir string) bool {
switch runtime.GOOS {
case "darwin":
path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist")
_, err := os.Stat(path)
return err == nil
default:
return false
}
}
func isTemporaryExecutable() bool {
exe, err := os.Executable()
if err != nil {
return true
}
return strings.Contains(exe, "go-build")
}
+48
View File
@@ -0,0 +1,48 @@
//go:build darwin
package watcher
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func installAutostart(homeDir string) error {
exe, err := os.Executable()
if err != nil {
return err
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
return err
}
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.godeez.watch</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>watch</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>`, exe)
path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist")
if err := os.WriteFile(path, []byte(plist), 0644); err != nil {
return err
}
return exec.Command("launchctl", "load", path).Run()
}
+71
View File
@@ -0,0 +1,71 @@
package watcher
import (
"context"
"errors"
"log"
"os"
"path/filepath"
"time"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/downloader"
"github.com/mathismqn/godeez/internal/logger"
"github.com/mathismqn/godeez/internal/store"
)
type Watcher struct {
appConfig *config.Config
logger *logger.Logger
}
func New(appConfig *config.Config) *Watcher {
logFile := filepath.Join(appConfig.HomeDir, ".godeez", "watcher.log")
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Fatalf("Failed to open log file: %v\n", err)
}
base := log.New(file, "", log.LstdFlags)
log := logger.New(base)
return &Watcher{
appConfig: appConfig,
logger: log,
}
}
func (w *Watcher) Run(ctx context.Context, opts downloader.Options) {
w.logger.Infof("Starting watcher...")
for {
select {
case <-ctx.Done():
return
default:
playlists, err := store.ListWatchedPlaylists()
if err != nil {
w.logger.Errorf("Failed to list watched playlists: %v\n", err)
} else {
for _, playlist := range playlists {
dl := downloader.New(w.appConfig, "playlist")
dl.Logger = w.logger
if err := dl.Run(ctx, opts, playlist.ID); err != nil {
if errors.Is(err, context.Canceled) {
return
}
w.logger.Errorf("Playlist %s: %v\n", playlist.ID, err)
}
}
}
select {
case <-ctx.Done():
return
case <-time.After(15 * time.Minute):
// Continue to the next iteration to check for updates
}
}
}
}
+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)
} }