feat(tags): fetch genre and add it to file tags with --genre

This commit is contained in:
Mathis Maquenne
2025-09-10 16:05:53 +02:00
parent 0493866141
commit 8c9b327b2c
7 changed files with 134 additions and 16 deletions
+1
View File
@@ -29,6 +29,7 @@ func init() {
downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]") downloadCmd.PersistentFlags().StringVarP(&opts.Quality, "quality", "q", "mp3_320", "download quality [mp3_128, mp3_320, flac]")
downloadCmd.PersistentFlags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)") downloadCmd.PersistentFlags().DurationVarP(&opts.Timeout, "timeout", "t", 2*time.Minute, "timeout for each download (e.g. 10s, 1m, 2m30s)")
downloadCmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags") downloadCmd.PersistentFlags().BoolVar(&opts.BPM, "bpm", false, "fetch BPM/key and add to file tags")
downloadCmd.PersistentFlags().BoolVar(&opts.Genre, "genre", false, "fetch genre and add to file tags")
downloadCmd.PersistentFlags().BoolVar(&opts.Strict, "strict", false, "fail the song download if the quality is not available") downloadCmd.PersistentFlags().BoolVar(&opts.Strict, "strict", false, "fail the song download if the quality is not available")
downloadCmd.AddCommand( downloadCmd.AddCommand(
+39 -11
View File
@@ -193,15 +193,15 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
} }
var bpmChan chan provider.BPMKey var bpmChan chan provider.BPMKey
var errChan chan error var bpmErrChan chan error
if opts.BPM { if opts.BPM {
bpmChan = make(chan provider.BPMKey, 1) bpmChan = make(chan provider.BPMKey, 1)
errChan = make(chan error, 1) bpmErrChan = make(chan error, 1)
go func() { go func() {
p := provider.BPMProvider{} p := provider.BPMProvider{}
bpmKey, err := p.Fetch(ctx, c.deezerClient.Session.HttpClient, song.Artist, song.Title, song.Duration) bpmKey, err := p.Fetch(ctx, c.deezerClient.Session.HttpClient, song.Artist, song.Title, song.Duration)
if err != nil { if err != nil {
errChan <- err bpmErrChan <- err
return return
} }
@@ -209,6 +209,23 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
}() }()
} }
var genreChan chan string
var genreErrChan chan error
if opts.Genre {
genreChan = make(chan string, 1)
genreErrChan = make(chan error, 1)
go func() {
p := provider.GenreProvider{}
genre, err := p.Fetch(ctx, c.deezerClient.Session.HttpClient, song.Artist, song.GetTitle())
if err != nil {
genreErrChan <- err
return
}
genreChan <- genre
}()
}
stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID) stream, err := c.deezerClient.GetMediaStream(ctx, media, song.ID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get media stream: %w", err) return nil, fmt.Errorf("failed to get media stream: %w", err)
@@ -227,27 +244,38 @@ func (c *Client) downloadSong(ctx context.Context, resource deezer.Resource, son
return nil, fmt.Errorf("failed to stream to file: %w", err) return nil, fmt.Errorf("failed to stream to file: %w", err)
} }
if opts.Quality != strings.ToLower(mediaFormat) {
warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat)))
}
bpmKey := provider.BPMKey{} bpmKey := provider.BPMKey{}
if opts.BPM { if opts.BPM {
select { select {
case bpmKey = <-bpmChan: case bpmKey = <-bpmChan:
case err := <-errChan: case err := <-bpmErrChan:
if !errors.Is(err, context.Canceled) { if !errors.Is(err, context.Canceled) {
warnings = append(warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err)) warnings = append(warnings, fmt.Sprintf("failed to fetch BPM and key: %v", err))
} }
} }
} }
var genre string
if opts.Genre {
select {
case genre = <-genreChan:
case err := <-genreErrChan:
if !errors.Is(err, context.Canceled) {
warnings = append(warnings, fmt.Sprintf("failed to fetch genre: %v", err))
}
}
}
if opts.Quality != strings.ToLower(mediaFormat) {
warnings = append(warnings, fmt.Sprintf("requested quality '%s' not available, using '%s' instead", opts.Quality, strings.ToLower(mediaFormat)))
}
cover, err := c.deezerClient.FetchCoverImage(ctx, song) cover, err := c.deezerClient.FetchCoverImage(ctx, song)
if err != nil && !errors.Is(err, context.Canceled) { if err != nil && !errors.Is(err, context.Canceled) {
warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err)) warnings = append(warnings, fmt.Sprintf("failed to fetch cover image: %v", err))
} }
warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, cover, bpmKey)...) warnings = append(warnings, c.finalizeDownload(resource, song, outputPath, mediaFormat, genre, cover, bpmKey)...)
return warnings, nil return warnings, nil
} }
@@ -309,10 +337,10 @@ func (c *Client) streamToFile(ctx context.Context, stream io.ReadCloser, outputP
return nil return nil
} }
func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat string, cover []byte, bpmKey provider.BPMKey) []string { func (c *Client) finalizeDownload(resource deezer.Resource, song *deezer.Song, outputPath, mediaFormat, genre string, cover []byte, bpmKey provider.BPMKey) []string {
var warnings []string var warnings []string
if err := tags.AddTags(resource, song, cover, outputPath, bpmKey.BPM, bpmKey.Key); err != nil { if err := tags.AddTags(resource, song, cover, outputPath, bpmKey.BPM, bpmKey.Key, genre); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err)) warnings = append(warnings, fmt.Sprintf("failed to add tags: %v", err))
} }
+1
View File
@@ -16,6 +16,7 @@ type Options struct {
Timeout time.Duration Timeout time.Duration
Limit int Limit int
BPM bool BPM bool
Genre bool
Strict bool Strict bool
} }
+86
View File
@@ -0,0 +1,86 @@
package provider
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/PuerkitoBio/goquery"
)
type GenreProvider struct{}
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)
if err != nil {
return "", err
}
tags := p.parse(doc)
if len(tags) == 0 {
return "", fmt.Errorf("no data found")
}
if len(tags) > 1 {
tags = tags[:2]
}
genre := p.formatTags(tags)
return genre, nil
}
func (p GenreProvider) fetchPage(ctx context.Context, httpClient *http.Client, reqUrl string) (*goquery.Document, error) {
req, err := http.NewRequestWithContext(ctx, "GET", reqUrl, nil)
if err != nil {
return nil, err
}
resp, err := 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)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
}
return doc, nil
}
func (p GenreProvider) parse(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 != "" {
tags = append(tags, tag)
}
})
return tags
}
func (p GenreProvider) formatTags(tags []string) string {
var formatted []string
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
words := strings.Fields(tag)
for i, w := range words {
words[i] = strings.Title(w)
}
formatted = append(formatted, strings.Join(words, " "))
}
return strings.Join(formatted, "/")
}
+2 -1
View File
@@ -16,7 +16,7 @@ type flacTagger struct {
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, genre 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 {
@@ -37,6 +37,7 @@ func (t *flacTagger) addTags(resource deezer.Resource, song *deezer.Song, cover
t.addTag("TITLE", song.GetTitle()) t.addTag("TITLE", song.GetTitle())
t.addTag("COMPOSER", strings.Join(song.Contributors.Composers, ", ")) t.addTag("COMPOSER", strings.Join(song.Contributors.Composers, ", "))
t.addTag("LYRICIST", strings.Join(song.Contributors.Authors, ", ")) t.addTag("LYRICIST", strings.Join(song.Contributors.Authors, ", "))
t.addTag("GENRE", genre)
t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain) t.addTag("REPLAYGAIN_TRACK_GAIN", song.Gain)
t.addTag("ISRC", song.ISRC) t.addTag("ISRC", song.ISRC)
+2 -1
View File
@@ -13,7 +13,7 @@ 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, genre string) error {
defer t.tag.Close() defer t.tag.Close()
duration, err := strconv.Atoi(song.Duration) duration, err := strconv.Atoi(song.Duration)
@@ -37,6 +37,7 @@ func (t *id3v2Tagger) addTags(resource deezer.Resource, song *deezer.Song, cover
t.addTag("TIT2", song.GetTitle()) t.addTag("TIT2", song.GetTitle())
t.addTag("TCOM", strings.Join(song.Contributors.Composers, ", ")) t.addTag("TCOM", strings.Join(song.Contributors.Composers, ", "))
t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", ")) t.addTag("TEXT", strings.Join(song.Contributors.Authors, ", "))
t.addTag("TCON", genre)
t.addTag("TLEN", song.Duration) t.addTag("TLEN", song.Duration)
t.addTXXXTag("GAIN", song.Gain) t.addTXXXTag("GAIN", song.Gain)
t.addTXXXTag("ISRC", song.ISRC) t.addTXXXTag("ISRC", song.ISRC)
+3 -3
View File
@@ -10,7 +10,7 @@ import (
) )
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, genre string) error
} }
func newTagger(filePath string) (tagger, error) { func newTagger(filePath string) (tagger, error) {
@@ -39,11 +39,11 @@ func newTagger(filePath string) (tagger, error) {
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, cover []byte, filePath, tempo, key string) error { func AddTags(resource deezer.Resource, song *deezer.Song, cover []byte, filePath, tempo, key, genre string) error {
tagger, err := newTagger(filePath) tagger, err := newTagger(filePath)
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, genre)
} }