refactor: simplify codebase (-209 lines)
This commit is contained in:
+25
-25
@@ -12,6 +12,10 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const appConfigKey contextKey = "appConfig"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
opts downloader.Options
|
opts downloader.Options
|
||||||
cfgPath string
|
cfgPath string
|
||||||
@@ -41,51 +45,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(cfgPath)
|
||||||
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
@@ -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
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -4,15 +4,13 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NOTE: The watch command is disabled due to database concurrency issues.
|
||||||
|
// To re-enable, uncomment RootCmd.AddCommand(watchCmd) in init().
|
||||||
var watchCmd = &cobra.Command{
|
var watchCmd = &cobra.Command{
|
||||||
Use: "watch",
|
Use: "watch",
|
||||||
Short: "Watch playlists and auto-download new tracks",
|
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() {
|
func init() {
|
||||||
// RootCmd.AddCommand(watchCmd)
|
// RootCmd.AddCommand(watchCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -15,6 +15,7 @@ var watchAddCmd = &cobra.Command{
|
|||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
id := args[0]
|
id := args[0]
|
||||||
|
|
||||||
ok, err := store.IsWatched(id)
|
ok, err := store.IsWatched(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -30,12 +31,11 @@ var watchAddCmd = &cobra.Command{
|
|||||||
BPM: opts.BPM,
|
BPM: opts.BPM,
|
||||||
Timeout: opts.Timeout,
|
Timeout: opts.Timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := playlist.Save(); err != nil {
|
if err := playlist.Save(); err != nil {
|
||||||
return fmt.Errorf("failed to add playlist %s to watch list: %w", id, err)
|
return fmt.Errorf("failed to add playlist %s to watch list: %w", id, err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Playlist %s added to watch list\n", id)
|
|
||||||
|
|
||||||
|
fmt.Printf("Playlist %s added to watch list\n", id)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-7
@@ -17,15 +17,11 @@ var watchRunCmd = &cobra.Command{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig))
|
cmd.SetContext(context.WithValue(cmd.Context(), appConfigKey, appConfig))
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
ctx := cmd.Context()
|
appConfig, _ := cmd.Context().Value(appConfigKey).(*config.Config)
|
||||||
appConfigVal := ctx.Value("appConfig")
|
watcher.New(appConfig).Run(cmd.Context(), opts)
|
||||||
appConfig, _ := appConfigVal.(*config.Config)
|
|
||||||
|
|
||||||
w := watcher.New(appConfig)
|
|
||||||
w.Run(ctx, opts)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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"
|
||||||
@@ -30,7 +29,7 @@ func New(cfgPath string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfgPath == "" {
|
if cfgPath == "" {
|
||||||
cfgPath = path.Join(cfgDir, "config.toml")
|
cfgPath = filepath.Join(cfgDir, "config.toml")
|
||||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||||
fmt.Printf("Config file not found, creating one at %s\n", cfgPath)
|
fmt.Printf("Config file not found, creating one at %s\n", cfgPath)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -12,7 +12,7 @@ var iv = []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
|
|||||||
|
|
||||||
func GetKey(secretKey, songID string) []byte {
|
func GetKey(secretKey, 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 := []byte(secretKey)
|
||||||
for i := 0; i < len(hash); i++ {
|
for i := 0; i < len(hash); i++ {
|
||||||
@@ -28,9 +28,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
Format string `json:"format"`
|
||||||
Cipher Cipher `json:"cipher"`
|
Sources []struct {
|
||||||
Format string `json:"format"`
|
URL string `json:"url"`
|
||||||
Sources []Source `json:"sources"`
|
} `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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,26 +40,24 @@ 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 + ". "
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,10 +132,8 @@ 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,46 +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)
|
metadataChan := make(chan metadataResult, 1)
|
||||||
go func() {
|
go func() {
|
||||||
metadataResult := metadataFetcher.fetch(ctx, song, opts)
|
metadataChan <- fetchMetadata(c.deezerClient.Session.HttpClient, ctx, song, opts)
|
||||||
metadataChan <- metadataResult
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
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(c.appConfig.SecretKey, 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)))
|
||||||
}
|
}
|
||||||
@@ -199,16 +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))
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataResult := <-metadataChan
|
metadata := <-metadataChan
|
||||||
warnings = append(warnings, metadataResult.warnings...)
|
warnings = append(warnings, metadata.warnings...)
|
||||||
|
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, metadata.genre, cover, metadata.bpmKey)...)
|
||||||
|
|
||||||
finalizeWarnings := c.finalizeDownload(resource, song, outputPath, mediaFormat, metadataResult.genre, cover, metadataResult.bpmKey)
|
return downloadResult{warnings: warnings}
|
||||||
warnings = append(warnings, finalizeWarnings...)
|
|
||||||
|
|
||||||
return downloadResult{
|
|
||||||
success: true,
|
|
||||||
warnings: warnings,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
|
func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error {
|
||||||
@@ -226,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 {
|
||||||
@@ -255,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,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 {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package downloader
|
|
||||||
|
|
||||||
func handleError(err error) downloadResult {
|
|
||||||
if path, ok := IsSkipError(err); ok {
|
|
||||||
return downloadResult{
|
|
||||||
skipped: true,
|
|
||||||
path: path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return downloadResult{
|
|
||||||
success: false,
|
|
||||||
err: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,81 +10,69 @@ import (
|
|||||||
"github.com/mathismqn/godeez/internal/provider"
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,23 +11,22 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
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 +34,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 +70,13 @@ func (pt *progressTracker) handleResult(index int, song *deezer.Song, result dow
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pt.stats.downloaded++
|
||||||
|
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 {
|
||||||
|
|||||||
+24
-30
@@ -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 fileutil.FileExists(existing.Path) {
|
if err != nil || existing.Quality != mediaFormat {
|
||||||
return existing.Path, true
|
return "", false
|
||||||
}
|
|
||||||
if existing.Hash != "" {
|
|
||||||
if err := c.initHashIndex(ctx); err == nil {
|
|
||||||
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok {
|
|
||||||
existing.Path = foundPath
|
|
||||||
_ = existing.Save()
|
|
||||||
|
|
||||||
return foundPath, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", false
|
if fileutil.FileExists(existing.Path) {
|
||||||
|
return existing.Path, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing.Hash == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.initHashIndex(ctx); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
foundPath, ok := c.hashIndex.Find(existing.Hash)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Path = foundPath
|
||||||
|
_ = existing.Save()
|
||||||
|
|
||||||
|
return foundPath, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package fileutil
|
|||||||
|
|
||||||
import (
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+38
-59
@@ -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 {
|
lowerTitle := strings.ToLower(title)
|
||||||
lowerSelection := strings.ToLower(selection.Text())
|
lowerArtist := strings.ToLower(artist)
|
||||||
lowerTitle := strings.ToLower(title)
|
|
||||||
lowerArtist := strings.ToLower(artist)
|
var matchURL string
|
||||||
if !strings.Contains(lowerSelection, lowerTitle) || !strings.Contains(lowerSelection, lowerArtist) {
|
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
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-69
@@ -9,48 +9,50 @@ import (
|
|||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
)
|
)
|
||||||
|
|
||||||
var electronicKeywords = []string{
|
var electronicKeywords = toLower([]string{
|
||||||
"Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo",
|
"Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo",
|
||||||
"Drum And Bass", "Dub", "Dubstep", "EDM", "Electro", "Electronic", "Electronica",
|
"Drum And Bass", "Dub", "Dubstep", "EDM", "Electro", "Electronic", "Electronica",
|
||||||
"Eurodance", "Gabber", "Garage", "Hardcore", "Hardstyle", "House", "Industrial",
|
"Eurodance", "Gabber", "Garage", "Hardcore", "Hardstyle", "House", "Industrial",
|
||||||
"Jungle", "Moombahton", "Synthpop", "Synthwave", "Techno", "Trance", "Trap",
|
"Jungle", "Moombahton", "Synthpop", "Synthwave", "Techno", "Trance", "Trap",
|
||||||
"Trip Hop", "Vaporwave",
|
"Trip Hop", "Vaporwave",
|
||||||
}
|
})
|
||||||
|
|
||||||
var nonElectronicKeywords = []string{
|
var nonElectronicKeywords = toLower([]string{
|
||||||
"Blues", "Chillout", "Classical", "Country", "Folk", "Funk", "Hip Hop", "Jazz",
|
"Blues", "Chillout", "Classical", "Country", "Folk", "Funk", "Hip Hop", "Jazz",
|
||||||
"Latin", "Metal", "Pop", "R&B", "Rap", "Reggae", "Rock", "Soul",
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
type GenreProvider struct{}
|
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)
|
||||||
|
|
||||||
func (p GenreProvider) Fetch(ctx context.Context, httpClient *http.Client, artist, title string) (string, error) {
|
doc, err := fetchGenrePage(ctx, httpClient, reqURL)
|
||||||
reqUrl := fmt.Sprintf("https://www.last.fm/music/%s/%s/+tags", artist, title)
|
|
||||||
doc, err := p.fetchPage(ctx, httpClient, reqUrl)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
tags := p.parse(doc)
|
tags := parseGenreTags(doc)
|
||||||
if len(tags) == 0 {
|
|
||||||
return "", fmt.Errorf("no data found")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tags) > 2 {
|
if len(tags) > 2 {
|
||||||
tags = tags[:2]
|
tags = tags[:2]
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredTags := p.filterTags(tags)
|
filtered := filterTags(tags)
|
||||||
if len(filteredTags) == 0 {
|
if len(filtered) == 0 {
|
||||||
return "", fmt.Errorf("no data found")
|
return "", fmt.Errorf("no data found")
|
||||||
}
|
}
|
||||||
|
|
||||||
genre := p.formatTags(filteredTags)
|
return formatTags(filtered), nil
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -65,84 +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) filterTags(tags []string) []string {
|
func matchesKeyword(tag string, keywords []string) bool {
|
||||||
var electronicTags []string
|
tagLower := strings.ToLower(tag)
|
||||||
var nonElectronicTags []string
|
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 {
|
for _, tag := range tags {
|
||||||
if p.isElectronicGenre(tag) {
|
if matchesKeyword(tag, electronicKeywords) {
|
||||||
electronicTags = append(electronicTags, tag)
|
electronic = append(electronic, tag)
|
||||||
} else if p.isNonElectronicGenre(tag) {
|
} else if matchesKeyword(tag, nonElectronicKeywords) {
|
||||||
nonElectronicTags = append(nonElectronicTags, tag)
|
nonElectronic = append(nonElectronic, tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var filteredTags []string
|
if len(electronic) > 0 {
|
||||||
filteredTags = append(filteredTags, electronicTags...)
|
return append(electronic, nonElectronic...)
|
||||||
|
|
||||||
if len(electronicTags) > 0 {
|
|
||||||
filteredTags = append(filteredTags, nonElectronicTags...)
|
|
||||||
}
|
}
|
||||||
|
return electronic
|
||||||
return filteredTags
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p GenreProvider) isElectronicGenre(tag string) bool {
|
func formatTags(tags []string) string {
|
||||||
tagLower := strings.ToLower(tag)
|
formatted := make([]string, 0, len(tags))
|
||||||
for _, allowed := range electronicKeywords {
|
|
||||||
if strings.Contains(tagLower, strings.ToLower(allowed)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p GenreProvider) isNonElectronicGenre(tag string) bool {
|
|
||||||
tagLower := strings.ToLower(tag)
|
|
||||||
for _, allowed := range nonElectronicKeywords {
|
|
||||||
if strings.Contains(tagLower, strings.ToLower(allowed)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p GenreProvider) formatTags(tags []string) string {
|
|
||||||
var formatted []string
|
|
||||||
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 {
|
||||||
if len(w) > 0 {
|
words[i] = strings.ToUpper(w[:1]) + strings.ToLower(w[1:])
|
||||||
words[i] = strings.ToUpper(string(w[0])) + strings.ToLower(w[1:])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
formatted = append(formatted, strings.Join(words, " "))
|
formatted = append(formatted, strings.Join(words, " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(formatted, " / ")
|
return strings.Join(formatted, " / ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,9 @@ var db *bolt.DB
|
|||||||
|
|
||||||
func OpenDB(cfgDir string) error {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-27
@@ -18,9 +18,8 @@ type flacTagger struct {
|
|||||||
|
|
||||||
func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error {
|
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
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// EnsureAutostart installs the watcher as a system autostart service.
|
||||||
|
// Currently disabled: installAutostart is not called due to DB concurrency issues.
|
||||||
func EnsureAutostart(homeDir string) error {
|
func EnsureAutostart(homeDir string) error {
|
||||||
if isAutostartInstalled(homeDir) || isTemporaryExecutable() {
|
if isAutostartInstalled(homeDir) || isTemporaryExecutable() {
|
||||||
return nil
|
return nil
|
||||||
@@ -18,15 +20,13 @@ func EnsureAutostart(homeDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isAutostartInstalled(homeDir string) bool {
|
func isAutostartInstalled(homeDir string) bool {
|
||||||
switch runtime.GOOS {
|
if runtime.GOOS != "darwin" {
|
||||||
case "darwin":
|
|
||||||
path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist")
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
|
|
||||||
return err == nil
|
|
||||||
default:
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist")
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isTemporaryExecutable() bool {
|
func isTemporaryExecutable() bool {
|
||||||
@@ -34,6 +34,5 @@ func isTemporaryExecutable() bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Contains(exe, "go-build")
|
return strings.Contains(exe, "go-build")
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-27
@@ -26,12 +26,11 @@ func New(appConfig *config.Config) *Watcher {
|
|||||||
log.Fatalf("Failed to open log file: %v\n", err)
|
log.Fatalf("Failed to open log file: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base := log.New(file, "", log.LstdFlags)
|
l := logger.New(log.New(file, "", log.LstdFlags))
|
||||||
log := logger.New(base)
|
|
||||||
|
|
||||||
return &Watcher{
|
return &Watcher{
|
||||||
appConfig: appConfig,
|
appConfig: appConfig,
|
||||||
logger: log,
|
logger: l,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,33 +38,26 @@ func (w *Watcher) Run(ctx context.Context, opts downloader.Options) {
|
|||||||
w.logger.Infof("Starting watcher...")
|
w.logger.Infof("Starting watcher...")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
playlists, err := store.ListWatchedPlaylists()
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Errorf("Failed to list watched playlists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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", playlist.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
default:
|
case <-time.After(15 * time.Minute):
|
||||||
playlists, err := store.ListWatchedPlaylists()
|
|
||||||
if err != nil {
|
|
||||||
w.logger.Errorf("Failed to list watched playlists: %v\n", err)
|
|
||||||
} else {
|
|
||||||
for _, playlist := range playlists {
|
|
||||||
dl := downloader.New(w.appConfig, "playlist")
|
|
||||||
dl.Logger = w.logger
|
|
||||||
if err := dl.Run(ctx, opts, playlist.ID); err != nil {
|
|
||||||
if errors.Is(err, context.Canceled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.logger.Errorf("Playlist %s: %v\n", playlist.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-time.After(15 * time.Minute):
|
|
||||||
// Continue to the next iteration to check for updates
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user