Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ef1ba42a8 | ||
|
|
e4c421ff00 | ||
|
|
9d87f635d0 | ||
|
|
8c9b327b2c | ||
|
|
0493866141 | ||
|
|
523b562ed0 | ||
|
|
c2b4fcfa11 | ||
|
|
9b23e3a8d9 | ||
|
|
cf7f76948b | ||
|
|
4b2c9c92b8 | ||
|
|
22ddb2408b | ||
|
|
3022e226ce | ||
|
|
c4329431b5 | ||
|
|
be40910480 | ||
|
|
1813a16bb7 | ||
|
|
283c5d76e9 | ||
|
|
7154c15cc3 | ||
|
|
50fde9dfa9 | ||
|
|
b4f69396af | ||
|
|
fe3491e16d | ||
|
|
ef018b38ad | ||
|
|
33e774d078 | ||
|
|
a619b37922 | ||
|
|
31d8a3b04f | ||
|
|
95c81a2a67 | ||
|
|
498f7c8d3f | ||
|
|
b19c92f227 | ||
|
|
99bdc22a25 | ||
|
|
0680d8c8e7 | ||
|
|
67b4fe404e | ||
|
|
1fdd0e8c19 | ||
|
|
b4e00e9306 | ||
|
|
327d55cff5 | ||
|
|
ce1690137e | ||
|
|
7daee4dab6 | ||
|
|
5951e13abe | ||
|
|
56e3e894ab | ||
|
|
68f22ce571 | ||
|
|
674eb99ffd | ||
|
|
adfd71204d | ||
|
|
e50395ae1d | ||
|
|
0a9e6c9a4d | ||
|
|
5d18ab2088 | ||
|
|
ebfb312718 | ||
|
|
67af80403b | ||
|
|
0bae78a1ff | ||
|
|
c74531b36c | ||
|
|
917ac5f6c2 | ||
|
|
6d3935c744 | ||
|
|
f931cad746 | ||
|
|
6675834842 | ||
|
|
7302685e34 | ||
|
|
ddc8240da8 | ||
|
|
d0599f654e |
@@ -1,4 +0,0 @@
|
|||||||
arl_cookie = '1d9e90abb452a61b1b7463f0953e1b303c4e2e7e5fd404fde9b385f4de01c340ac0f62f1c8c1550405b0b9beded0e28c481e96a6148e8f4548351add5d7db746b2785ecf83b1768e5dd8cc73b1ad30c18d07c9cb37f5c6b9cd7a78a4de2aff11'
|
|
||||||
license_token = 'AAAAA1bcDFgHJKLmnOPqrsTUvwXYZ12o3lmNOjR2xWiQRT3v5zxL4mKoWjxL82jD7sWx93KfYGsN2IkdPxsUw9PTas7oFbgR3nY9qpZmVyHFJKLPxT7sWmqErXyTn5iKoNpRtV7bPdQZ8qLmWoJpU2nKrXz6vW'
|
|
||||||
secret_key = 'hTv1IAw19qWy9i3f'
|
|
||||||
iv = '0001020304050607'
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
build.sh
|
||||||
|
builds/
|
||||||
@@ -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 artist’s top tracks.
|
||||||
|
- Add `--limit` flag for the `artist` command to restrict the number of tracks.
|
||||||
|
- Add `--strict` flag for downloads: fail if the requested quality is unavailable.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 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 user’s home directory with a `.godeez/` directory, which now stores both `config.toml` and `tracks.db`.
|
||||||
|
👉 If upgrading, move your existing config into `.godeez/config.toml`.
|
||||||
|
- Simplify `config.toml`: remove the need for `iv` and `license_token`.
|
||||||
|
- Automatically delete corrupted or incomplete files on download failure.
|
||||||
|
|
||||||
|
## [1.0.0] - 2024-10-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release of **GoDeez** with basic Deezer album and playlist downloading capabilities.
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
# GoDeez
|
# GoDeez
|
||||||
|
|
||||||
|
[](https://github.com/mathismqn/godeez/releases)
|
||||||
|
[](https://github.com/mathismqn/godeez/blob/main/LICENSE)
|
||||||
|
[](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** (⚠️ non‑premium 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 Deezer’s 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 Deezer’s 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 Deezer’s 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, you’ll 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. Here’s 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 it’s a bug fix, a new feature, or improving documentation, your input is valuable.
|
Whether it’s a bug fix, a new feature, or improving documentation, your input is valuable.
|
||||||
|
|
||||||
If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion.
|
If you have an idea for improvement, feel free to fork the repository and submit a pull request. You can also open an issue if you spot a bug or have a feature suggestion.
|
||||||
Every bit of support counts, so don’t forget to give the project a star if you enjoy using it. Thank you for helping make this project better!
|
|
||||||
|
## ⭐ Support the Project
|
||||||
|
|
||||||
|
If **GoDeez** helps you enjoy your music collection, please consider giving it a star!
|
||||||
|
|
||||||
|
**Why star us?**
|
||||||
|
- Helps more music lovers discover the project
|
||||||
|
- Shows appreciation for the work and motivates development
|
||||||
|
- Takes just one click but means the world to us!
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License. See the [LICENSE](https://github.com/mathismqn/godeez/blob/main/LICENSE) file for details.
|
This project is licensed under the MIT License. See the [LICENSE](https://github.com/mathismqn/godeez/blob/main/LICENSE) file for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> ⚠️ This tool is provided for educational and personal use only. Please ensure your usage complies with Deezer’s Terms of Service.
|
||||||
|
|||||||
+59
-126
@@ -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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package downloader
|
||||||
|
|
||||||
|
func handleError(err error) downloadResult {
|
||||||
|
if path, ok := IsSkipError(err); ok {
|
||||||
|
return downloadResult{
|
||||||
|
skipped: true,
|
||||||
|
path: path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return downloadResult{
|
||||||
|
success: false,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package downloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/mathismqn/godeez/internal/deezer"
|
||||||
|
"github.com/mathismqn/godeez/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
type metadataResult struct {
|
||||||
|
bpmKey provider.BPMKey
|
||||||
|
genre string
|
||||||
|
warnings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type metadataFetcher struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetadataFetcher(httpClient *http.Client) *metadataFetcher {
|
||||||
|
return &metadataFetcher{
|
||||||
|
httpClient: httpClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mf *metadataFetcher) fetch(ctx context.Context, song *deezer.Song, opts Options) metadataResult {
|
||||||
|
result := metadataResult{
|
||||||
|
bpmKey: provider.BPMKey{},
|
||||||
|
genre: "",
|
||||||
|
warnings: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.BPM && !opts.Genre {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
bmpChan := make(chan provider.BPMKey, 1)
|
||||||
|
bmpErrChan := make(chan error, 1)
|
||||||
|
genreChan := make(chan string, 1)
|
||||||
|
genreErrChan := make(chan error, 1)
|
||||||
|
|
||||||
|
if opts.BPM {
|
||||||
|
go func() {
|
||||||
|
p := provider.BPMProvider{}
|
||||||
|
bmpKey, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.Title, song.Duration)
|
||||||
|
if err != nil {
|
||||||
|
bmpErrChan <- err
|
||||||
|
} else {
|
||||||
|
bmpChan <- bmpKey
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Genre {
|
||||||
|
go func() {
|
||||||
|
p := provider.GenreProvider{}
|
||||||
|
genre, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.GetTitle())
|
||||||
|
if err != nil {
|
||||||
|
genreErrChan <- err
|
||||||
|
} else {
|
||||||
|
genreChan <- genre
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.BPM {
|
||||||
|
select {
|
||||||
|
case bmpKey := <-bmpChan:
|
||||||
|
result.bpmKey = bmpKey
|
||||||
|
case err := <-bmpErrChan:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Genre {
|
||||||
|
select {
|
||||||
|
case genre := <-genreChan:
|
||||||
|
result.genre = genre
|
||||||
|
case err := <-genreErrChan:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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, "/")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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,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)
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user