diff --git a/cmd/download.go b/cmd/download.go index 091fb65..373750c 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -12,6 +12,10 @@ import ( "github.com/spf13/cobra" ) +type contextKey string + +const appConfigKey contextKey = "appConfig" + var ( opts downloader.Options cfgPath string @@ -41,51 +45,47 @@ func init() { } func newDownloadCmd(resourceType string) *cobra.Command { - article := "a" - if resourceType == "album" { - article = "an" - } - cmd := &cobra.Command{ Use: fmt.Sprintf("%s <%s_id>", resourceType, resourceType), - Short: fmt.Sprintf("Download songs from %s %s", article, resourceType), + Short: downloadShort(resourceType), Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { appConfig, err := config.New(cfgPath) if err != nil { return err } - cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig)) + cmd.SetContext(context.WithValue(cmd.Context(), appConfigKey, appConfig)) opts.Quality = strings.ToLower(opts.Quality) - return opts.Validate() }, RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - appConfigVal := ctx.Value("appConfig") - appConfig, _ := appConfigVal.(*config.Config) + appConfig := cmd.Context().Value(appConfigKey).(*config.Config) - dl := downloader.New(appConfig, resourceType) - if err := dl.Run(ctx, opts, args[0]); err != nil { - if errors.Is(err, context.Canceled) { - return nil - } - - return err + err := downloader.New(appConfig, resourceType).Run(cmd.Context(), opts, args[0]) + if errors.Is(err, context.Canceled) { + return nil } - - return nil + return err }, } - switch resourceType { - case "artist": - cmd.Short = "Download top songs from an artist" + if resourceType == "artist" { cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "number of songs to download") - case "track": - cmd.Short = "Download a single track" } return cmd } + +func downloadShort(resourceType string) string { + switch resourceType { + case "artist": + return "Download top songs from an artist" + case "track": + return "Download a single track" + case "album": + return "Download songs from an album" + default: + return fmt.Sprintf("Download songs from a %s", resourceType) + } +} diff --git a/cmd/root.go b/cmd/root.go index acf6d25..46dd93b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,23 +8,4 @@ var RootCmd = &cobra.Command{ Use: "godeez", Short: "GoDeez is a tool to download music from Deezer", SilenceUsage: true, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // TEMPORARILY DISABLED: - // Watcher autostart (EnsureAutostart) has been disabled due to - // concurrency issues with database access (e.g., when using `download`). - // To re-enable, uncomment the line below. - - /* - homeDir, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("failed to get home directory: %w", err) - } - - if err := watcher.EnsureAutostart(homeDir); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to install autostart for watcher: %v\n", err) - } - */ - - return nil - }, } diff --git a/cmd/watch.go b/cmd/watch.go index f136a48..963357b 100644 --- a/cmd/watch.go +++ b/cmd/watch.go @@ -4,15 +4,13 @@ import ( "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{ Use: "watch", Short: "Watch playlists and auto-download new tracks", } -// TEMPORARILY DISABLED: -// The `watch` command and all its subcommands are currently disabled -// due to known issues (e.g., database access conflicts with `download`). -// To re-enable, uncomment the line below. func init() { // RootCmd.AddCommand(watchCmd) } diff --git a/cmd/watch_add.go b/cmd/watch_add.go index 2fba5f2..414e5f4 100644 --- a/cmd/watch_add.go +++ b/cmd/watch_add.go @@ -15,6 +15,7 @@ var watchAddCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { id := args[0] + ok, err := store.IsWatched(id) if err != nil { return err @@ -30,12 +31,11 @@ var watchAddCmd = &cobra.Command{ BPM: opts.BPM, Timeout: opts.Timeout, } - if err := playlist.Save(); err != nil { return fmt.Errorf("failed to add playlist %s to watch list: %w", id, err) } - fmt.Printf("Playlist %s added to watch list\n", id) + fmt.Printf("Playlist %s added to watch list\n", id) return nil }, } diff --git a/cmd/watch_run.go b/cmd/watch_run.go index 6acbc64..535d9f4 100644 --- a/cmd/watch_run.go +++ b/cmd/watch_run.go @@ -17,15 +17,11 @@ var watchRunCmd = &cobra.Command{ if err != nil { return } - cmd.SetContext(context.WithValue(cmd.Context(), "appConfig", appConfig)) + cmd.SetContext(context.WithValue(cmd.Context(), appConfigKey, appConfig)) }, Run: func(cmd *cobra.Command, args []string) { - ctx := cmd.Context() - appConfigVal := ctx.Value("appConfig") - appConfig, _ := appConfigVal.(*config.Config) - - w := watcher.New(appConfig) - w.Run(ctx, opts) + appConfig, _ := cmd.Context().Value(appConfigKey).(*config.Config) + watcher.New(appConfig).Run(cmd.Context(), opts) }, } diff --git a/internal/config/config.go b/internal/config/config.go index a57b8bb..c18934a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,7 +3,6 @@ package config import ( "fmt" "os" - "path" "path/filepath" "github.com/mathismqn/godeez/internal/fileutil" @@ -30,7 +29,7 @@ func New(cfgPath string) (*Config, error) { } if cfgPath == "" { - cfgPath = path.Join(cfgDir, "config.toml") + cfgPath = filepath.Join(cfgDir, "config.toml") if _, err := os.Stat(cfgPath); os.IsNotExist(err) { fmt.Printf("Config file not found, creating one at %s\n", cfgPath) diff --git a/internal/crypto/blowfish.go b/internal/crypto/blowfish.go index e6704a8..59b36f3 100644 --- a/internal/crypto/blowfish.go +++ b/internal/crypto/blowfish.go @@ -3,7 +3,7 @@ package crypto import ( "crypto/cipher" "crypto/md5" - "fmt" + "encoding/hex" "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 { hash := md5.Sum([]byte(songID)) - hashHex := fmt.Sprintf("%x", hash) + hashHex := hex.EncodeToString(hash[:]) key := []byte(secretKey) for i := 0; i < len(hash); i++ { @@ -28,9 +28,8 @@ func Decrypt(data, key []byte) ([]byte, error) { return nil, err } - mode := cipher.NewCBCDecrypter(block, iv) decrypted := make([]byte, len(data)) - mode.CryptBlocks(decrypted, data) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(decrypted, data) return decrypted, nil } diff --git a/internal/deezer/album.go b/internal/deezer/album.go index fa923b5..8af454a 100644 --- a/internal/deezer/album.go +++ b/internal/deezer/album.go @@ -67,9 +67,7 @@ func (a *Album) SetSongs(s []*Song) { func (a *Album) GetOutputDir(outputDir string) string { base := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title) base, _ = filenamify.Filenamify(base, filenamify.Options{}) - outputDir = path.Join(outputDir, base) - - return outputDir + return path.Join(outputDir, base) } func (a *Album) Unmarshal(data []byte) error { diff --git a/internal/deezer/artist.go b/internal/deezer/artist.go index 0358797..7f30ccb 100644 --- a/internal/deezer/artist.go +++ b/internal/deezer/artist.go @@ -22,6 +22,35 @@ type Artist struct { } `json:"results"` } +func (a *Artist) String() string { + songs := a.Results.Songs.Data + count := len(songs) + + totalSec := 0 + for _, s := range songs { + if d, err := strconv.Atoi(s.Duration); err == nil { + totalSec += d + } + } + + limit := min(3, count) + + var b strings.Builder + fmt.Fprintf(&b, "============= [ Artist Info ] =============\n") + fmt.Fprintf(&b, "Artist: %s\n", a.Results.Data.Name) + fmt.Fprintf(&b, "Tracks: %d\n", count) + fmt.Fprintf(&b, "Playtime: %s\n", time.Duration(totalSec)*time.Second) + fmt.Fprintf(&b, "-------------------------------------------\n") + fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit) + for i := 0; i < limit; i++ { + s := songs[i] + fmt.Fprintf(&b, " %2d. %s – %s\n", i+1, s.Artist, s.GetTitle()) + } + fmt.Fprintf(&b, "===========================================\n") + + return b.String() +} + func (a *Artist) GetType() string { return "Artist" } @@ -39,44 +68,10 @@ func (a *Artist) SetSongs(s []*Song) { } func (a *Artist) GetOutputDir(outputDir string) string { - base, _ := filenamify.Filenamify(a.GetTitle(), filenamify.Options{}) + base, _ := filenamify.Filenamify(a.Results.Data.Name, filenamify.Options{}) return path.Join(outputDir, base) } func (a *Artist) Unmarshal(data []byte) error { return json.Unmarshal(data, a) } - -func (a *Artist) String() string { - tracks := a.GetSongs() - count := len(tracks) - - limit := 3 - if count < limit { - limit = count - } - - totalSec := 0 - for _, s := range tracks { - if d, err := strconv.Atoi(s.Duration); err == nil { - totalSec += d - } - } - totalDuration := time.Duration(totalSec) * time.Second - - var b strings.Builder - fmt.Fprintf(&b, "============= [ Artist Info ] =============\n") - fmt.Fprintf(&b, "Artist: %s\n", a.GetTitle()) - fmt.Fprintf(&b, "Tracks: %d\n", count) - fmt.Fprintf(&b, "Playtime: %s\n", totalDuration) - fmt.Fprintf(&b, "-------------------------------------------\n") - fmt.Fprintf(&b, "Top %d most popular tracks:\n", limit) - for i := 0; i < limit; i++ { - s := tracks[i] - title := s.GetTitle() - fmt.Fprintf(&b, " %2d. %s – %s\n", i+1, s.Artist, title) - } - fmt.Fprintf(&b, "===========================================\n") - - return b.String() -} diff --git a/internal/deezer/client.go b/internal/deezer/client.go index e9f46d9..ff443d8 100644 --- a/internal/deezer/client.go +++ b/internal/deezer/client.go @@ -38,18 +38,21 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string "tags": true, "header": true, } - switch r := resource.(type) { + + var idKey string + switch resource.(type) { case *Playlist: - payload["playlist_id"] = id + idKey = "playlist_id" case *Album: - payload["alb_id"] = id + idKey = "alb_id" case *Artist: - payload["art_id"] = id + idKey = "art_id" case *Track: - payload["sng_id"] = id + idKey = "sng_id" default: - return fmt.Errorf("unsupported resource type: %T", r) + return fmt.Errorf("unsupported resource type: %T", resource) } + payload[idKey] = id jsonData, err := json.Marshal(payload) if err != nil { @@ -77,18 +80,22 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string return err } - switch { - case strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`): - return fmt.Errorf("invalid playlist ID") - case strings.Contains(string(body), `"DATA_ERROR":"album::getData"`): - return fmt.Errorf("invalid album ID") - case strings.Contains(string(body), `"DATA_ERROR":"artist::getData"`): - return fmt.Errorf("invalid artist ID") - case strings.Contains(string(body), `"DATA_ERROR":"song::getData"`): - return fmt.Errorf("invalid track ID") + bodyStr := string(body) + for _, check := range []struct { + marker string + errMsg string + }{ + {`"DATA_ERROR":"playlist::getData"`, "invalid playlist ID"}, + {`"DATA_ERROR":"album::getData"`, "invalid album ID"}, + {`"DATA_ERROR":"artist::getData"`, "invalid artist ID"}, + {`"DATA_ERROR":"song::getData"`, "invalid track ID"}, + } { + if strings.Contains(bodyStr, check.marker) { + return fmt.Errorf("%s", check.errMsg) + } } - if strings.Contains(string(body), `"results":{}`) { + if strings.Contains(bodyStr, `"results":{}`) { return fmt.Errorf("unexpected response") } @@ -96,18 +103,13 @@ func (c *Client) FetchResource(ctx context.Context, resource Resource, id string } func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*Media, error) { - var formats string - - switch quality { - case "mp3_128": - formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]` - case "mp3_320": - formats = `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]` - case "flac": - formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]` + qualityFormats := map[string]string{ + "mp3_128": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`, + "mp3_320": `[{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`, + "flac": `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]`, } - reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, formats, song.TrackToken) + reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, qualityFormats[quality], song.TrackToken) req, err := http.NewRequestWithContext(ctx, "POST", "https://media.deezer.com/v1/get_url", bytes.NewBuffer([]byte(reqBody))) if err != nil { return nil, err @@ -129,8 +131,7 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M } var media Media - err = json.Unmarshal(body, &media) - if err != nil { + if err := json.Unmarshal(body, &media); err != nil { return nil, err } @@ -138,7 +139,6 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M if media.Errors[0].Code == 1000 { return nil, fmt.Errorf("invalid license token") } - return nil, fmt.Errorf("%s", media.Errors[0].Message) } @@ -146,7 +146,6 @@ func (c *Client) FetchMedia(ctx context.Context, song *Song, quality string) (*M if media.Data[0].Errors[0].Code == 2002 { return nil, fmt.Errorf("invalid track token") } - return nil, fmt.Errorf("%s", media.Data[0].Errors[0].Message) } @@ -177,9 +176,8 @@ func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error return io.ReadAll(resp.Body) } -func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) { - url := media.GetURL() - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) +func (c *Client) GetMediaStream(ctx context.Context, media *Media) (io.ReadCloser, error) { + req, err := http.NewRequestWithContext(ctx, "GET", media.GetURL(), nil) if err != nil { return nil, err } diff --git a/internal/deezer/media.go b/internal/deezer/media.go index 5f53a5a..9347339 100644 --- a/internal/deezer/media.go +++ b/internal/deezer/media.go @@ -1,32 +1,23 @@ package deezer type Media struct { - Errors []MediaError `json:"errors"` + Errors []mediaError `json:"errors"` Data []struct { Media []struct { - Type string `json:"media_type"` - Cipher Cipher `json:"cipher"` - Format string `json:"format"` - Sources []Source `json:"sources"` + Format string `json:"format"` + Sources []struct { + URL string `json:"url"` + } `json:"sources"` } - Errors []MediaError `json:"errors"` + Errors []mediaError `json:"errors"` } `json:"data"` } -type MediaError struct { +type mediaError struct { Code int `json:"code"` Message string `json:"message"` } -type Cipher struct { - Type string `json:"type"` -} - -type Source struct { - URL string `json:"url"` - Provider string `json:"provider"` -} - func (m *Media) GetURL() string { return m.Data[0].Media[0].Sources[0].URL } diff --git a/internal/deezer/playlist.go b/internal/deezer/playlist.go index f500f14..5ce75f4 100644 --- a/internal/deezer/playlist.go +++ b/internal/deezer/playlist.go @@ -13,7 +13,6 @@ type Playlist struct { Results struct { Data struct { Title string `json:"TITLE"` - Status int `json:"STATUS"` Creator string `json:"PARENT_USERNAME"` Duration int `json:"DURATION"` } `json:"DATA"` @@ -55,10 +54,8 @@ func (p *Playlist) SetSongs(s []*Song) { } func (p *Playlist) GetOutputDir(outputDir string) string { - p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{}) - outputDir = path.Join(outputDir, p.Results.Data.Title) - - return outputDir + base, _ := filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{}) + return path.Join(outputDir, base) } func (p *Playlist) Unmarshal(data []byte) error { diff --git a/internal/deezer/session.go b/internal/deezer/session.go index b224869..c4c1d60 100644 --- a/internal/deezer/session.go +++ b/internal/deezer/session.go @@ -10,22 +10,7 @@ import ( "time" ) -type UserDataResponse struct { - Results struct { - APIToken string `json:"checkForm"` - User struct { - Id int `json:"USER_ID"` - Options struct { - LicenseToken string `json:"license_token"` - MobileOffline bool `json:"mobile_offline"` - WebOffline bool `json:"web_offline"` - } `json:"OPTIONS"` - } `json:"USER"` - } `json:"results"` -} - type Session struct { - ArlCookie string APIToken string LicenseToken string HttpClient *http.Client @@ -68,22 +53,32 @@ func Authenticate(ctx context.Context, arlCookie string) (*Session, error) { return nil, err } - var res UserDataResponse + var res struct { + Results struct { + APIToken string `json:"checkForm"` + User struct { + ID int `json:"USER_ID"` + Options struct { + LicenseToken string `json:"license_token"` + MobileOffline bool `json:"mobile_offline"` + WebOffline bool `json:"web_offline"` + } `json:"OPTIONS"` + } `json:"USER"` + } `json:"results"` + } if err := json.Unmarshal(body, &res); err != nil { return nil, err } - if res.Results.User.Id == 0 { + if res.Results.User.ID == 0 { return nil, fmt.Errorf("invalid arl cookie") } - isPremium := res.Results.User.Options.MobileOffline || res.Results.User.Options.WebOffline - + opts := res.Results.User.Options return &Session{ - ArlCookie: arlCookie, APIToken: res.Results.APIToken, - LicenseToken: res.Results.User.Options.LicenseToken, + LicenseToken: opts.LicenseToken, HttpClient: client, - Premium: isPremium, + Premium: opts.MobileOffline || opts.WebOffline, }, nil } diff --git a/internal/deezer/song.go b/internal/deezer/song.go index 08aea52..1754286 100644 --- a/internal/deezer/song.go +++ b/internal/deezer/song.go @@ -40,26 +40,24 @@ type Song struct { } func (s *Song) GetTitle() string { - songTitle := s.Title if s.Version != "" { - songTitle = fmt.Sprintf("%s %s", s.Title, s.Version) + return s.Title + " " + s.Version } - - return songTitle + return s.Title } -func (s *Song) GetFileName(resourceType, mediaFormat string, song *Song) string { +func (s *Song) GetFileName(resourceType, mediaFormat string) string { ext := "mp3" if mediaFormat == "FLAC" { ext = "flac" } - trackNumber := "" + + prefix := "" if resourceType == "album" { - trackNumber = song.TrackNumber + ". " + prefix = s.TrackNumber + ". " } - fileName := fmt.Sprintf("%s%s - %s.%s", trackNumber, s.Artist, s.GetTitle(), ext) + fileName := fmt.Sprintf("%s%s - %s.%s", prefix, s.Artist, s.GetTitle(), ext) fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{MaxLength: 255}) - return fileName } diff --git a/internal/deezer/track.go b/internal/deezer/track.go index d110b8d..c464b0c 100644 --- a/internal/deezer/track.go +++ b/internal/deezer/track.go @@ -4,9 +4,8 @@ import ( "encoding/json" "fmt" "path" + "strconv" "time" - - "github.com/flytam/filenamify" ) type Track struct { @@ -20,11 +19,9 @@ func (t *Track) String() string { return "Track: No data available" } - duration := "Unknown" - if t.Results.Data.Duration != "" { - if d, err := time.ParseDuration(t.Results.Data.Duration + "s"); err == nil { - duration = d.String() - } + duration, err := strconv.Atoi(t.Results.Data.Duration) + if err != nil { + duration = 0 } return fmt.Sprintf( @@ -35,7 +32,7 @@ Duration: %s ==================================================`, t.Results.Data.GetTitle(), t.Results.Data.Artist, - duration, + time.Duration(duration)*time.Second, ) } @@ -52,7 +49,7 @@ func (t *Track) GetTitle() string { func (t *Track) GetSongs() []*Song { if t.Results.Data == nil { - return []*Song{} + return nil } return []*Song{t.Results.Data} } @@ -60,16 +57,7 @@ func (t *Track) GetSongs() []*Song { func (t *Track) SetSongs(songs []*Song) {} func (t *Track) GetOutputDir(outputDir string) string { - if t.Results.Data == nil { - return outputDir - } - - // For single tracks, create a simple "Singles" folder - base := "Singles" - base, _ = filenamify.Filenamify(base, filenamify.Options{}) - outputDir = path.Join(outputDir, base) - - return outputDir + return path.Join(outputDir, "Singles") } func (t *Track) Unmarshal(data []byte) error { diff --git a/internal/downloader/client.go b/internal/downloader/client.go index 830a7b2..e3a8a69 100644 --- a/internal/downloader/client.go +++ b/internal/downloader/client.go @@ -16,7 +16,6 @@ import ( "github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/fileutil" "github.com/mathismqn/godeez/internal/logger" - "github.com/mathismqn/godeez/internal/provider" "github.com/mathismqn/godeez/internal/store" "github.com/mathismqn/godeez/internal/tags" ) @@ -38,8 +37,7 @@ func New(appConfig *config.Config, resourceType string) *Client { return &Client{ appConfig: appConfig, resourceType: resourceType, - deezerClient: nil, - Logger: logger.New(nil), // Initialize with a nil logger, can be set later + Logger: logger.New(nil), } } @@ -89,16 +87,15 @@ func (c *Client) prepareResource(ctx context.Context, id string, opts Options) ( } if c.resourceType == "artist" && len(songs) > opts.Limit { - songs = songs[:opts.Limit] - resource.SetSongs(songs) + resource.SetSongs(songs[:opts.Limit]) } - resourceOutputDir := resource.GetOutputDir(c.appConfig.OutputDir) - if err := fileutil.EnsureDir(resourceOutputDir); err != nil { + outputDir := resource.GetOutputDir(c.appConfig.OutputDir) + if err := fileutil.EnsureDir(outputDir); err != nil { return nil, "", fmt.Errorf("failed to create output directory: %w", err) } - return resource, resourceOutputDir, nil + return resource, outputDir, nil } func (c *Client) createResource() (deezer.Resource, error) { @@ -135,10 +132,8 @@ func (c *Client) downloadAllSongs(ctx context.Context, resource deezer.Resource, result := c.downloadSong(ctx, resource, song, opts, outputDir) sp.Stop() - if result.err != nil { - if errors.Is(result.err, context.Canceled) { - return result.err - } + if result.err != nil && errors.Is(result.err, context.Canceled) { + return result.err } progress.handleResult(i, song, result) @@ -150,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 { - var warnings []string - media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality) if err != nil { - return handleError(fmt.Errorf("failed to fetch media: %w", err)) + return downloadResult{err: fmt.Errorf("failed to fetch media: %w", err)} } mediaFormat := media.GetFormat() if opts.Strict && strings.ToLower(mediaFormat) != opts.Quality { - return handleError(fmt.Errorf("requested quality '%s' not available", opts.Quality)) + return downloadResult{err: fmt.Errorf("requested quality '%s' not available", opts.Quality)} } - if path, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip { - return handleError(SkipError{Path: path}) + if skipPath, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip { + return downloadResult{skipped: true, path: skipPath} } - metadataFetcher := newMetadataFetcher(c.deezerClient.Session.HttpClient) metadataChan := make(chan metadataResult, 1) go func() { - metadataResult := metadataFetcher.fetch(ctx, song, opts) - metadataChan <- metadataResult + metadataChan <- fetchMetadata(c.deezerClient.Session.HttpClient, ctx, song, opts) }() - stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID) + stream, err := c.deezerClient.GetMediaStream(ctx, media) if err != nil { - return handleError(fmt.Errorf("failed to get media stream: %w", err)) + return downloadResult{err: fmt.Errorf("failed to get media stream: %w", err)} } dlCtx, cancel := context.WithTimeout(ctx, opts.Timeout) defer cancel() - fileName := song.GetFileName(c.resourceType, mediaFormat, song) + fileName := song.GetFileName(c.resourceType, mediaFormat) outputPath := path.Join(outputDir, fileName) key := crypto.GetKey(c.appConfig.SecretKey, song.ID) if err := c.streamToFile(dlCtx, stream, outputPath, key); err != nil { fileutil.DeleteFile(outputPath) - return handleError(fmt.Errorf("failed to stream to file: %w", err)) + return downloadResult{err: fmt.Errorf("failed to stream to file: %w", err)} } + var warnings []string + if opts.Quality != strings.ToLower(mediaFormat) { warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat))) } @@ -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)) } - metadataResult := <-metadataChan - warnings = append(warnings, metadataResult.warnings...) + metadata := <-metadataChan + 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) - warnings = append(warnings, finalizeWarnings...) - - return downloadResult{ - success: true, - warnings: warnings, - } + return downloadResult{warnings: warnings} } func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath string, key []byte) error { @@ -226,22 +214,18 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP case <-ctx.Done(): return ctx.Err() default: - // continue } totalRead := 0 for totalRead < chunkSize { n, err := stream.Read(buffer[totalRead:]) + totalRead += n if err != nil { if errors.Is(err, io.EOF) { break } return err } - - if n > 0 { - totalRead += n - } } if totalRead == 0 { @@ -255,8 +239,7 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP } } - _, err = file.Write(buffer[:totalRead]) - if err != nil { + if _, err = file.Write(buffer[:totalRead]); err != nil { return err } @@ -268,7 +251,7 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP return nil } -func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat, genre string, cover []byte, bpmKey provider.BPMKey) []string { +func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat, genre string, cover []byte, bpmKey bpmKey) []string { var warnings []string if err := tags.AddTags(resource, song, cover, outputPath, bpmKey.BPM, bpmKey.Key, genre); err != nil { diff --git a/internal/downloader/errors.go b/internal/downloader/errors.go deleted file mode 100644 index 752ad29..0000000 --- a/internal/downloader/errors.go +++ /dev/null @@ -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, - } -} diff --git a/internal/downloader/metadata.go b/internal/downloader/metadata.go index ebb2e06..71e3aef 100644 --- a/internal/downloader/metadata.go +++ b/internal/downloader/metadata.go @@ -10,81 +10,69 @@ import ( "github.com/mathismqn/godeez/internal/provider" ) +type bpmKey struct { + BPM string + Key string +} + type metadataResult struct { - bpmKey provider.BPMKey + bpmKey bpmKey genre string warnings []string } -type metadataFetcher struct { - httpClient *http.Client -} - -func newMetadataFetcher(httpClient *http.Client) *metadataFetcher { - return &metadataFetcher{ - httpClient: httpClient, - } -} - -func (mf *metadataFetcher) fetch(ctx context.Context, song *deezer.Song, opts Options) metadataResult { - result := metadataResult{ - bpmKey: provider.BPMKey{}, - genre: "", - warnings: []string{}, - } - +func fetchMetadata(httpClient *http.Client, ctx context.Context, song *deezer.Song, opts Options) metadataResult { if !opts.BPM && !opts.Genre { - return result + return metadataResult{} } - bmpChan := make(chan provider.BPMKey, 1) - bmpErrChan := make(chan error, 1) - genreChan := make(chan string, 1) - genreErrChan := make(chan error, 1) + type bpmResult struct { + value bpmKey + err error + } + type genreResult struct { + value string + err error + } + + bpmChan := make(chan bpmResult, 1) + genreChan := make(chan genreResult, 1) if opts.BPM { go func() { - p := provider.BPMProvider{} - bmpKey, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.Title, song.Duration) - if err != nil { - bmpErrChan <- err - } else { - bmpChan <- bmpKey - } + result, err := provider.FetchBPM(ctx, httpClient, song.Artist, song.Title, song.Duration) + bpmChan <- bpmResult{value: bpmKey{BPM: result.BPM, Key: result.Key}, err: err} }() } if opts.Genre { go func() { - p := provider.GenreProvider{} - genre, err := p.Fetch(ctx, mf.httpClient, song.Artist, song.GetTitle()) - if err != nil { - genreErrChan <- err - } else { - genreChan <- genre - } + genre, err := provider.FetchGenre(ctx, httpClient, song.Artist, song.GetTitle()) + genreChan <- genreResult{value: genre, err: err} }() } + var result metadataResult + if opts.BPM { - select { - case bmpKey := <-bmpChan: - result.bpmKey = bmpKey - case err := <-bmpErrChan: - if !errors.Is(err, context.Canceled) { - result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err)) + r := <-bpmChan + if r.err != nil { + if !errors.Is(r.err, context.Canceled) { + result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch BPM and key: %v", r.err)) } + } else { + result.bpmKey = r.value } } if opts.Genre { - select { - case genre := <-genreChan: - result.genre = genre - case err := <-genreErrChan: - if !errors.Is(err, context.Canceled) { - result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", err)) + r := <-genreChan + if r.err != nil { + if !errors.Is(r.err, context.Canceled) { + result.warnings = append(result.warnings, fmt.Sprintf("failed to fetch genre: %v", r.err)) } + } else { + result.genre = r.value } } diff --git a/internal/downloader/progress.go b/internal/downloader/progress.go index dfc565d..dc2ed34 100644 --- a/internal/downloader/progress.go +++ b/internal/downloader/progress.go @@ -11,23 +11,22 @@ import ( "github.com/mathismqn/godeez/internal/logger" ) -type downloadStats struct { - downloaded int - skipped int - failed int -} - type downloadResult struct { - success bool skipped bool path string warnings []string err error } +type downloadStats struct { + downloaded int + skipped int + failed int +} + type progressTracker struct { logger *logger.Logger - stats *downloadStats + stats downloadStats totalSongs int resourceType string } @@ -35,20 +34,18 @@ type progressTracker struct { func newProgressTracker(logger *logger.Logger, totalSongs int, resourceType string) *progressTracker { return &progressTracker{ logger: logger, - stats: &downloadStats{}, totalSongs: totalSongs, resourceType: resourceType, } } func (pt *progressTracker) startDownload(index int, song *deezer.Song) *spinner.Spinner { - songTitle := song.GetTitle() trackProgress := fmt.Sprintf("[%d/%d]", index+1, pt.totalSongs) sp := spinner.New(spinner.CharSets[14], 100*time.Millisecond) sp.Writer = os.Stdout sp.Prefix = trackProgress + " " - sp.Suffix = fmt.Sprintf(" Downloading: %s - %s", song.Artist, songTitle) + sp.Suffix = fmt.Sprintf(" Downloading: %s - %s", song.Artist, song.GetTitle()) sp.Start() return sp @@ -73,13 +70,13 @@ func (pt *progressTracker) handleResult(index int, song *deezer.Song, result dow return } + pt.stats.downloaded++ + pt.logger.Infof("Downloaded %s - %s\n", song.Artist, songTitle) + symbol := "✔" if len(result.warnings) > 0 { symbol = "⚠" } - - pt.stats.downloaded++ - pt.logger.Infof("Downloaded %s - %s\n", song.Artist, songTitle) fmt.Printf("%s %s Downloaded: %s - %s\n", trackProgress, symbol, song.Artist, songTitle) for _, w := range result.warnings { diff --git a/internal/downloader/skip.go b/internal/downloader/skip.go index 76a2fa4..9239a86 100644 --- a/internal/downloader/skip.go +++ b/internal/downloader/skip.go @@ -7,37 +7,31 @@ import ( "github.com/mathismqn/godeez/internal/store" ) -type SkipError struct { - Path string -} - -func (e SkipError) Error() string { - return e.Path -} - -func IsSkipError(err error) (string, bool) { - if skipErr, ok := err.(SkipError); ok { - return skipErr.Path, true - } - return "", false -} - func (c *Client) shouldSkipDownload(ctx context.Context, songID, mediaFormat string) (string, bool) { - if existing, err := store.GetDownloadInfo(songID); err == nil && existing.Quality == mediaFormat { - if fileutil.FileExists(existing.Path) { - return existing.Path, true - } - if existing.Hash != "" { - if err := c.initHashIndex(ctx); err == nil { - if foundPath, ok := c.hashIndex.Find(existing.Hash); ok { - existing.Path = foundPath - _ = existing.Save() - - return foundPath, true - } - } - } + existing, err := store.GetDownloadInfo(songID) + if err != nil || existing.Quality != mediaFormat { + return "", false } - return "", false + if fileutil.FileExists(existing.Path) { + return existing.Path, true + } + + if existing.Hash == "" { + return "", false + } + + if err := c.initHashIndex(ctx); err != nil { + return "", false + } + + foundPath, ok := c.hashIndex.Find(existing.Hash) + if !ok { + return "", false + } + + existing.Path = foundPath + _ = existing.Save() + + return foundPath, true } diff --git a/internal/fileutil/file.go b/internal/fileutil/file.go index 2c0343a..6aab16b 100644 --- a/internal/fileutil/file.go +++ b/internal/fileutil/file.go @@ -2,6 +2,7 @@ package fileutil import ( "crypto/sha256" + "encoding/hex" "fmt" "io" "os" @@ -18,13 +19,11 @@ func EnsureDir(path string) error { if !info.IsDir() { return fmt.Errorf("file already exists at %s", path) } - return nil } func FileExists(path string) bool { info, err := os.Stat(path) - return err == nil && !info.IsDir() } @@ -32,7 +31,6 @@ func DeleteFile(path string) error { if !FileExists(path) { return nil } - return os.Remove(path) } @@ -43,10 +41,10 @@ func GetFileHash(path string) (string, error) { } defer file.Close() - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { + h := sha256.New() + if _, err := io.Copy(h, file); err != nil { return "", err } - return fmt.Sprintf("%x", hash.Sum(nil)), nil + return hex.EncodeToString(h.Sum(nil)), nil } diff --git a/internal/fileutil/hashindex.go b/internal/fileutil/hashindex.go index 9e88fa7..f5071e8 100644 --- a/internal/fileutil/hashindex.go +++ b/internal/fileutil/hashindex.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "io" + "io/fs" "os" "path/filepath" ) @@ -16,12 +17,11 @@ type HashIndex struct { func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) { index := &HashIndex{files: make(map[string]string)} - err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if ctx.Err() != nil { return ctx.Err() } - - if err != nil || info.IsDir() { + if err != nil || d.IsDir() { return nil } @@ -36,12 +36,9 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) { return nil } - sum := hex.EncodeToString(h.Sum(nil)) - index.files[sum] = path - + index.files[hex.EncodeToString(h.Sum(nil))] = path return nil }) - if err != nil { return nil, err } @@ -51,6 +48,5 @@ func NewHashIndex(ctx context.Context, root string) (*HashIndex, error) { func (h *HashIndex) Find(hash string) (string, bool) { path, ok := h.files[hash] - return path, ok } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 1a11aaa..3f9985e 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -10,20 +10,12 @@ func New(l *log.Logger) *Logger { return &Logger{l: l} } -func (l *Logger) Infof(format string, args ...any) { +func (l *Logger) logf(level, format string, args ...any) { if l.l != nil { - l.l.Printf("[INFO] "+format, args...) + l.l.Printf("["+level+"] "+format, args...) } } -func (l *Logger) Warnf(format string, args ...any) { - if l.l != nil { - l.l.Printf("[WARN] "+format, args...) - } -} - -func (l *Logger) Errorf(format string, args ...any) { - if l.l != nil { - l.l.Printf("[ERROR] "+format, args...) - } -} +func (l *Logger) Infof(format string, args ...any) { l.logf("INFO", format, args...) } +func (l *Logger) Warnf(format string, args ...any) { l.logf("WARN", format, args...) } +func (l *Logger) Errorf(format string, args ...any) { l.logf("ERROR", format, args...) } diff --git a/internal/provider/bpm.go b/internal/provider/bpm.go index abdcd64..edad4f3 100644 --- a/internal/provider/bpm.go +++ b/internal/provider/bpm.go @@ -14,40 +14,43 @@ import ( "github.com/PuerkitoBio/goquery" ) -type BPMProvider struct{} - type BPMKey struct { BPM string Key string } -func (p BPMProvider) Fetch(ctx context.Context, httpClient *http.Client, artist, title, duration string) (BPMKey, error) { - url, err := p.findSongURL(ctx, httpClient, artist, title, duration) +var ( + bpmRegex = regexp.MustCompile(`tempo of ]*>(\d+) BPM`) + keyRegex = regexp.MustCompile(`with a ]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?) key`) + modeRegex = regexp.MustCompile(`a ]*>([a-z]+) mode`) +) + +func FetchBPM(ctx context.Context, httpClient *http.Client, artist, title, duration string) (BPMKey, error) { + songURL, err := findSongURL(ctx, httpClient, artist, title, duration) if err != nil { return BPMKey{}, err } - html, err := p.fetchPage(ctx, httpClient, url) + html, err := fetchBPMPage(ctx, httpClient, songURL) if err != nil { return BPMKey{}, err } - return p.parse(html) + return parseBPM(html) } -func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) { - rootUrl := "https://songbpm.com" - reqUrl := rootUrl + "/searches" +func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) { + const rootURL = "https://songbpm.com" values := neturl.Values{} values.Add("query", fmt.Sprintf("%s %s", artist, title)) - req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBufferString(values.Encode())) + req, err := http.NewRequestWithContext(ctx, "POST", rootURL+"/searches", bytes.NewBufferString(values.Encode())) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Origin", "https://songbpm.com") + req.Header.Set("Origin", rootURL) resp, err := httpClient.Do(req) if err != nil { @@ -64,20 +67,22 @@ func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, a return "", err } - var ( - found bool - url string - ) + wantDuration, err := strconv.Atoi(duration) + if err != nil { + return "", fmt.Errorf("invalid duration: %w", err) + } - doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, selection *goquery.Selection) bool { - lowerSelection := strings.ToLower(selection.Text()) - lowerTitle := strings.ToLower(title) - lowerArtist := strings.ToLower(artist) - if !strings.Contains(lowerSelection, lowerTitle) || !strings.Contains(lowerSelection, lowerArtist) { + lowerTitle := strings.ToLower(title) + lowerArtist := strings.ToLower(artist) + + var matchURL string + doc.Find("a.flex.flex-col").EachWithBreak(func(_ int, sel *goquery.Selection) bool { + text := strings.ToLower(sel.Text()) + if !strings.Contains(text, lowerTitle) || !strings.Contains(text, lowerArtist) { return true } - durationStr := strings.TrimSpace(selection.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text()) + durationStr := strings.TrimSpace(sel.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text()) parts := strings.Split(durationStr, ":") if len(parts) != 2 { return true @@ -91,31 +96,24 @@ func (p BPMProvider) findSongURL(ctx context.Context, httpClient *http.Client, a return true } + const toleranceSec = 2 foundDuration := minutes*60 + seconds - wantDuration, err := strconv.Atoi(duration) - if err != nil { + if foundDuration <= wantDuration-toleranceSec || foundDuration >= wantDuration+toleranceSec { return true } - const durationToleranceSec = 2 - if foundDuration <= (wantDuration-durationToleranceSec) || foundDuration >= (wantDuration+durationToleranceSec) { - return true - } - - url = selection.AttrOr("href", "") - found = true - + matchURL = sel.AttrOr("href", "") return false }) - if !found { + if matchURL == "" { return "", fmt.Errorf("no data found") } - return rootUrl + url, nil + return rootURL + matchURL, nil } -func (p BPMProvider) fetchPage(ctx context.Context, httpClient *http.Client, url string) (string, error) { +func fetchBPMPage(ctx context.Context, httpClient *http.Client, url string) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return "", err @@ -139,42 +137,23 @@ func (p BPMProvider) fetchPage(ctx context.Context, httpClient *http.Client, url return string(body), nil } -func (p BPMProvider) parse(html string) (BPMKey, error) { - bpmRegex := regexp.MustCompile(`tempo of ]*>(\d+) BPM`) +func parseBPM(html string) (BPMKey, error) { bpmMatch := bpmRegex.FindStringSubmatch(html) - - keyRegex := regexp.MustCompile(`with a ]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?) key`) keyMatch := keyRegex.FindStringSubmatch(html) - - modeRegex := regexp.MustCompile(`a ]*>([a-z]+) mode`) modeMatch := modeRegex.FindStringSubmatch(html) if len(bpmMatch) != 2 || len(keyMatch) != 2 || len(modeMatch) != 2 { return BPMKey{}, fmt.Errorf("no data found") } - isMinor := false bpm := bpmMatch[1] - key := keyMatch[1] + key := strings.SplitN(keyMatch[1], "/", 2)[0] + key = strings.ReplaceAll(key, "\u266f", "#") + key = strings.ReplaceAll(key, "\u266d", "b") + if modeMatch[1] == "minor" { - isMinor = true - } - - if strings.Contains(key, "/") { - parts := strings.Split(key, "/") - key = parts[0] - } - - key = strings.ReplaceAll(key, "♯", "#") - key = strings.ReplaceAll(key, "♭", "b") - - if isMinor && !strings.HasSuffix(key, "m") { key += "m" } - return BPMKey{ - BPM: bpm, - Key: key, - }, nil - + return BPMKey{BPM: bpm, Key: key}, nil } diff --git a/internal/provider/genre.go b/internal/provider/genre.go index 0ef48ee..a32db83 100644 --- a/internal/provider/genre.go +++ b/internal/provider/genre.go @@ -9,48 +9,50 @@ import ( "github.com/PuerkitoBio/goquery" ) -var electronicKeywords = []string{ +var electronicKeywords = toLower([]string{ "Ambient", "Bass", "Big Room", "Breakbeat", "Dance", "Disco", "Downtempo", "Drum And Bass", "Dub", "Dubstep", "EDM", "Electro", "Electronic", "Electronica", "Eurodance", "Gabber", "Garage", "Hardcore", "Hardstyle", "House", "Industrial", "Jungle", "Moombahton", "Synthpop", "Synthwave", "Techno", "Trance", "Trap", "Trip Hop", "Vaporwave", -} +}) -var nonElectronicKeywords = []string{ +var nonElectronicKeywords = toLower([]string{ "Blues", "Chillout", "Classical", "Country", "Folk", "Funk", "Hip Hop", "Jazz", "Latin", "Metal", "Pop", "R&B", "Rap", "Reggae", "Rock", "Soul", +}) + +func toLower(ss []string) []string { + out := make([]string, len(ss)) + for i, s := range ss { + out[i] = strings.ToLower(s) + } + return out } -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) { - reqUrl := fmt.Sprintf("https://www.last.fm/music/%s/%s/+tags", artist, title) - doc, err := p.fetchPage(ctx, httpClient, reqUrl) + doc, err := fetchGenrePage(ctx, httpClient, reqURL) if err != nil { return "", err } - tags := p.parse(doc) - if len(tags) == 0 { - return "", fmt.Errorf("no data found") - } - + tags := parseGenreTags(doc) if len(tags) > 2 { tags = tags[:2] } - filteredTags := p.filterTags(tags) - if len(filteredTags) == 0 { + filtered := filterTags(tags) + if len(filtered) == 0 { return "", fmt.Errorf("no data found") } - genre := p.formatTags(filteredTags) - return genre, nil + return formatTags(filtered), nil } -func (p GenreProvider) fetchPage(ctx context.Context, httpClient *http.Client, reqUrl string) (*goquery.Document, error) { - req, err := http.NewRequestWithContext(ctx, "GET", reqUrl, nil) +func fetchGenrePage(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } @@ -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) } - doc, err := goquery.NewDocumentFromReader(resp.Body) - if err != nil { - return nil, err - } - - return doc, nil + return goquery.NewDocumentFromReader(resp.Body) } -func (p GenreProvider) parse(doc *goquery.Document) []string { +func parseGenreTags(doc *goquery.Document) []string { var tags []string doc.Find("ol.big-tags .big-tags-item-name a").Each(func(_ int, s *goquery.Selection) { - tag := strings.TrimSpace(s.Text()) - if tag != "" { + if tag := strings.TrimSpace(s.Text()); tag != "" { tags = append(tags, tag) } }) - return tags } -func (p GenreProvider) filterTags(tags []string) []string { - var electronicTags []string - var nonElectronicTags []string +func matchesKeyword(tag string, keywords []string) bool { + tagLower := strings.ToLower(tag) + for _, kw := range keywords { + if strings.Contains(tagLower, kw) { + return true + } + } + return false +} + +func filterTags(tags []string) []string { + var electronic, nonElectronic []string for _, tag := range tags { - if p.isElectronicGenre(tag) { - electronicTags = append(electronicTags, tag) - } else if p.isNonElectronicGenre(tag) { - nonElectronicTags = append(nonElectronicTags, tag) + if matchesKeyword(tag, electronicKeywords) { + electronic = append(electronic, tag) + } else if matchesKeyword(tag, nonElectronicKeywords) { + nonElectronic = append(nonElectronic, tag) } } - var filteredTags []string - filteredTags = append(filteredTags, electronicTags...) - - if len(electronicTags) > 0 { - filteredTags = append(filteredTags, nonElectronicTags...) + if len(electronic) > 0 { + return append(electronic, nonElectronic...) } - - return filteredTags + return electronic } -func (p GenreProvider) isElectronicGenre(tag string) bool { - tagLower := strings.ToLower(tag) - 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 +func formatTags(tags []string) string { + formatted := make([]string, 0, len(tags)) for _, tag := range tags { tag = strings.TrimSpace(tag) if tag == "" { continue } - words := strings.Fields(tag) for i, w := range words { - if len(w) > 0 { - words[i] = strings.ToUpper(string(w[0])) + strings.ToLower(w[1:]) - } + words[i] = strings.ToUpper(w[:1]) + strings.ToLower(w[1:]) } formatted = append(formatted, strings.Join(words, " ")) } - return strings.Join(formatted, " / ") } diff --git a/internal/store/store.go b/internal/store/store.go index 6f2d1bc..edd5cf8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -11,12 +11,9 @@ var db *bolt.DB func OpenDB(cfgDir string) error { var err error - - dbPath := path.Join(cfgDir, "tracks.db") - db, err = bolt.Open(dbPath, 0600, nil) + db, err = bolt.Open(path.Join(cfgDir, "tracks.db"), 0600, nil) if err != nil { return fmt.Errorf("failed to open database: %w", err) } - return nil } diff --git a/internal/tags/flac.go b/internal/tags/flac.go index 4102fe3..2c3401b 100644 --- a/internal/tags/flac.go +++ b/internal/tags/flac.go @@ -18,9 +18,8 @@ type flacTagger struct { func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error { if album, ok := resource.(*deezer.Album); ok { - dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-") - if len(dateParts) == 3 { - album.Results.Data.PhysicalReleaseDate = dateParts[0] + if parts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-"); len(parts) == 3 { + album.Results.Data.PhysicalReleaseDate = parts[0] } t.addTag("TRACKNUMBER", song.TrackNumber) @@ -40,26 +39,29 @@ func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover t.addTag("GENRE", genre) t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain) t.addTag("ISRC", song.ISRC) - t.addTag("BPM", tempo) t.addTag("KEY", key) t.addTag("INITIALKEY", key) - cmtsmeta := t.cmts.Marshal() + cmtsMeta := t.cmts.Marshal() if t.index > 0 { - t.file.Meta[t.index] = &cmtsmeta + t.file.Meta[t.index] = &cmtsMeta } else { - t.file.Meta = append(t.file.Meta, &cmtsmeta) + t.file.Meta = append(t.file.Meta, &cmtsMeta) } picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg") if err != nil { return err } - picturemeta := picture.Marshal() - t.file.Meta = append(t.file.Meta, &picturemeta) + pictureMeta := picture.Marshal() + t.file.Meta = append(t.file.Meta, &pictureMeta) - return t.saveTags(path) + tmpPath := path + ".tmp" + if err := t.file.Save(tmpPath); err != nil { + return err + } + return os.Rename(tmpPath, path) } func (t *flacTagger) addTag(name, value string) { @@ -68,26 +70,14 @@ func (t *flacTagger) addTag(name, value string) { } } -func (t *flacTagger) saveTags(path string) error { - tempPath := path + ".tmp" - t.file.Save(tempPath) - - return os.Rename(tempPath, path) -} - -func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int, error) { - var cmt *flacvorbis.MetaDataBlockVorbisComment - var cmtIdx int - var err error +func extractFLACComment(file *flac.File) (*flacvorbis.MetaDataBlockVorbisComment, int) { for idx, meta := range file.Meta { if meta.Type == flac.VorbisComment { - cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) - cmtIdx = idx - if err != nil { - return nil, 0, err + cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta) + if err == nil { + return cmt, idx } } } - - return cmt, cmtIdx, nil + return nil, 0 } diff --git a/internal/tags/id3v2.go b/internal/tags/id3v2.go index 66408f3..5fd52c8 100644 --- a/internal/tags/id3v2.go +++ b/internal/tags/id3v2.go @@ -39,20 +39,18 @@ func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", ")) t.addTag("TCON", genre) t.addTag("TLEN", song.Duration) - t.addTXXXTag("GAIN", song.Gain) - t.addTXXXTag("ISRC", song.ISRC) - t.addTag("TBPM", tempo) t.addTag("TKEY", key) + t.addTXXX("GAIN", song.Gain) + t.addTXXX("ISRC", song.ISRC) - frame := id3v2.PictureFrame{ + t.tag.AddAttachedPicture(id3v2.PictureFrame{ Encoding: t.tag.DefaultEncoding(), MimeType: "image/jpeg", PictureType: id3v2.PTFrontCover, Description: "Cover", Picture: cover, - } - t.tag.AddAttachedPicture(frame) + }) return t.tag.Save() } @@ -63,13 +61,12 @@ func (t *id3v2Tagger) addTag(name, value string) { } } -func (t *id3v2Tagger) addTXXXTag(description, value string) { +func (t *id3v2Tagger) addTXXX(description, value string) { if value != "" { - udf := id3v2.UserDefinedTextFrame{ + t.tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{ Encoding: t.tag.DefaultEncoding(), Description: description, Value: value, - } - t.tag.AddUserDefinedTextFrame(udf) + }) } } diff --git a/internal/tags/tags.go b/internal/tags/tags.go index 495c292..789d57d 100644 --- a/internal/tags/tags.go +++ b/internal/tags/tags.go @@ -10,17 +10,15 @@ import ( ) type tagger interface { - addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key, genre string) error + addTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error } func newTagger(filePath string) (tagger, error) { - ext := path.Ext(filePath) - if ext == ".mp3" { + if path.Ext(filePath) == ".mp3" { tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) if err != nil { return nil, err } - return &id3v2Tagger{tag: tag}, nil } @@ -28,11 +26,9 @@ func newTagger(filePath string) (tagger, error) { if err != nil { return nil, err } - cmts, idx, err := extractFLACComment(file) - if err != nil { - return nil, err - } - if cmts == nil && idx > 0 { + + cmts, idx := extractFLACComment(file) + if cmts == nil { cmts = flacvorbis.New() } @@ -40,10 +36,9 @@ func newTagger(filePath string) (tagger, error) { } func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error { - tagger, err := newTagger(filePath) + t, err := newTagger(filePath) if err != nil { return err } - - return tagger.addTags(resource, song, cover, filePath, tempo, key, genre) + return t.addTags(resource, song, cover, filePath, tempo, key, genre) } diff --git a/internal/watcher/autostart.go b/internal/watcher/autostart.go index dbd4bf8..d299efa 100644 --- a/internal/watcher/autostart.go +++ b/internal/watcher/autostart.go @@ -7,6 +7,8 @@ import ( "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 { if isAutostartInstalled(homeDir) || isTemporaryExecutable() { return nil @@ -18,15 +20,13 @@ func EnsureAutostart(homeDir string) error { } func isAutostartInstalled(homeDir string) bool { - switch runtime.GOOS { - case "darwin": - path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist") - _, err := os.Stat(path) - - return err == nil - default: + if runtime.GOOS != "darwin" { return false } + + path := filepath.Join(homeDir, "Library", "LaunchAgents", "com.godeez.watch.plist") + _, err := os.Stat(path) + return err == nil } func isTemporaryExecutable() bool { @@ -34,6 +34,5 @@ func isTemporaryExecutable() bool { if err != nil { return true } - return strings.Contains(exe, "go-build") } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index c156db7..fc3f9be 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -26,12 +26,11 @@ func New(appConfig *config.Config) *Watcher { log.Fatalf("Failed to open log file: %v\n", err) } - base := log.New(file, "", log.LstdFlags) - log := logger.New(base) + l := logger.New(log.New(file, "", log.LstdFlags)) return &Watcher{ 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...") 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 { case <-ctx.Done(): return - default: - playlists, err := store.ListWatchedPlaylists() - if err != nil { - w.logger.Errorf("Failed to list watched playlists: %v\n", err) - } else { - for _, playlist := range playlists { - dl := downloader.New(w.appConfig, "playlist") - dl.Logger = w.logger - if err := dl.Run(ctx, opts, playlist.ID); err != nil { - if errors.Is(err, context.Canceled) { - return - } - - w.logger.Errorf("Playlist %s: %v\n", playlist.ID, err) - } - } - } - - select { - case <-ctx.Done(): - return - case <-time.After(15 * time.Minute): - // Continue to the next iteration to check for updates - } + case <-time.After(15 * time.Minute): } } }