refactor: simplify codebase (-209 lines)

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