Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7580d256 | ||
|
|
d50945a7ef | ||
|
|
08160b0290 | ||
|
|
cf699834f6 | ||
|
|
650d578b80 | ||
|
|
acf468eddd | ||
|
|
08b608ec80 | ||
|
|
cd45c3f40a | ||
|
|
0a8da3d0e7 |
+34
-2
@@ -5,47 +5,79 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.4.0] - 2026-03-01
|
||||
|
||||
### Added
|
||||
|
||||
- Automatic database migration from `~/.godeez/tracks.db` to `~/Music/GoDeez/.tracks.db`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Configuration now uses `DEEZER_ARL` environment variable (replaces `config.toml`).
|
||||
- Database moved from `~/.godeez/tracks.db` to `~/Music/GoDeez/.tracks.db`.
|
||||
- Show warning count in download summary.
|
||||
|
||||
### Removed
|
||||
|
||||
- `config.toml` configuration file and `~/.godeez` directory.
|
||||
- `--config` flag from CLI.
|
||||
- `secret_key` and `output_dir` configuration options.
|
||||
- Watcher feature (`watch` subcommands).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Track number zero-padding for correct file sorting.
|
||||
|
||||
## [1.3.0] - 2025-09-11
|
||||
|
||||
### Added
|
||||
|
||||
- Add new `track` command to download individual songs.
|
||||
- Add `--genre` flag to fetch and embed genre information into file metadata tags.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handle empty media resources gracefully to prevent crashes.
|
||||
|
||||
## [1.2.0] - 2025-08-18
|
||||
|
||||
### Added
|
||||
|
||||
- Add new `artist` command to download an 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`.
|
||||
👉 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.
|
||||
|
||||
@@ -53,4 +85,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release of **GoDeez** with basic Deezer album and playlist downloading capabilities.
|
||||
- Initial release of **GoDeez** with basic Deezer album and playlist downloading capabilities.
|
||||
|
||||
@@ -37,56 +37,38 @@ To install **GoDeez**, download the latest binary for your platform from the [Re
|
||||
3. (Optional) Move the binary to a directory included in `$PATH` for easier access.
|
||||
|
||||
Example (Linux/macOS):
|
||||
|
||||
```bash
|
||||
# Move the downloaded binary to /usr/local/bin for easy access from anywhere
|
||||
mv godeez-1.3.0-linux-amd64 /usr/local/bin/godeez
|
||||
mv godeez-1.4.0-linux-amd64 /usr/local/bin/godeez
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The first time you run **GoDeez**, a configuration directory named `.godeez` will be automatically created in your home directory (`$HOME` on Linux/macOS, `%USERPROFILE%` on Windows).
|
||||
**GoDeez** requires a Deezer ARL cookie for authentication. Set it as an environment variable:
|
||||
|
||||
Inside this directory:
|
||||
- `config.toml`: main configuration file you need to edit manually
|
||||
- `tracks.db`: internal database used to track downloads and avoid duplicates
|
||||
|
||||
### Steps to configure
|
||||
|
||||
1. Run the application once: this creates the `.godeez` directory and the `config.toml` file.
|
||||
2. Edit the `config.toml` file with a text editor to set the required values.
|
||||
|
||||
### Variables to configure
|
||||
|
||||
Here are the key variables you need to set in `config.toml`:
|
||||
|
||||
1. `arl_cookie`
|
||||
* **What is it?**: The `arl_cookie` is a session cookie used for authentication with Deezer. Without this cookie, the downloader cannot access your account to retrieve playlists, albums, or songs.
|
||||
* **How to retrieve it**:
|
||||
1. Open your browser and log in to your Deezer account.
|
||||
2. Open the Developer Tools (right-click on the page and select “Inspect” or press F12).
|
||||
3. Navigate to the Application tab (in Chrome/Edge) or Storage tab (in Firefox).
|
||||
4. In the left panel, look for Cookies and select `https://www.deezer.com`.
|
||||
5. Find the arl cookie and copy its value.
|
||||
|
||||
2. `secret_key`
|
||||
* **What is it?**: The `secret_key` is a cryptographic value used to decrypt 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.
|
||||
|
||||
3. `output_dir` (optional)
|
||||
* **What is it?**: The `output_dir` is the path where downloaded music files will be saved.
|
||||
* **Default**: If left empty, it defaults to `~/Music/GoDeez`.
|
||||
* **Note**: Once set, it's recommended not to change it, as this may interfere with the skip system that relies on consistent file paths and hash indexing to detect already downloaded songs.
|
||||
|
||||
### Example
|
||||
|
||||
```toml
|
||||
# ~/.godeez/config.toml
|
||||
|
||||
arl_cookie = 'your_arl_cookie_here'
|
||||
secret_key = 'your_secret_key_here'
|
||||
output_dir = '' # optional
|
||||
```bash
|
||||
export DEEZER_ARL=”your_arl_cookie_here”
|
||||
```
|
||||
|
||||
To make it persistent, add the line above to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.).
|
||||
|
||||
### How to retrieve your ARL cookie
|
||||
|
||||
1. Open your browser and log in to your [Deezer](https://www.deezer.com) account.
|
||||
2. Open the Developer Tools (right-click on the page and select “Inspect” or press F12).
|
||||
3. Navigate to the **Application** tab (Chrome/Edge) or **Storage** tab (Firefox).
|
||||
4. In the left panel, look for **Cookies** and select `https://www.deezer.com`.
|
||||
5. Find the `arl` cookie and copy its value.
|
||||
|
||||
> **Note:** The ARL cookie may expire after some time. If you get authentication errors, retrieve a fresh cookie using the steps above.
|
||||
|
||||
### Output directory
|
||||
|
||||
Downloaded files are saved to `~/Music/GoDeez`. The download database (`.tracks.db`) is stored alongside your music in the output directory.
|
||||
|
||||
> **Upgrading from v1.3.0?** The `~/.godeez` directory and `config.toml` are no longer used. Set the `DEEZER_ARL` environment variable instead. Your existing database will be migrated automatically on first run.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI Overview
|
||||
@@ -105,8 +87,7 @@ Available Commands:
|
||||
help Help about any command
|
||||
|
||||
Flags:
|
||||
--config string config file (default ~/.godeez/config.toml)
|
||||
-h, --help help for godeez
|
||||
-h, --help help for godeez
|
||||
|
||||
Use "godeez [command] --help" for more information about a command.
|
||||
```
|
||||
@@ -168,7 +149,8 @@ If you have an idea for improvement, feel free to fork the repository and submit
|
||||
If **GoDeez** helps you enjoy your music collection, please consider giving it a star!
|
||||
|
||||
**Why star us?**
|
||||
- Helps more music lovers discover the project
|
||||
|
||||
- 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!
|
||||
|
||||
|
||||
+27
-31
@@ -12,10 +12,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
opts downloader.Options
|
||||
cfgPath string
|
||||
)
|
||||
type contextKey string
|
||||
|
||||
const appConfigKey contextKey = "appConfig"
|
||||
|
||||
var opts downloader.Options
|
||||
|
||||
var downloadCmd = &cobra.Command{
|
||||
Use: "download",
|
||||
@@ -25,7 +26,6 @@ var downloadCmd = &cobra.Command{
|
||||
func init() {
|
||||
RootCmd.AddCommand(downloadCmd)
|
||||
|
||||
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")
|
||||
@@ -41,51 +41,47 @@ func init() {
|
||||
}
|
||||
|
||||
func newDownloadCmd(resourceType string) *cobra.Command {
|
||||
article := "a"
|
||||
if resourceType == "album" {
|
||||
article = "an"
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType),
|
||||
Short: fmt.Sprintf("Download songs from %s %s", article, resourceType),
|
||||
Short: downloadShort(resourceType),
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
appConfig, err := config.New(cfgPath)
|
||||
appConfig, err := config.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig))
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), appConfigKey, appConfig))
|
||||
|
||||
opts.Quality = strings.ToLower(opts.Quality)
|
||||
|
||||
return opts.Validate()
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
appConfigVal := ctx.Value("appConfig")
|
||||
appConfig, _ := appConfigVal.(*config.Config)
|
||||
appConfig := cmd.Context().Value(appConfigKey).(*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
|
||||
}
|
||||
|
||||
return err
|
||||
err := downloader.New(appConfig, resourceType).Run(cmd.Context(), opts, args[0])
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
switch resourceType {
|
||||
case "artist":
|
||||
cmd.Short = "Download top songs from an artist"
|
||||
if resourceType == "artist" {
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download")
|
||||
case "track":
|
||||
cmd.Short = "Download a single track"
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func downloadShort(resourceType string) string {
|
||||
switch resourceType {
|
||||
case "artist":
|
||||
return "Download top songs from an artist"
|
||||
case "track":
|
||||
return "Download a single track"
|
||||
case "album":
|
||||
return "Download songs from an album"
|
||||
default:
|
||||
return fmt.Sprintf("Download songs from a %s", resourceType)
|
||||
}
|
||||
}
|
||||
|
||||
-19
@@ -8,23 +8,4 @@ var RootCmd = &cobra.Command{
|
||||
Use: "godeez",
|
||||
Short: "GoDeez is a tool to download music from Deezer",
|
||||
SilenceUsage: true,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// 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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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,36 +1,23 @@
|
||||
module github.com/mathismqn/godeez
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.24.4
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
)
|
||||
require github.com/spf13/cobra v1.10.2
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/sagikazarmark/locafero v0.10.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // 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
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/term v0.34.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/bogem/id3v2/v2 v2.1.4
|
||||
github.com/briandowns/spinner v1.23.2
|
||||
github.com/flytam/filenamify v1.2.0
|
||||
@@ -38,7 +25,7 @@ require (
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
go.etcd.io/bbolt v1.4.2
|
||||
golang.org/x/crypto v0.41.0
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/crypto v0.48.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
|
||||
@@ -13,67 +13,41 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
|
||||
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
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/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
||||
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
|
||||
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
|
||||
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -88,8 +62,8 @@ 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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -97,8 +71,8 @@ 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/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -111,8 +85,8 @@ 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/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -122,8 +96,8 @@ 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/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -134,8 +108,8 @@ 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/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -144,7 +118,5 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+27
-53
@@ -3,81 +3,55 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mathismqn/godeez/internal/fileutil"
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ArlCookie string `mapstructure:"arl_cookie"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
OutputDir string `mapstructure:"output_dir"`
|
||||
ArlCookie string
|
||||
OutputDir string
|
||||
HomeDir string
|
||||
}
|
||||
|
||||
func New(cfgPath string) (*Config, error) {
|
||||
func New() (*Config, error) {
|
||||
arl := os.Getenv("DEEZER_ARL")
|
||||
if arl == "" {
|
||||
return nil, fmt.Errorf("DEEZER_ARL is not set. Export it in your shell: export DEEZER_ARL='your_cookie'")
|
||||
}
|
||||
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
cfgDir := filepath.Join(homeDir, ".godeez")
|
||||
if err := fileutil.EnsureDir(cfgDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||
outputDir := filepath.Join(homeDir, "Music", "GoDeez")
|
||||
if err := fileutil.EnsureDir(outputDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
if cfgPath == "" {
|
||||
cfgPath = path.Join(cfgDir, "config.toml")
|
||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||
fmt.Printf("Config file not found, creating one at %s\n", cfgPath)
|
||||
|
||||
content := []byte("arl_cookie = ''\nsecret_key = ''\noutput_dir = ''\n")
|
||||
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to create config file: %w", err)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
// Migrate tracks.db from ~/.godeez/ to output dir
|
||||
oldDB := filepath.Join(homeDir, ".godeez", "tracks.db")
|
||||
newDB := filepath.Join(outputDir, ".tracks.db")
|
||||
if _, err := os.Stat(oldDB); err == nil {
|
||||
if _, err := os.Stat(newDB); os.IsNotExist(err) {
|
||||
os.Rename(oldDB, newDB)
|
||||
}
|
||||
}
|
||||
|
||||
viper.SetConfigFile(cfgPath)
|
||||
viper.SetConfigType("toml")
|
||||
viper.AutomaticEnv()
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
// Clean up old config directory
|
||||
oldDir := filepath.Join(homeDir, ".godeez")
|
||||
os.Remove(filepath.Join(oldDir, "config.toml"))
|
||||
os.Remove(oldDir) // fails silently if not empty
|
||||
|
||||
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 {
|
||||
if err := store.OpenDB(outputDir); 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
|
||||
return &Config{
|
||||
ArlCookie: arl,
|
||||
OutputDir: outputDir,
|
||||
HomeDir: homeDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,18 +3,22 @@ package crypto
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"encoding/hex"
|
||||
|
||||
"golang.org/x/crypto/blowfish"
|
||||
)
|
||||
|
||||
var iv = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
|
||||
var (
|
||||
iv = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
|
||||
secretKey = []byte("g4el58wc0zvf9na1")
|
||||
)
|
||||
|
||||
func GetKey(secretKey, songID string) []byte {
|
||||
func GetKey(songID string) []byte {
|
||||
hash := md5.Sum([]byte(songID))
|
||||
hashHex := fmt.Sprintf("%x", hash)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
key := []byte(secretKey)
|
||||
key := make([]byte, len(secretKey))
|
||||
copy(key, secretKey)
|
||||
for i := 0; i < len(hash); i++ {
|
||||
key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16]
|
||||
}
|
||||
@@ -28,9 +32,8 @@ func Decrypt(data, key []byte) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
decrypted := make([]byte, len(data))
|
||||
mode.CryptBlocks(decrypted, data)
|
||||
cipher.NewCBCDecrypter(block, iv).CryptBlocks(decrypted, data)
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
@@ -67,9 +67,7 @@ func (a *Album) SetSongs(s []*Song) {
|
||||
func (a *Album) GetOutputDir(outputDir string) string {
|
||||
base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
|
||||
base, _ = filenamify.Filenamify(base, filenamify.Options{})
|
||||
outputDir = path.Join(outputDir, base)
|
||||
|
||||
return outputDir
|
||||
return path.Join(outputDir, base)
|
||||
}
|
||||
|
||||
func (a *Album) Unmarshal(data []byte) error {
|
||||
|
||||
+30
-35
@@ -22,6 +22,35 @@ type Artist struct {
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func (a *Artist) String() string {
|
||||
songs := a.Results.Songs.Data
|
||||
count := len(songs)
|
||||
|
||||
totalSec := 0
|
||||
for _, s := range songs {
|
||||
if d, err := strconv.Atoi(s.Duration); err == nil {
|
||||
totalSec += d
|
||||
}
|
||||
}
|
||||
|
||||
limit := min(3, count)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "============= [ Artist Info ] =============\n")
|
||||
fmt.Fprintf(&b, "Artist: %s\n", a.Results.Data.Name)
|
||||
fmt.Fprintf(&b, "Tracks: %d\n", count)
|
||||
fmt.Fprintf(&b, "Playtime: %s\n", time.Duration(totalSec)*time.Second)
|
||||
fmt.Fprintf(&b, "-------------------------------------------\n")
|
||||
fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
s := songs[i]
|
||||
fmt.Fprintf(&b, " %2d. %s – %s\n", i+1, s.Artist, s.GetTitle())
|
||||
}
|
||||
fmt.Fprintf(&b, "===========================================\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (a *Artist) GetType() string {
|
||||
return "Artist"
|
||||
}
|
||||
@@ -39,44 +68,10 @@ func (a *Artist) SetSongs(s []*Song) {
|
||||
}
|
||||
|
||||
func (a *Artist) GetOutputDir(outputDir string) string {
|
||||
base, _ := filenamify.Filenamify(a.GetTitle(), filenamify.Options{})
|
||||
base, _ := filenamify.Filenamify(a.Results.Data.Name, filenamify.Options{})
|
||||
return path.Join(outputDir, base)
|
||||
}
|
||||
|
||||
func (a *Artist) Unmarshal(data []byte) error {
|
||||
return json.Unmarshal(data, a)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
+31
-33
@@ -38,18 +38,21 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
|
||||
"tags": true,
|
||||
"header": true,
|
||||
}
|
||||
switch r := resource.(type) {
|
||||
|
||||
var idKey string
|
||||
switch resource.(type) {
|
||||
case *Playlist:
|
||||
payload["playlist_id"] = id
|
||||
idKey = "playlist_id"
|
||||
case *Album:
|
||||
payload["alb_id"] = id
|
||||
idKey = "alb_id"
|
||||
case *Artist:
|
||||
payload["art_id"] = id
|
||||
idKey = "art_id"
|
||||
case *Track:
|
||||
payload["sng_id"] = id
|
||||
idKey = "sng_id"
|
||||
default:
|
||||
return fmt.Errorf("unsupported resource type: %T", r)
|
||||
return fmt.Errorf("unsupported resource type: %T", resource)
|
||||
}
|
||||
payload[idKey] = id
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -77,18 +80,22 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
|
||||
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")
|
||||
bodyStr := string(body)
|
||||
for _, check := range []struct {
|
||||
marker string
|
||||
errMsg string
|
||||
}{
|
||||
{`"DATA_ERROR":"playlist::getData"`, "invalid playlist ID"},
|
||||
{`"DATA_ERROR":"album::getData"`, "invalid album ID"},
|
||||
{`"DATA_ERROR":"artist::getData"`, "invalid artist ID"},
|
||||
{`"DATA_ERROR":"song::getData"`, "invalid track ID"},
|
||||
} {
|
||||
if strings.Contains(bodyStr, check.marker) {
|
||||
return fmt.Errorf("%s", check.errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(string(body), `"results":{}`) {
|
||||
if strings.Contains(bodyStr, `"results":{}`) {
|
||||
return fmt.Errorf("unexpected response")
|
||||
}
|
||||
|
||||
@@ -96,18 +103,13 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
|
||||
}
|
||||
|
||||
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"}]`
|
||||
qualityFormats := map[string]string{
|
||||
"mp3_128": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
|
||||
"mp3_320": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
|
||||
"flac": `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
|
||||
}
|
||||
|
||||
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, formats, song.TrackToken)
|
||||
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, qualityFormats[quality], song.TrackToken)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -129,8 +131,7 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
|
||||
}
|
||||
|
||||
var media Media
|
||||
err = json.Unmarshal(body, &media)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(body, &media); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -138,7 +139,6 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
|
||||
if media.Errors[0].Code == 1000 {
|
||||
return nil, fmt.Errorf("invalid license token")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s", media.Errors[0].Message)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,6 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -177,9 +176,8 @@ func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) {
|
||||
url := media.GetURL()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
func (c *Client) GetMediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", media.GetURL(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
package deezer
|
||||
|
||||
type Media struct {
|
||||
Errors []MediaError `json:"errors"`
|
||||
Errors []mediaError `json:"errors"`
|
||||
Data []struct {
|
||||
Media []struct {
|
||||
Type string `json:"media_type"`
|
||||
Cipher Cipher `json:"cipher"`
|
||||
Format string `json:"format"`
|
||||
Sources []Source `json:"sources"`
|
||||
Format string `json:"format"`
|
||||
Sources []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
Errors []MediaError `json:"errors"`
|
||||
Errors []mediaError `json:"errors"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type MediaError struct {
|
||||
type mediaError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type Cipher struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
URL string `json:"url"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
func (m *Media) GetURL() string {
|
||||
return m.Data[0].Media[0].Sources[0].URL
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ type Playlist struct {
|
||||
Results struct {
|
||||
Data struct {
|
||||
Title string `json:"TITLE"`
|
||||
Status int `json:"STATUS"`
|
||||
Creator string `json:"PARENT_USERNAME"`
|
||||
Duration int `json:"DURATION"`
|
||||
} `json:"DATA"`
|
||||
@@ -55,10 +54,8 @@ func (p *Playlist) SetSongs(s []*Song) {
|
||||
}
|
||||
|
||||
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
|
||||
base, _ := filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
|
||||
return path.Join(outputDir, base)
|
||||
}
|
||||
|
||||
func (p *Playlist) Unmarshal(data []byte) error {
|
||||
|
||||
+17
-22
@@ -10,22 +10,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserDataResponse struct {
|
||||
Results struct {
|
||||
APIToken string `json:"checkForm"`
|
||||
User struct {
|
||||
Id int `json:"USER_ID"`
|
||||
Options struct {
|
||||
LicenseToken string `json:"license_token"`
|
||||
MobileOffline bool `json:"mobile_offline"`
|
||||
WebOffline bool `json:"web_offline"`
|
||||
} `json:"OPTIONS"`
|
||||
} `json:"USER"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ArlCookie string
|
||||
APIToken string
|
||||
LicenseToken string
|
||||
HttpClient *http.Client
|
||||
@@ -68,22 +53,32 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res UserDataResponse
|
||||
var res struct {
|
||||
Results struct {
|
||||
APIToken string `json:"checkForm"`
|
||||
User struct {
|
||||
ID int `json:"USER_ID"`
|
||||
Options struct {
|
||||
LicenseToken string `json:"license_token"`
|
||||
MobileOffline bool `json:"mobile_offline"`
|
||||
WebOffline bool `json:"web_offline"`
|
||||
} `json:"OPTIONS"`
|
||||
} `json:"USER"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.Results.User.Id == 0 {
|
||||
if res.Results.User.ID == 0 {
|
||||
return nil, fmt.Errorf("invalid arl cookie")
|
||||
}
|
||||
|
||||
isPremium := res.Results.User.Options.MobileOffline || res.Results.User.Options.WebOffline
|
||||
|
||||
opts := res.Results.User.Options
|
||||
return &Session{
|
||||
ArlCookie: arlCookie,
|
||||
APIToken: res.Results.APIToken,
|
||||
LicenseToken: res.Results.User.Options.LicenseToken,
|
||||
LicenseToken: opts.LicenseToken,
|
||||
HttpClient: client,
|
||||
Premium: isPremium,
|
||||
Premium: opts.MobileOffline || opts.WebOffline,
|
||||
}, nil
|
||||
}
|
||||
|
||||
+12
-9
@@ -3,6 +3,7 @@ package deezer
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/flytam/filenamify"
|
||||
)
|
||||
@@ -40,26 +41,28 @@ type Song struct {
|
||||
}
|
||||
|
||||
func (s *Song) GetTitle() string {
|
||||
songTitle := s.Title
|
||||
if s.Version != "" {
|
||||
songTitle = fmt.Sprintf("%s %s", s.Title, s.Version)
|
||||
return s.Title + " " + s.Version
|
||||
}
|
||||
|
||||
return songTitle
|
||||
return s.Title
|
||||
}
|
||||
|
||||
func (s *Song) GetFileName(resourceType, mediaFormat string, song *Song) string {
|
||||
func (s *Song) GetFileName(resourceType, mediaFormat string) string {
|
||||
ext := "mp3"
|
||||
if mediaFormat == "FLAC" {
|
||||
ext = "flac"
|
||||
}
|
||||
trackNumber := ""
|
||||
|
||||
prefix := ""
|
||||
if resourceType == "album" {
|
||||
trackNumber = song.TrackNumber + ". "
|
||||
if n, err := strconv.Atoi(s.TrackNumber); err == nil {
|
||||
prefix = fmt.Sprintf("%02d. ", n)
|
||||
} else {
|
||||
prefix = s.TrackNumber + ". "
|
||||
}
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("%s%s - %s.%s", trackNumber, s.Artist, s.GetTitle(), ext)
|
||||
fileName := fmt.Sprintf("%s%s - %s.%s", prefix, s.Artist, s.GetTitle(), ext)
|
||||
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{MaxLength: 255})
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/flytam/filenamify"
|
||||
)
|
||||
|
||||
type Track struct {
|
||||
@@ -20,11 +19,9 @@ func (t *Track) String() string {
|
||||
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()
|
||||
}
|
||||
duration, err := strconv.Atoi(t.Results.Data.Duration)
|
||||
if err != nil {
|
||||
duration = 0
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
@@ -35,7 +32,7 @@ Duration: %s
|
||||
==================================================`,
|
||||
t.Results.Data.GetTitle(),
|
||||
t.Results.Data.Artist,
|
||||
duration,
|
||||
time.Duration(duration)*time.Second,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,7 +49,7 @@ func (t *Track) GetTitle() string {
|
||||
|
||||
func (t *Track) GetSongs() []*Song {
|
||||
if t.Results.Data == nil {
|
||||
return []*Song{}
|
||||
return nil
|
||||
}
|
||||
return []*Song{t.Results.Data}
|
||||
}
|
||||
@@ -60,16 +57,7 @@ func (t *Track) GetSongs() []*Song {
|
||||
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
|
||||
return path.Join(outputDir, "Singles")
|
||||
}
|
||||
|
||||
func (t *Track) Unmarshal(data []byte) error {
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -38,8 +37,7 @@ 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
|
||||
Logger: logger.New(nil),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,16 +87,15 @@ func (c *Client) prepareResource(ctx context.Context, id string, opts Options) (
|
||||
}
|
||||
|
||||
if c.resourceType == "artist" && len(songs) > opts.Limit {
|
||||
songs = songs[:opts.Limit]
|
||||
resource.SetSongs(songs)
|
||||
resource.SetSongs(songs[:opts.Limit])
|
||||
}
|
||||
|
||||
resourceOutputDir := resource.GetOutputDir(c.appConfig.OutputDir)
|
||||
if err := fileutil.EnsureDir(resourceOutputDir); err != nil {
|
||||
outputDir := resource.GetOutputDir(c.appConfig.OutputDir)
|
||||
if err := fileutil.EnsureDir(outputDir); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
return resource, resourceOutputDir, nil
|
||||
return resource, outputDir, nil
|
||||
}
|
||||
|
||||
func (c *Client) createResource() (deezer.Resource, error) {
|
||||
@@ -135,10 +132,8 @@ func (c *Client) downloadAllSongs(ctx context.Context, resource deezer.Resource,
|
||||
result := c.downloadSong(ctx, resource, song, opts, outputDir)
|
||||
sp.Stop()
|
||||
|
||||
if result.err != nil {
|
||||
if errors.Is(result.err, context.Canceled) {
|
||||
return result.err
|
||||
}
|
||||
if result.err != nil && errors.Is(result.err, context.Canceled) {
|
||||
return result.err
|
||||
}
|
||||
|
||||
progress.handleResult(i, song, result)
|
||||
@@ -150,43 +145,44 @@ func (c *Client) downloadAllSongs(ctx context.Context, resource deezer.Resource,
|
||||
}
|
||||
|
||||
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))
|
||||
return downloadResult{err: 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))
|
||||
return downloadResult{err: fmt.Errorf("requested quality '%s' not available", opts.Quality)}
|
||||
}
|
||||
|
||||
if path, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
|
||||
return handleError(SkipError{Path: path})
|
||||
if skipPath, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
|
||||
return downloadResult{skipped: true, path: skipPath}
|
||||
}
|
||||
|
||||
metadataFetcher := newMetadataFetcher(c.deezerClient.Session.HttpClient)
|
||||
metadataResult := metadataFetcher.fetch(ctx, song, opts)
|
||||
warnings = append(warnings, metadataResult.warnings...)
|
||||
metadataChan := make(chan metadataResult, 1)
|
||||
go func() {
|
||||
metadataChan <- fetchMetadata(c.deezerClient.Session.HttpClient, ctx, song, opts)
|
||||
}()
|
||||
|
||||
stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID)
|
||||
stream, err := c.deezerClient.GetMediaStream(ctx, media)
|
||||
if err != nil {
|
||||
return handleError(fmt.Errorf("failed to get media stream: %w", err))
|
||||
return downloadResult{err: fmt.Errorf("failed to get media stream: %w", err)}
|
||||
}
|
||||
|
||||
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
|
||||
fileName := song.GetFileName(c.resourceType, mediaFormat, song)
|
||||
fileName := song.GetFileName(c.resourceType, mediaFormat)
|
||||
outputPath := path.Join(outputDir, fileName)
|
||||
|
||||
key := crypto.GetKey(c.appConfig.SecretKey, song.ID)
|
||||
key := crypto.GetKey(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))
|
||||
return downloadResult{err: fmt.Errorf("failed to stream to file: %w", err)}
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
|
||||
if opts.Quality != strings.ToLower(mediaFormat) {
|
||||
warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat)))
|
||||
}
|
||||
@@ -196,13 +192,11 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
|
||||
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
|
||||
}
|
||||
|
||||
finalizeWarnings := c.finalizeDownload(resource, song, outputPath, mediaFormat, metadataResult.genre, cover, metadataResult.bpmKey)
|
||||
warnings = append(warnings, finalizeWarnings...)
|
||||
metadata := <-metadataChan
|
||||
warnings = append(warnings, metadata.warnings...)
|
||||
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, metadata.genre, cover, metadata.bpmKey)...)
|
||||
|
||||
return downloadResult{
|
||||
success: true,
|
||||
warnings: warnings,
|
||||
}
|
||||
return downloadResult{warnings: warnings}
|
||||
}
|
||||
|
||||
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
|
||||
@@ -220,22 +214,18 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// continue
|
||||
}
|
||||
|
||||
totalRead := 0
|
||||
for totalRead < chunkSize {
|
||||
n, err := stream.Read(buffer[totalRead:])
|
||||
totalRead += n
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
totalRead += n
|
||||
}
|
||||
}
|
||||
|
||||
if totalRead == 0 {
|
||||
@@ -249,8 +239,7 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP
|
||||
}
|
||||
}
|
||||
|
||||
_, err = file.Write(buffer[:totalRead])
|
||||
if err != nil {
|
||||
if _, err = file.Write(buffer[:totalRead]); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -262,7 +251,7 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat, genre string, cover []byte, bpmKey provider.BPMKey) []string {
|
||||
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat, genre string, cover []byte, bpmKey bpmKey) []string {
|
||||
var warnings []string
|
||||
|
||||
if err := tags.AddTags(resource, song, cover, outputPath, bpmKey.BPM, bpmKey.Key, genre); err != nil {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -10,81 +10,69 @@ import (
|
||||
"github.com/mathismqn/godeez/internal/provider"
|
||||
)
|
||||
|
||||
type bpmKey struct {
|
||||
BPM string
|
||||
Key string
|
||||
}
|
||||
|
||||
type metadataResult struct {
|
||||
bpmKey provider.BPMKey
|
||||
bpmKey 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{},
|
||||
}
|
||||
|
||||
func fetchMetadata(httpClient *http.Client, ctx context.Context, song *deezer.Song, opts Options) metadataResult {
|
||||
if !opts.BPM && !opts.Genre {
|
||||
return result
|
||||
return metadataResult{}
|
||||
}
|
||||
|
||||
bmpChan := make(chan provider.BPMKey, 1)
|
||||
bmpErrChan := make(chan error, 1)
|
||||
genreChan := make(chan string, 1)
|
||||
genreErrChan := make(chan error, 1)
|
||||
type bpmResult struct {
|
||||
value bpmKey
|
||||
err error
|
||||
}
|
||||
type genreResult struct {
|
||||
value string
|
||||
err error
|
||||
}
|
||||
|
||||
bpmChan := make(chan bpmResult, 1)
|
||||
genreChan := make(chan genreResult, 1)
|
||||
|
||||
if opts.BPM {
|
||||
go func() {
|
||||
p := provider.BPMProvider{}
|
||||
bmpKey, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.Title, song.Duration)
|
||||
if err != nil {
|
||||
bmpErrChan <- err
|
||||
} else {
|
||||
bmpChan <- bmpKey
|
||||
}
|
||||
result, err := provider.FetchBPM(ctx, httpClient, song.Artist, song.Title, song.Duration)
|
||||
bpmChan <- bpmResult{value: bpmKey{BPM: result.BPM, Key: result.Key}, err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Genre {
|
||||
go func() {
|
||||
p := provider.GenreProvider{}
|
||||
genre, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.GetTitle())
|
||||
if err != nil {
|
||||
genreErrChan <- err
|
||||
} else {
|
||||
genreChan <- genre
|
||||
}
|
||||
genre, err := provider.FetchGenre(ctx, httpClient, song.Artist, song.GetTitle())
|
||||
genreChan <- genreResult{value: genre, err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
var result metadataResult
|
||||
|
||||
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))
|
||||
r := <-bpmChan
|
||||
if r.err != nil {
|
||||
if !errors.Is(r.err, context.Canceled) {
|
||||
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", r.err))
|
||||
}
|
||||
} else {
|
||||
result.bpmKey = r.value
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Genre {
|
||||
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))
|
||||
r := <-genreChan
|
||||
if r.err != nil {
|
||||
if !errors.Is(r.err, context.Canceled) {
|
||||
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", r.err))
|
||||
}
|
||||
} else {
|
||||
result.genre = r.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,23 +11,23 @@ import (
|
||||
"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 downloadStats struct {
|
||||
downloaded int
|
||||
skipped int
|
||||
failed int
|
||||
warnings int
|
||||
}
|
||||
|
||||
type progressTracker struct {
|
||||
logger *logger.Logger
|
||||
stats *downloadStats
|
||||
stats downloadStats
|
||||
totalSongs int
|
||||
resourceType string
|
||||
}
|
||||
@@ -35,20 +35,18 @@ type progressTracker struct {
|
||||
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.Suffix = fmt.Sprintf(" Downloading: %s - %s", song.Artist, song.GetTitle())
|
||||
sp.Start()
|
||||
|
||||
return sp
|
||||
@@ -73,13 +71,16 @@ func (pt *progressTracker) handleResult(index int, song *deezer.Song, result dow
|
||||
return
|
||||
}
|
||||
|
||||
pt.stats.downloaded++
|
||||
if len(result.warnings) > 0 {
|
||||
pt.stats.warnings++
|
||||
}
|
||||
pt.logger.Infof("Downloaded %s - %s\n", song.Artist, songTitle)
|
||||
|
||||
symbol := "✔"
|
||||
if len(result.warnings) > 0 {
|
||||
symbol = "⚠"
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -95,11 +96,15 @@ func (pt *progressTracker) printSummary(resourceTitle, resourceID, outputDir str
|
||||
}
|
||||
|
||||
if pt.resourceType != "track" {
|
||||
warningsLine := ""
|
||||
if pt.stats.warnings > 0 {
|
||||
warningsLine = fmt.Sprintf("\nWarnings: %d", pt.stats.warnings)
|
||||
}
|
||||
fmt.Printf(`
|
||||
================== [ Summary ] ==================
|
||||
Downloaded: %d
|
||||
Skipped: %d
|
||||
Failed: %d
|
||||
Failed: %d%s
|
||||
Elapsed time: %s
|
||||
Files saved to: %s
|
||||
=================================================
|
||||
@@ -107,6 +112,7 @@ Files saved to: %s
|
||||
pt.stats.downloaded,
|
||||
pt.stats.skipped,
|
||||
pt.stats.failed,
|
||||
warningsLine,
|
||||
elapsed.Round(time.Second),
|
||||
outputDir,
|
||||
)
|
||||
|
||||
+24
-30
@@ -7,37 +7,31 @@ import (
|
||||
"github.com/mathismqn/godeez/internal/store"
|
||||
)
|
||||
|
||||
type SkipError struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e SkipError) Error() string {
|
||||
return e.Path
|
||||
}
|
||||
|
||||
func IsSkipError(err error) (string, bool) {
|
||||
if skipErr, ok := err.(SkipError); ok {
|
||||
return skipErr.Path, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) {
|
||||
if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat {
|
||||
if fileutil.FileExists(existing.Path) {
|
||||
return existing.Path, true
|
||||
}
|
||||
if existing.Hash != "" {
|
||||
if err := c.initHashIndex(ctx); err == nil {
|
||||
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok {
|
||||
existing.Path = foundPath
|
||||
_ = existing.Save()
|
||||
|
||||
return foundPath, true
|
||||
}
|
||||
}
|
||||
}
|
||||
existing, err := store.GetDownloadInfo(songID)
|
||||
if err != nil || existing.Quality != mediaFormat {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return "", false
|
||||
if fileutil.FileExists(existing.Path) {
|
||||
return existing.Path, true
|
||||
}
|
||||
|
||||
if existing.Hash == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if err := c.initHashIndex(ctx); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
foundPath, ok := c.hashIndex.Find(existing.Hash)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
existing.Path = foundPath
|
||||
_ = existing.Save()
|
||||
|
||||
return foundPath, true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package fileutil
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -18,13 +19,11 @@ func EnsureDir(path string) error {
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("file already exists at %s", path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func FileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
@@ -32,7 +31,6 @@ func DeleteFile(path string) error {
|
||||
if !FileExists(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
@@ -43,10 +41,10 @@ func GetFileHash(path string) (string, error) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
@@ -16,12 +17,11 @@ type HashIndex struct {
|
||||
func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
|
||||
index := &HashIndex{files: make(map[string]string)}
|
||||
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
if err != nil || info.IsDir() {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -36,12 +36,9 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
index.files[sum] = path
|
||||
|
||||
index.files[hex.EncodeToString(h.Sum(nil))] = path
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -51,6 +48,5 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
|
||||
|
||||
func (h *HashIndex) Find(hash string) (string, bool) {
|
||||
path, ok := h.files[hash]
|
||||
|
||||
return path, ok
|
||||
}
|
||||
|
||||
@@ -10,20 +10,12 @@ func New(l *log.Logger) *Logger {
|
||||
return &Logger{l: l}
|
||||
}
|
||||
|
||||
func (l *Logger) Infof(format string, args ...any) {
|
||||
func (l *Logger) logf(level, format string, args ...any) {
|
||||
if l.l != nil {
|
||||
l.l.Printf("[INFO] "+format, args...)
|
||||
l.l.Printf("["+level+"] "+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...)
|
||||
}
|
||||
}
|
||||
func (l *Logger) Infof(format string, args ...any) { l.logf("INFO", format, args...) }
|
||||
func (l *Logger) Warnf(format string, args ...any) { l.logf("WARN", format, args...) }
|
||||
func (l *Logger) Errorf(format string, args ...any) { l.logf("ERROR", format, args...) }
|
||||
|
||||
+38
-59
@@ -14,40 +14,43 @@ import (
|
||||
"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)
|
||||
var (
|
||||
bpmRegex = regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
|
||||
keyRegex = regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
|
||||
modeRegex = regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
|
||||
)
|
||||
|
||||
func FetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (BPMKey, error) {
|
||||
songURL, err := findSongURL(ctx, httpClient, artist, title, duration)
|
||||
if err != nil {
|
||||
return BPMKey{}, err
|
||||
}
|
||||
|
||||
html, err := p.fetchPage(ctx, httpClient, url)
|
||||
html, err := fetchBPMPage(ctx, httpClient, songURL)
|
||||
if err != nil {
|
||||
return BPMKey{}, err
|
||||
}
|
||||
|
||||
return p.parse(html)
|
||||
return parseBPM(html)
|
||||
}
|
||||
|
||||
func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
|
||||
rootUrl := "https://songbpm.com"
|
||||
reqUrl := rootUrl + "/searches"
|
||||
func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
|
||||
const rootURL = "https://songbpm.com"
|
||||
|
||||
values := neturl.Values{}
|
||||
values.Add("query", fmt.Sprintf("%s %s", artist, title))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBufferString(values.Encode()))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", rootURL+"/searches", bytes.NewBufferString(values.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", "https://songbpm.com")
|
||||
req.Header.Set("Origin", rootURL)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -64,20 +67,22 @@ func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, a
|
||||
return "", err
|
||||
}
|
||||
|
||||
var (
|
||||
found bool
|
||||
url string
|
||||
)
|
||||
wantDuration, err := strconv.Atoi(duration)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid duration: %w", err)
|
||||
}
|
||||
|
||||
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) {
|
||||
lowerTitle := strings.ToLower(title)
|
||||
lowerArtist := strings.ToLower(artist)
|
||||
|
||||
var matchURL string
|
||||
doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
|
||||
text := strings.ToLower(sel.Text())
|
||||
if !strings.Contains(text, lowerTitle) || !strings.Contains(text, lowerArtist) {
|
||||
return true
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(selection.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text())
|
||||
durationStr := strings.TrimSpace(sel.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text())
|
||||
parts := strings.Split(durationStr, ":")
|
||||
if len(parts) != 2 {
|
||||
return true
|
||||
@@ -91,31 +96,24 @@ func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, a
|
||||
return true
|
||||
}
|
||||
|
||||
const toleranceSec = 2
|
||||
foundDuration := minutes*60 + seconds
|
||||
wantDuration, err := strconv.Atoi(duration)
|
||||
if err != nil {
|
||||
if foundDuration <= wantDuration-toleranceSec || foundDuration >= wantDuration+toleranceSec {
|
||||
return true
|
||||
}
|
||||
|
||||
const durationToleranceSec = 2
|
||||
if foundDuration <= (wantDuration-durationToleranceSec) || foundDuration >= (wantDuration+durationToleranceSec) {
|
||||
return true
|
||||
}
|
||||
|
||||
url = selection.AttrOr("href", "")
|
||||
found = true
|
||||
|
||||
matchURL = sel.AttrOr("href", "")
|
||||
return false
|
||||
})
|
||||
|
||||
if !found {
|
||||
if matchURL == "" {
|
||||
return "", fmt.Errorf("no data found")
|
||||
}
|
||||
|
||||
return rootUrl + url, nil
|
||||
return rootURL + matchURL, nil
|
||||
}
|
||||
|
||||
func (p BPMProvider) fetchPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
|
||||
func fetchBPMPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -139,42 +137,23 @@ func (p BPMProvider) fetchPage(ctx context.Context, httpClient *http.Client, url
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func (p BPMProvider) parse(html string) (BPMKey, error) {
|
||||
bpmRegex := regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
|
||||
func parseBPM(html string) (BPMKey, error) {
|
||||
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]
|
||||
key := strings.SplitN(keyMatch[1], "/", 2)[0]
|
||||
key = strings.ReplaceAll(key, "\u266f", "#")
|
||||
key = strings.ReplaceAll(key, "\u266d", "b")
|
||||
|
||||
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
|
||||
|
||||
return BPMKey{BPM: bpm, Key: key}, nil
|
||||
}
|
||||
|
||||
+68
-30
@@ -9,30 +9,50 @@ import (
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
type GenreProvider struct{}
|
||||
var electronicKeywords = toLower([]string{
|
||||
"Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo",
|
||||
"Drum And Bass", "Dub", "Dubstep", "EDM", "Electro", "Electronic", "Electronica",
|
||||
"Eurodance", "Gabber", "Garage", "Hardcore", "Hardstyle", "House", "Industrial",
|
||||
"Jungle", "Moombahton", "Synthpop", "Synthwave", "Techno", "Trance", "Trap",
|
||||
"Trip Hop", "Vaporwave",
|
||||
})
|
||||
|
||||
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)
|
||||
var nonElectronicKeywords = toLower([]string{
|
||||
"Blues", "Chillout", "Classical", "Country", "Folk", "Funk", "Hip Hop", "Jazz",
|
||||
"Latin", "Metal", "Pop", "R&B", "Rap", "Reggae", "Rock", "Soul",
|
||||
})
|
||||
|
||||
func toLower(ss []string) []string {
|
||||
out := make([]string, len(ss))
|
||||
for i, s := range ss {
|
||||
out[i] = strings.ToLower(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func FetchGenre(ctx context.Context, httpClient *http.Client, artist, title string) (string, error) {
|
||||
reqURL := fmt.Sprintf("https://www.last.fm/music/%s/%s/+tags", artist, title)
|
||||
|
||||
doc, err := fetchGenrePage(ctx, httpClient, reqURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tags := p.parse(doc)
|
||||
if len(tags) == 0 {
|
||||
tags := parseGenreTags(doc)
|
||||
if len(tags) > 2 {
|
||||
tags = tags[:2]
|
||||
}
|
||||
|
||||
filtered := filterTags(tags)
|
||||
if len(filtered) == 0 {
|
||||
return "", fmt.Errorf("no data found")
|
||||
}
|
||||
|
||||
if len(tags) > 1 {
|
||||
tags = tags[:2]
|
||||
}
|
||||
genre := p.formatTags(tags)
|
||||
|
||||
return genre, nil
|
||||
return formatTags(filtered), nil
|
||||
}
|
||||
|
||||
func (p GenreProvider) fetchPage(ctx context.Context, httpClient *http.Client, reqUrl string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", reqUrl, nil)
|
||||
func fetchGenrePage(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -47,40 +67,58 @@ func (p GenreProvider) fetchPage(ctx context.Context, httpClient *http.Client, r
|
||||
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
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
}
|
||||
|
||||
func (p GenreProvider) parse(doc *goquery.Document) []string {
|
||||
func parseGenreTags(doc *goquery.Document) []string {
|
||||
var tags []string
|
||||
doc.Find("ol.big-tags .big-tags-item-name a").Each(func(_ int, s *goquery.Selection) {
|
||||
tag := strings.TrimSpace(s.Text())
|
||||
if tag != "" {
|
||||
if tag := strings.TrimSpace(s.Text()); tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
})
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
func (p GenreProvider) formatTags(tags []string) string {
|
||||
var formatted []string
|
||||
func matchesKeyword(tag string, keywords []string) bool {
|
||||
tagLower := strings.ToLower(tag)
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(tagLower, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterTags(tags []string) []string {
|
||||
var electronic, nonElectronic []string
|
||||
|
||||
for _, tag := range tags {
|
||||
if matchesKeyword(tag, electronicKeywords) {
|
||||
electronic = append(electronic, tag)
|
||||
} else if matchesKeyword(tag, nonElectronicKeywords) {
|
||||
nonElectronic = append(nonElectronic, tag)
|
||||
}
|
||||
}
|
||||
|
||||
if len(electronic) > 0 {
|
||||
return append(electronic, nonElectronic...)
|
||||
}
|
||||
return electronic
|
||||
}
|
||||
|
||||
func formatTags(tags []string) string {
|
||||
formatted := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
words := strings.Fields(tag)
|
||||
for i, w := range words {
|
||||
words[i] = strings.Title(w)
|
||||
words[i] = strings.ToUpper(w[:1]) + strings.ToLower(w[1:])
|
||||
}
|
||||
formatted = append(formatted, strings.Join(words, " "))
|
||||
}
|
||||
|
||||
return strings.Join(formatted, "/")
|
||||
return strings.Join(formatted, " / ")
|
||||
}
|
||||
|
||||
@@ -11,12 +11,9 @@ var db *bolt.DB
|
||||
|
||||
func OpenDB(cfgDir string) error {
|
||||
var err error
|
||||
|
||||
dbPath := path.Join(cfgDir, "tracks.db")
|
||||
db, err = bolt.Open(dbPath, 0600, nil)
|
||||
db, err = bolt.Open(path.Join(cfgDir, ".tracks.db"), 0600, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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
|
||||
}
|
||||
+17
-27
@@ -18,9 +18,8 @@ type flacTagger struct {
|
||||
|
||||
func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error {
|
||||
if album, ok := resource.(*deezer.Album); ok {
|
||||
dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-")
|
||||
if len(dateParts) == 3 {
|
||||
album.Results.Data.PhysicalReleaseDate = dateParts[0]
|
||||
if parts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-"); len(parts) == 3 {
|
||||
album.Results.Data.PhysicalReleaseDate = parts[0]
|
||||
}
|
||||
|
||||
t.addTag("TRACKNUMBER", song.TrackNumber)
|
||||
@@ -40,26 +39,29 @@ func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover
|
||||
t.addTag("GENRE", genre)
|
||||
t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain)
|
||||
t.addTag("ISRC", song.ISRC)
|
||||
|
||||
t.addTag("BPM", tempo)
|
||||
t.addTag("KEY", key)
|
||||
t.addTag("INITIALKEY", key)
|
||||
|
||||
cmtsmeta := t.cmts.Marshal()
|
||||
cmtsMeta := t.cmts.Marshal()
|
||||
if t.index > 0 {
|
||||
t.file.Meta[t.index] = &cmtsmeta
|
||||
t.file.Meta[t.index] = &cmtsMeta
|
||||
} else {
|
||||
t.file.Meta = append(t.file.Meta, &cmtsmeta)
|
||||
t.file.Meta = append(t.file.Meta, &cmtsMeta)
|
||||
}
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
picturemeta := picture.Marshal()
|
||||
t.file.Meta = append(t.file.Meta, &picturemeta)
|
||||
pictureMeta := picture.Marshal()
|
||||
t.file.Meta = append(t.file.Meta, &pictureMeta)
|
||||
|
||||
return t.saveTags(path)
|
||||
tmpPath := path + ".tmp"
|
||||
if err := t.file.Save(tmpPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
func (t *flacTagger) addTag(name, value string) {
|
||||
@@ -68,26 +70,14 @@ func (t *flacTagger) addTag(name, value string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *flacTagger) saveTags(path string) error {
|
||||
tempPath := path + ".tmp"
|
||||
t.file.Save(tempPath)
|
||||
|
||||
return os.Rename(tempPath, path)
|
||||
}
|
||||
|
||||
func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int, error) {
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
var cmtIdx int
|
||||
var err error
|
||||
func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int) {
|
||||
for idx, meta := range file.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
cmtIdx = idx
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err == nil {
|
||||
return cmt, idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cmt, cmtIdx, nil
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
+7
-10
@@ -39,20 +39,18 @@ func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover
|
||||
t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", "))
|
||||
t.addTag("TCON", genre)
|
||||
t.addTag("TLEN", song.Duration)
|
||||
t.addTXXXTag("GAIN", song.Gain)
|
||||
t.addTXXXTag("ISRC", song.ISRC)
|
||||
|
||||
t.addTag("TBPM", tempo)
|
||||
t.addTag("TKEY", key)
|
||||
t.addTXXX("GAIN", song.Gain)
|
||||
t.addTXXX("ISRC", song.ISRC)
|
||||
|
||||
frame := id3v2.PictureFrame{
|
||||
t.tag.AddAttachedPicture(id3v2.PictureFrame{
|
||||
Encoding: t.tag.DefaultEncoding(),
|
||||
MimeType: "image/jpeg",
|
||||
PictureType: id3v2.PTFrontCover,
|
||||
Description: "Cover",
|
||||
Picture: cover,
|
||||
}
|
||||
t.tag.AddAttachedPicture(frame)
|
||||
})
|
||||
|
||||
return t.tag.Save()
|
||||
}
|
||||
@@ -63,13 +61,12 @@ func (t *id3v2Tagger) addTag(name, value string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *id3v2Tagger) addTXXXTag(description, value string) {
|
||||
func (t *id3v2Tagger) addTXXX(description, value string) {
|
||||
if value != "" {
|
||||
udf := id3v2.UserDefinedTextFrame{
|
||||
t.tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
|
||||
Encoding: t.tag.DefaultEncoding(),
|
||||
Description: description,
|
||||
Value: value,
|
||||
}
|
||||
t.tag.AddUserDefinedTextFrame(udf)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+7
-12
@@ -10,17 +10,15 @@ import (
|
||||
)
|
||||
|
||||
type tagger interface {
|
||||
addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error
|
||||
addTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error
|
||||
}
|
||||
|
||||
func newTagger(filePath string) (tagger, error) {
|
||||
ext := path.Ext(filePath)
|
||||
if ext == ".mp3" {
|
||||
if path.Ext(filePath) == ".mp3" {
|
||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &id3v2Tagger{tag: tag}, nil
|
||||
}
|
||||
|
||||
@@ -28,11 +26,9 @@ func newTagger(filePath string) (tagger, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmts, idx, err := extractFLACComment(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cmts == nil && idx > 0 {
|
||||
|
||||
cmts, idx := extractFLACComment(file)
|
||||
if cmts == nil {
|
||||
cmts = flacvorbis.New()
|
||||
}
|
||||
|
||||
@@ -40,10 +36,9 @@ func newTagger(filePath string) (tagger, error) {
|
||||
}
|
||||
|
||||
func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error {
|
||||
tagger, err := newTagger(filePath)
|
||||
t, err := newTagger(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tagger.addTags(resource, song, cover, filePath, tempo, key, genre)
|
||||
return t.addTags(resource, song, cover, filePath, tempo, key, genre)
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//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()
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user