refactor: major project restructure
This commit is contained in:
+31
-206
@@ -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
|
||||
}
|
||||
|
||||
if quality == "" {
|
||||
quality = "best"
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
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)")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
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
|
||||
}
|
||||
|
||||
+17
-213
@@ -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)
|
||||
return songTitle
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var media Media
|
||||
err = json.Unmarshal(body, &media)
|
||||
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"
|
||||
}
|
||||
trackNumber := ""
|
||||
if resourceType == "album" {
|
||||
trackNumber = song.TrackNumber + "."
|
||||
}
|
||||
|
||||
if len(media.Errors) > 0 {
|
||||
if media.Errors[0].Code == 1000 {
|
||||
return nil, fmt.Errorf("invalid license token")
|
||||
}
|
||||
fileName := fmt.Sprintf("%s %s - %s.%s", trackNumber, s.GetTitle(), strings.Join(song.Contributors.MainArtists, ", "), ext)
|
||||
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
|
||||
|
||||
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 (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
|
||||
return fileName
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
"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 {
|
||||
@@ -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
|
||||
}
|
||||
+14
-14
@@ -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)
|
||||
}
|
||||
|
||||
+16
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user