9 Commits
39 changed files with 528 additions and 1111 deletions
+33 -1
View File
@@ -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/), 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). 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 ## [1.3.0] - 2025-09-11
### Added ### Added
- Add new `track` command to download individual songs. - Add new `track` command to download individual songs.
- Add `--genre` flag to fetch and embed genre information into file metadata tags. - Add `--genre` flag to fetch and embed genre information into file metadata tags.
### Fixed ### Fixed
- Handle empty media resources gracefully to prevent crashes. - Handle empty media resources gracefully to prevent crashes.
## [1.2.0] - 2025-08-18 ## [1.2.0] - 2025-08-18
### Added ### Added
- Add new `artist` command to download an artists top tracks. - Add new `artist` command to download an artists top tracks.
- Add `--limit` flag for the `artist` command to restrict the number of 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. - Add `--strict` flag for downloads: fail if the requested quality is unavailable.
### Changed ### Changed
- Set default download quality to **MP3 320kbps**. - Set default download quality to **MP3 320kbps**.
### Removed ### Removed
- Remove `--quality=best` option. Fallback to lower quality is now the default behavior; use the `--strict` flag to prevent fallback. - Remove `--quality=best` option. Fallback to lower quality is now the default behavior; use the `--strict` flag to prevent fallback.
### Fixed ### Fixed
- Handle error when `SNG_CONTRIBUTORS` metadata is empty. - Handle error when `SNG_CONTRIBUTORS` metadata is empty.
## [1.1.1] - 2025-06-16 ## [1.1.1] - 2025-06-16
### Fixed ### Fixed
- Restore ability to download tracks without a Deezer Premium account (limited to **MP3 128kbps** for free accounts). - Restore ability to download tracks without a Deezer Premium account (limited to **MP3 128kbps** for free accounts).
## [1.1.0] - 2025-05-19 ## [1.1.0] - 2025-05-19
### Added ### Added
- Support downloading full albums and playlists with more than 40 tracks (previous limit removed). - Support downloading full albums and playlists with more than 40 tracks (previous limit removed).
- Fetch and embed **BPM** and **musical key** into metadata tags. - 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. - 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. - Improve CLI **output formatting** for a cleaner and more informative user experience.
### Changed ### Changed
- Replace the `godeez` file in the users home directory with a `.godeez/` directory, which now stores both `config.toml` and `tracks.db`. - Replace the `godeez` file in the users home directory with a `.godeez/` directory, which now stores both `config.toml` and `tracks.db`.
👉 If upgrading, move your existing config into `.godeez/config.toml`. 👉 If upgrading, move your existing config into `.godeez/config.toml`.
- Simplify `config.toml`: remove the need for `iv` and `license_token`. - Simplify `config.toml`: remove the need for `iv` and `license_token`.
- Automatically delete corrupted or incomplete files on download failure. - Automatically delete corrupted or incomplete files on download failure.
+24 -42
View File
@@ -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. 3. (Optional) Move the binary to a directory included in `$PATH` for easier access.
Example (Linux/macOS): Example (Linux/macOS):
```bash ```bash
# Move the downloaded binary to /usr/local/bin for easy access from anywhere # Move the downloaded binary to /usr/local/bin for easy access from anywhere
mv godeez-1.3.0-linux-amd64 /usr/local/bin/godeez mv godeez-1.4.0-linux-amd64 /usr/local/bin/godeez
``` ```
## Configuration ## 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: ```bash
- `config.toml`: main configuration file you need to edit manually export DEEZER_ARL=”your_arl_cookie_here”
- `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 Deezers media files.
* **How to retrieve it?**: While we cannot provide the specific secret_key in this documentation, it can be found online through various sources or developer communities that focus on Deezer.
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
``` ```
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 ## Usage
### CLI Overview ### CLI Overview
@@ -105,7 +87,6 @@ Available Commands:
help Help about any command help Help about any command
Flags: 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. Use "godeez [command] --help" for more information about a command.
@@ -168,6 +149,7 @@ 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! If **GoDeez** helps you enjoy your music collection, please consider giving it a star!
**Why star us?** **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 - Shows appreciation for the work and motivates development
- Takes just one click but means the world to us! - Takes just one click but means the world to us!
+24 -28
View File
@@ -12,10 +12,11 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ( type contextKey string
opts downloader.Options
cfgPath string const appConfigKey contextKey = "appConfig"
)
var opts downloader.Options
var downloadCmd = &cobra.Command{ var downloadCmd = &cobra.Command{
Use: "download", Use: "download",
@@ -25,7 +26,6 @@ var downloadCmd = &cobra.Command{
func init() { func init() {
RootCmd.AddCommand(downloadCmd) 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().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().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
downloadCmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags") downloadCmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
@@ -41,51 +41,47 @@ func init() {
} }
func newDownloadCmd(resourceType string) *cobra.Command { func newDownloadCmd(resourceType string) *cobra.Command {
article := "a"
if resourceType == "album" {
article = "an"
}
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType), 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), Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error { PreRunE: func(cmd *cobra.Command, args []string) error {
appConfig, err := config.New(cfgPath) appConfig, err := config.New()
if err != nil { if err != nil {
return err return err
} }
cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig)) cmd.SetContext(context.WithValue(cmd.Context(), appConfigKey, appConfig))
opts.Quality = strings.ToLower(opts.Quality) opts.Quality = strings.ToLower(opts.Quality)
return opts.Validate() return opts.Validate()
}, },
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context() appConfig := cmd.Context().Value(appConfigKey).(*config.Config)
appConfigVal := ctx.Value("appConfig")
appConfig, _ := appConfigVal.(*config.Config)
dl := downloader.New(appConfig, resourceType) err := downloader.New(appConfig, resourceType).Run(cmd.Context(), opts, args[0])
if err := dl.Run(ctx, opts, args[0]); err != nil {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
return nil return nil
} }
return err return err
}
return nil
}, },
} }
switch resourceType { if resourceType == "artist" {
case "artist":
cmd.Short = "Download top songs from an artist"
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download") cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download")
case "track":
cmd.Short = "Download a single track"
} }
return cmd 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
View File
@@ -8,23 +8,4 @@ var RootCmd = &cobra.Command{
Use: "godeez", Use: "godeez",
Short: "GoDeez is a tool to download music from Deezer", Short: "GoDeez is a tool to download music from Deezer",
SilenceUsage: true, 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
},
} }
-18
View File
@@ -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)
}
-49
View File
@@ -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")
}
-41
View File
@@ -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)
}
-35
View File
@@ -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)
}
-34
View File
@@ -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)
}
+11 -24
View File
@@ -1,36 +1,23 @@
module github.com/mathismqn/godeez module github.com/mathismqn/godeez
go 1.24.0 go 1.25.0
toolchain go1.24.4 require github.com/spf13/cobra v1.10.2
require (
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
)
require ( require (
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/fatih/color v1.18.0 // 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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/stretchr/testify v1.11.1 // indirect
github.com/sagikazarmark/locafero v0.10.0 // indirect golang.org/x/net v0.51.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect golang.org/x/sys v0.41.0 // indirect
github.com/spf13/afero v1.14.0 // indirect golang.org/x/term v0.40.0 // indirect
github.com/spf13/cast v1.9.2 // indirect golang.org/x/text v0.34.0 // 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
) )
require ( 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/bogem/id3v2/v2 v2.1.4
github.com/briandowns/spinner v1.23.2 github.com/briandowns/spinner v1.23.2
github.com/flytam/filenamify v1.2.0 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/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4 github.com/go-flac/go-flac/v2 v2.0.4
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/pflag v1.0.10 // indirect
go.etcd.io/bbolt v1.4.2 go.etcd.io/bbolt v1.4.3
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.48.0
) )
+24 -52
View File
@@ -1,5 +1,5 @@
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= 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 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
@@ -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/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI= github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8= github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/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 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0= 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-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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM= 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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -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/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+27 -53
View File
@@ -3,81 +3,55 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"path"
"path/filepath" "path/filepath"
"github.com/mathismqn/godeez/internal/fileutil" "github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/store" "github.com/mathismqn/godeez/internal/store"
"github.com/spf13/viper"
) )
type Config struct { type Config struct {
ArlCookie string `mapstructure:"arl_cookie"` ArlCookie string
SecretKey string `mapstructure:"secret_key"` OutputDir string
OutputDir string `mapstructure:"output_dir"`
HomeDir 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() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err) return nil, fmt.Errorf("failed to get home directory: %w", err)
} }
cfgDir := filepath.Join(homeDir, ".godeez") outputDir := filepath.Join(homeDir, "Music", "GoDeez")
if err := fileutil.EnsureDir(cfgDir); err != nil { if err := fileutil.EnsureDir(outputDir); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err) return nil, fmt.Errorf("failed to create output directory: %w", err)
} }
if cfgPath == "" { // Migrate tracks.db from ~/.godeez/ to output dir
cfgPath = path.Join(cfgDir, "config.toml") oldDB := filepath.Join(homeDir, ".godeez", "tracks.db")
if _, err := os.Stat(cfgPath); os.IsNotExist(err) { newDB := filepath.Join(outputDir, ".tracks.db")
fmt.Printf("Config file not found, creating one at %s\n", cfgPath) if _, err := os.Stat(oldDB); err == nil {
if _, err := os.Stat(newDB); os.IsNotExist(err) {
content := []byte("arl_cookie = ''\nsecret_key = ''\noutput_dir = ''\n") os.Rename(oldDB, newDB)
if err := os.WriteFile(cfgPath, content, 0644); err != nil {
return nil, fmt.Errorf("failed to create config file: %w", err)
}
os.Exit(0)
} }
} }
viper.SetConfigFile(cfgPath) // Clean up old config directory
viper.SetConfigType("toml") oldDir := filepath.Join(homeDir, ".godeez")
viper.AutomaticEnv() os.Remove(filepath.Join(oldDir, "config.toml"))
if err := viper.ReadInConfig(); err != nil { os.Remove(oldDir) // fails silently if not empty
return nil, fmt.Errorf("failed to read config file: %w", err)
}
cfg := &Config{HomeDir: homeDir} if err := store.OpenDB(outputDir); err != nil {
if err := viper.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
if err := store.OpenDB(cfgDir); err != nil {
return nil, err return nil, err
} }
return cfg, nil return &Config{
} ArlCookie: arl,
OutputDir: outputDir,
func (c *Config) Validate() error { HomeDir: homeDir,
if c.ArlCookie == "" { }, nil
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
} }
+10 -7
View File
@@ -3,18 +3,22 @@ package crypto
import ( import (
"crypto/cipher" "crypto/cipher"
"crypto/md5" "crypto/md5"
"fmt" "encoding/hex"
"golang.org/x/crypto/blowfish" "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)) 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++ { for i := 0; i < len(hash); i++ {
key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16] key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16]
} }
@@ -28,9 +32,8 @@ func Decrypt(data, key []byte) ([]byte, error) {
return nil, err return nil, err
} }
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data)) decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data) cipher.NewCBCDecrypter(block, iv).CryptBlocks(decrypted, data)
return decrypted, nil return decrypted, nil
} }
+1 -3
View File
@@ -67,9 +67,7 @@ func (a *Album) SetSongs(s []*Song) {
func (a *Album) GetOutputDir(outputDir string) string { func (a *Album) GetOutputDir(outputDir string) string {
base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title) base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{}) base, _ = filenamify.Filenamify(base, filenamify.Options{})
outputDir = path.Join(outputDir, base) return path.Join(outputDir, base)
return outputDir
} }
func (a *Album) Unmarshal(data []byte) error { func (a *Album) Unmarshal(data []byte) error {
+30 -35
View File
@@ -22,6 +22,35 @@ type Artist struct {
} `json:"results"` } `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 { func (a *Artist) GetType() string {
return "Artist" return "Artist"
} }
@@ -39,44 +68,10 @@ func (a *Artist) SetSongs(s []*Song) {
} }
func (a *Artist) GetOutputDir(outputDir string) string { 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) return path.Join(outputDir, base)
} }
func (a *Artist) Unmarshal(data []byte) error { func (a *Artist) Unmarshal(data []byte) error {
return json.Unmarshal(data, a) 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
View File
@@ -38,18 +38,21 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
"tags": true, "tags": true,
"header": true, "header": true,
} }
switch r := resource.(type) {
var idKey string
switch resource.(type) {
case *Playlist: case *Playlist:
payload["playlist_id"] = id idKey = "playlist_id"
case *Album: case *Album:
payload["alb_id"] = id idKey = "alb_id"
case *Artist: case *Artist:
payload["art_id"] = id idKey = "art_id"
case *Track: case *Track:
payload["sng_id"] = id idKey = "sng_id"
default: 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) jsonData, err := json.Marshal(payload)
if err != nil { if err != nil {
@@ -77,18 +80,22 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
return err return err
} }
switch { bodyStr := string(body)
case strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`): for _, check := range []struct {
return fmt.Errorf("invalid playlist ID") marker string
case strings.Contains(string(body), `"DATA_ERROR":"album::getData"`): errMsg string
return fmt.Errorf("invalid album ID") }{
case strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`): {`"DATA_ERROR":"playlist::getData"`, "invalid playlist ID"},
return fmt.Errorf("invalid artist ID") {`"DATA_ERROR":"album::getData"`, "invalid album ID"},
case strings.Contains(string(body), `"DATA_ERROR":"song::getData"`): {`"DATA_ERROR":"artist::getData"`, "invalid artist ID"},
return fmt.Errorf("invalid track 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") 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) { func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*Media, error) {
var formats string qualityFormats := map[string]string{
"mp3_128": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
switch quality { "mp3_320": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
case "mp3_128": "flac": `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "mp3_320":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "flac":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
} }
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, formats, song.TrackToken) 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))) req, err := http.NewRequestWithContext(ctx, "POST", "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -129,8 +131,7 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
} }
var media Media var media Media
err = json.Unmarshal(body, &media) if err := json.Unmarshal(body, &media); err != nil {
if err != nil {
return nil, err 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 { if media.Errors[0].Code == 1000 {
return nil, fmt.Errorf("invalid license token") return nil, fmt.Errorf("invalid license token")
} }
return nil, fmt.Errorf("%s", media.Errors[0].Message) 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 { if media.Data[0].Errors[0].Code == 2002 {
return nil, fmt.Errorf("invalid track token") return nil, fmt.Errorf("invalid track token")
} }
return nil, fmt.Errorf("%s", media.Data[0].Errors[0].Message) 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) return io.ReadAll(resp.Body)
} }
func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) { func (c *Client) GetMediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) {
url := media.GetURL() req, err := http.NewRequestWithContext(ctx, "GET", media.GetURL(), nil)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+6 -15
View File
@@ -1,32 +1,23 @@
package deezer package deezer
type Media struct { type Media struct {
Errors []MediaError `json:"errors"` Errors []mediaError `json:"errors"`
Data []struct { Data []struct {
Media []struct { Media []struct {
Type string `json:"media_type"`
Cipher Cipher `json:"cipher"`
Format string `json:"format"` Format string `json:"format"`
Sources []Source `json:"sources"` Sources []struct {
URL string `json:"url"`
} `json:"sources"`
} }
Errors []MediaError `json:"errors"` Errors []mediaError `json:"errors"`
} `json:"data"` } `json:"data"`
} }
type MediaError struct { type mediaError struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` 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 { func (m *Media) GetURL() string {
return m.Data[0].Media[0].Sources[0].URL return m.Data[0].Media[0].Sources[0].URL
} }
+2 -5
View File
@@ -13,7 +13,6 @@ type Playlist struct {
Results struct { Results struct {
Data struct { Data struct {
Title string `json:"TITLE"` Title string `json:"TITLE"`
Status int `json:"STATUS"`
Creator string `json:"PARENT_USERNAME"` Creator string `json:"PARENT_USERNAME"`
Duration int `json:"DURATION"` Duration int `json:"DURATION"`
} `json:"DATA"` } `json:"DATA"`
@@ -55,10 +54,8 @@ func (p *Playlist) SetSongs(s []*Song) {
} }
func (p *Playlist) GetOutputDir(outputDir string) string { func (p *Playlist) GetOutputDir(outputDir string) string {
p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{}) base, _ := filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
outputDir = path.Join(outputDir, p.Results.Data.Title) return path.Join(outputDir, base)
return outputDir
} }
func (p *Playlist) Unmarshal(data []byte) error { func (p *Playlist) Unmarshal(data []byte) error {
+17 -22
View File
@@ -10,22 +10,7 @@ import (
"time" "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 { type Session struct {
ArlCookie string
APIToken string APIToken string
LicenseToken string LicenseToken string
HttpClient *http.Client HttpClient *http.Client
@@ -68,22 +53,32 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
return nil, err 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 { if err := json.Unmarshal(body, &res); err != nil {
return nil, err return nil, err
} }
if res.Results.User.Id == 0 { if res.Results.User.ID == 0 {
return nil, fmt.Errorf("invalid arl cookie") 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{ return &Session{
ArlCookie: arlCookie,
APIToken: res.Results.APIToken, APIToken: res.Results.APIToken,
LicenseToken: res.Results.User.Options.LicenseToken, LicenseToken: opts.LicenseToken,
HttpClient: client, HttpClient: client,
Premium: isPremium, Premium: opts.MobileOffline || opts.WebOffline,
}, nil }, nil
} }
+12 -9
View File
@@ -3,6 +3,7 @@ package deezer
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"github.com/flytam/filenamify" "github.com/flytam/filenamify"
) )
@@ -40,26 +41,28 @@ type Song struct {
} }
func (s *Song) GetTitle() string { func (s *Song) GetTitle() string {
songTitle := s.Title
if s.Version != "" { if s.Version != "" {
songTitle = fmt.Sprintf("%s %s", s.Title, s.Version) return s.Title + " " + s.Version
} }
return s.Title
return songTitle
} }
func (s *Song) GetFileName(resourceType, mediaFormat string, song *Song) string { func (s *Song) GetFileName(resourceType, mediaFormat string) string {
ext := "mp3" ext := "mp3"
if mediaFormat == "FLAC" { if mediaFormat == "FLAC" {
ext = "flac" ext = "flac"
} }
trackNumber := ""
prefix := ""
if resourceType == "album" { 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}) fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{MaxLength: 255})
return fileName return fileName
} }
+7 -19
View File
@@ -4,9 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path" "path"
"strconv"
"time" "time"
"github.com/flytam/filenamify"
) )
type Track struct { type Track struct {
@@ -20,11 +19,9 @@ func (t *Track) String() string {
return "Track: No data available" return "Track: No data available"
} }
duration := "Unknown" duration, err := strconv.Atoi(t.Results.Data.Duration)
if t.Results.Data.Duration != "" { if err != nil {
if d, err := time.ParseDuration(t.Results.Data.Duration + "s"); err == nil { duration = 0
duration = d.String()
}
} }
return fmt.Sprintf( return fmt.Sprintf(
@@ -35,7 +32,7 @@ Duration: %s
==================================================`, ==================================================`,
t.Results.Data.GetTitle(), t.Results.Data.GetTitle(),
t.Results.Data.Artist, t.Results.Data.Artist,
duration, time.Duration(duration)*time.Second,
) )
} }
@@ -52,7 +49,7 @@ func (t *Track) GetTitle() string {
func (t *Track) GetSongs() []*Song { func (t *Track) GetSongs() []*Song {
if t.Results.Data == nil { if t.Results.Data == nil {
return []*Song{} return nil
} }
return []*Song{t.Results.Data} return []*Song{t.Results.Data}
} }
@@ -60,16 +57,7 @@ func (t *Track) GetSongs() []*Song {
func (t *Track) SetSongs(songs []*Song) {} func (t *Track) SetSongs(songs []*Song) {}
func (t *Track) GetOutputDir(outputDir string) string { func (t *Track) GetOutputDir(outputDir string) string {
if t.Results.Data == nil { return path.Join(outputDir, "Singles")
return outputDir
}
// For single tracks, create a simple "Singles" folder
base := "Singles"
base, _ = filenamify.Filenamify(base, filenamify.Options{})
outputDir = path.Join(outputDir, base)
return outputDir
} }
func (t *Track) Unmarshal(data []byte) error { func (t *Track) Unmarshal(data []byte) error {
+28 -39
View File
@@ -16,7 +16,6 @@ import (
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fileutil" "github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/logger" "github.com/mathismqn/godeez/internal/logger"
"github.com/mathismqn/godeez/internal/provider"
"github.com/mathismqn/godeez/internal/store" "github.com/mathismqn/godeez/internal/store"
"github.com/mathismqn/godeez/internal/tags" "github.com/mathismqn/godeez/internal/tags"
) )
@@ -38,8 +37,7 @@ func New(appConfig *config.Config, resourceType string) *Client {
return &Client{ return &Client{
appConfig: appConfig, appConfig: appConfig,
resourceType: resourceType, resourceType: resourceType,
deezerClient: nil, Logger: logger.New(nil),
Logger: logger.New(nil), // Initialize with a nil logger, can be set later
} }
} }
@@ -89,16 +87,15 @@ func (c *Client) prepareResource(ctx context.Context, id string, opts Options) (
} }
if c.resourceType == "artist" && len(songs) > opts.Limit { if c.resourceType == "artist" && len(songs) > opts.Limit {
songs = songs[:opts.Limit] resource.SetSongs(songs[:opts.Limit])
resource.SetSongs(songs)
} }
resourceOutputDir := resource.GetOutputDir(c.appConfig.OutputDir) outputDir := resource.GetOutputDir(c.appConfig.OutputDir)
if err := fileutil.EnsureDir(resourceOutputDir); err != nil { if err := fileutil.EnsureDir(outputDir); err != nil {
return nil, "", fmt.Errorf("failed to create output directory: %w", err) 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) { func (c *Client) createResource() (deezer.Resource, error) {
@@ -135,11 +132,9 @@ func (c *Client) downloadAllSongs(ctx context.Context, resource deezer.Resource,
result := c.downloadSong(ctx, resource, song, opts, outputDir) result := c.downloadSong(ctx, resource, song, opts, outputDir)
sp.Stop() sp.Stop()
if result.err != nil { if result.err != nil && errors.Is(result.err, context.Canceled) {
if errors.Is(result.err, context.Canceled) {
return result.err return result.err
} }
}
progress.handleResult(i, song, result) 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 { 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) media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality)
if err != nil { 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() mediaFormat := media.GetFormat()
if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality { 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 { if skipPath, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
return handleError(SkipError{Path: path}) return downloadResult{skipped: true, path: skipPath}
} }
metadataFetcher := newMetadataFetcher(c.deezerClient.Session.HttpClient) metadataChan := make(chan metadataResult, 1)
metadataResult := metadataFetcher.fetch(ctx, song, opts) go func() {
warnings = append(warnings, metadataResult.warnings...) 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 { 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) dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel() defer cancel()
fileName := song.GetFileName(c.resourceType, mediaFormat, song) fileName := song.GetFileName(c.resourceType, mediaFormat)
outputPath := path.Join(outputDir, fileName) 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 { if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil {
fileutil.DeleteFile(outputPath) 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) { if opts.Quality != strings.ToLower(mediaFormat) {
warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", 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)) warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
} }
finalizeWarnings := c.finalizeDownload(resource, song, outputPath, mediaFormat, metadataResult.genre, cover, metadataResult.bpmKey) metadata := <-metadataChan
warnings = append(warnings, finalizeWarnings...) warnings = append(warnings, metadata.warnings...)
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, metadata.genre, cover, metadata.bpmKey)...)
return downloadResult{ return downloadResult{warnings: warnings}
success: true,
warnings: warnings,
}
} }
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error { 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(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
default: default:
// continue
} }
totalRead := 0 totalRead := 0
for totalRead < chunkSize { for totalRead < chunkSize {
n, err := stream.Read(buffer[totalRead:]) n, err := stream.Read(buffer[totalRead:])
totalRead += n
if err != nil { if err != nil {
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
break break
} }
return err return err
} }
if n > 0 {
totalRead += n
}
} }
if totalRead == 0 { 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 = file.Write(buffer[:totalRead]); err != nil {
if err != nil {
return err return err
} }
@@ -262,7 +251,7 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP
return nil 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 var warnings []string
if err := tags.AddTags(resource, song, cover, outputPath, bpmKey.BPM, bpmKey.Key, genre); err != nil { if err := tags.AddTags(resource, song, cover, outputPath, bpmKey.BPM, bpmKey.Key, genre); err != nil {
-15
View File
@@ -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,
}
}
+37 -49
View File
@@ -10,81 +10,69 @@ import (
"github.com/mathismqn/godeez/internal/provider" "github.com/mathismqn/godeez/internal/provider"
) )
type bpmKey struct {
BPM string
Key string
}
type metadataResult struct { type metadataResult struct {
bpmKey provider.BPMKey bpmKey bpmKey
genre string genre string
warnings []string warnings []string
} }
type metadataFetcher struct { func fetchMetadata(httpClient *http.Client, ctx context.Context, song *deezer.Song, opts Options) metadataResult {
httpClient *http.Client
}
func newMetadataFetcher(httpClient *http.Client) *metadataFetcher {
return &metadataFetcher{
httpClient: httpClient,
}
}
func (mf *metadataFetcher) fetch(ctx context.Context, song *deezer.Song, opts Options) metadataResult {
result := metadataResult{
bpmKey: provider.BPMKey{},
genre: "",
warnings: []string{},
}
if !opts.BPM && !opts.Genre { if !opts.BPM && !opts.Genre {
return result return metadataResult{}
} }
bmpChan := make(chan provider.BPMKey, 1) type bpmResult struct {
bmpErrChan := make(chan error, 1) value bpmKey
genreChan := make(chan string, 1) err error
genreErrChan := make(chan error, 1) }
type genreResult struct {
value string
err error
}
bpmChan := make(chan bpmResult, 1)
genreChan := make(chan genreResult, 1)
if opts.BPM { if opts.BPM {
go func() { go func() {
p := provider.BPMProvider{} result, err := provider.FetchBPM(ctx, httpClient, song.Artist, song.Title, song.Duration)
bmpKey, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.Title, song.Duration) bpmChan <- bpmResult{value: bpmKey{BPM: result.BPM, Key: result.Key}, err: err}
if err != nil {
bmpErrChan <- err
} else {
bmpChan <- bmpKey
}
}() }()
} }
if opts.Genre { if opts.Genre {
go func() { go func() {
p := provider.GenreProvider{} genre, err := provider.FetchGenre(ctx, httpClient, song.Artist, song.GetTitle())
genre, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.GetTitle()) genreChan <- genreResult{value: genre, err: err}
if err != nil {
genreErrChan <- err
} else {
genreChan <- genre
}
}() }()
} }
var result metadataResult
if opts.BPM { if opts.BPM {
select { r := <-bpmChan
case bmpKey := <-bmpChan: if r.err != nil {
result.bpmKey = bmpKey if !errors.Is(r.err, context.Canceled) {
case err := <-bmpErrChan: result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", r.err))
if !errors.Is(err, context.Canceled) {
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err))
} }
} else {
result.bpmKey = r.value
} }
} }
if opts.Genre { if opts.Genre {
select { r := <-genreChan
case genre := <-genreChan: if r.err != nil {
result.genre = genre if !errors.Is(r.err, context.Canceled) {
case err := <-genreErrChan: result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", r.err))
if !errors.Is(err, context.Canceled) {
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", err))
} }
} else {
result.genre = r.value
} }
} }
+21 -15
View File
@@ -11,23 +11,23 @@ import (
"github.com/mathismqn/godeez/internal/logger" "github.com/mathismqn/godeez/internal/logger"
) )
type downloadStats struct {
downloaded int
skipped int
failed int
}
type downloadResult struct { type downloadResult struct {
success bool
skipped bool skipped bool
path string path string
warnings []string warnings []string
err error err error
} }
type downloadStats struct {
downloaded int
skipped int
failed int
warnings int
}
type progressTracker struct { type progressTracker struct {
logger *logger.Logger logger *logger.Logger
stats *downloadStats stats downloadStats
totalSongs int totalSongs int
resourceType string resourceType string
} }
@@ -35,20 +35,18 @@ type progressTracker struct {
func newProgressTracker(logger *logger.Logger, totalSongs int, resourceType string) *progressTracker { func newProgressTracker(logger *logger.Logger, totalSongs int, resourceType string) *progressTracker {
return &progressTracker{ return &progressTracker{
logger: logger, logger: logger,
stats: &downloadStats{},
totalSongs: totalSongs, totalSongs: totalSongs,
resourceType: resourceType, resourceType: resourceType,
} }
} }
func (pt *progressTracker) startDownload(index int, song *deezer.Song) *spinner.Spinner { func (pt *progressTracker) startDownload(index int, song *deezer.Song) *spinner.Spinner {
songTitle := song.GetTitle()
trackProgress := fmt.Sprintf("[%d/%d]", index+1, pt.totalSongs) trackProgress := fmt.Sprintf("[%d/%d]", index+1, pt.totalSongs)
sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond) sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
sp.Writer = os.Stdout sp.Writer = os.Stdout
sp.Prefix = trackProgress + " " 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() sp.Start()
return sp return sp
@@ -73,13 +71,16 @@ func (pt *progressTracker) handleResult(index int, song *deezer.Song, result dow
return return
} }
pt.stats.downloaded++
if len(result.warnings) > 0 {
pt.stats.warnings++
}
pt.logger.Infof("Downloaded %s - %s\n", song.Artist, songTitle)
symbol := "✔" symbol := "✔"
if len(result.warnings) > 0 { if len(result.warnings) > 0 {
symbol = "⚠" 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) fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, songTitle)
for _, w := range result.warnings { for _, w := range result.warnings {
@@ -95,11 +96,15 @@ func (pt *progressTracker) printSummary(resourceTitle, resourceID, outputDir str
} }
if pt.resourceType != "track" { if pt.resourceType != "track" {
warningsLine := ""
if pt.stats.warnings > 0 {
warningsLine = fmt.Sprintf("\nWarnings: %d", pt.stats.warnings)
}
fmt.Printf(` fmt.Printf(`
================== [ Summary ] ================== ================== [ Summary ] ==================
Downloaded: %d Downloaded: %d
Skipped: %d Skipped: %d
Failed: %d Failed: %d%s
Elapsed time: %s Elapsed time: %s
Files saved to: %s Files saved to: %s
================================================= =================================================
@@ -107,6 +112,7 @@ Files saved to: %s
pt.stats.downloaded, pt.stats.downloaded,
pt.stats.skipped, pt.stats.skipped,
pt.stats.failed, pt.stats.failed,
warningsLine,
elapsed.Round(time.Second), elapsed.Round(time.Second),
outputDir, outputDir,
) )
+19 -25
View File
@@ -7,37 +7,31 @@ import (
"github.com/mathismqn/godeez/internal/store" "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) { func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) {
if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat { existing, err := store.GetDownloadInfo(songID)
if err != nil || existing.Quality != mediaFormat {
return "", false
}
if fileutil.FileExists(existing.Path) { if fileutil.FileExists(existing.Path) {
return existing.Path, true return existing.Path, true
} }
if existing.Hash != "" {
if err := c.initHashIndex(ctx); err == nil { if existing.Hash == "" {
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok { 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.Path = foundPath
_ = existing.Save() _ = existing.Save()
return foundPath, true return foundPath, true
}
}
}
}
return "", false
} }
+4 -6
View File
@@ -2,6 +2,7 @@ package fileutil
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -18,13 +19,11 @@ func EnsureDir(path string) error {
if !info.IsDir() { if !info.IsDir() {
return fmt.Errorf("file already exists at %s", path) return fmt.Errorf("file already exists at %s", path)
} }
return nil return nil
} }
func FileExists(path string) bool { func FileExists(path string) bool {
info, err := os.Stat(path) info, err := os.Stat(path)
return err == nil && !info.IsDir() return err == nil && !info.IsDir()
} }
@@ -32,7 +31,6 @@ func DeleteFile(path string) error {
if !FileExists(path) { if !FileExists(path) {
return nil return nil
} }
return os.Remove(path) return os.Remove(path)
} }
@@ -43,10 +41,10 @@ func GetFileHash(path string) (string, error) {
} }
defer file.Close() defer file.Close()
hash := sha256.New() h := sha256.New()
if _, err := io.Copy(hash, file); err != nil { if _, err := io.Copy(h, file); err != nil {
return "", err return "", err
} }
return fmt.Sprintf("%x", hash.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
+4 -8
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"io" "io"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
) )
@@ -16,12 +17,11 @@ type HashIndex struct {
func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) { func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
index := &HashIndex{files: make(map[string]string)} 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 { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
} }
if err != nil || d.IsDir() {
if err != nil || info.IsDir() {
return nil return nil
} }
@@ -36,12 +36,9 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
return nil return nil
} }
sum := hex.EncodeToString(h.Sum(nil)) index.files[hex.EncodeToString(h.Sum(nil))] = path
index.files[sum] = path
return nil return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -51,6 +48,5 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
func (h *HashIndex) Find(hash string) (string, bool) { func (h *HashIndex) Find(hash string) (string, bool) {
path, ok := h.files[hash] path, ok := h.files[hash]
return path, ok return path, ok
} }
+5 -13
View File
@@ -10,20 +10,12 @@ func New(l *log.Logger) *Logger {
return &Logger{l: l} 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 { if l.l != nil {
l.l.Printf("[INFO] "+format, args...) l.l.Printf("["+level+"] "+format, args...)
} }
} }
func (l *Logger) Warnf(format string, args ...any) { func (l *Logger) Infof(format string, args ...any) { l.logf("INFO", format, args...) }
if l.l != nil { func (l *Logger) Warnf(format string, args ...any) { l.logf("WARN", format, args...) }
l.l.Printf("[WARN] "+format, args...) func (l *Logger) Errorf(format string, args ...any) { l.logf("ERROR", format, args...) }
}
}
func (l *Logger) Errorf(format string, args ...any) {
if l.l != nil {
l.l.Printf("[ERROR] "+format, args...)
}
}
+36 -57
View File
@@ -14,40 +14,43 @@ import (
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
type BPMProvider struct{}
type BPMKey struct { type BPMKey struct {
BPM string BPM string
Key string Key string
} }
func (p BPMProvider) Fetch(ctx context.Context, httpClient *http.Client, artist, title, duration string) (BPMKey, error) { var (
url, err := p.findSongURL(ctx, httpClient, artist, title, duration) 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 { if err != nil {
return BPMKey{}, err return BPMKey{}, err
} }
html, err := p.fetchPage(ctx, httpClient, url) html, err := fetchBPMPage(ctx, httpClient, songURL)
if err != nil { if err != nil {
return BPMKey{}, err 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) { func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
rootUrl := "https://songbpm.com" const rootURL = "https://songbpm.com"
reqUrl := rootUrl + "/searches"
values := neturl.Values{} values := neturl.Values{}
values.Add("query", fmt.Sprintf("%s %s", artist, title)) 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 { if err != nil {
return "", err return "", err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 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) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
@@ -64,20 +67,22 @@ func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, a
return "", err return "", err
} }
var ( wantDuration, err := strconv.Atoi(duration)
found bool if err != nil {
url string 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) lowerTitle := strings.ToLower(title)
lowerArtist := strings.ToLower(artist) lowerArtist := strings.ToLower(artist)
if !strings.Contains(lowerSelection, lowerTitle) || !strings.Contains(lowerSelection, lowerArtist) {
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 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, ":") parts := strings.Split(durationStr, ":")
if len(parts) != 2 { if len(parts) != 2 {
return true return true
@@ -91,31 +96,24 @@ func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, a
return true return true
} }
const toleranceSec = 2
foundDuration := minutes*60 + seconds foundDuration := minutes*60 + seconds
wantDuration, err := strconv.Atoi(duration) if foundDuration <= wantDuration-toleranceSec || foundDuration >= wantDuration+toleranceSec {
if err != nil {
return true return true
} }
const durationToleranceSec = 2 matchURL = sel.AttrOr("href", "")
if foundDuration <= (wantDuration-durationToleranceSec) || foundDuration >= (wantDuration+durationToleranceSec) {
return true
}
url = selection.AttrOr("href", "")
found = true
return false return false
}) })
if !found { if matchURL == "" {
return "", fmt.Errorf("no data found") 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) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return "", err return "", err
@@ -139,42 +137,23 @@ func (p BPMProvider) fetchPage(ctx context.Context, httpClient *http.Client, url
return string(body), nil return string(body), nil
} }
func (p BPMProvider) parse(html string) (BPMKey, error) { func parseBPM(html string) (BPMKey, error) {
bpmRegex := regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
bpmMatch := bpmRegex.FindStringSubmatch(html) bpmMatch := bpmRegex.FindStringSubmatch(html)
keyRegex := regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
keyMatch := keyRegex.FindStringSubmatch(html) keyMatch := keyRegex.FindStringSubmatch(html)
modeRegex := regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
modeMatch := modeRegex.FindStringSubmatch(html) modeMatch := modeRegex.FindStringSubmatch(html)
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 { if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
return BPMKey{}, fmt.Errorf("no data found") return BPMKey{}, fmt.Errorf("no data found")
} }
isMinor := false
bpm := bpmMatch[1] 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" { 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" key += "m"
} }
return BPMKey{ return BPMKey{BPM: bpm, Key: key}, nil
BPM: bpm,
Key: key,
}, nil
} }
+68 -30
View File
@@ -9,30 +9,50 @@ import (
"github.com/PuerkitoBio/goquery" "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) { var nonElectronicKeywords = toLower([]string{
reqUrl := fmt.Sprintf("https://www.last.fm/music/%s/%s/+tags", artist, title) "Blues", "Chillout", "Classical", "Country", "Folk", "Funk", "Hip Hop", "Jazz",
doc, err := p.fetchPage(ctx, httpClient, reqUrl) "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 { if err != nil {
return "", err return "", err
} }
tags := p.parse(doc) tags := parseGenreTags(doc)
if len(tags) == 0 { if len(tags) > 2 {
tags = tags[:2]
}
filtered := filterTags(tags)
if len(filtered) == 0 {
return "", fmt.Errorf("no data found") return "", fmt.Errorf("no data found")
} }
if len(tags) > 1 { return formatTags(filtered), nil
tags = tags[:2]
}
genre := p.formatTags(tags)
return genre, nil
} }
func (p GenreProvider) fetchPage(ctx context.Context, httpClient *http.Client, reqUrl string) (*goquery.Document, error) { func fetchGenrePage(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) {
req, err := http.NewRequestWithContext(ctx, "GET", reqUrl, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return nil, err 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) return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
} }
doc, err := goquery.NewDocumentFromReader(resp.Body) return goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
}
return doc, nil
} }
func (p GenreProvider) parse(doc *goquery.Document) []string { func parseGenreTags(doc *goquery.Document) []string {
var tags []string var tags []string
doc.Find("ol.big-tags .big-tags-item-name a").Each(func(_ int, s *goquery.Selection) { doc.Find("ol.big-tags .big-tags-item-name a").Each(func(_ int, s *goquery.Selection) {
tag := strings.TrimSpace(s.Text()) if tag := strings.TrimSpace(s.Text()); tag != "" {
if tag != "" {
tags = append(tags, tag) tags = append(tags, tag)
} }
}) })
return tags return tags
} }
func (p GenreProvider) formatTags(tags []string) string { func matchesKeyword(tag string, keywords []string) bool {
var formatted []string 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 { for _, tag := range tags {
tag = strings.TrimSpace(tag) tag = strings.TrimSpace(tag)
if tag == "" { if tag == "" {
continue continue
} }
words := strings.Fields(tag) words := strings.Fields(tag)
for i, w := range words { 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, " ")) formatted = append(formatted, strings.Join(words, " "))
} }
return strings.Join(formatted, " / ")
return strings.Join(formatted, "/")
} }
+1 -4
View File
@@ -11,12 +11,9 @@ var db *bolt.DB
func OpenDB(cfgDir string) error { func OpenDB(cfgDir string) error {
var err error var err error
db, err = bolt.Open(path.Join(cfgDir, ".tracks.db"), 0600, nil)
dbPath := path.Join(cfgDir, "tracks.db")
db, err = bolt.Open(dbPath, 0600, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to open database: %w", err) return fmt.Errorf("failed to open database: %w", err)
} }
return nil return nil
} }
-84
View File
@@ -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
View File
@@ -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 { func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error {
if album, ok := resource.(*deezer.Album); ok { if album, ok := resource.(*deezer.Album); ok {
dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-") if parts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-"); len(parts) == 3 {
if len(dateParts) == 3 { album.Results.Data.PhysicalReleaseDate = parts[0]
album.Results.Data.PhysicalReleaseDate = dateParts[0]
} }
t.addTag("TRACKNUMBER", song.TrackNumber) 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("GENRE", genre)
t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain) t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain)
t.addTag("ISRC", song.ISRC) t.addTag("ISRC", song.ISRC)
t.addTag("BPM", tempo) t.addTag("BPM", tempo)
t.addTag("KEY", key) t.addTag("KEY", key)
t.addTag("INITIALKEY", key) t.addTag("INITIALKEY", key)
cmtsmeta := t.cmts.Marshal() cmtsMeta := t.cmts.Marshal()
if t.index > 0 { if t.index > 0 {
t.file.Meta[t.index] = &cmtsmeta t.file.Meta[t.index] = &cmtsMeta
} else { } else {
t.file.Meta = append(t.file.Meta, &cmtsmeta) t.file.Meta = append(t.file.Meta, &cmtsMeta)
} }
picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg") picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg")
if err != nil { if err != nil {
return err return err
} }
picturemeta := picture.Marshal() pictureMeta := picture.Marshal()
t.file.Meta = append(t.file.Meta, &picturemeta) t.file.Meta = append(t.file.Meta, &pictureMeta)
return t.saveTags(path) 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) { 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 { func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int) {
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
for idx, meta := range file.Meta { for idx, meta := range file.Meta {
if meta.Type == flac.VorbisComment { if meta.Type == flac.VorbisComment {
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
cmtIdx = idx if err == nil {
if err != nil { return cmt, idx
return nil, 0, err
} }
} }
} }
return nil, 0
return cmt, cmtIdx, nil
} }
+7 -10
View File
@@ -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("TEXT", strings.Join(song.Contributors.Authors, ", "))
t.addTag("TCON", genre) t.addTag("TCON", genre)
t.addTag("TLEN", song.Duration) t.addTag("TLEN", song.Duration)
t.addTXXXTag("GAIN", song.Gain)
t.addTXXXTag("ISRC", song.ISRC)
t.addTag("TBPM", tempo) t.addTag("TBPM", tempo)
t.addTag("TKEY", key) 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(), Encoding: t.tag.DefaultEncoding(),
MimeType: "image/jpeg", MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover, PictureType: id3v2.PTFrontCover,
Description: "Cover", Description: "Cover",
Picture: cover, Picture: cover,
} })
t.tag.AddAttachedPicture(frame)
return t.tag.Save() 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 != "" { if value != "" {
udf := id3v2.UserDefinedTextFrame{ t.tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
Encoding: t.tag.DefaultEncoding(), Encoding: t.tag.DefaultEncoding(),
Description: description, Description: description,
Value: value, Value: value,
} })
t.tag.AddUserDefinedTextFrame(udf)
} }
} }
+7 -12
View File
@@ -10,17 +10,15 @@ import (
) )
type tagger interface { 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) { func newTagger(filePath string) (tagger, error) {
ext := path.Ext(filePath) if path.Ext(filePath) == ".mp3" {
if ext == ".mp3" {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &id3v2Tagger{tag: tag}, nil return &id3v2Tagger{tag: tag}, nil
} }
@@ -28,11 +26,9 @@ func newTagger(filePath string) (tagger, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
cmts, idx, err := extractFLACComment(file)
if err != nil { cmts, idx := extractFLACComment(file)
return nil, err if cmts == nil {
}
if cmts == nil && idx > 0 {
cmts = flacvorbis.New() 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 { 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 { if err != nil {
return err return err
} }
return t.addTags(resource, song, cover, filePath, tempo, key, genre)
return tagger.addTags(resource, song, cover, filePath, tempo, key, genre)
} }
-39
View File
@@ -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")
}
-48
View File
@@ -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()
}
-71
View File
@@ -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
}
}
}
}