refactor: major project restructure
This commit is contained in:
+35
-210
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if quality == "" {
|
return cmd
|
||||||
quality = "best"
|
|
||||||
}
|
|
||||||
|
|
||||||
validQualities := map[string]bool{
|
|
||||||
"mp3_128": true,
|
|
||||||
"mp3_320": true,
|
|
||||||
"flac": true,
|
|
||||||
"best": true,
|
|
||||||
}
|
|
||||||
if !validQualities[quality] {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: invalid quality option: %s\n", quality)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadContent(contentType string, args []string) {
|
|
||||||
session, err := deezer.Authenticate(config.Cfg.ArlCookie)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not authenticate: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
nArgs := len(args)
|
|
||||||
separator := "--------------------------------------------------"
|
|
||||||
|
|
||||||
for i, id := range args {
|
|
||||||
fmt.Println(separator)
|
|
||||||
fmt.Printf("[%d/%d] Getting data for %s %s...", i+1, nArgs, contentType, id)
|
|
||||||
|
|
||||||
var resource deezer.Resource
|
|
||||||
var songs []*deezer.Song
|
|
||||||
|
|
||||||
switch contentType {
|
|
||||||
case "album":
|
|
||||||
album := &deezer.Album{}
|
|
||||||
if err := session.GetData(album, id); err != nil {
|
|
||||||
fmt.Printf("\r[%d/%d] Getting data for album %s... FAILED\n", i+1, nArgs, id)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not get album data: %v\n", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resource = album
|
|
||||||
songs = album.GetSongs()
|
|
||||||
case "playlist":
|
|
||||||
playlist := &deezer.Playlist{}
|
|
||||||
if err := session.GetData(playlist, id); err != nil {
|
|
||||||
fmt.Printf("\r[%d/%d] Getting data for playlist %s... FAILED\n", i+1, nArgs, id)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not get playlist data: %v\n", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if playlist.Results.Data.Status == 1 && playlist.Results.Data.CollabKey == "" {
|
|
||||||
fmt.Printf("\r[%d/%d] Getting data for playlist %s... FAILED\n", i+1, nArgs, id)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: playlist is private and no valid arl cookie was provided\n")
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
resource = playlist
|
|
||||||
songs = playlist.GetSongs()
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\r[%d/%d] Getting data for %s %s... DONE\n", i+1, nArgs, contentType, id)
|
|
||||||
|
|
||||||
output := resource.GetOutputPath(outputDir)
|
|
||||||
if err := utils.EnsureDir(output); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not create output directory: %v\n", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
title := resource.GetTitle()
|
|
||||||
fmt.Printf("Starting download of %s: %s\n", contentType, title)
|
|
||||||
|
|
||||||
for _, song := range songs {
|
|
||||||
songTitle := song.Title
|
|
||||||
if song.Version != "" {
|
|
||||||
songTitle = fmt.Sprintf("%s %s", song.Title, song.Version)
|
|
||||||
}
|
|
||||||
|
|
||||||
media, err := song.GetMediaData(session.LicenseToken, quality)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf(" Downloading %s... FAILED\n", songTitle)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not get media data: %v\n", err)
|
|
||||||
if err.Error() == "invalid license token" {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(media.Data) == 0 || len(media.Data[0].Media) == 0 || len(media.Data[0].Media[0].Sources) == 0 {
|
|
||||||
fmt.Printf(" Downloading %s... FAILED\n", songTitle)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not get media sources\n")
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
url := media.Data[0].Media[0].Sources[0].URL
|
|
||||||
for _, source := range media.Data[0].Media[0].Sources {
|
|
||||||
if source.Provider == "ak" {
|
|
||||||
url = source.URL
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := "mp3"
|
|
||||||
if media.Data[0].Media[0].Format == "FLAC" {
|
|
||||||
ext = "flac"
|
|
||||||
}
|
|
||||||
trackNumber := ""
|
|
||||||
if contentType == "album" {
|
|
||||||
trackNumber = song.TrackNumber + "."
|
|
||||||
}
|
|
||||||
|
|
||||||
fileName := fmt.Sprintf("%s %s - %s.%s", trackNumber, songTitle, strings.Join(song.Contributors.MainArtists, ", "), ext)
|
|
||||||
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
|
|
||||||
filePath := path.Join(output, fileName)
|
|
||||||
|
|
||||||
if existing, err := db.Get(song.ID); err == nil && existing.Quality == media.Data[0].Media[0].Format {
|
|
||||||
if utils.FileExists(existing.Path) {
|
|
||||||
fmt.Printf(" Skipping %s (already downloaded at %s)\n", songTitle, existing.Path)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if existing.Hash != "" {
|
|
||||||
if foundPath, err := utils.FindFileByHash(appDir, existing.Hash); err == nil && foundPath != "" {
|
|
||||||
existing.Path = foundPath
|
|
||||||
_ = existing.Save()
|
|
||||||
fmt.Printf(" Recovered %s at %s, skipping download\n", songTitle, foundPath)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" Downloading %s...", songTitle)
|
|
||||||
|
|
||||||
bpmCh := make(chan bpmResult, 1)
|
|
||||||
go func() {
|
|
||||||
t, k, e := song.GetTempoAndKey()
|
|
||||||
bpmCh <- bpmResult{tempo: t, key: k, err: e}
|
|
||||||
}()
|
|
||||||
|
|
||||||
err = media.Download(url, filePath, song.ID)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("\r Downloading %s... FAILED\n", songTitle)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not download song: %v\n", err)
|
|
||||||
utils.DeleteFile(filePath)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf("\r Downloading %s... DONE\n", songTitle)
|
|
||||||
|
|
||||||
res := <-bpmCh
|
|
||||||
if res.err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: could not get tempo/key for %s: %v\n", songTitle, res.err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf(" Tempo: %s\n", res.tempo)
|
|
||||||
fmt.Printf(" Key: %s\n", res.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tags.AddTags(resource, song, filePath, res.tempo, res.key); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: could not add tags to song: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := utils.GetFileHash(filePath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: could not get file hash: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info := &db.DownloadInfo{
|
|
||||||
SongID: song.ID,
|
|
||||||
Quality: media.Data[0].Media[0].Format,
|
|
||||||
Path: filePath,
|
|
||||||
Hash: hash,
|
|
||||||
Downloaded: time.Now(),
|
|
||||||
}
|
|
||||||
if err := info.Save(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: could not save download info: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(separator)
|
|
||||||
fmt.Println("All downloads completed")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+12
-48
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
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)
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
Jar: jar,
|
Timeout: 20 * time.Second,
|
||||||
|
Jar: jar,
|
||||||
}
|
}
|
||||||
|
|
||||||
url := "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token="
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-212
@@ -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)
|
|
||||||
|
|
||||||
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 (s *Song) GetCoverImage() ([]byte, error) {
|
func (s *Song) GetFileName(resourceType string, song *Song, media *Media) string {
|
||||||
url := fmt.Sprintf("https://e-cdn-images.dzcdn.net/images/cover/%s/500x500-000000-80-0-0.jpg", s.Cover)
|
ext := "mp3"
|
||||||
resp, err := http.Get(url)
|
if media.Data[0].Media[0].Format == "FLAC" {
|
||||||
if err != nil {
|
ext = "flac"
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
trackNumber := ""
|
||||||
|
if resourceType == "album" {
|
||||||
if resp.StatusCode != http.StatusOK {
|
trackNumber = song.TrackNumber + "."
|
||||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return io.ReadAll(resp.Body)
|
fileName := fmt.Sprintf("%s %s - %s.%s", trackNumber, s.GetTitle(), strings.Join(song.Contributors.MainArtists, ", "), ext)
|
||||||
}
|
fileName, _ = filenamify.Filenamify(fileName, filenamify.Options{})
|
||||||
|
|
||||||
func (s *Song) GetTempoAndKey() (string, string, error) {
|
return fileName
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
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
@@ -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
@@ -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,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)
|
||||||
}
|
}
|
||||||
@@ -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
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user