19 Commits
Author SHA1 Message Date
Mathis Maquenne 3022e226ce fix(build): comment out unfinished autostart implementation 2025-08-18 11:29:02 +02:00
Mathis Maquenne c4329431b5 docs: update for v1.2.0 2025-08-18 11:20:29 +02:00
Mathis Maquenne be40910480 chore(deps): update all dependencies 2025-08-18 10:49:42 +02:00
Mathis Maquenne 1813a16bb7 feat(cli): change default download quality from flac to mp3_320 2025-08-09 19:40:06 +02:00
Mathis Maquenne 283c5d76e9 feat(cli): add --strict flag to fail track download if quality is unavailable 2025-08-09 19:17:29 +02:00
Mathis Maquenne 7154c15cc3 refactor(cli): remove --quality=best flag since fallback is now automatic 2025-08-06 22:28:58 +02:00
Mathis Maquenne 50fde9dfa9 feat(media): fallback to lower quality if requested format is unavailable 2025-08-06 22:09:06 +02:00
Mathis Maquenne b4f69396af fix(song): handle empty SNG_CONTRIBUTORS array during unmarshal 2025-08-06 20:45:27 +02:00
Mathis Maquenne fe3491e16d chore(watcher): disable feature due to known DB concurrency issues 2025-08-06 18:18:17 +02:00
Mathis Maquenne ef018b38ad fix(logger): initialize downloader with nil logger to avoid error 2025-06-23 21:03:27 -04:00
Mathis Maquenne 33e774d078 refactor: simplify homeDir and appConfig usage 2025-06-23 20:58:08 -04:00
Mathis Maquenne a619b37922 feat(watcher): add macOS autostart support via launchd 2025-06-23 20:03:56 -04:00
Mathis Maquenne 31d8a3b04f feat(watch): add command to remove a playlist from the watch list 2025-06-22 17:51:17 -04:00
Mathis Maquenne 95c81a2a67 feat(watch): add command to list watched playlists 2025-06-22 17:47:54 -04:00
Mathis Maquenne 498f7c8d3f feat(watch): add command to register a playlist to the watch list 2025-06-22 17:47:05 -04:00
Mathis Maquenne b19c92f227 feat: watch playlists and download newly added tracks 2025-06-22 17:35:16 -04:00
Mathis Maquenne 99bdc22a25 fix(cli): restrict --limit option to download artist command 2025-06-21 16:22:16 -04:00
Mathis Maquenne 0680d8c8e7 chore(deps): update golang.org/x/crypto and golang.org/x/net to patch security vulnerabilities 2025-06-20 20:07:51 -04:00
Mathis Maquenne 67b4fe404e feat: download artist's top tracks with --limit flag 2025-06-20 20:00:59 -04:00
28 changed files with 832 additions and 209 deletions
+16
View File
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2025-08-18
### Added
- New `artist` command to download an artists top tracks.
- `--limit` flag for the `artist` command to restrict the number of tracks.
- `--strict` flag for downloads: fail if the requested quality is unavailable.
### Changed
- Default download quality is now **MP3 320kbps**.
### Removed
- The `--quality=best` option. Fallback to lower quality is now the **default behavior**; use the new `--strict` flag if you want to prevent fallback.
### Fixed
- Handle error when `SNG_CONTRIBUTORS` metadata is empty.
## [1.1.1] - 2025-06-16
### Fixed
+10 -10
View File
@@ -19,8 +19,8 @@ A simple Go tool for downloading music from [Deezer](https://www.deezer.com).
## Features
* Download playlists and albums from Deezer
* Select audio quality: MP3 128kbps, MP3 320kbps, or FLAC (⚠️ non-premium accounts are limited to 128kbps)
* Download playlists, albums, and artists' top tracks from Deezer
* Select audio quality: MP3 128kbps, MP3 320kbps (default), or FLAC (⚠️ non-premium accounts are limited to 128kbps)
* Automatically adds metadata tags to downloaded files
* Fetch and tag songs with BPM and musical key
* Smart skip system: avoids re-downloading already existing files using hashes and metadata
@@ -38,7 +38,7 @@ To install **GoDeez**, simply download the latest binary for your platform from
Example (Linux/macOS):
```bash
# Move the downloaded binary to /usr/local/bin for easy access from anywhere
mv godeez-1.1.0-linux-amd64 /usr/local/bin/godeez
mv godeez-1.2.0-linux-amd64 /usr/local/bin/godeez
```
## Configuration
@@ -119,17 +119,17 @@ Usage:
godeez download [command]
Available Commands:
album Download songs from album
playlist Download songs from playlist
album Download songs from an album
artist Download top songs from an artist
playlist Download songs from a playlist
Flags:
--bpm fetch BPM/key and add to file tags
-h, --help help for download
-q, --quality string download quality [mp3_128, mp3_320, flac, best] (default "best")
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
Global Flags:
--config string config file (default ~/.godeez/config.toml)
-h, --help help for download
-q, --quality string download quality [mp3_128, mp3_320, flac] (default "mp3_320")
--strict fail the song download if the quality is not available
-t, --timeout duration timeout for each download (e.g. 10s, 1m, 2m30s) (default 2m0s)
Use "godeez download [command] --help" for more information about a command.
```
+32 -4
View File
@@ -4,13 +4,18 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/downloader"
"github.com/spf13/cobra"
)
var opts downloader.Options
var (
opts downloader.Options
cfgPath string
)
var downloadCmd = &cobra.Command{
Use: "download",
@@ -20,28 +25,46 @@ var downloadCmd = &cobra.Command{
func init() {
RootCmd.AddCommand(downloadCmd)
downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "best", "download quality [mp3_128, mp3_320, flac, best]")
downloadCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "config file (default ~/.godeez/config.toml)")
downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]")
downloadCmd.PersistentFlags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
downloadCmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
downloadCmd.PersistentFlags().BoolVar(&opts.Strict, "strict", false, "fail the song download if the quality is not available")
downloadCmd.AddCommand(
newDownloadCmd("album"),
newDownloadCmd("playlist"),
newDownloadCmd("artist"),
)
}
func newDownloadCmd(resourceType string) *cobra.Command {
article := "a"
if resourceType == "album" {
article = "an"
}
cmd := &cobra.Command{
Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType),
Short: fmt.Sprintf("Download songs from %s", resourceType),
Short: fmt.Sprintf("Download songs from %s %s", article, resourceType),
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
appConfig, err := config.New(cfgPath)
if err != nil {
return err
}
cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig))
opts.Quality = strings.ToLower(opts.Quality)
return opts.Validate()
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dl := downloader.New(appConfig, resourceType)
appConfigVal := ctx.Value("appConfig")
appConfig, _ := appConfigVal.(*config.Config)
dl := downloader.New(appConfig, resourceType)
if err := dl.Run(ctx, opts, args[0]); err != nil {
if errors.Is(err, context.Canceled) {
return nil
@@ -54,5 +77,10 @@ func newDownloadCmd(resourceType string) *cobra.Command {
},
}
if resourceType == "artist" {
cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download")
cmd.Short = "Download top songs from an artist"
}
return cmd
}
+16 -13
View File
@@ -1,27 +1,30 @@
package cmd
import (
"github.com/mathismqn/godeez/internal/config"
"github.com/spf13/cobra"
)
var (
cfgPath string
appConfig *config.Config
)
var RootCmd = &cobra.Command{
Use: "godeez",
Short: "GoDeez is a tool to download music from Deezer",
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
var err error
appConfig, err = config.New(cfgPath)
// 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.
return err
/*
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
if err := watcher.EnsureAutostart(homeDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to install autostart for watcher: %v\n", err)
}
*/
return nil
},
}
func init() {
RootCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "config file (default ~/.godeez/config.toml)")
}
+18
View File
@@ -0,0 +1,18 @@
package cmd
import (
"github.com/spf13/cobra"
)
var watchCmd = &cobra.Command{
Use: "watch",
Short: "Watch playlists and auto-download new tracks",
}
// TEMPORARILY DISABLED:
// The `watch` command and all its subcommands are currently disabled
// due to known issues (e.g., database access conflicts with `download`).
// To re-enable, uncomment the line below.
func init() {
// RootCmd.AddCommand(watchCmd)
}
+49
View File
@@ -0,0 +1,49 @@
package cmd
import (
"fmt"
"strings"
"time"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/cobra"
)
var watchAddCmd = &cobra.Command{
Use: "add <playlist_id>",
Short: "Add a playlist to the watch list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := args[0]
ok, err := store.IsWatched(id)
if err != nil {
return err
}
if ok {
fmt.Printf("Playlist %s is already being watched\n", id)
return nil
}
playlist := &store.WatchedPlaylist{
ID: id,
Quality: strings.ToLower(opts.Quality),
BPM: opts.BPM,
Timeout: opts.Timeout,
}
if err := playlist.Save(); err != nil {
return fmt.Errorf("failed to add playlist %s to watch list: %w", id, err)
}
fmt.Printf("Playlist %s added to watch list\n", id)
return nil
},
}
func init() {
watchCmd.AddCommand(watchAddCmd)
watchAddCmd.Flags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]")
watchAddCmd.Flags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
watchAddCmd.Flags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
}
+41
View File
@@ -0,0 +1,41 @@
package cmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/cobra"
)
var watchListCmd = &cobra.Command{
Use: "list",
Short: "List watched playlists",
RunE: func(cmd *cobra.Command, args []string) error {
playlists, err := store.ListWatchedPlaylists()
if err != nil {
return fmt.Errorf("failed to list watched playlists: %w", err)
}
if len(playlists) == 0 {
fmt.Println("No watched playlists.")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tQuality\tFetch BPM\tTimeout")
fmt.Fprintln(w, "---\t-------\t----------\t-------")
for _, playlist := range playlists {
fmt.Fprintf(w, "%s\t%s\t%t\t%s\n", playlist.ID, playlist.Quality, playlist.BPM, playlist.Timeout)
}
w.Flush()
return nil
},
}
func init() {
watchCmd.AddCommand(watchListCmd)
}
+35
View File
@@ -0,0 +1,35 @@
package cmd
import (
"fmt"
"github.com/mathismqn/godeez/internal/store"
"github.com/spf13/cobra"
)
var watchRemoveCmd = &cobra.Command{
Use: "remove <playlist_id>",
Short: "Remove a playlist from the watch list",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := args[0]
ok, err := store.IsWatched(id)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("playlist %s is not being watched", id)
}
if err := store.RemoveWatchedPlaylist(id); err != nil {
return fmt.Errorf("failed to remove playlist %s from watch list: %w", id, err)
}
fmt.Printf("Playlist %s removed from watch list\n", id)
return nil
},
}
func init() {
watchCmd.AddCommand(watchRemoveCmd)
}
+34
View File
@@ -0,0 +1,34 @@
package cmd
import (
"context"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/watcher"
"github.com/spf13/cobra"
)
var watchRunCmd = &cobra.Command{
Use: "run",
Short: "Start the background playlist watcher",
Hidden: true,
PreRun: func(cmd *cobra.Command, args []string) {
appConfig, err := config.New("")
if err != nil {
return
}
cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig))
},
Run: func(cmd *cobra.Command, args []string) {
ctx := cmd.Context()
appConfigVal := ctx.Value("appConfig")
appConfig, _ := appConfigVal.(*config.Config)
w := watcher.New(appConfig)
w.Run(ctx, opts)
},
}
func init() {
watchCmd.AddCommand(watchRunCmd)
}
+25 -30
View File
@@ -1,49 +1,44 @@
module github.com/mathismqn/godeez
go 1.23.1
go 1.24.0
toolchain go1.24.4
require (
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
)
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/fatih/color v1.14.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.10.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.25.0 // indirect
golang.org/x/text v0.19.0 // indirect
gopkg.in/ini.v1 v1.67.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 (
github.com/PuerkitoBio/goquery v1.10.0
github.com/PuerkitoBio/goquery v1.10.3
github.com/bogem/id3v2/v2 v2.1.4
github.com/briandowns/spinner v1.23.2
github.com/flytam/filenamify v1.2.0
github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.1
github.com/go-flac/go-flac/v2 v2.0.4
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
go.etcd.io/bbolt v1.4.0
golang.org/x/crypto v0.28.0
github.com/spf13/pflag v1.0.7 // indirect
go.etcd.io/bbolt v1.4.2
golang.org/x/crypto v0.41.0
)
+84 -82
View File
@@ -1,148 +1,150 @@
github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac/v2 v2.0.1 h1:1zilNkbmmpK9DLsz2NbjLHG8avOmthYqUfVc9YKB/Ps=
github.com/go-flac/go-flac/v2 v2.0.1/go.mod h1:hvgeR2hElLbwk0Q1/vMazIDmIc2LAFSd9Bx/Fk6ViKo=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.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.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+7 -6
View File
@@ -15,6 +15,7 @@ type Config struct {
ArlCookie string `mapstructure:"arl_cookie"`
SecretKey string `mapstructure:"secret_key"`
OutputDir string `mapstructure:"output_dir"`
HomeDir string
}
func New(cfgPath string) (*Config, error) {
@@ -49,11 +50,11 @@ func New(cfgPath string) (*Config, error) {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
cfg := &Config{HomeDir: homeDir}
if err := viper.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if err := cfg.Validate(homeDir); err != nil {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
@@ -61,10 +62,10 @@ func New(cfgPath string) (*Config, error) {
return nil, err
}
return &cfg, nil
return cfg, nil
}
func (c *Config) Validate(homeDir string) error {
func (c *Config) Validate() error {
if c.ArlCookie == "" {
return fmt.Errorf("arl_cookie is not set")
}
@@ -75,7 +76,7 @@ func (c *Config) Validate(homeDir string) error {
return fmt.Errorf("secret_key must be 16 bytes long")
}
if c.OutputDir == "" {
c.OutputDir = filepath.Join(homeDir, "Music", "GoDeez")
c.OutputDir = filepath.Join(c.HomeDir, "Music", "GoDeez")
}
return nil
+4
View File
@@ -59,6 +59,10 @@ func (a *Album) GetSongs() []*Song {
return a.Results.Songs.Data
}
func (a *Album) SetSongs(s []*Song) {
a.Results.Songs.Data = s
}
func (a *Album) GetOutputDir(outputDir string) string {
base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{})
+82
View File
@@ -0,0 +1,82 @@
package deezer
import (
"encoding/json"
"fmt"
"path"
"strconv"
"strings"
"time"
"github.com/flytam/filenamify"
)
type Artist struct {
Results struct {
Data struct {
Name string `json:"ART_NAME"`
} `json:"DATA"`
Songs struct {
Data []*Song `json:"data"`
} `json:"TOP"`
} `json:"results"`
}
func (a *Artist) GetType() string {
return "Artist"
}
func (a *Artist) GetTitle() string {
return a.Results.Data.Name
}
func (a *Artist) GetSongs() []*Song {
return a.Results.Songs.Data
}
func (a *Artist) SetSongs(s []*Song) {
a.Results.Songs.Data = s
}
func (a *Artist) GetOutputDir(outputDir string) string {
base, _ := filenamify.Filenamify(a.GetTitle(), filenamify.Options{})
return path.Join(outputDir, base)
}
func (a *Artist) Unmarshal(data []byte) error {
return json.Unmarshal(data, a)
}
func (a *Artist) String() string {
tracks := a.GetSongs()
count := len(tracks)
limit := 3
if count < limit {
limit = count
}
totalSec := 0
for _, s := range tracks {
if d, err := strconv.Atoi(s.Duration); err == nil {
totalSec += d
}
}
totalDuration := time.Duration(totalSec) * time.Second
var b strings.Builder
fmt.Fprintf(&b, "============= [ Artist Info ] =============\n")
fmt.Fprintf(&b, "Artist: %s\n", a.GetTitle())
fmt.Fprintf(&b, "Tracks: %d\n", count)
fmt.Fprintf(&b, "Playtime: %s\n", totalDuration)
fmt.Fprintf(&b, "-------------------------------------------\n")
fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit)
for i := 0; i < limit; i++ {
s := tracks[i]
title := s.GetTitle()
fmt.Fprintf(&b, " %2d. %s %s\n", i+1, s.Artist, title)
}
fmt.Fprintf(&b, "===========================================\n")
return b.String()
}
+18 -8
View File
@@ -29,23 +29,32 @@ func NewClient(ctx context.Context, appConfig *config.Config) (*Client, error) {
}, nil
}
func (c *Client) FetchResource(ctx context.Context, ressource Resource, id string) error {
func (c *Client) FetchResource(ctx context.Context, resource Resource, id string) error {
payload := map[string]interface{}{
"nb": 10000,
"start": 0,
"playlist_id": id,
"alb_id": id,
"lang": "en",
"tab": 0,
"tags": true,
"header": true,
}
switch r := resource.(type) {
case *Playlist:
payload["playlist_id"] = id
case *Album:
payload["alb_id"] = id
case *Artist:
payload["art_id"] = id
default:
return fmt.Errorf("unsupported resource type: %T", r)
}
jsonData, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", ressource.GetType(), c.Session.APIToken)
url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", resource.GetType(), c.Session.APIToken)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
@@ -72,11 +81,14 @@ func (c *Client) FetchResource(ctx context.Context, ressource Resource, id strin
if strings.Contains(string(body), `"DATA_ERROR":"album::getData"`) {
return fmt.Errorf("invalid album ID")
}
if strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`) {
return fmt.Errorf("invalid artist ID")
}
if strings.Contains(string(body), `"results":{}`) {
return fmt.Errorf("unexpected response")
}
return ressource.Unmarshal(body)
return resource.Unmarshal(body)
}
func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*Media, error) {
@@ -86,10 +98,8 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
case "mp3_128":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "mp3_320":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"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"}]`
case "best":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
}
+1 -9
View File
@@ -36,15 +36,7 @@ func (m *Media) GetURL() (string, error) {
return "", fmt.Errorf("no media sources found")
}
url := m.Data[0].Media[0].Sources[0].URL
for _, source := range m.Data[0].Media[0].Sources {
if source.Provider == "ak" {
url = source.URL
break
}
}
return url, nil
return m.Data[0].Media[0].Sources[0].URL, nil
}
func (m *Media) GetFormat() (string, error) {
+4
View File
@@ -50,6 +50,10 @@ func (p *Playlist) GetSongs() []*Song {
return p.Results.Songs.Data
}
func (p *Playlist) SetSongs(s []*Song) {
p.Results.Songs.Data = s
}
func (p *Playlist) GetOutputDir(outputDir string) string {
p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
outputDir = path.Join(outputDir, p.Results.Data.Title)
+1
View File
@@ -4,6 +4,7 @@ type Resource interface {
GetTitle() string
GetType() string
GetSongs() []*Song
SetSongs(songs []*Song)
GetOutputDir(outputDir string) string
Unmarshal(data []byte) error
}
+20 -5
View File
@@ -1,22 +1,37 @@
package deezer
import (
"encoding/json"
"fmt"
"github.com/flytam/filenamify"
)
type Contributors struct {
MainArtists []string `json:"main_artist"`
Composers []string `json:"composer"`
Authors []string `json:"author"`
}
func (c *Contributors) UnmarshalJSON(data []byte) error {
if string(data) == "[]" {
*c = Contributors{}
return nil
}
type Alias Contributors
aux := (*Alias)(c)
return json.Unmarshal(data, aux)
}
type Song struct {
ID string `json:"SNG_ID"`
Artist string `json:"ART_NAME"`
Title string `json:"SNG_TITLE"`
Version string `json:"VERSION"`
Cover string `json:"ALB_PICTURE"`
Contributors struct {
MainArtists []string `json:"main_artist"`
Composers []string `json:"composer"`
Authors []string `json:"author"`
} `json:"SNG_CONTRIBUTORS"`
Contributors Contributors `json:"SNG_CONTRIBUTORS"`
Duration string `json:"DURATION"`
Gain string `json:"GAIN"`
ISRC string `json:"ISRC"`
+31 -6
View File
@@ -7,6 +7,7 @@ import (
"io"
"os"
"path"
"strings"
"sync"
"time"
@@ -16,6 +17,7 @@ import (
"github.com/mathismqn/godeez/internal/crypto"
"github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/logger"
"github.com/mathismqn/godeez/internal/store"
"github.com/mathismqn/godeez/internal/tags"
)
@@ -26,6 +28,7 @@ type Client struct {
appConfig *config.Config
resourceType string
deezerClient *deezer.Client
Logger *logger.Logger
hashIndexOnce sync.Once
hashIndex *fileutil.HashIndex
@@ -37,6 +40,7 @@ func New(appConfig *config.Config, resourceType string) *Client {
appConfig: appConfig,
resourceType: resourceType,
deezerClient: nil,
Logger: logger.New(nil), // Initialize with a nil logger, can be set later
}
}
@@ -48,7 +52,7 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error {
}
if !c.deezerClient.Session.Premium && (opts.Quality == "mp3_320" || opts.Quality == "flac") {
return fmt.Errorf("premium account required for %s quality", opts.Quality)
return fmt.Errorf("premium account required for '%s' quality", opts.Quality)
}
var resource deezer.Resource
@@ -57,6 +61,8 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error {
resource = &deezer.Album{}
case "playlist":
resource = &deezer.Playlist{}
case "artist":
resource = &deezer.Artist{}
default:
return fmt.Errorf("unsupported resource type: %s", c.resourceType)
}
@@ -69,6 +75,10 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error {
if len(songs) == 0 {
return fmt.Errorf("%s has no songs", c.resourceType)
}
if c.resourceType == "artist" && len(songs) > opts.Limit {
songs = songs[:opts.Limit]
resource.SetSongs(songs)
}
rootOutputDir := c.appConfig.OutputDir
resourceOutputDir := resource.GetOutputDir(rootOutputDir)
@@ -111,7 +121,9 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error {
}
failed++
c.Logger.Errorf("Failed to download %s - %s: %v\n", song.Artist, song.Title, err)
fmt.Printf("%s ✖ Failed: %s - %s:\n Error: %v\n", trackProgress, song.Artist, song.Title, err)
continue
}
@@ -121,12 +133,18 @@ func (c *Client) Run(ctx context.Context, opts Options, id string) error {
}
downloaded++
c.Logger.Infof("Downloaded %s - %s\n", song.Artist, song.Title)
fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, song.Title)
for _, w := range warnings {
c.Logger.Warnf("Warning: %s\n", w)
fmt.Printf(" Warning: %s\n", w)
}
}
if downloaded > 0 || failed > 0 {
c.Logger.Infof("Playlist %s (%s): %d downloaded, %d skipped, %d failed\n", resource.GetTitle(), id, downloaded, skipped, failed)
}
fmt.Printf(`
================== [ Summary ] ==================
Downloaded: %d
@@ -151,7 +169,7 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality)
if err != nil {
return warnings, fmt.Errorf("failed to fetch media: %w", err)
return nil, fmt.Errorf("failed to fetch media: %w", err)
}
fileName := song.GetFileName(c.resourceType, song, media)
@@ -159,11 +177,14 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
mediaFormat, err := media.GetFormat()
if err != nil {
return warnings, fmt.Errorf("failed to get media format: %w", err)
return nil, fmt.Errorf("failed to get media format: %w", err)
}
if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality {
return nil, fmt.Errorf("requested quality '%s' not available", opts.Quality)
}
if path, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip {
return warnings, SkipError{Path: path}
return nil, SkipError{Path: path}
}
var metricsChan chan *bpm.Metrics
@@ -184,7 +205,7 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID)
if err != nil {
return warnings, fmt.Errorf("failed to get media stream: %w", err)
return nil, fmt.Errorf("failed to get media stream: %w", err)
}
dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
@@ -194,7 +215,11 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil {
fileutil.DeleteFile(outputPath)
return warnings, fmt.Errorf("failed to stream to file: %w", err)
return nil, fmt.Errorf("failed to stream to file: %w", err)
}
if opts.Quality != strings.ToLower(mediaFormat) {
warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat)))
}
metrics := &bpm.Metrics{}
+11 -1
View File
@@ -9,19 +9,29 @@ var validQualities = map[string]bool{
"mp3_128": true,
"mp3_320": true,
"flac": true,
"best": true,
}
type Options struct {
Quality string
Timeout time.Duration
Limit int
BPM bool
Strict bool
}
func (o *Options) Validate() error {
if !validQualities[o.Quality] {
return fmt.Errorf("invalid quality option: %s", o.Quality)
}
if o.Timeout <= 0 {
return fmt.Errorf("timeout must be a positive duration")
}
if o.Limit <= 0 {
return fmt.Errorf("limit must be a positive integer")
}
if o.Limit > 100 {
return fmt.Errorf("limit must not exceed 100")
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package logger
import "log"
type Logger struct {
l *log.Logger
}
func New(l *log.Logger) *Logger {
return &Logger{l: l}
}
func (l *Logger) Infof(format string, args ...any) {
if l.l != nil {
l.l.Printf("[INFO] "+format, args...)
}
}
func (l *Logger) Warnf(format string, args ...any) {
if l.l != nil {
l.l.Printf("[WARN] "+format, args...)
}
}
func (l *Logger) Errorf(format string, args ...any) {
if l.l != nil {
l.l.Printf("[ERROR] "+format, args...)
}
}
+6 -9
View File
@@ -16,6 +16,8 @@ type DownloadInfo struct {
Downloaded time.Time `json:"downloaded_at"`
}
var trackBucket = []byte("tracks")
func GetDownloadInfo(songID string) (*DownloadInfo, error) {
var info DownloadInfo
@@ -39,9 +41,9 @@ func GetDownloadInfo(songID string) (*DownloadInfo, error) {
func (d *DownloadInfo) Save() error {
return db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(trackBucket)
if b == nil {
return fmt.Errorf("bucket not found")
b, err := tx.CreateBucketIfNotExists(trackBucket)
if err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
data, err := json.Marshal(d)
@@ -49,11 +51,6 @@ func (d *DownloadInfo) Save() error {
return err
}
err = b.Put([]byte(d.SongID), data)
if err != nil {
return err
}
return nil
return b.Put([]byte(d.SongID), data)
})
}
+1 -11
View File
@@ -7,10 +7,7 @@ import (
bolt "go.etcd.io/bbolt"
)
var (
db *bolt.DB
trackBucket = []byte("tracks")
)
var db *bolt.DB
func OpenDB(cfgDir string) error {
var err error
@@ -21,12 +18,5 @@ func OpenDB(cfgDir string) error {
return fmt.Errorf("failed to open database: %w", err)
}
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(trackBucket)
return err
}); err != nil {
return fmt.Errorf("failed to create bucket: %w", err)
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package store
import (
"encoding/json"
"fmt"
"time"
bolt "go.etcd.io/bbolt"
)
type WatchedPlaylist struct {
ID string `json:"id"`
Quality string `json:"quality"`
BPM bool `json:"bpm"`
Timeout time.Duration `json:"timeout"`
}
var watchedBucket = []byte("watched")
func ListWatchedPlaylists() ([]*WatchedPlaylist, error) {
var playlists []*WatchedPlaylist
if err := db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(watchedBucket)
if b == nil {
return nil
}
return b.ForEach(func(k, v []byte) error {
var p WatchedPlaylist
if err := json.Unmarshal(v, &p); err != nil {
return err
}
playlists = append(playlists, &p)
return nil
})
}); err != nil {
return nil, err
}
return playlists, nil
}
func (p *WatchedPlaylist) Save() error {
return db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists(watchedBucket)
if err != nil {
return err
}
data, err := json.Marshal(p)
if err != nil {
return err
}
return b.Put([]byte(p.ID), data)
})
}
func RemoveWatchedPlaylist(playlistID string) error {
return db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(watchedBucket)
if b == nil {
return fmt.Errorf("bucket not found")
}
return b.Delete([]byte(playlistID))
})
}
func IsWatched(playlistID string) (bool, error) {
var found bool
err := db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(watchedBucket)
if b == nil {
return nil
}
found = b.Get([]byte(playlistID)) != nil
return nil
})
return found, err
}
+39
View File
@@ -0,0 +1,39 @@
package watcher
import (
"os"
"path/filepath"
"runtime"
"strings"
)
func EnsureAutostart(homeDir string) error {
if isAutostartInstalled(homeDir) || isTemporaryExecutable() {
return nil
}
// return installAutostart(homeDir)
return nil
}
func isAutostartInstalled(homeDir string) bool {
switch runtime.GOOS {
case "darwin":
path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist")
_, err := os.Stat(path)
return err == nil
default:
return false
}
}
func isTemporaryExecutable() bool {
exe, err := os.Executable()
if err != nil {
return true
}
return strings.Contains(exe, "go-build")
}
+48
View File
@@ -0,0 +1,48 @@
//go:build darwin
package watcher
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func installAutostart(homeDir string) error {
exe, err := os.Executable()
if err != nil {
return err
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
return err
}
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.godeez.watch</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>watch</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>`, exe)
path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist")
if err := os.WriteFile(path, []byte(plist), 0644); err != nil {
return err
}
return exec.Command("launchctl", "load", path).Run()
}
+71
View File
@@ -0,0 +1,71 @@
package watcher
import (
"context"
"errors"
"log"
"os"
"path/filepath"
"time"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/downloader"
"github.com/mathismqn/godeez/internal/logger"
"github.com/mathismqn/godeez/internal/store"
)
type Watcher struct {
appConfig *config.Config
logger *logger.Logger
}
func New(appConfig *config.Config) *Watcher {
logFile := filepath.Join(appConfig.HomeDir, ".godeez", "watcher.log")
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Fatalf("Failed to open log file: %v\n", err)
}
base := log.New(file, "", log.LstdFlags)
log := logger.New(base)
return &Watcher{
appConfig: appConfig,
logger: log,
}
}
func (w *Watcher) Run(ctx context.Context, opts downloader.Options) {
w.logger.Infof("Starting watcher...")
for {
select {
case <-ctx.Done():
return
default:
playlists, err := store.ListWatchedPlaylists()
if err != nil {
w.logger.Errorf("Failed to list watched playlists: %v\n", err)
} else {
for _, playlist := range playlists {
dl := downloader.New(w.appConfig, "playlist")
dl.Logger = w.logger
if err := dl.Run(ctx, opts, playlist.ID); err != nil {
if errors.Is(err, context.Canceled) {
return
}
w.logger.Errorf("Playlist %s: %v\n", playlist.ID, err)
}
}
}
select {
case <-ctx.Done():
return
case <-time.After(15 * time.Minute):
// Continue to the next iteration to check for updates
}
}
}
}