From 0bae78a1ff7180640f2d2c2cbf5f96eabaab218a Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Thu, 1 May 2025 20:30:56 +0200 Subject: [PATCH] refactor: major project restructure --- cmd/download.go | 245 +++--------------- cmd/download_album.go | 17 -- cmd/download_playlist.go | 17 -- cmd/root.go | 60 +---- internal/app/context.go | 53 ++++ internal/bpm/bpm.go | 176 +++++++++++++ internal/config/config.go | 36 +-- internal/crypto/{crypto.go => blowfish.go} | 13 +- internal/deezer/album.go | 14 +- internal/deezer/client.go | 182 ++++++++++++++ internal/deezer/media.go | 74 ++---- internal/deezer/playlist.go | 14 +- internal/deezer/resource.go | 64 +---- internal/deezer/session.go | 18 +- internal/deezer/song.go | 228 ++--------------- internal/downloader/client.go | 275 +++++++++++++++++++++ internal/downloader/options.go | 33 +++ internal/fileutil/file.go | 52 ++++ internal/fileutil/hashindex.go | 56 +++++ internal/{db => store}/download_info.go | 4 +- internal/{db/setup.go => store/store.go} | 13 +- internal/tags/flac.go | 28 +-- internal/tags/id3v2.go | 29 ++- internal/tags/{tag.go => tags.go} | 21 +- internal/utils/dir.go | 21 -- internal/utils/file.go | 73 ------ main.go | 13 +- 27 files changed, 1017 insertions(+), 812 deletions(-) delete mode 100644 cmd/download_album.go delete mode 100644 cmd/download_playlist.go create mode 100644 internal/app/context.go create mode 100644 internal/bpm/bpm.go rename internal/crypto/{crypto.go => blowfish.go} (63%) create mode 100644 internal/deezer/client.go create mode 100644 internal/downloader/client.go create mode 100644 internal/downloader/options.go create mode 100644 internal/fileutil/file.go create mode 100644 internal/fileutil/hashindex.go rename internal/{db => store}/download_info.go (92%) rename internal/{db/setup.go => store/store.go} (63%) rename internal/tags/{tag.go => tags.go} (55%) delete mode 100644 internal/utils/dir.go delete mode 100644 internal/utils/file.go diff --git a/cmd/download.go b/cmd/download.go index f6c9682..d02d5ce 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -1,231 +1,56 @@ package cmd import ( + "context" + "errors" "fmt" - "os" - "path" - "strings" - "time" - "github.com/flytam/filenamify" - "github.com/mathismqn/godeez/internal/config" - "github.com/mathismqn/godeez/internal/db" - "github.com/mathismqn/godeez/internal/deezer" - "github.com/mathismqn/godeez/internal/tags" - "github.com/mathismqn/godeez/internal/utils" + "github.com/mathismqn/godeez/internal/downloader" "github.com/spf13/cobra" ) -var ( - outputDir string - quality string -) +var opts downloader.Options var downloadCmd = &cobra.Command{ Use: "download", Short: "Download songs from Deezer", } -type bpmResult struct { - tempo, key string - err error -} - func init() { RootCmd.AddCommand(downloadCmd) - downloadCmd.PersistentFlags().StringVarP(&outputDir, "output", "o", "", "output directory (default is $HOME/Music/GoDeez)") - downloadCmd.PersistentFlags().StringVarP(&quality, "quality", "q", "", "download quality [mp3_128, mp3_320, flac, best] (default is best)") + + downloadCmd.PersistentFlags().StringVarP(&opts.OutputDir, "output", "o", "", "output directory (default is $HOME/Music/GoDeez)") + downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "", "download quality [mp3_128, mp3_320, flac, best] (default is best)") + + downloadCmd.AddCommand( + newDownloadCmd("album"), + newDownloadCmd("playlist"), + ) } -func validateInput() { - if outputDir == "" { - outputDir = appDir +func newDownloadCmd(resourceType string) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("%s [%s_id...]", resourceType, resourceType), + Short: fmt.Sprintf("Download songs from one or more %ss", resourceType), + Args: cobra.MinimumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + return opts.Validate(appCtx.AppDir) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + dl := downloader.New(appCtx, resourceType) + + if err := dl.Run(ctx, opts, args); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil + } + + return err + } + + return nil + }, } - if quality == "" { - quality = "best" - } - - validQualities := map[string]bool{ - "mp3_128": true, - "mp3_320": true, - "flac": true, - "best": true, - } - if !validQualities[quality] { - fmt.Fprintf(os.Stderr, "Error: invalid quality option: %s\n", quality) - os.Exit(1) - } -} - -func downloadContent(contentType string, args []string) { - session, err := deezer.Authenticate(config.Cfg.ArlCookie) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: could not authenticate: %v\n", err) - os.Exit(1) - } - - nArgs := len(args) - separator := "--------------------------------------------------" - - for i, id := range args { - fmt.Println(separator) - fmt.Printf("[%d/%d] Getting data for %s %s...", i+1, nArgs, contentType, id) - - var resource deezer.Resource - var songs []*deezer.Song - - switch contentType { - case "album": - album := &deezer.Album{} - if err := session.GetData(album, id); err != nil { - fmt.Printf("\r[%d/%d] Getting data for album %s... FAILED\n", i+1, nArgs, id) - fmt.Fprintf(os.Stderr, "Error: could not get album data: %v\n", err) - - continue - } - resource = album - songs = album.GetSongs() - case "playlist": - playlist := &deezer.Playlist{} - if err := session.GetData(playlist, id); err != nil { - fmt.Printf("\r[%d/%d] Getting data for playlist %s... FAILED\n", i+1, nArgs, id) - fmt.Fprintf(os.Stderr, "Error: could not get playlist data: %v\n", err) - - continue - } - if playlist.Results.Data.Status == 1 && playlist.Results.Data.CollabKey == "" { - fmt.Printf("\r[%d/%d] Getting data for playlist %s... FAILED\n", i+1, nArgs, id) - fmt.Fprintf(os.Stderr, "Error: playlist is private and no valid arl cookie was provided\n") - - continue - } - - resource = playlist - songs = playlist.GetSongs() - } - - fmt.Printf("\r[%d/%d] Getting data for %s %s... DONE\n", i+1, nArgs, contentType, id) - - output := resource.GetOutputPath(outputDir) - if err := utils.EnsureDir(output); err != nil { - fmt.Fprintf(os.Stderr, "Error: could not create output directory: %v\n", err) - - continue - } - - title := resource.GetTitle() - fmt.Printf("Starting download of %s: %s\n", contentType, title) - - for _, song := range songs { - songTitle := song.Title - if song.Version != "" { - songTitle = fmt.Sprintf("%s %s", song.Title, song.Version) - } - - media, err := song.GetMediaData(session.LicenseToken, quality) - if err != nil { - fmt.Printf(" Downloading %s... FAILED\n", songTitle) - fmt.Fprintf(os.Stderr, "Error: could not get media data: %v\n", err) - if err.Error() == "invalid license token" { - os.Exit(1) - } - - continue - } - - if len(media.Data) == 0 || len(media.Data[0].Media) == 0 || len(media.Data[0].Media[0].Sources) == 0 { - fmt.Printf(" Downloading %s... FAILED\n", songTitle) - fmt.Fprintf(os.Stderr, "Error: could not get media sources\n") - - continue - } - - url := media.Data[0].Media[0].Sources[0].URL - for _, source := range media.Data[0].Media[0].Sources { - if source.Provider == "ak" { - url = source.URL - break - } - } - - ext := "mp3" - if media.Data[0].Media[0].Format == "FLAC" { - ext = "flac" - } - trackNumber := "" - if contentType == "album" { - trackNumber = song.TrackNumber + "." - } - - fileName := fmt.Sprintf("%s %s - %s.%s", trackNumber, songTitle, strings.Join(song.Contributors.MainArtists, ", "), ext) - fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{}) - filePath := path.Join(output, fileName) - - if existing, err := db.Get(song.ID); err == nil && existing.Quality == media.Data[0].Media[0].Format { - if utils.FileExists(existing.Path) { - fmt.Printf(" Skipping %s (already downloaded at %s)\n", songTitle, existing.Path) - continue - } - if existing.Hash != "" { - if foundPath, err := utils.FindFileByHash(appDir, existing.Hash); err == nil && foundPath != "" { - existing.Path = foundPath - _ = existing.Save() - fmt.Printf(" Recovered %s at %s, skipping download\n", songTitle, foundPath) - - continue - } - } - } - - fmt.Printf(" Downloading %s...", songTitle) - - bpmCh := make(chan bpmResult, 1) - go func() { - t, k, e := song.GetTempoAndKey() - bpmCh <- bpmResult{tempo: t, key: k, err: e} - }() - - err = media.Download(url, filePath, song.ID) - if err != nil { - fmt.Printf("\r Downloading %s... FAILED\n", songTitle) - fmt.Fprintf(os.Stderr, "Error: could not download song: %v\n", err) - utils.DeleteFile(filePath) - - continue - } - fmt.Printf("\r Downloading %s... DONE\n", songTitle) - - res := <-bpmCh - if res.err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not get tempo/key for %s: %v\n", songTitle, res.err) - } else { - fmt.Printf(" Tempo: %s\n", res.tempo) - fmt.Printf(" Key: %s\n", res.key) - } - - if err := tags.AddTags(resource, song, filePath, res.tempo, res.key); err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not add tags to song: %v\n", err) - } - - hash, err := utils.GetFileHash(filePath) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not get file hash: %v\n", err) - } - - info := &db.DownloadInfo{ - SongID: song.ID, - Quality: media.Data[0].Media[0].Format, - Path: filePath, - Hash: hash, - Downloaded: time.Now(), - } - if err := info.Save(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: could not save download info: %v\n", err) - } - } - } - - fmt.Println(separator) - fmt.Println("All downloads completed") + return cmd } diff --git a/cmd/download_album.go b/cmd/download_album.go deleted file mode 100644 index 1cb9ce5..0000000 --- a/cmd/download_album.go +++ /dev/null @@ -1,17 +0,0 @@ -package cmd - -import "github.com/spf13/cobra" - -var albumCmd = &cobra.Command{ - Use: "album [album_id...]", - Short: "Download songs from one or more albums", - Args: cobra.MinimumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - validateInput() - downloadContent("album", args) - }, -} - -func init() { - downloadCmd.AddCommand(albumCmd) -} diff --git a/cmd/download_playlist.go b/cmd/download_playlist.go deleted file mode 100644 index 698cf66..0000000 --- a/cmd/download_playlist.go +++ /dev/null @@ -1,17 +0,0 @@ -package cmd - -import "github.com/spf13/cobra" - -var playlistCmd = &cobra.Command{ - Use: "playlist [playlist_id...]", - Short: "Download songs from one or more playlists", - Args: cobra.MinimumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - validateInput() - downloadContent("playlist", args) - }, -} - -func init() { - downloadCmd.AddCommand(playlistCmd) -} diff --git a/cmd/root.go b/cmd/root.go index 8fa357f..62503c5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,63 +1,27 @@ package cmd import ( - "fmt" - "os" - "path" - "path/filepath" - - "github.com/mathismqn/godeez/internal/config" - "github.com/mathismqn/godeez/internal/db" - "github.com/mathismqn/godeez/internal/utils" + "github.com/mathismqn/godeez/internal/app" "github.com/spf13/cobra" ) var ( - cfgDir string - musicDir string - appDir string - cfgFile string + cfgPath string + appCtx *app.Context ) var RootCmd = &cobra.Command{ - Use: "godeez", - Short: "GoDeez is a tool to download music from Deezer", - Run: func(cmd *cobra.Command, args []string) { - cmd.Help() + Use: "godeez", + Short: "GoDeez is a tool to download music from Deezer", + SilenceUsage: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var err error + appCtx, err = app.NewContext(cfgPath) + + return err }, } func init() { - RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.godeez)") - cobra.OnInitialize(func() { - initDirs() - config.Init(cfgFile, cfgDir) - db.Init(cfgDir) - }) -} - -func initDirs() { - home, err := os.UserHomeDir() - if err != nil { - fmt.Fprintf(os.Stderr, "Error: could not get home directory: %v\n", err) - os.Exit(1) - } - - cfgDir = filepath.Join(home, ".godeez") - if err := utils.EnsureDir(cfgDir); err != nil { - fmt.Fprintf(os.Stderr, "Error: could not create app directory: %v\n", err) - os.Exit(1) - } - - musicDir = filepath.Join(home, "Music") - if err := utils.EnsureDir(musicDir); err != nil { - fmt.Fprintf(os.Stderr, "Error: could not create music directory: %v\n", err) - os.Exit(1) - } - - appDir = path.Join(musicDir, "GoDeez") - if err := utils.EnsureDir(appDir); err != nil { - fmt.Fprintf(os.Stderr, "Error: could not create GoDeez directory: %v\n", err) - os.Exit(1) - } + RootCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "config file (default is $HOME/.godeez)") } diff --git a/internal/app/context.go b/internal/app/context.go new file mode 100644 index 0000000..ba519c1 --- /dev/null +++ b/internal/app/context.go @@ -0,0 +1,53 @@ +package app + +import ( + "fmt" + "os" + "path" + "path/filepath" + + "github.com/mathismqn/godeez/internal/config" + "github.com/mathismqn/godeez/internal/fileutil" + "github.com/mathismqn/godeez/internal/store" +) + +type Context struct { + AppDir string + Config *config.Config +} + +func NewContext(cfgPath string) (*Context, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + + cfgDir := filepath.Join(home, ".godeez") + if err := fileutil.EnsureDir(cfgDir); err != nil { + return nil, fmt.Errorf("failed to create config directory: %w", err) + } + + musicDir := filepath.Join(home, "Music") + if err := fileutil.EnsureDir(musicDir); err != nil { + return nil, fmt.Errorf("failed to create music directory: %w", err) + } + + appDir := path.Join(musicDir, "GoDeez") + if err := fileutil.EnsureDir(appDir); err != nil { + return nil, fmt.Errorf("failed to create app directory: %w", err) + } + + cfg, err := config.New(cfgPath, cfgDir) + if err != nil { + return nil, err + } + + if err := store.OpenDB(cfgDir); err != nil { + return nil, err + } + + return &Context{ + AppDir: appDir, + Config: cfg, + }, nil +} diff --git a/internal/bpm/bpm.go b/internal/bpm/bpm.go new file mode 100644 index 0000000..400e568 --- /dev/null +++ b/internal/bpm/bpm.go @@ -0,0 +1,176 @@ +package bpm + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +type Metrics struct { + BPM string + Key string +} + +func FetchMetrics(ctx context.Context, httpClient *http.Client, artist, title, duration string) (*Metrics, error) { + url, err := findSongURL(ctx, httpClient, artist, title, duration) + if err != nil { + return nil, err + } + + html, err := fetchPage(ctx, httpClient, url) + if err != nil { + return nil, err + } + + return parseMetrics(html) +} + +func findSongURL(ctx context.Context, httpClient *http.Client, artist, title, duration string) (string, error) { + rootUrl := "https://songbpm.com" + reqUrl := rootUrl + "/searches" + + values := url.Values{} + values.Add("query", fmt.Sprintf("%s %s", artist, title)) + + req, err := http.NewRequestWithContext(ctx, "POST", reqUrl, 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") + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(resp.Body) + if err != nil { + return "", err + } + + var ( + found bool + url string + ) + + 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) { + return true + } + + durationStr := strings.TrimSpace(selection.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 + } + minutes, err := strconv.Atoi(parts[0]) + if err != nil { + return true + } + seconds, err := strconv.Atoi(parts[1]) + if err != nil { + return true + } + + foundDuration := minutes*60 + seconds + duration, err := strconv.Atoi(duration) + if err != nil { + return true + } + + if foundDuration <= (duration-2) || foundDuration >= (duration+2) { + return true + } + + url = selection.AttrOr("href", "") + found = true + + return false + }) + + if !found { + return "", fmt.Errorf("no data found") + } + + return rootUrl + url, nil +} + +func fetchPage(ctx context.Context, httpClient *http.Client, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", err + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(body), nil +} + +func parseMetrics(html string) (*Metrics, error) { + bpmRegex := regexp.MustCompile(`tempo of ]*>(\d+) BPM`) + 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 nil, fmt.Errorf("no data found") + } + + isMinor := false + bpm := bpmMatch[1] + key := keyMatch[1] + 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 &Metrics{ + BPM: bpm, + Key: key, + }, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 0c55016..e3bc16f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,11 +14,9 @@ type Config struct { IV string `mapstructure:"iv"` } -var Cfg Config - -func Init(cfgFile, cfgDir string) { - if cfgFile != "" { - viper.SetConfigFile(cfgFile) +func New(cfgPath, cfgDir string) (*Config, error) { + if cfgPath != "" { + viper.SetConfigFile(cfgPath) } else { cfgPath := path.Join(cfgDir, "config.toml") if _, err := os.Stat(cfgPath); os.IsNotExist(err) { @@ -26,8 +24,7 @@ func Init(cfgFile, cfgDir string) { content := []byte("arl_cookie = ''\nsecret_key = ''\niv = '0001020304050607'\n") if err := os.WriteFile(cfgPath, content, 0644); err != nil { - fmt.Fprintf(os.Stderr, "Error: could not create config file: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("failed to create config file: %w", err) } } @@ -38,22 +35,29 @@ func Init(cfgFile, cfgDir string) { viper.SetConfigType("toml") viper.AutomaticEnv() if err := viper.ReadInConfig(); err != nil { - fmt.Fprintf(os.Stderr, "Error: could not read config file: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("failed to read config file: %w", err) } - cfg := &Cfg + cfg := &Config{} if err := viper.Unmarshal(&cfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: could not unmarshal config file: %v\n", err) - os.Exit(1) + return nil, fmt.Errorf("failed to unmarshal config: %w", err) } + if cfg.ArlCookie == "" { + return nil, fmt.Errorf("arl_cookie is not set in config file") + } if cfg.SecretKey == "" { - fmt.Fprintln(os.Stderr, "Error: secret_key is not set in config file") - os.Exit(1) + return nil, fmt.Errorf("secret_key is not set in config file") + } + if len(cfg.SecretKey) != 16 { + return nil, fmt.Errorf("secret_key must be 16 bytes long") } if cfg.IV == "" { - fmt.Fprintln(os.Stderr, "Error: iv is not set in config file") - os.Exit(1) + return nil, fmt.Errorf("iv is not set in config file") } + if len(cfg.IV) != 16 { + return nil, fmt.Errorf("iv must be 16 bytes long") + } + + return cfg, nil } diff --git a/internal/crypto/crypto.go b/internal/crypto/blowfish.go similarity index 63% rename from internal/crypto/crypto.go rename to internal/crypto/blowfish.go index 62d6701..09a06cb 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/blowfish.go @@ -3,18 +3,16 @@ package crypto import ( "crypto/cipher" "crypto/md5" - "encoding/hex" "fmt" - "github.com/mathismqn/godeez/internal/config" "golang.org/x/crypto/blowfish" ) -func GetBlowfishKey(songID string) []byte { +func GetKey(secretKey, songID string) []byte { hash := md5.Sum([]byte(songID)) hashHex := fmt.Sprintf("%x", hash) - key := []byte(config.Cfg.SecretKey) + key := []byte(secretKey) for i := 0; i < len(hash); i++ { key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16] } @@ -22,17 +20,12 @@ func GetBlowfishKey(songID string) []byte { return key } -func DecryptBlowfish(data, key []byte) ([]byte, error) { +func Decrypt(data, key, iv []byte) ([]byte, error) { block, err := blowfish.NewCipher(key) if err != nil { return nil, err } - iv, err := hex.DecodeString(config.Cfg.IV) - if err != nil { - return nil, err - } - mode := cipher.NewCBCDecrypter(block, iv) decrypted := make([]byte, len(data)) mode.CryptBlocks(decrypted, data) diff --git a/internal/deezer/album.go b/internal/deezer/album.go index b167418..611f87d 100644 --- a/internal/deezer/album.go +++ b/internal/deezer/album.go @@ -28,22 +28,22 @@ func (a *Album) GetType() string { return "Album" } -func (a *Album) UnmarshalData(data []byte) error { - return json.Unmarshal(data, a) +func (a *Album) GetTitle() string { + return a.Results.Data.Title } func (a *Album) GetSongs() []*Song { return a.Results.Songs.Data } -func (a *Album) GetOutputPath(outputDir string) string { +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{}) - outputPath := path.Join(outputDir, base) + outputDir = path.Join(outputDir, base) - return outputPath + return outputDir } -func (a *Album) GetTitle() string { - return a.Results.Data.Title +func (a *Album) Unmarshal(data []byte) error { + return json.Unmarshal(data, a) } diff --git a/internal/deezer/client.go b/internal/deezer/client.go new file mode 100644 index 0000000..8a1059f --- /dev/null +++ b/internal/deezer/client.go @@ -0,0 +1,182 @@ +package deezer + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/mathismqn/godeez/internal/app" +) + +type Client struct { + AppCtx *app.Context + Session *Session +} + +func NewClient(ctx context.Context, appCtx *app.Context) (*Client, error) { + session, err := Authenticate(ctx, appCtx.Config.ArlCookie) + if err != nil { + return nil, fmt.Errorf("failed to authenticate: %w", err) + } + + return &Client{ + AppCtx: appCtx, + Session: session, + }, nil +} + +func (c *Client) FetchResource(ctx context.Context, ressource Resource, id string) error { + payload := map[string]interface{}{ + "nb": 10000, + "start": 0, + "playlist_id": id, + "alb_id": id, + "lang": "en", + "tab": 0, + "tags": true, + "header": true, + } + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", ressource.GetType(), c.Session.APIToken) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + resp, err := c.Session.HttpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`) { + return fmt.Errorf("invalid playlist ID") + } + if strings.Contains(string(body), `"DATA_ERROR":"album::getData"`) { + return fmt.Errorf("invalid album ID") + } + if strings.Contains(string(body), `"results":{}`) { + return fmt.Errorf("unexpected response") + } + + return ressource.Unmarshal(body) +} + +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"}]` + case "flac": + formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"}]` + case "best": + formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]` + } + + reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, c.Session.LicenseToken, formats, 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 + } + + resp, err := c.Session.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var media Media + err = json.Unmarshal(body, &media) + if err != nil { + return nil, err + } + + if len(media.Errors) > 0 { + if media.Errors[0].Code == 1000 { + return nil, fmt.Errorf("invalid license token") + } + + return nil, fmt.Errorf("%s", media.Errors[0].Message) + } + if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 { + 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) + } + + return &media, nil +} + +func (c *Client) FetchCoverImage(ctx context.Context, song *Song) ([]byte, error) { + url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", song.Cover) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := c.Session.HttpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +func (c *Client) GetMediaStream(ctx context.Context, media *Media, songID string) (io.ReadCloser, error) { + url, err := media.GetURL() + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := c.Session.HttpClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return resp.Body, nil +} diff --git a/internal/deezer/media.go b/internal/deezer/media.go index fae0bbc..928075b 100644 --- a/internal/deezer/media.go +++ b/internal/deezer/media.go @@ -2,10 +2,6 @@ package deezer import ( "fmt" - "net/http" - "os" - - "github.com/mathismqn/godeez/internal/crypto" ) type Media struct { @@ -35,64 +31,26 @@ type Source struct { Provider string `json:"provider"` } -const ChunkSize = 2048 - -func (m *Media) Download(url, path, songID string) error { - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) +func (m *Media) GetURL() (string, error) { + if len(m.Data) == 0 || len(m.Data[0].Media) == 0 || len(m.Data[0].Media[0].Sources) == 0 { + return "", fmt.Errorf("no media sources found") } - file, err := os.Create(path) - if err != nil { - return err - } - defer file.Close() - - key := crypto.GetBlowfishKey(songID) - buffer := make([]byte, ChunkSize) - - for chunk := 0; ; chunk++ { - totalRead := 0 - for totalRead < ChunkSize { - n, err := resp.Body.Read(buffer[totalRead:]) - if err != nil { - if err.Error() == "EOF" { - break - } - return err - } - - if n > 0 { - totalRead += n - } - } - - if totalRead == 0 { - break - } - - if chunk%3 == 0 && totalRead == ChunkSize { - buffer, err = crypto.DecryptBlowfish(buffer, key) - if err != nil { - return err - } - } - - _, err = file.Write(buffer[:totalRead]) - if err != nil { - return err - } - - if totalRead < ChunkSize { + url := m.Data[0].Media[0].Sources[0].URL + for _, source := range m.Data[0].Media[0].Sources { + if source.Provider == "ak" { + url = source.URL break } } - return nil + return url, nil +} + +func (m *Media) GetFormat() (string, error) { + if len(m.Data) == 0 || len(m.Data[0].Media) == 0 { + return "", fmt.Errorf("no media format found") + } + + return m.Data[0].Media[0].Format, nil } diff --git a/internal/deezer/playlist.go b/internal/deezer/playlist.go index 14677e3..8ee84d8 100644 --- a/internal/deezer/playlist.go +++ b/internal/deezer/playlist.go @@ -24,21 +24,21 @@ func (p *Playlist) GetType() string { return "Playlist" } -func (p *Playlist) UnmarshalData(data []byte) error { - return json.Unmarshal(data, p) +func (p *Playlist) GetTitle() string { + return p.Results.Data.Title } func (p *Playlist) GetSongs() []*Song { return p.Results.Songs.Data } -func (p *Playlist) GetOutputPath(outputDir string) string { +func (p *Playlist) GetOutputDir(outputDir string) string { p.Results.Data.Title, _ = filenamify.Filenamify(p.Results.Data.Title, filenamify.Options{}) - outputPath := path.Join(outputDir, p.Results.Data.Title) + outputDir = path.Join(outputDir, p.Results.Data.Title) - return outputPath + return outputDir } -func (p *Playlist) GetTitle() string { - return p.Results.Data.Title +func (p *Playlist) Unmarshal(data []byte) error { + return json.Unmarshal(data, p) } diff --git a/internal/deezer/resource.go b/internal/deezer/resource.go index aea9cec..2149788 100644 --- a/internal/deezer/resource.go +++ b/internal/deezer/resource.go @@ -1,65 +1,9 @@ package deezer -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" -) - type Resource interface { - GetType() string - UnmarshalData(data []byte) error - GetSongs() []*Song - GetOutputPath(outputDir string) string GetTitle() string -} - -func (s *Session) GetData(r Resource, id string) error { - payload := map[string]interface{}{ - "nb": 10000, - "start": 0, - "playlist_id": id, - "alb_id": id, - "lang": "en", - "tab": 0, - "tags": true, - "header": true, - } - jsonData, err := json.Marshal(payload) - if err != nil { - return err - } - - url := fmt.Sprintf("https://www.deezer.com/ajax/gw-light.php?method=deezer.page%s&input=3&api_version=1.0&api_token=%s", r.GetType(), s.APIToken) - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return err - } - - resp, err := s.Client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - body, _ := io.ReadAll(resp.Body) - - if strings.Contains(string(body), `"DATA_ERROR":"playlist::getData"`) { - return fmt.Errorf("invalid playlist ID") - } - if strings.Contains(string(body), `"DATA_ERROR":"album::getData"`) { - return fmt.Errorf("invalid album ID") - } - if strings.Contains(string(body), `"results":{}`) { - return fmt.Errorf("unexpected response") - } - - return r.UnmarshalData(body) + GetType() string + GetSongs() []*Song + GetOutputDir(outputDir string) string + Unmarshal(data []byte) error } diff --git a/internal/deezer/session.go b/internal/deezer/session.go index ae46a8c..28e05b4 100644 --- a/internal/deezer/session.go +++ b/internal/deezer/session.go @@ -1,11 +1,13 @@ package deezer import ( + "context" "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" + "time" ) type UserDataResponse struct { @@ -26,20 +28,21 @@ type Session struct { ArlCookie string APIToken string LicenseToken string - Client *http.Client + HttpClient *http.Client } -func Authenticate(arlCookie string) (*Session, error) { +func Authenticate(ctx context.Context, arlCookie string) (*Session, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err } client := &http.Client{ - Jar: jar, + Timeout: 20 * time.Second, + Jar: jar, } url := "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=" - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } @@ -59,7 +62,10 @@ func Authenticate(arlCookie string) (*Session, error) { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } var res UserDataResponse if err := json.Unmarshal(body, &res); err != nil { @@ -77,6 +83,6 @@ func Authenticate(arlCookie string) (*Session, error) { ArlCookie: arlCookie, APIToken: res.Results.APIToken, LicenseToken: res.Results.User.Options.LicenseToken, - Client: client, + HttpClient: client, }, nil } diff --git a/internal/deezer/song.go b/internal/deezer/song.go index 749c3cd..518160e 100644 --- a/internal/deezer/song.go +++ b/internal/deezer/song.go @@ -1,17 +1,10 @@ package deezer import ( - "bytes" - "encoding/json" "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strconv" "strings" - "github.com/PuerkitoBio/goquery" + "github.com/flytam/filenamify" ) type Song struct { @@ -32,216 +25,27 @@ type Song struct { TrackToken string `json:"TRACK_TOKEN"` } -func (s *Song) GetMediaData(licenseToken, 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"}]` - case "flac": - formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"}]` - case "best": - formats = `[{"cipher":"BF_CBC_STRIPE","format":"FLAC"},{"cipher":"BF_CBC_STRIPE","format":"MP3_320"},{"cipher":"BF_CBC_STRIPE","format":"MP3_128"}]` +func (s *Song) GetTitle() string { + songTitle := s.Title + if s.Version != "" { + songTitle = fmt.Sprintf("%s %s", s.Title, s.Version) } - reqBody := fmt.Sprintf(`{"license_token":"%s","media":[{"type":"FULL","formats":%s}],"track_tokens":["%s"]}`, licenseToken, formats, s.TrackToken) - resp, err := http.Post("https://media.deezer.com/v1/get_url", "application/json", bytes.NewBuffer([]byte(reqBody))) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - body, _ := io.ReadAll(resp.Body) - - var media Media - err = json.Unmarshal(body, &media) - if err != nil { - return nil, err - } - - if len(media.Errors) > 0 { - if media.Errors[0].Code == 1000 { - return nil, fmt.Errorf("invalid license token") - } - - return nil, fmt.Errorf("%s", media.Errors[0].Message) - } - if len(media.Data) > 0 && len(media.Data[0].Errors) > 0 { - 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) - } - - return &media, nil + return songTitle } -func (s *Song) GetCoverImage() ([]byte, error) { - url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", s.Cover) - resp, err := http.Get(url) - if err != nil { - return nil, err +func (s *Song) GetFileName(resourceType string, song *Song, media *Media) string { + ext := "mp3" + if media.Data[0].Media[0].Format == "FLAC" { + ext = "flac" } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + trackNumber := "" + if resourceType == "album" { + trackNumber = song.TrackNumber + "." } - return io.ReadAll(resp.Body) -} + fileName := fmt.Sprintf("%s %s - %s.%s", trackNumber, s.GetTitle(), strings.Join(song.Contributors.MainArtists, ", "), ext) + fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{}) -func (s *Song) GetTempoAndKey() (string, string, error) { - client := &http.Client{} - link, err := s.findSongLink(client) - if err != nil { - return "", "", err - } - - html, err := fetchPage(client, link) - if err != nil { - return "", "", err - } - - return parseBPMAndKey(html) -} - -func (s *Song) findSongLink(client *http.Client) (string, error) { - rootUrl := "https://songbpm.com" - reqUrl := rootUrl + "/searches" - - values := url.Values{} - values.Add("query", fmt.Sprintf("%s %s %s", s.Artist, s.Title, s.Version)) - - req, err := http.NewRequest("POST", reqUrl, 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") - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - doc, err := goquery.NewDocumentFromReader(resp.Body) - if err != nil { - return "", err - } - - var found bool - var link string - - doc.Find("a.flex.flex-col").Each(func(i int, selection *goquery.Selection) { - if strings.Contains(selection.Text(), s.Title) && strings.Contains(selection.Text(), s.Artist) { - foundArtist := selection.Find("p.text-sm.font-light.uppercase").Text() - foundTitle := selection.Find("p.pr-2.text-lg").Text() - - if strings.Contains(strings.ToLower(foundArtist), strings.ToLower(s.Artist)) && strings.Contains(strings.ToLower(foundTitle), strings.ToLower(s.Title)) { - durationStr := strings.TrimSpace(selection.Find("div.flex-1.flex-col.items-center").Eq(1).Find("span.text-2xl").Text()) - parts := strings.Split(durationStr, ":") - if len(parts) != 2 { - return - } - minutes, err := strconv.Atoi(parts[0]) - if err != nil { - fmt.Println(err) - return - } - seconds, err := strconv.Atoi(parts[1]) - if err != nil { - return - } - - foundDuration := minutes*60 + seconds - duration, err := strconv.Atoi(s.Duration) - if err != nil { - return - } - - if foundDuration > (duration-2) || foundDuration < (duration+2) { - link = selection.AttrOr("href", "") - found = true - - return - } - } - } - }) - - if !found { - return "", fmt.Errorf("no data found") - } - - return rootUrl + link, nil -} - -func fetchPage(client *http.Client, link string) (string, error) { - req, err := http.NewRequest("GET", link, nil) - if err != nil { - return "", err - } - - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - body, _ := io.ReadAll(resp.Body) - - return string(body), nil -} - -func parseBPMAndKey(html string) (string, string, error) { - bpmRegex := regexp.MustCompile(`tempo of ]*>(\d+) BPM`) - 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 "", "", fmt.Errorf("no data found") - } - - isMinor := false - bpm := bpmMatch[1] - key := keyMatch[1] - 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 bpm, key, nil + return fileName } diff --git a/internal/downloader/client.go b/internal/downloader/client.go new file mode 100644 index 0000000..c642411 --- /dev/null +++ b/internal/downloader/client.go @@ -0,0 +1,275 @@ +package downloader + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path" + "sync" + "time" + + "github.com/mathismqn/godeez/internal/app" + "github.com/mathismqn/godeez/internal/bpm" + "github.com/mathismqn/godeez/internal/crypto" + "github.com/mathismqn/godeez/internal/deezer" + "github.com/mathismqn/godeez/internal/fileutil" + "github.com/mathismqn/godeez/internal/store" + "github.com/mathismqn/godeez/internal/tags" +) + +const ChunkSize = 2048 + +type Client struct { + appCtx *app.Context + resourceType string + deezerClient *deezer.Client + + hashIndexOnce sync.Once + hashIndex *fileutil.HashIndex + hashIndexErr error +} + +func New(appCtx *app.Context, resourceType string) *Client { + return &Client{ + appCtx: appCtx, + resourceType: resourceType, + deezerClient: nil, + } +} + +func (c *Client) Run(ctx context.Context, opts Options, ids []string) error { + var err error + c.deezerClient, err = deezer.NewClient(ctx, c.appCtx) + if err != nil { + return err + } + + for _, id := range ids { + if ctx.Err() != nil { + return ctx.Err() + } + + var resource deezer.Resource + + switch c.resourceType { + case "album": + resource = &deezer.Album{} + case "playlist": + resource = &deezer.Playlist{} + default: + return fmt.Errorf("unsupported resource type: %s", c.resourceType) + } + + if err := c.deezerClient.FetchResource(ctx, resource, id); err != nil { + return fmt.Errorf("failed to fetch resource: %w", err) + } + + songs := resource.GetSongs() + if len(songs) == 0 { + return fmt.Errorf("%s has no songs", c.resourceType) + } + + outputDir := resource.GetOutputDir(opts.OutputDir) + if err := fileutil.EnsureDir(outputDir); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + for _, song := range songs { + if ctx.Err() != nil { + return ctx.Err() + } + + if err := c.downloadSong(ctx, resource, song, opts, outputDir); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + + fmt.Fprintf(os.Stderr, "Error: failed to download %s: %v\n", song.Title, err) + continue + } + } + } + + return nil +} + +func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, song *deezer.Song, opts Options, outputDir string) error { + media, err := c.deezerClient.FetchMedia(ctx, song, opts.Quality) + if err != nil { + return err + } + + fileName := song.GetFileName(c.resourceType, song, media) + outputPath := path.Join(outputDir, fileName) + + mediaFormat, err := media.GetFormat() + if err != nil { + return err + } + + if _, skip := c.shouldSkipDownload(ctx, song.ID, mediaFormat); skip { + return nil + } + + metricsChan := make(chan *bpm.Metrics, 1) + errChan := make(chan error, 1) + + go func() { + metrics, err := bpm.FetchMetrics(ctx, c.deezerClient.Session.HttpClient, song.Artist, song.GetTitle(), song.Duration) + if err != nil { + errChan <- err + return + } + + metricsChan <- metrics + }() + + stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID) + if err != nil { + return fmt.Errorf("media stream unavailable: %w", err) + } + + dlCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + if err := c.streamToFile(dlCtx, stream, outputPath, song.ID); err != nil { + fileutil.DeleteFile(outputPath) + + return fmt.Errorf("unable to write to file: %w", err) + } + + metrics := &bpm.Metrics{} + select { + case metrics = <-metricsChan: + fmt.Printf("BPM: %s, Key: %s\n", metrics.BPM, metrics.Key) + case err := <-errChan: + + fmt.Printf("Warning: failed to fetch BPM and key: %v\n", err) + } + + cover, err := c.deezerClient.FetchCoverImage(ctx, song) + if err != nil { + fmt.Printf("Warning: failed to fetch cover image: %v\n", err) + } + + c.finalizeDownload(resource, song, outputPath, mediaFormat, cover, metrics) + + return nil +} + +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 + } + } + } + } + + return "", false +} + +func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputPath, songID string) error { + defer stream.Close() + + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + key := crypto.GetKey(c.appCtx.Config.SecretKey, songID) + iv, err := hex.DecodeString(c.appCtx.Config.IV) + if err != nil { + return err + } + + buffer := make([]byte, ChunkSize) + for chunk := 0; ; chunk++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + // continue + } + + totalRead := 0 + for totalRead < ChunkSize { + n, err := stream.Read(buffer[totalRead:]) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + + if n > 0 { + totalRead += n + } + } + + if totalRead == 0 { + break + } + + if chunk%3 == 0 && totalRead == ChunkSize { + buffer, err = crypto.Decrypt(buffer, key, iv) + if err != nil { + return err + } + } + + _, err = file.Write(buffer[:totalRead]) + if err != nil { + return err + } + + if totalRead < ChunkSize { + break + } + } + + return nil +} + +func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat string, cover []byte, metrics *bpm.Metrics) { + if err := tags.AddTags(resource, song, cover, outputPath, metrics.BPM, metrics.Key); err != nil { + fmt.Printf("Warning: failed to add tags: %v\n", err) + } + + hash, err := fileutil.GetFileHash(outputPath) + if err != nil { + fmt.Printf("Warning: failed to get file hash: %v\n", err) + } + + info := &store.DownloadInfo{ + SongID: song.ID, + Quality: mediaFormat, + Path: outputPath, + Hash: hash, + Downloaded: time.Now(), + } + + if err := info.Save(); err != nil { + fmt.Printf("Warning: failed to save download info: %v\n", err) + } +} + +func (c *Client) initHashIndex(ctx context.Context) error { + c.hashIndexOnce.Do(func() { + c.hashIndex, c.hashIndexErr = fileutil.NewHashIndex(ctx, c.appCtx.AppDir) + }) + + return c.hashIndexErr +} diff --git a/internal/downloader/options.go b/internal/downloader/options.go new file mode 100644 index 0000000..5f7a59b --- /dev/null +++ b/internal/downloader/options.go @@ -0,0 +1,33 @@ +package downloader + +import ( + "fmt" +) + +var validQualities = map[string]bool{ + "mp3_128": true, + "mp3_320": true, + "flac": true, + "best": true, +} + +type Options struct { + OutputDir string + Quality string +} + +func (o *Options) Validate(appDir string) error { + if o.OutputDir == "" { + o.OutputDir = appDir + } + + if o.Quality == "" { + o.Quality = "best" + } + + if !validQualities[o.Quality] { + return fmt.Errorf("invalid quality option: %s", o.Quality) + } + + return nil +} diff --git a/internal/fileutil/file.go b/internal/fileutil/file.go new file mode 100644 index 0000000..2c0343a --- /dev/null +++ b/internal/fileutil/file.go @@ -0,0 +1,52 @@ +package fileutil + +import ( + "crypto/sha256" + "fmt" + "io" + "os" +) + +func EnsureDir(path string) error { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return os.MkdirAll(path, 0755) + } + if err != nil { + return err + } + 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() +} + +func DeleteFile(path string) error { + if !FileExists(path) { + return nil + } + + return os.Remove(path) +} + +func GetFileHash(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} diff --git a/internal/fileutil/hashindex.go b/internal/fileutil/hashindex.go new file mode 100644 index 0000000..9e88fa7 --- /dev/null +++ b/internal/fileutil/hashindex.go @@ -0,0 +1,56 @@ +package fileutil + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" +) + +type HashIndex struct { + files map[string]string +} + +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 { + if ctx.Err() != nil { + return ctx.Err() + } + + if err != nil || info.IsDir() { + return nil + } + + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + + h := sha256.New() + if _, err := io.Copy(h, file); err != nil { + return nil + } + + sum := hex.EncodeToString(h.Sum(nil)) + index.files[sum] = path + + return nil + }) + + if err != nil { + return nil, err + } + + return index, nil +} + +func (h *HashIndex) Find(hash string) (string, bool) { + path, ok := h.files[hash] + + return path, ok +} diff --git a/internal/db/download_info.go b/internal/store/download_info.go similarity index 92% rename from internal/db/download_info.go rename to internal/store/download_info.go index 00fd911..8c217fa 100644 --- a/internal/db/download_info.go +++ b/internal/store/download_info.go @@ -1,4 +1,4 @@ -package db +package store import ( "encoding/json" @@ -16,7 +16,7 @@ type DownloadInfo struct { Downloaded time.Time `json:"downloaded_at"` } -func Get(songID string) (*DownloadInfo, error) { +func GetDownloadInfo(songID string) (*DownloadInfo, error) { var info DownloadInfo if err := db.View(func(tx *bbolt.Tx) error { diff --git a/internal/db/setup.go b/internal/store/store.go similarity index 63% rename from internal/db/setup.go rename to internal/store/store.go index d449402..14339a4 100644 --- a/internal/db/setup.go +++ b/internal/store/store.go @@ -1,8 +1,7 @@ -package db +package store import ( "fmt" - "os" "path" bolt "go.etcd.io/bbolt" @@ -13,21 +12,21 @@ var ( trackBucket = []byte("tracks") ) -func Init(cfgDir string) { +func OpenDB(cfgDir string) error { var err error dbPath := path.Join(cfgDir, "tracks.db") db, err = bolt.Open(dbPath, 0600, nil) if err != nil { - fmt.Fprintf(os.Stderr, "Error: could not open database: %v\n", err) - os.Exit(1) + return fmt.Errorf("failed to open database: %w", err) } if err := db.Update(func(tx *bolt.Tx) error { _, err := tx.CreateBucketIfNotExists(trackBucket) return err }); err != nil { - fmt.Fprintf(os.Stderr, "Error: could not create bucket: %v\n", err) - os.Exit(1) + return fmt.Errorf("failed to create bucket: %w", err) } + + return nil } diff --git a/internal/tags/flac.go b/internal/tags/flac.go index 745a256..70d01c2 100644 --- a/internal/tags/flac.go +++ b/internal/tags/flac.go @@ -10,13 +10,13 @@ import ( "github.com/mathismqn/godeez/internal/deezer" ) -type FLACTagger struct { - File *flac.File - Cmts *flacvorbis.MetaDataBlockVorbisComment - Index int +type flacTagger struct { + file *flac.File + cmts *flacvorbis.MetaDataBlockVorbisComment + index int } -func (t *FLACTagger) AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error { +func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error { if album, ok := resource.(*deezer.Album); ok { dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-") if len(dateParts) == 3 { @@ -43,11 +43,11 @@ func (t *FLACTagger) AddTags(resource deezer.Resource, song *deezer.Song, cover t.addTag("KEY", key) t.addTag("INITIALKEY", key) - cmtsmeta := t.Cmts.Marshal() - if t.Index > 0 { - t.File.Meta[t.Index] = &cmtsmeta + cmtsmeta := t.cmts.Marshal() + if t.index > 0 { + 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") @@ -55,20 +55,20 @@ func (t *FLACTagger) AddTags(resource deezer.Resource, song *deezer.Song, cover return err } picturemeta := picture.Marshal() - t.File.Meta = append(t.File.Meta, &picturemeta) + t.file.Meta = append(t.file.Meta, &picturemeta) return t.saveTags(path) } -func (t *FLACTagger) addTag(name, value string) { +func (t *flacTagger) addTag(name, value string) { if value != "" { - t.Cmts.Add(name, value) + t.cmts.Add(name, value) } } -func (t *FLACTagger) saveTags(path string) error { +func (t *flacTagger) saveTags(path string) error { tempPath := path + ".tmp" - t.File.Save(tempPath) + t.file.Save(tempPath) return os.Rename(tempPath, path) } diff --git a/internal/tags/id3v2.go b/internal/tags/id3v2.go index 631d908..b9a0358 100644 --- a/internal/tags/id3v2.go +++ b/internal/tags/id3v2.go @@ -9,14 +9,17 @@ import ( "github.com/mathismqn/godeez/internal/deezer" ) -type ID3v2Tagger struct { - Tag *id3v2.Tag +type id3v2Tagger struct { + tag *id3v2.Tag } -func (t *ID3v2Tagger) AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error { - defer t.Tag.Close() +func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error { + defer t.tag.Close() - duration, _ := strconv.Atoi(song.Duration) + duration, err := strconv.Atoi(song.Duration) + if err != nil { + return err + } song.Duration = fmt.Sprintf("%d", duration*1000) if album, ok := resource.(*deezer.Album); ok { @@ -41,30 +44,30 @@ func (t *ID3v2Tagger) AddTags(resource deezer.Resource, song *deezer.Song, cover t.addTag("TKEY", key) frame := id3v2.PictureFrame{ - Encoding: t.Tag.DefaultEncoding(), + Encoding: t.tag.DefaultEncoding(), MimeType: "image/jpeg", PictureType: id3v2.PTFrontCover, Description: "Cover", Picture: cover, } - t.Tag.AddAttachedPicture(frame) + t.tag.AddAttachedPicture(frame) - return t.Tag.Save() + return t.tag.Save() } -func (t *ID3v2Tagger) addTag(name, value string) { +func (t *id3v2Tagger) addTag(name, value string) { if value != "" { - t.Tag.AddTextFrame(name, t.Tag.DefaultEncoding(), value) + t.tag.AddTextFrame(name, t.tag.DefaultEncoding(), value) } } -func (t *ID3v2Tagger) addTXXXTag(description, value string) { +func (t *id3v2Tagger) addTXXXTag(description, value string) { if value != "" { udf := id3v2.UserDefinedTextFrame{ - Encoding: t.Tag.DefaultEncoding(), + Encoding: t.tag.DefaultEncoding(), Description: description, Value: value, } - t.Tag.AddUserDefinedTextFrame(udf) + t.tag.AddUserDefinedTextFrame(udf) } } diff --git a/internal/tags/tag.go b/internal/tags/tags.go similarity index 55% rename from internal/tags/tag.go rename to internal/tags/tags.go index 7ea4f43..2d149df 100644 --- a/internal/tags/tag.go +++ b/internal/tags/tags.go @@ -9,18 +9,19 @@ import ( "github.com/mathismqn/godeez/internal/deezer" ) -type Tagger interface { - AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error +type tagger interface { + addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error } -func NewTagger(filePath string) (Tagger, error) { +func newTagger(filePath string) (tagger, error) { ext := path.Ext(filePath) if ext == ".mp3" { tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) if err != nil { return nil, err } - return &ID3v2Tagger{Tag: tag}, nil + + return &id3v2Tagger{tag: tag}, nil } file, err := flac.ParseFile(filePath) @@ -35,18 +36,14 @@ func NewTagger(filePath string) (Tagger, error) { cmts = flacvorbis.New() } - return &FLACTagger{File: file, Cmts: cmts, Index: idx}, nil + return &flacTagger{file: file, cmts: cmts, index: idx}, nil } -func AddTags(resource deezer.Resource, song *deezer.Song, filePath, tempo, key string) error { - tagger, err := NewTagger(filePath) - if err != nil { - return err - } - cover, err := song.GetCoverImage() +func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key string) error { + tagger, err := newTagger(filePath) if err != nil { return err } - return tagger.AddTags(resource, song, cover, filePath, tempo, key) + return tagger.addTags(resource, song, cover, filePath, tempo, key) } diff --git a/internal/utils/dir.go b/internal/utils/dir.go deleted file mode 100644 index 7b1fdcb..0000000 --- a/internal/utils/dir.go +++ /dev/null @@ -1,21 +0,0 @@ -package utils - -import ( - "fmt" - "os" -) - -func EnsureDir(path string) error { - info, err := os.Stat(path) - if os.IsNotExist(err) { - return os.MkdirAll(path, 0755) - } - if err != nil { - return err - } - if !info.IsDir() { - return fmt.Errorf("file already exists at %s", path) - } - - return nil -} diff --git a/internal/utils/file.go b/internal/utils/file.go deleted file mode 100644 index f6dadad..0000000 --- a/internal/utils/file.go +++ /dev/null @@ -1,73 +0,0 @@ -package utils - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "os" - "path/filepath" -) - -func FileExists(path string) bool { - info, err := os.Stat(path) - - return err == nil && !info.IsDir() -} - -func DeleteFile(path string) error { - if !FileExists(path) { - return nil - } - - return os.Remove(path) -} - -func GetFileHash(path string) (string, error) { - file, err := os.Open(path) - if err != nil { - return "", err - } - defer file.Close() - - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return "", err - } - - return fmt.Sprintf("%x", hash.Sum(nil)), nil -} - -func FindFileByHash(root, targetHash string) (string, error) { - var found string - - if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - - f, err := os.Open(path) - if err != nil { - return nil - } - defer f.Close() - - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return nil - } - sum := hex.EncodeToString(h.Sum(nil)) - if sum == targetHash { - found = path - return filepath.SkipDir - } - return nil - }); err != nil { - return "", err - } - - return found, nil -} diff --git a/main.go b/main.go index 853e490..23b8053 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,16 @@ package main -import "github.com/mathismqn/godeez/cmd" +import ( + "context" + "os" + "os/signal" + + "github.com/mathismqn/godeez/cmd" +) func main() { - cmd.RootCmd.Execute() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + cmd.RootCmd.ExecuteContext(ctx) }