refactor: major project restructure

This commit is contained in:
Mathis Maquenne
2025-05-01 20:30:56 +02:00
parent c74531b36c
commit 0bae78a1ff
27 changed files with 1017 additions and 812 deletions
+31 -206
View File
@@ -1,231 +1,56 @@
package cmd package cmd
import ( import (
"context"
"errors"
"fmt" "fmt"
"os"
"path"
"strings"
"time"
"github.com/flytam/filenamify" "github.com/mathismqn/godeez/internal/downloader"
"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/spf13/cobra" "github.com/spf13/cobra"
) )
var ( var opts downloader.Options
outputDir string
quality string
)
var downloadCmd = &cobra.Command{ var downloadCmd = &cobra.Command{
Use: "download", Use: "download",
Short: "Download songs from Deezer", Short: "Download songs from Deezer",
} }
type bpmResult struct {
tempo, key string
err error
}
func init() { func init() {
RootCmd.AddCommand(downloadCmd) 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() { func newDownloadCmd(resourceType string) *cobra.Command {
if outputDir == "" { cmd := &cobra.Command{
outputDir = appDir 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
} }
if quality == "" { return err
quality = "best"
} }
validQualities := map[string]bool{ return nil
"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) { return cmd
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")
} }
-17
View File
@@ -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)
}
-17
View File
@@ -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)
}
+10 -46
View File
@@ -1,63 +1,27 @@
package cmd package cmd
import ( import (
"fmt" "github.com/mathismqn/godeez/internal/app"
"os"
"path"
"path/filepath"
"github.com/mathismqn/godeez/internal/config"
"github.com/mathismqn/godeez/internal/db"
"github.com/mathismqn/godeez/internal/utils"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ( var (
cfgDir string cfgPath string
musicDir string appCtx *app.Context
appDir string
cfgFile string
) )
var RootCmd = &cobra.Command{ var RootCmd = &cobra.Command{
Use: "godeez", Use: "godeez",
Short: "GoDeez is a tool to download music from Deezer", Short: "GoDeez is a tool to download music from Deezer",
Run: func(cmd *cobra.Command, args []string) { SilenceUsage: true,
cmd.Help() PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
var err error
appCtx, err = app.NewContext(cfgPath)
return err
}, },
} }
func init() { func init() {
RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.godeez)") RootCmd.PersistentFlags().StringVar(&cfgPath, "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)
}
} }
+53
View File
@@ -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
}
+176
View File
@@ -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 <span[^>]*>(\d+) BPM`)
bpmMatch := bpmRegex.FindStringSubmatch(html)
keyRegex := regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
keyMatch := keyRegex.FindStringSubmatch(html)
modeRegex := regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> 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
}
+20 -16
View File
@@ -14,11 +14,9 @@ type Config struct {
IV string `mapstructure:"iv"` IV string `mapstructure:"iv"`
} }
var Cfg Config func New(cfgPath, cfgDir string) (*Config, error) {
if cfgPath != "" {
func Init(cfgFile, cfgDir string) { viper.SetConfigFile(cfgPath)
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else { } else {
cfgPath := path.Join(cfgDir, "config.toml") cfgPath := path.Join(cfgDir, "config.toml")
if _, err := os.Stat(cfgPath); os.IsNotExist(err) { 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") content := []byte("arl_cookie = ''\nsecret_key = ''\niv = '0001020304050607'\n")
if err := os.WriteFile(cfgPath, content, 0644); err != nil { if err := os.WriteFile(cfgPath, content, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not create config file: %v\n", err) return nil, fmt.Errorf("failed to create config file: %w", err)
os.Exit(1)
} }
} }
@@ -38,22 +35,29 @@ func Init(cfgFile, cfgDir string) {
viper.SetConfigType("toml") viper.SetConfigType("toml")
viper.AutomaticEnv() viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not read config file: %v\n", err) return nil, fmt.Errorf("failed to read config file: %w", err)
os.Exit(1)
} }
cfg := &Cfg cfg := &Config{}
if err := viper.Unmarshal(&cfg); err != nil { if err := viper.Unmarshal(&cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not unmarshal config file: %v\n", err) return nil, fmt.Errorf("failed to unmarshal config: %w", err)
os.Exit(1)
} }
if cfg.ArlCookie == "" {
return nil, fmt.Errorf("arl_cookie is not set in config file")
}
if cfg.SecretKey == "" { if cfg.SecretKey == "" {
fmt.Fprintln(os.Stderr, "Error: secret_key is not set in config file") return nil, fmt.Errorf("secret_key is not set in config file")
os.Exit(1) }
if len(cfg.SecretKey) != 16 {
return nil, fmt.Errorf("secret_key must be 16 bytes long")
} }
if cfg.IV == "" { if cfg.IV == "" {
fmt.Fprintln(os.Stderr, "Error: iv is not set in config file") return nil, fmt.Errorf("iv is not set in config file")
os.Exit(1)
} }
if len(cfg.IV) != 16 {
return nil, fmt.Errorf("iv must be 16 bytes long")
}
return cfg, nil
} }
@@ -3,18 +3,16 @@ package crypto
import ( import (
"crypto/cipher" "crypto/cipher"
"crypto/md5" "crypto/md5"
"encoding/hex"
"fmt" "fmt"
"github.com/mathismqn/godeez/internal/config"
"golang.org/x/crypto/blowfish" "golang.org/x/crypto/blowfish"
) )
func GetBlowfishKey(songID string) []byte { func GetKey(secretKey, songID string) []byte {
hash := md5.Sum([]byte(songID)) hash := md5.Sum([]byte(songID))
hashHex := fmt.Sprintf("%x", hash) hashHex := fmt.Sprintf("%x", hash)
key := []byte(config.Cfg.SecretKey) key := []byte(secretKey)
for i := 0; i < len(hash); i++ { for i := 0; i < len(hash); i++ {
key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16] key[i] = key[i] ^ hashHex[i] ^ hashHex[i+16]
} }
@@ -22,17 +20,12 @@ func GetBlowfishKey(songID string) []byte {
return key return key
} }
func DecryptBlowfish(data, key []byte) ([]byte, error) { func Decrypt(data, key, iv []byte) ([]byte, error) {
block, err := blowfish.NewCipher(key) block, err := blowfish.NewCipher(key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
iv, err := hex.DecodeString(config.Cfg.IV)
if err != nil {
return nil, err
}
mode := cipher.NewCBCDecrypter(block, iv) mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(data)) decrypted := make([]byte, len(data))
mode.CryptBlocks(decrypted, data) mode.CryptBlocks(decrypted, data)
+7 -7
View File
@@ -28,22 +28,22 @@ func (a *Album) GetType() string {
return "Album" return "Album"
} }
func (a *Album) UnmarshalData(data []byte) error { func (a *Album) GetTitle() string {
return json.Unmarshal(data, a) return a.Results.Data.Title
} }
func (a *Album) GetSongs() []*Song { func (a *Album) GetSongs() []*Song {
return a.Results.Songs.Data 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 := fmt.Sprintf("%s - %s", a.Results.Data.Artist, a.Results.Data.Title)
base, _ = filenamify.Filenamify(base, filenamify.Options{}) 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 { func (a *Album) Unmarshal(data []byte) error {
return a.Results.Data.Title return json.Unmarshal(data, a)
} }
+182
View File
@@ -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
}
+16 -58
View File
@@ -2,10 +2,6 @@ package deezer
import ( import (
"fmt" "fmt"
"net/http"
"os"
"github.com/mathismqn/godeez/internal/crypto"
) )
type Media struct { type Media struct {
@@ -35,64 +31,26 @@ type Source struct {
Provider string `json:"provider"` Provider string `json:"provider"`
} }
const ChunkSize = 2048 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 {
func (m *Media) Download(url, path, songID string) error { return "", fmt.Errorf("no media sources found")
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)
} }
file, err := os.Create(path) url := m.Data[0].Media[0].Sources[0].URL
if err != nil { for _, source := range m.Data[0].Media[0].Sources {
return err if source.Provider == "ak" {
} url = source.URL
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 {
break 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
} }
+7 -7
View File
@@ -24,21 +24,21 @@ func (p *Playlist) GetType() string {
return "Playlist" return "Playlist"
} }
func (p *Playlist) UnmarshalData(data []byte) error { func (p *Playlist) GetTitle() string {
return json.Unmarshal(data, p) return p.Results.Data.Title
} }
func (p *Playlist) GetSongs() []*Song { func (p *Playlist) GetSongs() []*Song {
return p.Results.Songs.Data 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{}) 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 { func (p *Playlist) Unmarshal(data []byte) error {
return p.Results.Data.Title return json.Unmarshal(data, p)
} }
+4 -60
View File
@@ -1,65 +1,9 @@
package deezer package deezer
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type Resource interface { type Resource interface {
GetType() string
UnmarshalData(data []byte) error
GetSongs() []*Song
GetOutputPath(outputDir string) string
GetTitle() string GetTitle() string
} GetType() string
GetSongs() []*Song
func (s *Session) GetData(r Resource, id string) error { GetOutputDir(outputDir string) string
payload := map[string]interface{}{ Unmarshal(data []byte) error
"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)
} }
+11 -5
View File
@@ -1,11 +1,13 @@
package deezer package deezer
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"time"
) )
type UserDataResponse struct { type UserDataResponse struct {
@@ -26,20 +28,21 @@ type Session struct {
ArlCookie string ArlCookie string
APIToken string APIToken string
LicenseToken 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) jar, err := cookiejar.New(nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
client := &http.Client{ client := &http.Client{
Timeout: 20 * time.Second,
Jar: jar, Jar: jar,
} }
url := "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=" 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 { if err != nil {
return nil, err return nil, err
} }
@@ -59,7 +62,10 @@ func Authenticate(arlCookie string) (*Session, error) {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 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 var res UserDataResponse
if err := json.Unmarshal(body, &res); err != nil { if err := json.Unmarshal(body, &res); err != nil {
@@ -77,6 +83,6 @@ func Authenticate(arlCookie string) (*Session, error) {
ArlCookie: arlCookie, ArlCookie: arlCookie,
APIToken: res.Results.APIToken, APIToken: res.Results.APIToken,
LicenseToken: res.Results.User.Options.LicenseToken, LicenseToken: res.Results.User.Options.LicenseToken,
Client: client, HttpClient: client,
}, nil }, nil
} }
+17 -213
View File
@@ -1,17 +1,10 @@
package deezer package deezer
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings" "strings"
"github.com/PuerkitoBio/goquery" "github.com/flytam/filenamify"
) )
type Song struct { type Song struct {
@@ -32,216 +25,27 @@ type Song struct {
TrackToken string `json:"TRACK_TOKEN"` TrackToken string `json:"TRACK_TOKEN"`
} }
func (s *Song) GetMediaData(licenseToken, quality string) (*Media, error) { func (s *Song) GetTitle() string {
var formats string songTitle := s.Title
if s.Version != "" {
switch quality { songTitle = fmt.Sprintf("%s %s", s.Title, s.Version)
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"]}`, licenseToken, formats, s.TrackToken) return songTitle
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) func (s *Song) GetFileName(resourceType string, song *Song, media *Media) string {
ext := "mp3"
var media Media if media.Data[0].Media[0].Format == "FLAC" {
err = json.Unmarshal(body, &media) ext = "flac"
if err != nil { }
return nil, err trackNumber := ""
if resourceType == "album" {
trackNumber = song.TrackNumber + "."
} }
if len(media.Errors) > 0 { fileName := fmt.Sprintf("%s %s - %s.%s", trackNumber, s.GetTitle(), strings.Join(song.Contributors.MainArtists, ", "), ext)
if media.Errors[0].Code == 1000 { fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
return nil, fmt.Errorf("invalid license token")
}
return nil, fmt.Errorf("%s", media.Errors[0].Message) return fileName
}
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 (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
}
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 (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 <span[^>]*>(\d+) BPM`)
bpmMatch := bpmRegex.FindStringSubmatch(html)
keyRegex := regexp.MustCompile(`with a <span[^>]*>([A-G](?:♯|#|♭|b)?(?:/[A-G](?:♯|#|♭|b)?)?)</span> key`)
keyMatch := keyRegex.FindStringSubmatch(html)
modeRegex := regexp.MustCompile(`a <span[^>]*>([a-z]+)</span> 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
} }
+275
View File
@@ -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
}
+33
View File
@@ -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
}
+52
View File
@@ -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
}
+56
View File
@@ -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
}
@@ -1,4 +1,4 @@
package db package store
import ( import (
"encoding/json" "encoding/json"
@@ -16,7 +16,7 @@ type DownloadInfo struct {
Downloaded time.Time `json:"downloaded_at"` Downloaded time.Time `json:"downloaded_at"`
} }
func Get(songID string) (*DownloadInfo, error) { func GetDownloadInfo(songID string) (*DownloadInfo, error) {
var info DownloadInfo var info DownloadInfo
if err := db.View(func(tx *bbolt.Tx) error { if err := db.View(func(tx *bbolt.Tx) error {
@@ -1,8 +1,7 @@
package db package store
import ( import (
"fmt" "fmt"
"os"
"path" "path"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
@@ -13,21 +12,21 @@ var (
trackBucket = []byte("tracks") trackBucket = []byte("tracks")
) )
func Init(cfgDir string) { func OpenDB(cfgDir string) error {
var err error var err error
dbPath := path.Join(cfgDir, "tracks.db") dbPath := path.Join(cfgDir, "tracks.db")
db, err = bolt.Open(dbPath, 0600, nil) db, err = bolt.Open(dbPath, 0600, nil)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: could not open database: %v\n", err) return fmt.Errorf("failed to open database: %w", err)
os.Exit(1)
} }
if err := db.Update(func(tx *bolt.Tx) error { if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(trackBucket) _, err := tx.CreateBucketIfNotExists(trackBucket)
return err return err
}); err != nil { }); err != nil {
fmt.Fprintf(os.Stderr, "Error: could not create bucket: %v\n", err) return fmt.Errorf("failed to create bucket: %w", err)
os.Exit(1)
} }
return nil
} }
+14 -14
View File
@@ -10,13 +10,13 @@ import (
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
) )
type FLACTagger struct { type flacTagger struct {
File *flac.File file *flac.File
Cmts *flacvorbis.MetaDataBlockVorbisComment cmts *flacvorbis.MetaDataBlockVorbisComment
Index int 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 { if album, ok := resource.(*deezer.Album); ok {
dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-") dateParts := strings.Split(album.Results.Data.PhysicalReleaseDate, "-")
if len(dateParts) == 3 { 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("KEY", key)
t.addTag("INITIALKEY", key) t.addTag("INITIALKEY", key)
cmtsmeta := t.Cmts.Marshal() cmtsmeta := t.cmts.Marshal()
if t.Index > 0 { if t.index > 0 {
t.File.Meta[t.Index] = &cmtsmeta t.file.Meta[t.index] = &cmtsmeta
} else { } else {
t.File.Meta = append(t.File.Meta, &cmtsmeta) t.file.Meta = append(t.file.Meta, &cmtsmeta)
} }
picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg") picture, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg")
@@ -55,20 +55,20 @@ func (t *FLACTagger) AddTags(resource deezer.Resource, song *deezer.Song, cover
return err return err
} }
picturemeta := picture.Marshal() picturemeta := picture.Marshal()
t.File.Meta = append(t.File.Meta, &picturemeta) t.file.Meta = append(t.file.Meta, &picturemeta)
return t.saveTags(path) return t.saveTags(path)
} }
func (t *FLACTagger) addTag(name, value string) { func (t *flacTagger) addTag(name, value string) {
if value != "" { 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" tempPath := path + ".tmp"
t.File.Save(tempPath) t.file.Save(tempPath)
return os.Rename(tempPath, path) return os.Rename(tempPath, path)
} }
+16 -13
View File
@@ -9,14 +9,17 @@ import (
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
) )
type ID3v2Tagger struct { type id3v2Tagger struct {
Tag *id3v2.Tag tag *id3v2.Tag
} }
func (t *ID3v2Tagger) AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error { func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error {
defer t.Tag.Close() 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) song.Duration = fmt.Sprintf("%d", duration*1000)
if album, ok := resource.(*deezer.Album); ok { 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) t.addTag("TKEY", key)
frame := id3v2.PictureFrame{ frame := id3v2.PictureFrame{
Encoding: t.Tag.DefaultEncoding(), Encoding: t.tag.DefaultEncoding(),
MimeType: "image/jpeg", MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover, PictureType: id3v2.PTFrontCover,
Description: "Cover", Description: "Cover",
Picture: cover, Picture: cover,
} }
t.Tag.AddAttachedPicture(frame) 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 != "" { 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 != "" { if value != "" {
udf := id3v2.UserDefinedTextFrame{ udf := id3v2.UserDefinedTextFrame{
Encoding: t.Tag.DefaultEncoding(), Encoding: t.tag.DefaultEncoding(),
Description: description, Description: description,
Value: value, Value: value,
} }
t.Tag.AddUserDefinedTextFrame(udf) t.tag.AddUserDefinedTextFrame(udf)
} }
} }
+9 -12
View File
@@ -9,18 +9,19 @@ import (
"github.com/mathismqn/godeez/internal/deezer" "github.com/mathismqn/godeez/internal/deezer"
) )
type Tagger interface { type tagger interface {
AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, path, tempo, key string) error 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) ext := path.Ext(filePath)
if ext == ".mp3" { if ext == ".mp3" {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &ID3v2Tagger{Tag: tag}, nil
return &id3v2Tagger{tag: tag}, nil
} }
file, err := flac.ParseFile(filePath) file, err := flac.ParseFile(filePath)
@@ -35,18 +36,14 @@ func NewTagger(filePath string) (Tagger, error) {
cmts = flacvorbis.New() 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 { func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key string) error {
tagger, err := NewTagger(filePath) tagger, err := newTagger(filePath)
if err != nil {
return err
}
cover, err := song.GetCoverImage()
if err != nil { if err != nil {
return err return err
} }
return tagger.AddTags(resource, song, cover, filePath, tempo, key) return tagger.addTags(resource, song, cover, filePath, tempo, key)
} }
-21
View File
@@ -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
}
-73
View File
@@ -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
}
+11 -2
View File
@@ -1,7 +1,16 @@
package main package main
import "github.com/mathismqn/godeez/cmd" import (
"context"
"os"
"os/signal"
"github.com/mathismqn/godeez/cmd"
)
func main() { func main() {
cmd.RootCmd.Execute() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
cmd.RootCmd.ExecuteContext(ctx)
} }