refactor: simplify codebase (-209 lines)

This commit is contained in:
Mathis Maquenne
2026-03-01 21:55:42 +01:00
parent cd45c3f40a
commit 08b608ec80
31 changed files with 389 additions and 598 deletions
+22 -22
View File
@@ -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
View File
@@ -8,23 +8,4 @@ var RootCmd = &cobra.Command{
Use: "godeez", Use: "godeez",
Short: "GoDeez is a tool to download music from Deezer", Short: "GoDeez is a tool to download music from Deezer",
SilenceUsage: true, SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// TEMPORARILY DISABLED:
// Watcher autostart (EnsureAutostart) has been disabled due to
// concurrency issues with database access (e.g., when using `download`).
// To re-enable, uncomment the line below.
/*
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
if err := watcher.EnsureAutostart(homeDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to install autostart for watcher: %v\n", err)
}
*/
return nil
},
} }
+2 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}, },
} }
+1 -2
View File
@@ -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 -4
View File
@@ -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
} }
+1 -3
View File
@@ -67,9 +67,7 @@ func (a *Album) SetSongs(s []*Song) {
func (a *Album) GetOutputDir(outputDir string) string { func (a *Album) GetOutputDir(outputDir string) string {
base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title) base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{}) base, _ = filenamify.Filenamify(base, filenamify.Options{})
outputDir = path.Join(outputDir, base) return path.Join(outputDir, base)
return outputDir
} }
func (a *Album) Unmarshal(data []byte) error { func (a *Album) Unmarshal(data []byte) error {
+30 -35
View File
@@ -22,6 +22,35 @@ type Artist struct {
} `json:"results"` } `json:"results"`
} }
func (a *Artist) String() string {
songs := a.Results.Songs.Data
count := len(songs)
totalSec := 0
for _, s := range songs {
if d, err := strconv.Atoi(s.Duration); err == nil {
totalSec += d
}
}
limit := min(3, count)
var b strings.Builder
fmt.Fprintf(&b, "============= [ Artist Info ] =============\n")
fmt.Fprintf(&b, "Artist: %s\n", a.Results.Data.Name)
fmt.Fprintf(&b, "Tracks: %d\n", count)
fmt.Fprintf(&b, "Playtime: %s\n", time.Duration(totalSec)*time.Second)
fmt.Fprintf(&b, "-------------------------------------------\n")
fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit)
for i := 0; i < limit; i++ {
s := songs[i]
fmt.Fprintf(&b, " %2d. %s %s\n", i+1, s.Artist, s.GetTitle())
}
fmt.Fprintf(&b, "===========================================\n")
return b.String()
}
func (a *Artist) GetType() string { func (a *Artist) GetType() string {
return "Artist" return "Artist"
} }
@@ -39,44 +68,10 @@ func (a *Artist) SetSongs(s []*Song) {
} }
func (a *Artist) GetOutputDir(outputDir string) string { func (a *Artist) GetOutputDir(outputDir string) string {
base, _ := filenamify.Filenamify(a.GetTitle(), filenamify.Options{}) base, _ := filenamify.Filenamify(a.Results.Data.Name, filenamify.Options{})
return path.Join(outputDir, base) return path.Join(outputDir, base)
} }
func (a *Artist) Unmarshal(data []byte) error { func (a *Artist) Unmarshal(data []byte) error {
return json.Unmarshal(data, a) return json.Unmarshal(data, a)
} }
func (a *Artist) String() string {
tracks := a.GetSongs()
count := len(tracks)
limit := 3
if count < limit {
limit = count
}
totalSec := 0
for _, s := range tracks {
if d, err := strconv.Atoi(s.Duration); err == nil {
totalSec += d
}
}
totalDuration := time.Duration(totalSec) * time.Second
var b strings.Builder
fmt.Fprintf(&b, "============= [ Artist Info ] =============\n")
fmt.Fprintf(&b, "Artist: %s\n", a.GetTitle())
fmt.Fprintf(&b, "Tracks: %d\n", count)
fmt.Fprintf(&b, "Playtime: %s\n", totalDuration)
fmt.Fprintf(&b, "-------------------------------------------\n")
fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit)
for i := 0; i < limit; i++ {
s := tracks[i]
title := s.GetTitle()
fmt.Fprintf(&b, " %2d. %s %s\n", i+1, s.Artist, title)
}
fmt.Fprintf(&b, "===========================================\n")
return b.String()
}
+31 -33
View File
@@ -38,18 +38,21 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
"tags": true, "tags": true,
"header": true, "header": true,
} }
switch r := resource.(type) {
var idKey string
switch resource.(type) {
case *Playlist: case *Playlist:
payload["playlist_id"] = id idKey = "playlist_id"
case *Album: case *Album:
payload["alb_id"] = id idKey = "alb_id"
case *Artist: case *Artist:
payload["art_id"] = id idKey = "art_id"
case *Track: case *Track:
payload["sng_id"] = id idKey = "sng_id"
default: default:
return fmt.Errorf("unsupported resource type: %T", r) return fmt.Errorf("unsupported resource type: %T", resource)
} }
payload[idKey] = id
jsonData, err := json.Marshal(payload) jsonData, err := json.Marshal(payload)
if err != nil { if err != nil {
@@ -77,18 +80,22 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
return err return err
} }
switch { bodyStr := string(body)
case strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`): for _, check := range []struct {
return fmt.Errorf("invalid playlist ID") marker string
case strings.Contains(string(body), `"DATA_ERROR":"album::getData"`): errMsg string
return fmt.Errorf("invalid album ID") }{
case strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`): {`"DATA_ERROR":"playlist::getData"`, "invalid playlist ID"},
return fmt.Errorf("invalid artist ID") {`"DATA_ERROR":"album::getData"`, "invalid album ID"},
case strings.Contains(string(body), `"DATA_ERROR":"song::getData"`): {`"DATA_ERROR":"artist::getData"`, "invalid artist ID"},
return fmt.Errorf("invalid track ID") {`"DATA_ERROR":"song::getData"`, "invalid track ID"},
} {
if strings.Contains(bodyStr, check.marker) {
return fmt.Errorf("%s", check.errMsg)
}
} }
if strings.Contains(string(body), `"results":{}`) { if strings.Contains(bodyStr, `"results":{}`) {
return fmt.Errorf("unexpected response") return fmt.Errorf("unexpected response")
} }
@@ -96,18 +103,13 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string
} }
func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*Media, error) { func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*Media, error) {
var formats string qualityFormats := map[string]string{
"mp3_128": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
switch quality { "mp3_320": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
case "mp3_128": "flac": `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`,
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "mp3_320":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
case "flac":
formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`
} }
reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, formats, song.TrackToken) reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, qualityFormats[quality], song.TrackToken)
req, err := http.NewRequestWithContext(ctx, "POST", "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody))) req, err := http.NewRequestWithContext(ctx, "POST", "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody)))
if err != nil { if err != nil {
return nil, err return nil, err
@@ -129,8 +131,7 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
} }
var media Media var media Media
err = json.Unmarshal(body, &media) if err := json.Unmarshal(body, &media); err != nil {
if err != nil {
return nil, err return nil, err
} }
@@ -138,7 +139,6 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
if media.Errors[0].Code == 1000 { if media.Errors[0].Code == 1000 {
return nil, fmt.Errorf("invalid license token") return nil, fmt.Errorf("invalid license token")
} }
return nil, fmt.Errorf("%s", media.Errors[0].Message) return nil, fmt.Errorf("%s", media.Errors[0].Message)
} }
@@ -146,7 +146,6 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M
if media.Data[0].Errors[0].Code == 2002 { if media.Data[0].Errors[0].Code == 2002 {
return nil, fmt.Errorf("invalid track token") return nil, fmt.Errorf("invalid track token")
} }
return nil, fmt.Errorf("%s", media.Data[0].Errors[0].Message) return nil, fmt.Errorf("%s", media.Data[0].Errors[0].Message)
} }
@@ -177,9 +176,8 @@ func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error
return io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
} }
func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) { func (c *Client) GetMediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) {
url := media.GetURL() req, err := http.NewRequestWithContext(ctx, "GET", media.GetURL(), nil)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+6 -15
View File
@@ -1,32 +1,23 @@
package deezer package deezer
type Media struct { type Media struct {
Errors []MediaError `json:"errors"` Errors []mediaError `json:"errors"`
Data []struct { Data []struct {
Media []struct { Media []struct {
Type string `json:"media_type"`
Cipher Cipher `json:"cipher"`
Format string `json:"format"` Format string `json:"format"`
Sources []Source `json:"sources"` Sources []struct {
URL string `json:"url"`
} `json:"sources"`
} }
Errors []MediaError `json:"errors"` Errors []mediaError `json:"errors"`
} `json:"data"` } `json:"data"`
} }
type MediaError struct { type mediaError struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} }
type Cipher struct {
Type string `json:"type"`
}
type Source struct {
URL string `json:"url"`
Provider string `json:"provider"`
}
func (m *Media) GetURL() string { func (m *Media) GetURL() string {
return m.Data[0].Media[0].Sources[0].URL return m.Data[0].Media[0].Sources[0].URL
} }
+2 -5
View File
@@ -13,7 +13,6 @@ type Playlist struct {
Results struct { Results struct {
Data struct { Data struct {
Title string `json:"TITLE"` Title string `json:"TITLE"`
Status int `json:"STATUS"`
Creator string `json:"PARENT_USERNAME"` Creator string `json:"PARENT_USERNAME"`
Duration int `json:"DURATION"` Duration int `json:"DURATION"`
} `json:"DATA"` } `json:"DATA"`
@@ -55,10 +54,8 @@ func (p *Playlist) SetSongs(s []*Song) {
} }
func (p *Playlist) GetOutputDir(outputDir string) string { func (p *Playlist) GetOutputDir(outputDir string) string {
p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{}) base, _ := filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{})
outputDir = path.Join(outputDir, p.Results.Data.Title) return path.Join(outputDir, base)
return outputDir
} }
func (p *Playlist) Unmarshal(data []byte) error { func (p *Playlist) Unmarshal(data []byte) error {
+17 -22
View File
@@ -10,22 +10,7 @@ import (
"time" "time"
) )
type UserDataResponse struct {
Results struct {
APIToken string `json:"checkForm"`
User struct {
Id int `json:"USER_ID"`
Options struct {
LicenseToken string `json:"license_token"`
MobileOffline bool `json:"mobile_offline"`
WebOffline bool `json:"web_offline"`
} `json:"OPTIONS"`
} `json:"USER"`
} `json:"results"`
}
type Session struct { type Session struct {
ArlCookie string
APIToken string APIToken string
LicenseToken string LicenseToken string
HttpClient *http.Client HttpClient *http.Client
@@ -68,22 +53,32 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) {
return nil, err return nil, err
} }
var res UserDataResponse var res struct {
Results struct {
APIToken string `json:"checkForm"`
User struct {
ID int `json:"USER_ID"`
Options struct {
LicenseToken string `json:"license_token"`
MobileOffline bool `json:"mobile_offline"`
WebOffline bool `json:"web_offline"`
} `json:"OPTIONS"`
} `json:"USER"`
} `json:"results"`
}
if err := json.Unmarshal(body, &res); err != nil { if err := json.Unmarshal(body, &res); err != nil {
return nil, err return nil, err
} }
if res.Results.User.Id == 0 { if res.Results.User.ID == 0 {
return nil, fmt.Errorf("invalid arl cookie") return nil, fmt.Errorf("invalid arl cookie")
} }
isPremium := res.Results.User.Options.MobileOffline || res.Results.User.Options.WebOffline opts := res.Results.User.Options
return &Session{ return &Session{
ArlCookie: arlCookie,
APIToken: res.Results.APIToken, APIToken: res.Results.APIToken,
LicenseToken: res.Results.User.Options.LicenseToken, LicenseToken: opts.LicenseToken,
HttpClient: client, HttpClient: client,
Premium: isPremium, Premium: opts.MobileOffline || opts.WebOffline,
}, nil }, nil
} }
+7 -9
View File
@@ -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
} }
+7 -19
View File
@@ -4,9 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path" "path"
"strconv"
"time" "time"
"github.com/flytam/filenamify"
) )
type Track struct { type Track struct {
@@ -20,11 +19,9 @@ func (t *Track) String() string {
return "Track: No data available" return "Track: No data available"
} }
duration := "Unknown" duration, err := strconv.Atoi(t.Results.Data.Duration)
if t.Results.Data.Duration != "" { if err != nil {
if d, err := time.ParseDuration(t.Results.Data.Duration + "s"); err == nil { duration = 0
duration = d.String()
}
} }
return fmt.Sprintf( return fmt.Sprintf(
@@ -35,7 +32,7 @@ Duration: %s
==================================================`, ==================================================`,
t.Results.Data.GetTitle(), t.Results.Data.GetTitle(),
t.Results.Data.Artist, t.Results.Data.Artist,
duration, time.Duration(duration)*time.Second,
) )
} }
@@ -52,7 +49,7 @@ func (t *Track) GetTitle() string {
func (t *Track) GetSongs() []*Song { func (t *Track) GetSongs() []*Song {
if t.Results.Data == nil { if t.Results.Data == nil {
return []*Song{} return nil
} }
return []*Song{t.Results.Data} return []*Song{t.Results.Data}
} }
@@ -60,16 +57,7 @@ func (t *Track) GetSongs() []*Song {
func (t *Track) SetSongs(songs []*Song) {} func (t *Track) SetSongs(songs []*Song) {}
func (t *Track) GetOutputDir(outputDir string) string { func (t *Track) GetOutputDir(outputDir string) string {
if t.Results.Data == nil { return path.Join(outputDir, "Singles")
return outputDir
}
// For single tracks, create a simple "Singles" folder
base := "Singles"
base, _ = filenamify.Filenamify(base, filenamify.Options{})
outputDir = path.Join(outputDir, base)
return outputDir
} }
func (t *Track) Unmarshal(data []byte) error { func (t *Track) Unmarshal(data []byte) error {
+24 -41
View File
@@ -16,7 +16,6 @@ import (
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
"github.com/mathismqn/godeez/internal/fileutil" "github.com/mathismqn/godeez/internal/fileutil"
"github.com/mathismqn/godeez/internal/logger" "github.com/mathismqn/godeez/internal/logger"
"github.com/mathismqn/godeez/internal/provider"
"github.com/mathismqn/godeez/internal/store" "github.com/mathismqn/godeez/internal/store"
"github.com/mathismqn/godeez/internal/tags" "github.com/mathismqn/godeez/internal/tags"
) )
@@ -38,8 +37,7 @@ func New(appConfig *config.Config, resourceType string) *Client {
return &Client{ return &Client{
appConfig: appConfig, appConfig: appConfig,
resourceType: resourceType, resourceType: resourceType,
deezerClient: nil, Logger: logger.New(nil),
Logger: logger.New(nil), // Initialize with a nil logger, can be set later
} }
} }
@@ -89,16 +87,15 @@ func (c *Client) prepareResource(ctx context.Context, id string, opts Options) (
} }
if c.resourceType == "artist" && len(songs) > opts.Limit { if c.resourceType == "artist" && len(songs) > opts.Limit {
songs = songs[:opts.Limit] resource.SetSongs(songs[:opts.Limit])
resource.SetSongs(songs)
} }
resourceOutputDir := resource.GetOutputDir(c.appConfig.OutputDir) outputDir := resource.GetOutputDir(c.appConfig.OutputDir)
if err := fileutil.EnsureDir(resourceOutputDir); err != nil { if err := fileutil.EnsureDir(outputDir); err != nil {
return nil, "", fmt.Errorf("failed to create output directory: %w", err) return nil, "", fmt.Errorf("failed to create output directory: %w", err)
} }
return resource, resourceOutputDir, nil return resource, outputDir, nil
} }
func (c *Client) createResource() (deezer.Resource, error) { func (c *Client) createResource() (deezer.Resource, error) {
@@ -135,11 +132,9 @@ func (c *Client) downloadAllSongs(ctx context.Context, resource deezer.Resource,
result := c.downloadSong(ctx, resource, song, opts, outputDir) result := c.downloadSong(ctx, resource, song, opts, outputDir)
sp.Stop() sp.Stop()
if result.err != nil { if result.err != nil && errors.Is(result.err, context.Canceled) {
if errors.Is(result.err, context.Canceled) {
return result.err return result.err
} }
}
progress.handleResult(i, song, result) progress.handleResult(i, song, result)
} }
@@ -150,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 {
-15
View File
@@ -1,15 +0,0 @@
package downloader
func handleError(err error) downloadResult {
if path, ok := IsSkipError(err); ok {
return downloadResult{
skipped: true,
path: path,
}
}
return downloadResult{
success: false,
err: err,
}
}
+37 -49
View File
@@ -10,81 +10,69 @@ import (
"github.com/mathismqn/godeez/internal/provider" "github.com/mathismqn/godeez/internal/provider"
) )
type bpmKey struct {
BPM string
Key string
}
type metadataResult struct { type metadataResult struct {
bpmKey provider.BPMKey bpmKey bpmKey
genre string genre string
warnings []string warnings []string
} }
type metadataFetcher struct { func fetchMetadata(httpClient *http.Client, ctx context.Context, song *deezer.Song, opts Options) metadataResult {
httpClient *http.Client
}
func newMetadataFetcher(httpClient *http.Client) *metadataFetcher {
return &metadataFetcher{
httpClient: httpClient,
}
}
func (mf *metadataFetcher) fetch(ctx context.Context, song *deezer.Song, opts Options) metadataResult {
result := metadataResult{
bpmKey: provider.BPMKey{},
genre: "",
warnings: []string{},
}
if !opts.BPM && !opts.Genre { if !opts.BPM && !opts.Genre {
return result return metadataResult{}
} }
bmpChan := make(chan provider.BPMKey, 1) type bpmResult struct {
bmpErrChan := make(chan error, 1) value bpmKey
genreChan := make(chan string, 1) err error
genreErrChan := make(chan error, 1) }
type genreResult struct {
value string
err error
}
bpmChan := make(chan bpmResult, 1)
genreChan := make(chan genreResult, 1)
if opts.BPM { if opts.BPM {
go func() { go func() {
p := provider.BPMProvider{} result, err := provider.FetchBPM(ctx, httpClient, song.Artist, song.Title, song.Duration)
bmpKey, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.Title, song.Duration) bpmChan <- bpmResult{value: bpmKey{BPM: result.BPM, Key: result.Key}, err: err}
if err != nil {
bmpErrChan <- err
} else {
bmpChan <- bmpKey
}
}() }()
} }
if opts.Genre { if opts.Genre {
go func() { go func() {
p := provider.GenreProvider{} genre, err := provider.FetchGenre(ctx, httpClient, song.Artist, song.GetTitle())
genre, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.GetTitle()) genreChan <- genreResult{value: genre, err: err}
if err != nil {
genreErrChan <- err
} else {
genreChan <- genre
}
}() }()
} }
var result metadataResult
if opts.BPM { if opts.BPM {
select { r := <-bpmChan
case bmpKey := <-bmpChan: if r.err != nil {
result.bpmKey = bmpKey if !errors.Is(r.err, context.Canceled) {
case err := <-bmpErrChan: result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", r.err))
if !errors.Is(err, context.Canceled) {
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err))
} }
} else {
result.bpmKey = r.value
} }
} }
if opts.Genre { if opts.Genre {
select { r := <-genreChan
case genre := <-genreChan: if r.err != nil {
result.genre = genre if !errors.Is(r.err, context.Canceled) {
case err := <-genreErrChan: result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", r.err))
if !errors.Is(err, context.Canceled) {
result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", err))
} }
} else {
result.genre = r.value
} }
} }
+11 -14
View File
@@ -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 {
+19 -25
View File
@@ -7,37 +7,31 @@ import (
"github.com/mathismqn/godeez/internal/store" "github.com/mathismqn/godeez/internal/store"
) )
type SkipError struct {
Path string
}
func (e SkipError) Error() string {
return e.Path
}
func IsSkipError(err error) (string, bool) {
if skipErr, ok := err.(SkipError); ok {
return skipErr.Path, true
}
return "", false
}
func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) { func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) {
if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat { existing, err := store.GetDownloadInfo(songID)
if err != nil || existing.Quality != mediaFormat {
return "", false
}
if fileutil.FileExists(existing.Path) { if fileutil.FileExists(existing.Path) {
return existing.Path, true return existing.Path, true
} }
if existing.Hash != "" {
if err := c.initHashIndex(ctx); err == nil { if existing.Hash == "" {
if foundPath, ok := c.hashIndex.Find(existing.Hash); ok { return "", false
}
if err := c.initHashIndex(ctx); err != nil {
return "", false
}
foundPath, ok := c.hashIndex.Find(existing.Hash)
if !ok {
return "", false
}
existing.Path = foundPath existing.Path = foundPath
_ = existing.Save() _ = existing.Save()
return foundPath, true return foundPath, true
}
}
}
}
return "", false
} }
+4 -6
View File
@@ -2,6 +2,7 @@ package fileutil
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -18,13 +19,11 @@ func EnsureDir(path string) error {
if !info.IsDir() { if !info.IsDir() {
return fmt.Errorf("file already exists at %s", path) return fmt.Errorf("file already exists at %s", path)
} }
return nil return nil
} }
func FileExists(path string) bool { func FileExists(path string) bool {
info, err := os.Stat(path) info, err := os.Stat(path)
return err == nil && !info.IsDir() return err == nil && !info.IsDir()
} }
@@ -32,7 +31,6 @@ func DeleteFile(path string) error {
if !FileExists(path) { if !FileExists(path) {
return nil return nil
} }
return os.Remove(path) return os.Remove(path)
} }
@@ -43,10 +41,10 @@ func GetFileHash(path string) (string, error) {
} }
defer file.Close() defer file.Close()
hash := sha256.New() h := sha256.New()
if _, err := io.Copy(hash, file); err != nil { if _, err := io.Copy(h, file); err != nil {
return "", err return "", err
} }
return fmt.Sprintf("%x", hash.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
+4 -8
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"io" "io"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
) )
@@ -16,12 +17,11 @@ type HashIndex struct {
func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) { func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
index := &HashIndex{files: make(map[string]string)} index := &HashIndex{files: make(map[string]string)}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
} }
if err != nil || d.IsDir() {
if err != nil || info.IsDir() {
return nil return nil
} }
@@ -36,12 +36,9 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
return nil return nil
} }
sum := hex.EncodeToString(h.Sum(nil)) index.files[hex.EncodeToString(h.Sum(nil))] = path
index.files[sum] = path
return nil return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -51,6 +48,5 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) {
func (h *HashIndex) Find(hash string) (string, bool) { func (h *HashIndex) Find(hash string) (string, bool) {
path, ok := h.files[hash] path, ok := h.files[hash]
return path, ok return path, ok
} }
+5 -13
View File
@@ -10,20 +10,12 @@ func New(l *log.Logger) *Logger {
return &Logger{l: l} return &Logger{l: l}
} }
func (l *Logger) Infof(format string, args ...any) { func (l *Logger) logf(level, format string, args ...any) {
if l.l != nil { if l.l != nil {
l.l.Printf("[INFO] "+format, args...) l.l.Printf("["+level+"] "+format, args...)
} }
} }
func (l *Logger) Warnf(format string, args ...any) { func (l *Logger) Infof(format string, args ...any) { l.logf("INFO", format, args...) }
if l.l != nil { func (l *Logger) Warnf(format string, args ...any) { l.logf("WARN", format, args...) }
l.l.Printf("[WARN] "+format, args...) func (l *Logger) Errorf(format string, args ...any) { l.logf("ERROR", format, args...) }
}
}
func (l *Logger) Errorf(format string, args ...any) {
if l.l != nil {
l.l.Printf("[ERROR] "+format, args...)
}
}
+36 -57
View File
@@ -14,40 +14,43 @@ import (
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
type BPMProvider struct{}
type BPMKey struct { type BPMKey struct {
BPM string BPM string
Key string Key string
} }
func (p BPMProvider) Fetch(ctx context.Context, httpClient *http.Client, artist, title, duration string) (BPMKey, error) { var (
url, err := p.findSongURL(ctx, httpClient, artist, title, duration) bpmRegex = regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
keyRegex = regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
modeRegex = regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
)
func FetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (BPMKey, error) {
songURL, err := findSongURL(ctx, httpClient, artist, title, duration)
if err != nil { if err != nil {
return BPMKey{}, err return BPMKey{}, err
} }
html, err := p.fetchPage(ctx, httpClient, url) html, err := fetchBPMPage(ctx, httpClient, songURL)
if err != nil { if err != nil {
return BPMKey{}, err return BPMKey{}, err
} }
return p.parse(html) return parseBPM(html)
} }
func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) { func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) {
rootUrl := "https://songbpm.com" const rootURL = "https://songbpm.com"
reqUrl := rootUrl + "/searches"
values := neturl.Values{} values := neturl.Values{}
values.Add("query", fmt.Sprintf("%s %s", artist, title)) values.Add("query", fmt.Sprintf("%s %s", artist, title))
req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBufferString(values.Encode())) req, err := http.NewRequestWithContext(ctx, "POST", rootURL+"/searches", bytes.NewBufferString(values.Encode()))
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Origin", "https://songbpm.com") req.Header.Set("Origin", rootURL)
resp, err := httpClient.Do(req) resp, err := httpClient.Do(req)
if err != nil { if err != nil {
@@ -64,20 +67,22 @@ func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, a
return "", err return "", err
} }
var ( wantDuration, err := strconv.Atoi(duration)
found bool if err != nil {
url string return "", fmt.Errorf("invalid duration: %w", err)
) }
doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, selection *goquery.Selection) bool {
lowerSelection := strings.ToLower(selection.Text())
lowerTitle := strings.ToLower(title) lowerTitle := strings.ToLower(title)
lowerArtist := strings.ToLower(artist) lowerArtist := strings.ToLower(artist)
if !strings.Contains(lowerSelection, lowerTitle) || !strings.Contains(lowerSelection, lowerArtist) {
var matchURL string
doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, sel *goquery.Selection) bool {
text := strings.ToLower(sel.Text())
if !strings.Contains(text, lowerTitle) || !strings.Contains(text, lowerArtist) {
return true return true
} }
durationStr := strings.TrimSpace(selection.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text()) durationStr := strings.TrimSpace(sel.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text())
parts := strings.Split(durationStr, ":") parts := strings.Split(durationStr, ":")
if len(parts) != 2 { if len(parts) != 2 {
return true return true
@@ -91,31 +96,24 @@ func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, a
return true return true
} }
const toleranceSec = 2
foundDuration := minutes*60 + seconds foundDuration := minutes*60 + seconds
wantDuration, err := strconv.Atoi(duration) if foundDuration <= wantDuration-toleranceSec || foundDuration >= wantDuration+toleranceSec {
if err != nil {
return true return true
} }
const durationToleranceSec = 2 matchURL = sel.AttrOr("href", "")
if foundDuration <= (wantDuration-durationToleranceSec) || foundDuration >= (wantDuration+durationToleranceSec) {
return true
}
url = selection.AttrOr("href", "")
found = true
return false return false
}) })
if !found { if matchURL == "" {
return "", fmt.Errorf("no data found") return "", fmt.Errorf("no data found")
} }
return rootUrl + url, nil return rootURL + matchURL, nil
} }
func (p BPMProvider) fetchPage(ctx context.Context, httpClient *http.Client, url string) (string, error) { func fetchBPMPage(ctx context.Context, httpClient *http.Client, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return "", err return "", err
@@ -139,42 +137,23 @@ func (p BPMProvider) fetchPage(ctx context.Context, httpClient *http.Client, url
return string(body), nil return string(body), nil
} }
func (p BPMProvider) parse(html string) (BPMKey, error) { func parseBPM(html string) (BPMKey, error) {
bpmRegex := regexp.MustCompile(`tempo of <span[^>]*>(\d+) BPM`)
bpmMatch := bpmRegex.FindStringSubmatch(html) bpmMatch := bpmRegex.FindStringSubmatch(html)
keyRegex := regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
keyMatch := keyRegex.FindStringSubmatch(html) keyMatch := keyRegex.FindStringSubmatch(html)
modeRegex := regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> mode`)
modeMatch := modeRegex.FindStringSubmatch(html) modeMatch := modeRegex.FindStringSubmatch(html)
if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 { if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 {
return BPMKey{}, fmt.Errorf("no data found") return BPMKey{}, fmt.Errorf("no data found")
} }
isMinor := false
bpm := bpmMatch[1] bpm := bpmMatch[1]
key := keyMatch[1] key := strings.SplitN(keyMatch[1], "/", 2)[0]
key = strings.ReplaceAll(key, "\u266f", "#")
key = strings.ReplaceAll(key, "\u266d", "b")
if modeMatch[1] == "minor" { if modeMatch[1] == "minor" {
isMinor = true
}
if strings.Contains(key, "/") {
parts := strings.Split(key, "/")
key = parts[0]
}
key = strings.ReplaceAll(key, "♯", "#")
key = strings.ReplaceAll(key, "♭", "b")
if isMinor && !strings.HasSuffix(key, "m") {
key += "m" key += "m"
} }
return BPMKey{ return BPMKey{BPM: bpm, Key: key}, nil
BPM: bpm,
Key: key,
}, nil
} }
+45 -69
View File
@@ -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, " / ")
} }
+1 -4
View File
@@ -11,12 +11,9 @@ var db *bolt.DB
func OpenDB(cfgDir string) error { func OpenDB(cfgDir string) error {
var err error var err error
db, err = bolt.Open(path.Join(cfgDir, "tracks.db"), 0600, nil)
dbPath := path.Join(cfgDir, "tracks.db")
db, err = bolt.Open(dbPath, 0600, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to open database: %w", err) return fmt.Errorf("failed to open database: %w", err)
} }
return nil return nil
} }
+17 -27
View File
@@ -18,9 +18,8 @@ type flacTagger struct {
func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error { func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error {
if album, ok := resource.(*deezer.Album); ok { if album, ok := resource.(*deezer.Album); ok {
dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-") if parts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-"); len(parts) == 3 {
if len(dateParts) == 3 { album.Results.Data.PhysicalReleaseDate = parts[0]
album.Results.Data.PhysicalReleaseDate = dateParts[0]
} }
t.addTag("TRACKNUMBER", song.TrackNumber) t.addTag("TRACKNUMBER", song.TrackNumber)
@@ -40,26 +39,29 @@ func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover
t.addTag("GENRE", genre) t.addTag("GENRE", genre)
t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain) t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain)
t.addTag("ISRC", song.ISRC) t.addTag("ISRC", song.ISRC)
t.addTag("BPM", tempo) t.addTag("BPM", tempo)
t.addTag("KEY", key) t.addTag("KEY", key)
t.addTag("INITIALKEY", key) t.addTag("INITIALKEY", key)
cmtsmeta := t.cmts.Marshal() cmtsMeta := t.cmts.Marshal()
if t.index > 0 { if t.index > 0 {
t.file.Meta[t.index] = &cmtsmeta t.file.Meta[t.index] = &cmtsMeta
} else { } else {
t.file.Meta = append(t.file.Meta, &cmtsmeta) t.file.Meta = append(t.file.Meta, &cmtsMeta)
} }
picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg") picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg")
if err != nil { if err != nil {
return err return err
} }
picturemeta := picture.Marshal() pictureMeta := picture.Marshal()
t.file.Meta = append(t.file.Meta, &picturemeta) t.file.Meta = append(t.file.Meta, &pictureMeta)
return t.saveTags(path) tmpPath := path + ".tmp"
if err := t.file.Save(tmpPath); err != nil {
return err
}
return os.Rename(tmpPath, path)
} }
func (t *flacTagger) addTag(name, value string) { func (t *flacTagger) addTag(name, value string) {
@@ -68,26 +70,14 @@ func (t *flacTagger) addTag(name, value string) {
} }
} }
func (t *flacTagger) saveTags(path string) error { func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int) {
tempPath := path + ".tmp"
t.file.Save(tempPath)
return os.Rename(tempPath, path)
}
func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int, error) {
var cmt *flacvorbis.MetaDataBlockVorbisComment
var cmtIdx int
var err error
for idx, meta := range file.Meta { for idx, meta := range file.Meta {
if meta.Type == flac.VorbisComment { if meta.Type == flac.VorbisComment {
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
cmtIdx = idx if err == nil {
if err != nil { return cmt, idx
return nil, 0, err
} }
} }
} }
return nil, 0
return cmt, cmtIdx, nil
} }
+7 -10
View File
@@ -39,20 +39,18 @@ func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover
t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", ")) t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", "))
t.addTag("TCON", genre) t.addTag("TCON", genre)
t.addTag("TLEN", song.Duration) t.addTag("TLEN", song.Duration)
t.addTXXXTag("GAIN", song.Gain)
t.addTXXXTag("ISRC", song.ISRC)
t.addTag("TBPM", tempo) t.addTag("TBPM", tempo)
t.addTag("TKEY", key) t.addTag("TKEY", key)
t.addTXXX("GAIN", song.Gain)
t.addTXXX("ISRC", song.ISRC)
frame := id3v2.PictureFrame{ t.tag.AddAttachedPicture(id3v2.PictureFrame{
Encoding: t.tag.DefaultEncoding(), Encoding: t.tag.DefaultEncoding(),
MimeType: "image/jpeg", MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover, PictureType: id3v2.PTFrontCover,
Description: "Cover", Description: "Cover",
Picture: cover, Picture: cover,
} })
t.tag.AddAttachedPicture(frame)
return t.tag.Save() return t.tag.Save()
} }
@@ -63,13 +61,12 @@ func (t *id3v2Tagger) addTag(name, value string) {
} }
} }
func (t *id3v2Tagger) addTXXXTag(description, value string) { func (t *id3v2Tagger) addTXXX(description, value string) {
if value != "" { if value != "" {
udf := id3v2.UserDefinedTextFrame{ t.tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
Encoding: t.tag.DefaultEncoding(), Encoding: t.tag.DefaultEncoding(),
Description: description, Description: description,
Value: value, Value: value,
} })
t.tag.AddUserDefinedTextFrame(udf)
} }
} }
+7 -12
View File
@@ -10,17 +10,15 @@ import (
) )
type tagger interface { type tagger interface {
addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error addTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error
} }
func newTagger(filePath string) (tagger, error) { func newTagger(filePath string) (tagger, error) {
ext := path.Ext(filePath) if path.Ext(filePath) == ".mp3" {
if ext == ".mp3" {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &id3v2Tagger{tag: tag}, nil return &id3v2Tagger{tag: tag}, nil
} }
@@ -28,11 +26,9 @@ func newTagger(filePath string) (tagger, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
cmts, idx, err := extractFLACComment(file)
if err != nil { cmts, idx := extractFLACComment(file)
return nil, err if cmts == nil {
}
if cmts == nil && idx > 0 {
cmts = flacvorbis.New() cmts = flacvorbis.New()
} }
@@ -40,10 +36,9 @@ func newTagger(filePath string) (tagger, error) {
} }
func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error { func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error {
tagger, err := newTagger(filePath) t, err := newTagger(filePath)
if err != nil { if err != nil {
return err return err
} }
return t.addTags(resource, song, cover, filePath, tempo, key, genre)
return tagger.addTags(resource, song, cover, filePath, tempo, key, genre)
} }
+7 -8
View File
@@ -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")
} }
+6 -14
View File
@@ -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,14 +38,11 @@ func (w *Watcher) Run(ctx context.Context, opts downloader.Options) {
w.logger.Infof("Starting watcher...") w.logger.Infof("Starting watcher...")
for { for {
select {
case <-ctx.Done():
return
default:
playlists, err := store.ListWatchedPlaylists() playlists, err := store.ListWatchedPlaylists()
if err != nil { if err != nil {
w.logger.Errorf("Failed to list watched playlists: %v\n", err) w.logger.Errorf("Failed to list watched playlists: %v", err)
} else { }
for _, playlist := range playlists { for _, playlist := range playlists {
dl := downloader.New(w.appConfig, "playlist") dl := downloader.New(w.appConfig, "playlist")
dl.Logger = w.logger dl.Logger = w.logger
@@ -54,9 +50,7 @@ func (w *Watcher) Run(ctx context.Context, opts downloader.Options) {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
return return
} }
w.logger.Errorf("Playlist %s: %v", playlist.ID, err)
w.logger.Errorf("Playlist %s: %v\n", playlist.ID, err)
}
} }
} }
@@ -64,8 +58,6 @@ func (w *Watcher) Run(ctx context.Context, opts downloader.Options) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-time.After(15 * time.Minute): case <-time.After(15 * time.Minute):
// Continue to the next iteration to check for updates
}
} }
} }
} }