mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
Use album artist overrides during album ripping so compilation-style tracks do not change album performer tags, and add safer disc-number fallbacks for metadata/path generation when providers omit disc fields.
1154 lines
31 KiB
Go
1154 lines
31 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"streamrip-go/internal/artwork"
|
|
"streamrip-go/internal/audio/convert"
|
|
"streamrip-go/internal/audio/tag"
|
|
"streamrip-go/internal/config"
|
|
"streamrip-go/internal/domain/media"
|
|
"streamrip-go/internal/download"
|
|
"streamrip-go/internal/naming"
|
|
"streamrip-go/internal/provider"
|
|
deezerprovider "streamrip-go/internal/provider/deezer"
|
|
qobuzprovider "streamrip-go/internal/provider/qobuz"
|
|
soundcloudprovider "streamrip-go/internal/provider/soundcloud"
|
|
tidalprovider "streamrip-go/internal/provider/tidal"
|
|
"streamrip-go/internal/store"
|
|
)
|
|
|
|
type Main struct {
|
|
Config *config.Config
|
|
Providers map[string]provider.Client
|
|
Store store.Database
|
|
DL *download.Downloader
|
|
Tagger trackTagger
|
|
IgnoreDB bool
|
|
Pending []media.Pending
|
|
Media []media.Media
|
|
}
|
|
|
|
type ripTrackOptions struct {
|
|
albumFolder string
|
|
albumEmbedCover string
|
|
albumArtist string
|
|
index int
|
|
total int
|
|
albumDiscTotal int
|
|
forPlaylist bool
|
|
playlistName string
|
|
playlistPos int
|
|
}
|
|
|
|
type collectionAlbum struct {
|
|
ID string
|
|
Meta map[string]any
|
|
Title string
|
|
AlbumArtist string
|
|
BitDepth int
|
|
Sampling float64
|
|
Explicit bool
|
|
TrackCount int
|
|
}
|
|
|
|
var (
|
|
qobuzEssenceRe = regexp.MustCompile(`(?i)^([^\(\[]+)`)
|
|
qobuzExtraRe = regexp.MustCompile(`(?i)(anniversary|deluxe|live|collector|demo|expanded|remix)`)
|
|
qobuzRemasterRe = regexp.MustCompile(`(?i)(re)?master(ed)?`)
|
|
)
|
|
|
|
type trackTagger interface {
|
|
TagFLAC(path string, meta tag.Metadata, coverPath string) error
|
|
}
|
|
|
|
type videoDownloadableProvider interface {
|
|
GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error)
|
|
}
|
|
|
|
func New(cfg *config.Config) (*Main, error) {
|
|
var db store.Database
|
|
if cfg.Session.Database.DownloadsEnabled || cfg.Session.Database.FailedDownloadsEnabled {
|
|
s, err := store.NewSQLite(cfg.Session.Database.DownloadsPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db = s
|
|
} else {
|
|
db = store.NewDummy()
|
|
}
|
|
|
|
providers := map[string]provider.Client{
|
|
"qobuz": qobuzprovider.New(cfg),
|
|
"tidal": tidalprovider.New(cfg),
|
|
"deezer": deezerprovider.New(cfg),
|
|
"soundcloud": soundcloudprovider.New(cfg),
|
|
}
|
|
|
|
return &Main{
|
|
Config: cfg,
|
|
Providers: providers,
|
|
Store: db,
|
|
DL: download.NewWithOptions(cfg.Session.Downloads.VerifySSL, cfg.Session.CLI.ProgressBars),
|
|
Tagger: tag.New(),
|
|
Pending: []media.Pending{},
|
|
Media: []media.Media{},
|
|
}, nil
|
|
}
|
|
|
|
func (m *Main) Close() error {
|
|
m.DL.Close()
|
|
artwork.CleanupTempDirs()
|
|
for _, p := range m.Providers {
|
|
_ = p.Close()
|
|
}
|
|
return m.Store.Close()
|
|
}
|
|
|
|
func (m *Main) logf(format string, args ...any) {
|
|
if m != nil && m.DL != nil {
|
|
m.DL.Logf(format, args...)
|
|
return
|
|
}
|
|
fmt.Printf(format, args...)
|
|
}
|
|
|
|
func (m *Main) GetLoggedInProvider(ctx context.Context, source string) (provider.Client, error) {
|
|
p, ok := m.Providers[source]
|
|
if !ok {
|
|
return nil, fmt.Errorf("provider %q not registered", source)
|
|
}
|
|
if !p.LoggedIn() {
|
|
if err := p.Login(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error {
|
|
p, err := m.GetLoggedInProvider(ctx, source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pending := media.PendingFunc{
|
|
ResolveFn: func(ctx context.Context) (media.Media, error) {
|
|
meta, err := p.GetMetadata(ctx, id, mediaType)
|
|
if err != nil {
|
|
_ = m.Store.MarkFailed(ctx, source, mediaType, id)
|
|
return nil, err
|
|
}
|
|
|
|
displayTitle := titleFromMetadata(meta, id)
|
|
|
|
return media.MediaFunc{RipFn: func(ctx context.Context) error {
|
|
if m.Config.Session.CLI.TextOutput {
|
|
m.logf("Downloading %s: %s\n", mediaType, displayTitle)
|
|
}
|
|
switch mediaType {
|
|
case "track":
|
|
title := titleFromMetadata(meta, id)
|
|
return m.ripTrack(ctx, p, source, id, title, ripTrackOptions{})
|
|
case "album":
|
|
return m.ripAlbum(ctx, p, source, id, meta)
|
|
case "playlist":
|
|
return m.ripPlaylist(ctx, p, source, id, meta)
|
|
case "artist":
|
|
return m.ripCollection(ctx, p, source, "Artist", id, meta)
|
|
case "label":
|
|
return m.ripCollection(ctx, p, source, "Label", id, meta)
|
|
case "video":
|
|
return m.ripVideo(ctx, p, source, id, meta)
|
|
default:
|
|
return fmt.Errorf("unsupported media type %q", mediaType)
|
|
}
|
|
}}, nil
|
|
},
|
|
}
|
|
|
|
m.Pending = append(m.Pending, pending)
|
|
return nil
|
|
}
|
|
|
|
func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any) error {
|
|
name := titleFromMetadata(meta, id)
|
|
if n := stringFromAny(meta["name"]); n != "" {
|
|
name = n
|
|
}
|
|
|
|
albumIDs := extractAlbumIDs(meta)
|
|
m.logf("%s: %s (%d albums)\n", kind, name, len(albumIDs))
|
|
failures := 0
|
|
albums := make([]collectionAlbum, 0, len(albumIDs))
|
|
for i, albumID := range albumIDs {
|
|
albumMeta, err := p.GetMetadata(ctx, albumID, "album")
|
|
if err != nil {
|
|
failures++
|
|
m.logf("album failed [%d/%d]: id=%s reason=%v\n", i+1, len(albumIDs), albumID, err)
|
|
continue
|
|
}
|
|
albums = append(albums, buildCollectionAlbum(albumID, albumMeta))
|
|
}
|
|
|
|
if source == "qobuz" && kind == "Artist" {
|
|
before := len(albums)
|
|
albums = applyQobuzArtistFilters(name, albums, m.Config.Session.QobuzFilters)
|
|
m.logf("Artist filters applied: %d -> %d albums\n", before, len(albums))
|
|
}
|
|
|
|
for i, album := range albums {
|
|
if err := m.ripAlbum(ctx, p, source, album.ID, album.Meta); err != nil {
|
|
failures++
|
|
m.logf("album failed [%d/%d]: id=%s reason=%v\n", i+1, len(albums), album.ID, err)
|
|
}
|
|
}
|
|
if failures > 0 {
|
|
m.logf("%s done with %d failed album(s)\n", kind, failures)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID string, meta map[string]any) error {
|
|
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, videoID)
|
|
if err == nil && alreadyDownloaded && !m.IgnoreDB {
|
|
m.logf("skip (already downloaded) id=%s\n", videoID)
|
|
return nil
|
|
}
|
|
|
|
vp, ok := p.(videoDownloadableProvider)
|
|
if !ok {
|
|
return fmt.Errorf("provider %q does not support video downloads", source)
|
|
}
|
|
|
|
d, err := vp.GetVideoDownloadable(ctx, videoID)
|
|
if err != nil {
|
|
_ = m.Store.MarkFailed(ctx, source, "video", videoID)
|
|
return fmt.Errorf("id=%s get_video_downloadable: %w", videoID, err)
|
|
}
|
|
|
|
title := titleFromMetadata(meta, videoID)
|
|
outPath := m.videoOutputPath(source, videoID, title, d.Extension)
|
|
if err = m.DL.FileVideo(ctx, d.URL, outPath); err != nil {
|
|
_ = m.Store.MarkFailed(ctx, source, "video", videoID)
|
|
return fmt.Errorf("id=%s title=%q video download: %w", videoID, title, err)
|
|
}
|
|
|
|
if err = m.Store.MarkDownloaded(ctx, source, videoID); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildCollectionAlbum(id string, meta map[string]any) collectionAlbum {
|
|
trackCount := intFromAny(meta["tracks_count"])
|
|
if trackCount == 0 {
|
|
trackCount = intFromAny(meta["numberOfTracks"])
|
|
}
|
|
return collectionAlbum{
|
|
ID: id,
|
|
Meta: meta,
|
|
Title: titleFromMetadata(meta, id),
|
|
AlbumArtist: nestedString(meta, "artist", "name"),
|
|
BitDepth: intFromAny(meta["maximum_bit_depth"]),
|
|
Sampling: floatFromAny(meta["maximum_sampling_rate"]),
|
|
Explicit: boolFromAny(meta["parental_warning"]),
|
|
TrackCount: trackCount,
|
|
}
|
|
}
|
|
|
|
func applyQobuzArtistFilters(artistName string, albums []collectionAlbum, filt config.QobuzDiscographyFilterConfig) []collectionAlbum {
|
|
out := append([]collectionAlbum(nil), albums...)
|
|
|
|
if filt.Repeats {
|
|
out = filterQobuzRepeats(out)
|
|
}
|
|
if filt.Extras {
|
|
tmp := out[:0]
|
|
for _, a := range out {
|
|
if !qobuzExtraRe.MatchString(a.Title) {
|
|
tmp = append(tmp, a)
|
|
}
|
|
}
|
|
out = tmp
|
|
}
|
|
if filt.Features {
|
|
tmp := out[:0]
|
|
for _, a := range out {
|
|
if a.AlbumArtist == artistName {
|
|
tmp = append(tmp, a)
|
|
}
|
|
}
|
|
out = tmp
|
|
}
|
|
if filt.NonStudioAlbums {
|
|
tmp := out[:0]
|
|
for _, a := range out {
|
|
if a.AlbumArtist != "Various Artists" && !qobuzExtraRe.MatchString(a.Title) {
|
|
tmp = append(tmp, a)
|
|
}
|
|
}
|
|
out = tmp
|
|
}
|
|
if filt.NonRemaster {
|
|
tmp := out[:0]
|
|
for _, a := range out {
|
|
if !qobuzRemasterRe.MatchString(a.Title) {
|
|
tmp = append(tmp, a)
|
|
}
|
|
}
|
|
out = tmp
|
|
}
|
|
if filt.NonAlbums {
|
|
tmp := out[:0]
|
|
for _, a := range out {
|
|
if a.TrackCount > 1 {
|
|
tmp = append(tmp, a)
|
|
}
|
|
}
|
|
out = tmp
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func filterQobuzRepeats(albums []collectionAlbum) []collectionAlbum {
|
|
groups := map[string][]collectionAlbum{}
|
|
for _, a := range albums {
|
|
title := strings.TrimSpace(strings.ToLower(a.Title))
|
|
if m := qobuzEssenceRe.FindStringSubmatch(title); len(m) >= 2 {
|
|
title = strings.TrimSpace(m[1])
|
|
}
|
|
groups[title] = append(groups[title], a)
|
|
}
|
|
|
|
out := make([]collectionAlbum, 0, len(groups))
|
|
for _, g := range groups {
|
|
sort.SliceStable(g, func(i, j int) bool {
|
|
if g[i].BitDepth != g[j].BitDepth {
|
|
return g[i].BitDepth > g[j].BitDepth
|
|
}
|
|
if g[i].Sampling != g[j].Sampling {
|
|
return g[i].Sampling > g[j].Sampling
|
|
}
|
|
if g[i].Explicit != g[j].Explicit {
|
|
return g[i].Explicit
|
|
}
|
|
return g[i].Title < g[j].Title
|
|
})
|
|
out = append(out, g[0])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractAlbumIDs(meta map[string]any) []string {
|
|
albumsObj, ok := meta["albums"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
items := make([]any, 0)
|
|
switch a := albumsObj.(type) {
|
|
case map[string]any:
|
|
switch v := a["items"].(type) {
|
|
case []any:
|
|
items = v
|
|
case []map[string]any:
|
|
for _, it := range v {
|
|
items = append(items, it)
|
|
}
|
|
}
|
|
case []any:
|
|
items = a
|
|
case []map[string]any:
|
|
for _, it := range a {
|
|
items = append(items, it)
|
|
}
|
|
}
|
|
|
|
out := make([]string, 0, len(items))
|
|
seen := map[string]struct{}{}
|
|
for _, raw := range items {
|
|
itm, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := stringFromAny(itm["id"])
|
|
if id == "" {
|
|
if nested, ok := itm["album"].(map[string]any); ok {
|
|
id = stringFromAny(nested["id"])
|
|
}
|
|
}
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, dup := seen[id]; dup {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *Main) Resolve(ctx context.Context) error {
|
|
pendingCount := len(m.Pending)
|
|
resolved := make([]media.Media, 0, len(m.Pending))
|
|
for _, item := range m.Pending {
|
|
med, err := item.Resolve(ctx)
|
|
if err != nil {
|
|
m.logf("resolve failed: %v\n", err)
|
|
continue
|
|
}
|
|
resolved = append(resolved, med)
|
|
}
|
|
m.Media = append(m.Media, resolved...)
|
|
m.Pending = m.Pending[:0]
|
|
if pendingCount > 0 && len(resolved) == 0 {
|
|
return fmt.Errorf("resolve failed for all %d pending item(s)", pendingCount)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Main) Rip(ctx context.Context) error {
|
|
m.logf("Ripping %d media item(s)\n", len(m.Media))
|
|
failures := 0
|
|
for _, item := range m.Media {
|
|
if err := item.Rip(ctx); err != nil {
|
|
failures++
|
|
m.logf("media item failed: %v\n", err)
|
|
}
|
|
}
|
|
if failures > 0 {
|
|
return fmt.Errorf("%d media item(s) failed", failures)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID string, albumMeta map[string]any) error {
|
|
albumTitle := titleFromMetadata(albumMeta, albumID)
|
|
albumArtist := nestedString(albumMeta, "artist", "name")
|
|
if albumArtist == "" {
|
|
albumArtist = "Unknown"
|
|
}
|
|
releaseDate := stringFromAny(albumMeta["release_date_original"])
|
|
if releaseDate == "" {
|
|
releaseDate = stringFromAny(albumMeta["release_date"])
|
|
}
|
|
if releaseDate == "" {
|
|
releaseDate = stringFromAny(albumMeta["releaseDate"])
|
|
}
|
|
if releaseDate == "" {
|
|
releaseDate = stringFromAny(albumMeta["streamStartDate"])
|
|
}
|
|
year := naming.YearFromDate(releaseDate)
|
|
bitDepth := intFromAny(albumMeta["maximum_bit_depth"])
|
|
sampling := stringFromAny(albumMeta["maximum_sampling_rate"])
|
|
if bitDepth == 0 || sampling == "" {
|
|
fallbackBitDepth, fallbackSampling := m.qualityProfileForSource(source)
|
|
if bitDepth == 0 {
|
|
bitDepth = fallbackBitDepth
|
|
}
|
|
if sampling == "" {
|
|
sampling = fallbackSampling
|
|
}
|
|
}
|
|
|
|
tracksMap, ok := albumMeta["tracks"].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("album missing tracks data")
|
|
}
|
|
rawItems := make([]any, 0)
|
|
if itemsAny, ok := tracksMap["items"].([]any); ok {
|
|
rawItems = itemsAny
|
|
} else if itemsMap, ok := tracksMap["items"].([]map[string]any); ok {
|
|
for _, item := range itemsMap {
|
|
rawItems = append(rawItems, item)
|
|
}
|
|
} else {
|
|
return fmt.Errorf("album tracks missing items")
|
|
}
|
|
trackIDs := make([]string, 0, len(rawItems))
|
|
for _, item := range rawItems {
|
|
itm, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := stringFromAny(itm["id"])
|
|
if id != "" {
|
|
trackIDs = append(trackIDs, id)
|
|
}
|
|
}
|
|
|
|
folder := m.albumFolderPath(source, albumID, albumTitle, albumArtist, year, bitDepth, sampling)
|
|
artRes, _ := artwork.Prepare(ctx, m.DL, folder, albumMeta, m.Config.Session.Artwork, false)
|
|
total := len(trackIDs)
|
|
discTotal := intFromAny(albumMeta["media_count"])
|
|
if discTotal == 0 {
|
|
discTotal = intFromAny(albumMeta["numberOfVolumes"])
|
|
}
|
|
m.logf("Album: %s (%d tracks)\n", albumTitle, total)
|
|
failures := 0
|
|
|
|
if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 {
|
|
for i, trackID := range trackIDs {
|
|
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, albumArtist: albumArtist, index: i + 1, total: total, albumDiscTotal: discTotal}
|
|
if err := m.ripTrack(ctx, p, source, trackID, "", opts); err != nil {
|
|
failures++
|
|
m.logf("track failed: id=%s reason=%v\n", trackID, err)
|
|
}
|
|
}
|
|
if failures > 0 {
|
|
m.logf("Album done with %d failed track(s)\n", failures)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
maxWorkers := m.Config.Session.Downloads.MaxConnections
|
|
if maxWorkers <= 0 {
|
|
maxWorkers = 6
|
|
}
|
|
sem := make(chan struct{}, maxWorkers)
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, len(trackIDs))
|
|
for i, trackID := range trackIDs {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(idx int, tid string) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, albumArtist: albumArtist, index: idx, total: total, albumDiscTotal: discTotal}
|
|
if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil {
|
|
errCh <- err
|
|
}
|
|
}(i+1, trackID)
|
|
}
|
|
wg.Wait()
|
|
close(errCh)
|
|
for err := range errCh {
|
|
failures++
|
|
m.logf("track failed: reason=%v\n", err)
|
|
}
|
|
if failures > 0 {
|
|
m.logf("Album done with %d failed track(s)\n", failures)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playlistID string, playlistMeta map[string]any) error {
|
|
name := titleFromMetadata(playlistMeta, playlistID)
|
|
if n := stringFromAny(playlistMeta["name"]); n != "" {
|
|
name = n
|
|
}
|
|
folder := filepath.Join(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{
|
|
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
|
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
|
|
}))
|
|
|
|
tracksMap, ok := playlistMeta["tracks"].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("playlist missing tracks data")
|
|
}
|
|
rawItems, ok := tracksMap["items"].([]any)
|
|
if !ok {
|
|
if itemsMap, ok2 := tracksMap["items"].([]map[string]any); ok2 {
|
|
rawItems = make([]any, 0, len(itemsMap))
|
|
for _, it := range itemsMap {
|
|
rawItems = append(rawItems, it)
|
|
}
|
|
} else {
|
|
return fmt.Errorf("playlist tracks missing items")
|
|
}
|
|
}
|
|
|
|
ids := make([]string, 0, len(rawItems))
|
|
for _, raw := range rawItems {
|
|
itm, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := stringFromAny(itm["id"])
|
|
if id == "" {
|
|
id = stringFromAny(itm["track_id"])
|
|
}
|
|
if id != "" {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
|
|
total := len(ids)
|
|
m.logf("Playlist: %s (%d tracks)\n", name, total)
|
|
failures := 0
|
|
|
|
runOne := func(i int, id string) {
|
|
opts := ripTrackOptions{
|
|
albumFolder: folder,
|
|
index: i,
|
|
total: total,
|
|
forPlaylist: true,
|
|
playlistName: name,
|
|
playlistPos: i,
|
|
}
|
|
if err := m.ripTrack(ctx, p, source, id, "", opts); err != nil {
|
|
failures++
|
|
m.logf("track failed: id=%s reason=%v\n", id, err)
|
|
}
|
|
}
|
|
|
|
if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 {
|
|
for i, id := range ids {
|
|
runOne(i+1, id)
|
|
}
|
|
} else {
|
|
maxWorkers := m.Config.Session.Downloads.MaxConnections
|
|
if maxWorkers <= 0 {
|
|
maxWorkers = 6
|
|
}
|
|
sem := make(chan struct{}, maxWorkers)
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
for i, id := range ids {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(pos int, tid string) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
opts := ripTrackOptions{albumFolder: folder, index: pos, total: total, forPlaylist: true, playlistName: name, playlistPos: pos}
|
|
if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil {
|
|
mu.Lock()
|
|
failures++
|
|
m.logf("track failed: id=%s reason=%v\n", tid, err)
|
|
mu.Unlock()
|
|
}
|
|
}(i+1, id)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
if failures > 0 {
|
|
m.logf("Playlist done with %d failed track(s)\n", failures)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error {
|
|
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
|
|
if err == nil && alreadyDownloaded {
|
|
if m.IgnoreDB {
|
|
alreadyDownloaded = false
|
|
} else {
|
|
if opts.total > 0 {
|
|
m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id)
|
|
} else {
|
|
m.logf("skip (already downloaded) id=%s\n", id)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if m.IgnoreDB {
|
|
alreadyDownloaded = false
|
|
}
|
|
|
|
if alreadyDownloaded {
|
|
if opts.total > 0 {
|
|
m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id)
|
|
} else {
|
|
m.logf("skip (already downloaded) id=%s\n", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
meta, err := p.GetMetadata(ctx, id, "track")
|
|
if err != nil {
|
|
_ = m.Store.MarkFailed(ctx, source, "track", id)
|
|
return fmt.Errorf("id=%s metadata: %w", id, err)
|
|
}
|
|
|
|
title := titleFromMetadata(meta, id)
|
|
if title == id && fallbackTitle != "" {
|
|
title = fallbackTitle
|
|
}
|
|
|
|
if opts.forPlaylist {
|
|
applyPlaylistMetadataOverrides(meta, m.Config.Session.Metadata, opts.playlistName, opts.playlistPos)
|
|
}
|
|
|
|
d, err := p.GetDownloadable(ctx, id, m.qualityForSource(source))
|
|
if err != nil {
|
|
_ = m.Store.MarkFailed(ctx, source, "track", id)
|
|
return fmt.Errorf("id=%s title=%q get_downloadable: %w", id, title, err)
|
|
}
|
|
|
|
outPath := m.trackOutputPath(source, id, title, d.Extension, meta, opts.albumFolder, opts.albumDiscTotal)
|
|
if opts.total > 0 && (!m.Config.Session.CLI.ProgressBars || !m.Config.Session.CLI.TextOutput || !m.DL.ProgressEnabled()) {
|
|
m.logf("[%d/%d] %s\n", opts.index, opts.total, filepath.Base(outPath))
|
|
}
|
|
if err = m.DL.File(ctx, d.URL, outPath); err != nil {
|
|
m.logf("retry: %s (%v)\n", filepath.Base(outPath), err)
|
|
if err = m.DL.File(ctx, d.URL, outPath); err != nil {
|
|
_ = m.Store.MarkFailed(ctx, source, "track", id)
|
|
return fmt.Errorf("id=%s title=%q download: %w", id, title, err)
|
|
}
|
|
}
|
|
|
|
embedCoverPath := opts.albumEmbedCover
|
|
if opts.forPlaylist {
|
|
parent := opts.albumFolder
|
|
if parent == "" {
|
|
parent = filepath.Dir(outPath)
|
|
}
|
|
if res, prepErr := artwork.Prepare(ctx, m.DL, parent, trackMetaAlbum(meta), m.Config.Session.Artwork, true); prepErr == nil {
|
|
if res.EmbedPath != "" {
|
|
embedCoverPath = res.EmbedPath
|
|
}
|
|
}
|
|
} else if opts.albumFolder == "" {
|
|
parent := filepath.Dir(outPath)
|
|
if res, prepErr := artwork.Prepare(ctx, m.DL, parent, trackMetaAlbum(meta), m.Config.Session.Artwork, false); prepErr == nil {
|
|
if res.EmbedPath != "" {
|
|
embedCoverPath = res.EmbedPath
|
|
}
|
|
}
|
|
}
|
|
|
|
tagMeta := buildTagMetadata(meta, title, source, id, opts)
|
|
coverPath := ""
|
|
if m.Config.Session.Artwork.Embed {
|
|
coverPath = embedCoverPath
|
|
if coverPath == "" {
|
|
coverPath = tag.CoverPathForTrack(outPath, opts.albumFolder)
|
|
}
|
|
}
|
|
if err = m.Tagger.TagFLAC(outPath, tagMeta, coverPath); err != nil {
|
|
m.logf("warning: tag failed for %s: %v\n", filepath.Base(outPath), err)
|
|
}
|
|
|
|
if m.Config.Session.Conversion.Enabled {
|
|
convertedPath, convErr := convert.Convert(outPath, m.Config.Session.Conversion)
|
|
if convErr != nil {
|
|
_ = m.Store.MarkFailed(ctx, source, "track", id)
|
|
return fmt.Errorf("id=%s title=%q convert: %w", id, title, convErr)
|
|
}
|
|
outPath = convertedPath
|
|
}
|
|
|
|
return m.Store.MarkDownloaded(ctx, source, id)
|
|
}
|
|
|
|
func (m *Main) qualityForSource(source string) int {
|
|
switch source {
|
|
case "qobuz":
|
|
return m.Config.Session.Qobuz.Quality
|
|
case "tidal":
|
|
return m.Config.Session.Tidal.Quality
|
|
case "deezer":
|
|
return m.Config.Session.Deezer.Quality
|
|
case "soundcloud":
|
|
return m.Config.Session.Soundcloud.Quality
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (m *Main) qualityProfileForSource(source string) (int, string) {
|
|
q := m.qualityForSource(source)
|
|
switch source {
|
|
case "qobuz":
|
|
switch {
|
|
case q >= 4:
|
|
return 24, "192"
|
|
case q >= 3:
|
|
return 24, "96"
|
|
case q >= 2:
|
|
return 16, "44.1"
|
|
default:
|
|
return 16, "44.1"
|
|
}
|
|
case "tidal":
|
|
switch {
|
|
case q >= 3:
|
|
return 24, "96"
|
|
case q >= 2:
|
|
return 16, "44.1"
|
|
default:
|
|
return 16, "44.1"
|
|
}
|
|
default:
|
|
return 16, "44.1"
|
|
}
|
|
}
|
|
|
|
func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year string, bitDepth int, samplingRate string) string {
|
|
base := m.Config.Session.Downloads.Folder
|
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
|
base = filepath.Join(base, strings.Title(source))
|
|
}
|
|
|
|
vals := map[string]string{
|
|
"albumartist": albumArtist,
|
|
"title": albumTitle,
|
|
"year": year,
|
|
"bit_depth": strconv.Itoa(bitDepth),
|
|
"sampling_rate": samplingRate,
|
|
"id": albumID,
|
|
"container": "FLAC",
|
|
"albumcomposer": "Unknown",
|
|
}
|
|
folderName := naming.FormatTemplate(m.Config.Session.Filepaths.FolderFormat, vals)
|
|
folderName = naming.CleanName(folderName, naming.Config{
|
|
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
|
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
|
|
})
|
|
|
|
return filepath.Join(base, folderName)
|
|
}
|
|
|
|
func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[string]any, albumFolder string, albumDiscTotal int) string {
|
|
base := m.Config.Session.Downloads.Folder
|
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
|
base = filepath.Join(base, strings.Title(source))
|
|
}
|
|
|
|
if albumFolder == "" && m.Config.Session.Filepaths.AddSinglesToFolder {
|
|
albumTitle := nestedString(trackMeta, "album", "title")
|
|
albumID := nestedString(trackMeta, "album", "id")
|
|
if albumID == "" {
|
|
albumID = id
|
|
}
|
|
albumArtist := nestedString(trackMeta, "album", "artist", "name")
|
|
if albumArtist == "" {
|
|
albumArtist = nestedString(trackMeta, "performer", "name")
|
|
}
|
|
albumYear := naming.YearFromDate(stringFromAny(trackMeta["release_date_original"]))
|
|
if albumYear == "Unknown" {
|
|
albumYear = naming.YearFromDate(stringFromAny(trackMeta["release_date"]))
|
|
}
|
|
albumFolder = m.albumFolderPath(source, albumID, albumTitle, albumArtist, albumYear, intFromAny(trackMeta["maximum_bit_depth"]), stringFromAny(trackMeta["maximum_sampling_rate"]))
|
|
}
|
|
if albumFolder != "" {
|
|
base = albumFolder
|
|
if m.Config.Session.Downloads.DiscSubdirectories && albumDiscTotal > 1 {
|
|
discNumber := intFromAny(trackMeta["media_number"])
|
|
if discNumber == 0 {
|
|
discNumber = intFromAny(trackMeta["volumeNumber"])
|
|
}
|
|
if discNumber == 0 {
|
|
discNumber = intFromAny(trackMeta["disk_number"])
|
|
}
|
|
if discNumber == 0 {
|
|
discNumber = 1
|
|
}
|
|
if discNumber > 0 {
|
|
base = filepath.Join(base, "Disc "+strconv.Itoa(discNumber))
|
|
}
|
|
}
|
|
}
|
|
|
|
trackNumber := intFromAny(trackMeta["track_number"])
|
|
if trackNumber == 0 {
|
|
trackNumber = intFromAny(trackMeta["trackNumber"])
|
|
}
|
|
explicit := ""
|
|
if boolFromAny(trackMeta["parental_warning"]) || boolFromAny(trackMeta["explicit"]) {
|
|
explicit = " (Explicit)"
|
|
}
|
|
artist := nestedString(trackMeta, "performer", "name")
|
|
if artist == "" {
|
|
artist = nestedString(trackMeta, "artist", "name")
|
|
}
|
|
albumArtist := nestedString(trackMeta, "album", "artist", "name")
|
|
if albumArtist == "" {
|
|
albumArtist = artist
|
|
}
|
|
values := map[string]string{
|
|
"id": id,
|
|
"tracknumber": strconv.Itoa(trackNumber),
|
|
"artist": artist,
|
|
"albumartist": albumArtist,
|
|
"composer": "Unknown",
|
|
"title": title,
|
|
"albumcomposer": "Unknown",
|
|
"explicit": explicit,
|
|
}
|
|
fileName := naming.FormatTemplate(m.Config.Session.Filepaths.TrackFormat, values)
|
|
fileName = naming.CleanName(fileName, naming.Config{
|
|
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
|
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
|
|
})
|
|
return filepath.Join(base, fileName+"."+ext)
|
|
}
|
|
|
|
func (m *Main) videoOutputPath(source, id, title, ext string) string {
|
|
if strings.TrimSpace(ext) == "" {
|
|
ext = "mp4"
|
|
}
|
|
base := m.Config.Session.Downloads.Folder
|
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
|
base = filepath.Join(base, strings.Title(source))
|
|
}
|
|
fileName := naming.CleanName(title, naming.Config{
|
|
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
|
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
|
|
})
|
|
if fileName == "" {
|
|
fileName = id
|
|
}
|
|
return filepath.Join(base, fileName+"."+ext)
|
|
}
|
|
|
|
func titleFromMetadata(meta map[string]any, fallback string) string {
|
|
if title, ok := meta["title"].(string); ok {
|
|
title = strings.TrimSpace(title)
|
|
version := strings.TrimSpace(stringFromAny(meta["version"]))
|
|
if version != "" {
|
|
return title + " (" + version + ")"
|
|
}
|
|
if title != "" {
|
|
return title
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func nestedString(v map[string]any, keys ...string) string {
|
|
return stringFromAny(nestedAny(v, keys...))
|
|
}
|
|
|
|
func nestedAny(v map[string]any, keys ...string) any {
|
|
cur := any(v)
|
|
for _, key := range keys {
|
|
m, ok := cur.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
cur = m[key]
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func stringFromAny(v any) string {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case float64:
|
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
|
case int64:
|
|
return strconv.FormatInt(t, 10)
|
|
case int:
|
|
return strconv.Itoa(t)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func intFromAny(v any) int {
|
|
switch t := v.(type) {
|
|
case int:
|
|
return t
|
|
case int64:
|
|
return int(t)
|
|
case float64:
|
|
return int(t)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func floatFromAny(v any) float64 {
|
|
switch t := v.(type) {
|
|
case float64:
|
|
return t
|
|
case int:
|
|
return float64(t)
|
|
case int64:
|
|
return float64(t)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func boolFromAny(v any) bool {
|
|
b, _ := v.(bool)
|
|
return b
|
|
}
|
|
|
|
func replaygainGainFromAny(v any) string {
|
|
s := strings.TrimSpace(stringFromAny(v))
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
|
|
lower := strings.ToLower(s)
|
|
if strings.HasSuffix(lower, "db") {
|
|
n := strings.TrimSpace(s[:len(s)-2])
|
|
if n == "" {
|
|
return ""
|
|
}
|
|
return n + " dB"
|
|
}
|
|
|
|
if _, err := strconv.ParseFloat(s, 64); err == nil {
|
|
return s + " dB"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func replaygainPeakFromAny(v any) string {
|
|
return strings.TrimSpace(stringFromAny(v))
|
|
}
|
|
|
|
func trackMetaAlbum(trackMeta map[string]any) map[string]any {
|
|
album, ok := trackMeta["album"].(map[string]any)
|
|
if !ok {
|
|
return map[string]any{}
|
|
}
|
|
return album
|
|
}
|
|
|
|
func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, opts ripTrackOptions) tag.Metadata {
|
|
artist := nestedString(trackMeta, "performer", "name")
|
|
if artist == "" {
|
|
artist = nestedString(trackMeta, "artist", "name")
|
|
}
|
|
albumArtist := nestedString(trackMeta, "album", "artist", "name")
|
|
if albumArtist == "" {
|
|
albumArtist = artist
|
|
}
|
|
if strings.TrimSpace(opts.albumArtist) != "" {
|
|
albumArtist = strings.TrimSpace(opts.albumArtist)
|
|
}
|
|
trackNumber := intFromAny(trackMeta["track_number"])
|
|
if trackNumber == 0 {
|
|
trackNumber = intFromAny(trackMeta["trackNumber"])
|
|
}
|
|
discNumber := intFromAny(trackMeta["media_number"])
|
|
if discNumber == 0 {
|
|
discNumber = intFromAny(trackMeta["volumeNumber"])
|
|
}
|
|
if discNumber == 0 {
|
|
discNumber = intFromAny(trackMeta["disk_number"])
|
|
}
|
|
date := stringFromAny(trackMeta["release_date_original"])
|
|
if date == "" {
|
|
date = stringFromAny(trackMeta["release_date"])
|
|
}
|
|
if date == "" {
|
|
date = stringFromAny(trackMeta["streamStartDate"])
|
|
}
|
|
album := nestedString(trackMeta, "album", "title")
|
|
if album == "" {
|
|
album = stringFromAny(trackMeta["title"])
|
|
}
|
|
trackTotal := intFromAny(trackMeta["tracks_count"])
|
|
if trackTotal == 0 {
|
|
trackTotal = intFromAny(trackMeta["numberOfTracks"])
|
|
}
|
|
if trackTotal == 0 {
|
|
trackTotal = intFromAny(trackMeta["track_total"])
|
|
}
|
|
if opts.forPlaylist && opts.total > 0 {
|
|
trackTotal = opts.total
|
|
}
|
|
|
|
discTotal := intFromAny(trackMeta["media_count"])
|
|
if discTotal == 0 {
|
|
discTotal = intFromAny(trackMeta["numberOfVolumes"])
|
|
}
|
|
if discTotal == 0 && opts.albumDiscTotal > 0 {
|
|
discTotal = opts.albumDiscTotal
|
|
}
|
|
if opts.forPlaylist {
|
|
discTotal = 1
|
|
}
|
|
if !opts.forPlaylist && discNumber == 0 {
|
|
discNumber = 1
|
|
}
|
|
|
|
genre := nestedString(trackMeta, "genre", "name")
|
|
if genre == "" {
|
|
genre = stringFromAny(trackMeta["genre"])
|
|
}
|
|
|
|
comment := stringFromAny(trackMeta["comment"])
|
|
description := stringFromAny(trackMeta["description"])
|
|
lyrics := stringFromAny(trackMeta["lyrics"])
|
|
trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"])
|
|
if trackGain == "" {
|
|
trackGain = replaygainGainFromAny(trackMeta["replayGain"])
|
|
}
|
|
albumGain := replaygainGainFromAny(trackMeta["replaygain_album_gain"])
|
|
if albumGain == "" {
|
|
albumGain = replaygainGainFromAny(nestedAny(trackMeta, "album", "replaygain_album_gain"))
|
|
}
|
|
trackPeak := replaygainPeakFromAny(trackMeta["replaygain_track_peak"])
|
|
if trackPeak == "" {
|
|
trackPeak = replaygainPeakFromAny(trackMeta["peak"])
|
|
}
|
|
albumPeak := replaygainPeakFromAny(trackMeta["replaygain_album_peak"])
|
|
if albumPeak == "" {
|
|
albumPeak = replaygainPeakFromAny(nestedAny(trackMeta, "album", "replaygain_album_peak"))
|
|
}
|
|
|
|
sourceAlbumID := nestedString(trackMeta, "album", "id")
|
|
sourceArtistID := nestedString(trackMeta, "artist", "id")
|
|
if sourceArtistID == "" {
|
|
sourceArtistID = nestedString(trackMeta, "performer", "id")
|
|
}
|
|
|
|
return tag.Metadata{
|
|
Title: title,
|
|
Album: album,
|
|
Artist: artist,
|
|
AlbumArtist: albumArtist,
|
|
TrackNumber: trackNumber,
|
|
DiscNumber: discNumber,
|
|
TrackTotal: trackTotal,
|
|
DiscTotal: discTotal,
|
|
Date: date,
|
|
Genre: genre,
|
|
Comment: comment,
|
|
Description: description,
|
|
Lyrics: lyrics,
|
|
Copyright: stringFromAny(trackMeta["copyright"]),
|
|
ISRC: stringFromAny(trackMeta["isrc"]),
|
|
ReplaygainTrackGain: trackGain,
|
|
ReplaygainAlbumGain: albumGain,
|
|
ReplaygainTrackPeak: trackPeak,
|
|
ReplaygainAlbumPeak: albumPeak,
|
|
SourcePlatform: source,
|
|
SourceTrackID: trackID,
|
|
SourceAlbumID: sourceAlbumID,
|
|
SourceArtistID: sourceArtistID,
|
|
}
|
|
}
|
|
|
|
func applyPlaylistMetadataOverrides(meta map[string]any, cfg config.MetadataConfig, playlistName string, position int) {
|
|
if cfg.RenumberPlaylistTracks && position > 0 {
|
|
meta["track_number"] = position
|
|
meta["trackNumber"] = position
|
|
}
|
|
if !cfg.SetPlaylistToAlbum {
|
|
return
|
|
}
|
|
album, ok := meta["album"].(map[string]any)
|
|
if !ok {
|
|
album = map[string]any{}
|
|
meta["album"] = album
|
|
}
|
|
album["title"] = playlistName
|
|
artist, ok := album["artist"].(map[string]any)
|
|
if !ok {
|
|
artist = map[string]any{}
|
|
album["artist"] = artist
|
|
}
|
|
artist["name"] = "Various Artists"
|
|
}
|