mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 22:07:30 +02:00
Two related changes: a 68-track Qobuz album was gated by the API throttle
rather than the network, and Qobuz lyrics are now supported, which is what the
faster album path had to be designed around.
--- perf ---
ratelimit: token bucket instead of fixed spacing
The limiter spaced every request exactly interval apart with no burst, so at
the default 60 requests/minute it allowed exactly one request per second no
matter how many workers ran. Measured: 8 requests took 7s with 1 worker and
7s with 6, i.e. max_connections had no effect on anything touching the API.
Since a track needs its metadata and stream URL before downloading, workers
could only start a track every two seconds, which is why an album looked
serial even at 57 MiB/s per track.
New() now takes a burst (providers pass max_connections), idle time accrues
budget up to that burst, and burst is clamped to one minute's worth of
requests so a large max_connections cannot bypass a small configured rate.
app: reuse the track objects already in the album payload
ripAlbum fetched the album, kept only the track ids, then asked the provider
for every track again, doubling the request count. Providers opt in via
AlbumTracksAreComplete(); only Qobuz does, because it is only verified there
and because Tidal does extra work in its track lookup.
Verified against the live Qobuz API on a 68-track release: the embedded track
objects already carry title, version, track_number, media_number,
parental_warning, isrc, copyright, performer, performers, composer, duration,
audio_info, maximum_bit_depth, maximum_sampling_rate and
release_date_original. The only field a per-track lookup adds that this app
reads is the nested album object, so albumTrackMetadata attaches the parent
album with its own track list stripped, or a 68-track album would hold 68
copies of itself. trackMetaUsable refetches anyway when title or track number
is missing, so a thin payload cannot produce a wrongly named file.
Measured end-to-end through the real app and limiter at the shipped defaults,
68 tracks: 137 API calls / 2m11s before, 69 calls / 1m3s after.
--- qobuz lyrics ---
Qobuz's main API does not serve lyrics: has_lyrics is only a flag, extra=lyrics
adds nothing, and track/getLyrics does not exist. Its apps use a two-step flow
instead, confirmed live:
GET track/lyricsUrl?track_id=&app_id=&request_ts=&request_sig=
-> {"track_id","album_id","lyrics_url"} (signed CloudFront URL)
GET <lyrics_url>
-> {"original":{"type","lang","lines":[{"line","start","end"}]},
"translation_langs":[...],"writers","publishers":[...]}
type is plain, lsync or wsync; start/end are milliseconds; the line text is
under "line". Passing a language returns a translation instead of the original,
and only for languages that exist for the track (en 403s on an English song),
so the language parameter is deliberately omitted. A 403 on the signed URL is
how Qobuz represents "no lyrics", so it is not treated as an error.
Lyrics are exposed through a TrackLyrics method rather than inside GetMetadata,
because album rips skip the per-track metadata call. That keeps the fast path:
with lyrics on, an album costs the same two provider calls per track it always
did, and the lyrics document itself comes from a CDN that does not consume the
provider's request budget. With metadata.exclude = ["lyrics"] it drops to one
call per track and the full speedup applies.
Existing behaviour is preserved: Tidal and Deezer already attach lyrics in
their metadata, so attachLyrics leaves them alone, and a lyrics failure is
logged and ignored rather than failing a download.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1764 lines
51 KiB
Go
1764 lines
51 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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/jsonutil"
|
|
"streamrip-go/internal/naming"
|
|
"streamrip-go/internal/provider"
|
|
beatportprovider "streamrip-go/internal/provider/beatport"
|
|
deezerprovider "streamrip-go/internal/provider/deezer"
|
|
qobuzprovider "streamrip-go/internal/provider/qobuz"
|
|
soundcloudprovider "streamrip-go/internal/provider/soundcloud"
|
|
tidalprovider "streamrip-go/internal/provider/tidal"
|
|
yandexprovider "streamrip-go/internal/provider/yandex"
|
|
"streamrip-go/internal/store"
|
|
"streamrip-go/internal/verbose"
|
|
)
|
|
|
|
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 PlaylistTrackRef struct {
|
|
Source string
|
|
ID string
|
|
}
|
|
|
|
type ripTrackOptions struct {
|
|
albumFolder string
|
|
albumEmbedCover string
|
|
albumArtist string
|
|
prefetched *provider.Downloadable
|
|
prefetchedMeta map[string]any
|
|
index int
|
|
total int
|
|
albumDiscTotal int
|
|
forPlaylist bool
|
|
playlistName string
|
|
playlistPos int
|
|
}
|
|
|
|
type folderAudioValues struct {
|
|
container string
|
|
codec string
|
|
quality string
|
|
bitDepth int
|
|
samplingRate string
|
|
bitrateKbps 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(ctx context.Context, path string, meta tag.Metadata, coverPath string) error
|
|
}
|
|
|
|
// trackLyricsProvider is implemented by providers that can fetch lyrics for a
|
|
// track independently of its metadata lookup.
|
|
//
|
|
// Tidal and Deezer attach lyrics inside GetMetadata, which album rips skip
|
|
// when they reuse the album payload's track objects. Qobuz exposes this
|
|
// instead, so lyrics work on both paths.
|
|
type trackLyricsProvider interface {
|
|
TrackLyrics(ctx context.Context, trackID string) (plain string, synced string, err error)
|
|
}
|
|
|
|
// albumTracksSelfDescribing is implemented by providers whose album payload
|
|
// embeds track objects complete enough to tag from, making the per-track
|
|
// metadata call redundant.
|
|
//
|
|
// This is opt-in per provider rather than assumed, because it is only true
|
|
// where it has been checked against the live API, and because some providers
|
|
// do extra work inside their track lookup: Tidal, for instance, fetches lyrics
|
|
// there, which an album payload does not carry.
|
|
type albumTracksSelfDescribing interface {
|
|
AlbumTracksAreComplete() bool
|
|
}
|
|
|
|
type videoDownloadableProvider interface {
|
|
GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error)
|
|
}
|
|
|
|
type trackFallbackDownloader interface {
|
|
DownloadTrackFallback(ctx context.Context, trackID string, quality int, outputPath string) 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{
|
|
"beatport": beatportprovider.New(cfg),
|
|
"qobuz": qobuzprovider.New(cfg),
|
|
"tidal": tidalprovider.New(cfg),
|
|
"deezer": deezerprovider.New(cfg),
|
|
"yandex": yandexprovider.New(cfg),
|
|
"soundcloud": soundcloudprovider.New(cfg),
|
|
}
|
|
|
|
m := &Main{
|
|
Config: cfg,
|
|
Providers: providers,
|
|
Store: db,
|
|
DL: download.NewWithOptions(cfg.Session.Downloads.VerifySSL, cfg.Session.CLI.ProgressBars, downloaderMaxConnsPerHost(cfg.Session.Downloads.MaxConnections)),
|
|
Tagger: tag.New(),
|
|
Pending: []media.Pending{},
|
|
Media: []media.Media{},
|
|
}
|
|
// The downloader does the Deezer decryption, so it needs the protocol
|
|
// secret from config rather than a constant of its own.
|
|
m.DL.DeezerBlowfishSecret = cfg.Session.Deezer.BlowfishSecret
|
|
verbose.SetSink(func(msg string) { m.DL.Logf("%s", msg) })
|
|
return m, nil
|
|
}
|
|
|
|
// downloaderMaxConnsPerHost picks the per-host idle connection cap for the
|
|
// shared download client. We floor at 16 so artwork/manifest fetches and
|
|
// concurrent track downloads to the same CDN host can reuse keep-alive
|
|
// sockets even when the user configured a tiny max_connections.
|
|
func downloaderMaxConnsPerHost(maxConnections int) int {
|
|
if maxConnections > 16 {
|
|
return maxConnections
|
|
}
|
|
return 16
|
|
}
|
|
|
|
func (m *Main) Close() error {
|
|
verbose.SetSink(nil)
|
|
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...)
|
|
}
|
|
|
|
// bookkeepingTimeout bounds the detached store writes below. They must not
|
|
// inherit a cancelled context, or a single Ctrl-C silently loses the record of
|
|
// which tracks failed.
|
|
const bookkeepingTimeout = 5 * time.Second
|
|
|
|
// markFailed records a failed download. It deliberately detaches from ctx so
|
|
// the write still lands when the rip was cancelled, which is exactly when the
|
|
// failure record matters most.
|
|
func (m *Main) markFailed(ctx context.Context, source, mediaType, id string) {
|
|
if m.Store == nil {
|
|
return
|
|
}
|
|
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), bookkeepingTimeout)
|
|
defer cancel()
|
|
if err := m.Store.MarkFailed(writeCtx, source, mediaType, id); err != nil {
|
|
m.logf("warning: could not record failure for %s/%s/%s: %v\n", source, mediaType, id, err)
|
|
}
|
|
}
|
|
|
|
// markDownloaded records a completed download, detached from ctx for the same
|
|
// reason as markFailed: the file is already on disk, so losing the record would
|
|
// cause a needless re-download later.
|
|
func (m *Main) markDownloaded(ctx context.Context, source, id string) error {
|
|
if m.Store == nil {
|
|
return nil
|
|
}
|
|
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), bookkeepingTimeout)
|
|
defer cancel()
|
|
return m.Store.MarkDownloaded(writeCtx, source, id)
|
|
}
|
|
|
|
// defaultMaxWorkers is used when max_connections is unset or nonsensical.
|
|
const defaultMaxWorkers = 6
|
|
|
|
// runTrackWorkers rips ids, either serially or across max_connections workers
|
|
// depending on config, and returns how many failed.
|
|
//
|
|
// This replaces three copy-pasted dispatch blocks that had drifted apart: two
|
|
// counted failures under a mutex and logged inline, while the third collected
|
|
// errors in a channel and logged them all after wg.Wait() without the track id.
|
|
// The inline form is kept, since it reports failures as they happen and names
|
|
// the track. pos is 1-based to match the progress output's numbering.
|
|
func (m *Main) runTrackWorkers(ctx context.Context, ids []string, rip func(pos int, id string) error) int {
|
|
failures := 0
|
|
|
|
maxWorkers := m.Config.Session.Downloads.MaxConnections
|
|
if !m.Config.Session.Downloads.Concurrency || maxWorkers == 1 {
|
|
for i, id := range ids {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
if err := rip(i+1, id); err != nil {
|
|
failures++
|
|
m.logf("track failed: id=%s reason=%v\n", id, err)
|
|
}
|
|
}
|
|
return failures
|
|
}
|
|
if maxWorkers <= 0 {
|
|
maxWorkers = defaultMaxWorkers
|
|
}
|
|
|
|
sem := make(chan struct{}, maxWorkers)
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
for i, id := range ids {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func(pos int, tid string) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
if err := rip(pos, tid); err != nil {
|
|
// Holding mu across the log keeps failure lines from
|
|
// interleaving with each other.
|
|
mu.Lock()
|
|
failures++
|
|
m.logf("track failed: id=%s reason=%v\n", tid, err)
|
|
mu.Unlock()
|
|
}
|
|
}(i+1, id)
|
|
}
|
|
wg.Wait()
|
|
return failures
|
|
}
|
|
|
|
// metadataExcluded reports whether a tag was opted out via
|
|
// metadata.exclude in the config.
|
|
func (m *Main) metadataExcluded(field string) bool {
|
|
for _, k := range m.Config.Session.Metadata.Exclude {
|
|
if strings.EqualFold(strings.TrimSpace(k), field) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// attachLyrics fills in lyrics for providers that fetch them separately from
|
|
// track metadata. It is a no-op when the metadata already carries lyrics (as
|
|
// Tidal's and Deezer's does), when the provider has no lyrics support, or when
|
|
// the user excluded them.
|
|
func (m *Main) attachLyrics(ctx context.Context, p provider.Client, meta map[string]any, trackID string) {
|
|
if meta == nil || m.metadataExcluded("lyrics") {
|
|
return
|
|
}
|
|
if jsonutil.StringFromAny(meta["lyrics"]) != "" || jsonutil.StringFromAny(meta["lyrics_synced"]) != "" {
|
|
return
|
|
}
|
|
lp, ok := p.(trackLyricsProvider)
|
|
if !ok {
|
|
return
|
|
}
|
|
plain, synced, err := lp.TrackLyrics(ctx, trackID)
|
|
if err != nil {
|
|
// Lyrics are cosmetic; never fail a download over them.
|
|
m.logf("lyrics unavailable for id=%s: %v\n", trackID, err)
|
|
return
|
|
}
|
|
if plain != "" {
|
|
meta["lyrics"] = plain
|
|
}
|
|
if synced != "" {
|
|
meta["lyrics_synced"] = synced
|
|
}
|
|
}
|
|
|
|
// trackMetaUsable reports whether a track object carried over from an album
|
|
// payload is complete enough to tag and name a file from.
|
|
//
|
|
// It is a belt-and-braces check on top of the provider opting in: title and
|
|
// track number determine the output filename, so if either is missing the
|
|
// caller refetches rather than writing a wrongly named file.
|
|
func trackMetaUsable(meta map[string]any) bool {
|
|
if len(meta) == 0 {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(jsonutil.StringFromAny(meta["title"])) == "" {
|
|
return false
|
|
}
|
|
return jsonutil.IntFromAny(meta["track_number"]) > 0
|
|
}
|
|
|
|
// albumTrackMetadata builds the per-track metadata for an album rip from the
|
|
// track objects already embedded in the album payload.
|
|
//
|
|
// Verified against the live Qobuz API: the only field a dedicated track
|
|
// lookup adds that this app reads is the nested album object, which is the
|
|
// parent album we already have. The album copy has its own track list removed,
|
|
// so attaching it to every track does not duplicate the whole album n times.
|
|
func albumTrackMetadata(albumMeta map[string]any, rawItems []any) map[string]map[string]any {
|
|
albumForTrack := make(map[string]any, len(albumMeta))
|
|
for k, v := range albumMeta {
|
|
if k == "tracks" {
|
|
continue
|
|
}
|
|
albumForTrack[k] = v
|
|
}
|
|
|
|
out := make(map[string]map[string]any, len(rawItems))
|
|
for _, raw := range rawItems {
|
|
item, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := jsonutil.StringFromAny(item["id"])
|
|
if id == "" {
|
|
continue
|
|
}
|
|
meta := make(map[string]any, len(item)+1)
|
|
for k, v := range item {
|
|
meta[k] = v
|
|
}
|
|
if _, has := meta["album"]; !has {
|
|
meta["album"] = albumForTrack
|
|
}
|
|
out[id] = meta
|
|
}
|
|
return out
|
|
}
|
|
|
|
// anySlice normalizes a JSON array that may have been decoded as either
|
|
// []any or []map[string]any, which differs per provider because some clients
|
|
// hand-build their metadata maps instead of round-tripping through encoding/json.
|
|
//
|
|
// It reports false when the value is neither, so callers can distinguish
|
|
// "missing" from "empty".
|
|
func anySlice(v any) ([]any, bool) {
|
|
switch items := v.(type) {
|
|
case []any:
|
|
return items, true
|
|
case []map[string]any:
|
|
out := make([]any, 0, len(items))
|
|
for _, item := range items {
|
|
out = append(out, item)
|
|
}
|
|
return out, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
// safeJoin joins base with parts and asserts the result stays inside the
|
|
// configured downloads folder.
|
|
//
|
|
// naming.CleanName already rejects "." and ".." components, so this should
|
|
// never fire; it is a second line of defence because every component here is
|
|
// built from provider-supplied metadata, and writing outside the download root
|
|
// is the one failure mode worth being paranoid about.
|
|
func (m *Main) safeJoin(base string, parts ...string) string {
|
|
joined := filepath.Join(append([]string{base}, parts...)...)
|
|
|
|
root := strings.TrimSpace(m.Config.Session.Downloads.Folder)
|
|
if root == "" {
|
|
return joined
|
|
}
|
|
if pathWithin(root, joined) {
|
|
return joined
|
|
}
|
|
|
|
m.logf("warning: refusing to write outside the download folder (%q); using %q instead\n", joined, root)
|
|
return filepath.Join(root, "Unknown")
|
|
}
|
|
|
|
// pathWithin reports whether target is root itself or sits beneath it.
|
|
func pathWithin(root, target string) bool {
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
absTarget, err := filepath.Abs(target)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
rel, err := filepath.Rel(absRoot, absTarget)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
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.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":
|
|
if source == "beatport" {
|
|
return m.ripTrackCollection(ctx, p, source, "Label", id, meta, false)
|
|
}
|
|
return m.ripCollection(ctx, p, source, "Label", id, meta)
|
|
case "chart":
|
|
return m.ripTrackCollection(ctx, p, source, "Chart", id, meta, true)
|
|
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) AddMixedPlaylistByTrackRefs(ctx context.Context, playlistID, playlistName string, refs []PlaylistTrackRef) error {
|
|
if strings.TrimSpace(playlistName) == "" {
|
|
playlistName = playlistID
|
|
}
|
|
valid := make([]PlaylistTrackRef, 0, len(refs))
|
|
for _, ref := range refs {
|
|
source := strings.TrimSpace(ref.Source)
|
|
id := strings.TrimSpace(ref.ID)
|
|
if source == "" || id == "" {
|
|
continue
|
|
}
|
|
valid = append(valid, PlaylistTrackRef{Source: source, ID: id})
|
|
}
|
|
if len(valid) == 0 {
|
|
return fmt.Errorf("playlist %q has no track refs", playlistName)
|
|
}
|
|
|
|
pending := media.PendingFunc{
|
|
ResolveFn: func(context.Context) (media.Media, error) {
|
|
return media.MediaFunc{RipFn: func(ctx context.Context) error {
|
|
if m.Config.Session.CLI.TextOutput {
|
|
m.logf("Downloading playlist: %s\n", playlistName)
|
|
}
|
|
return m.ripPlaylistMixed(ctx, playlistID, playlistName, valid)
|
|
}}, 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 := jsonutil.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) ripTrackCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any, playlistLike bool) error {
|
|
if err := m.requireSourceDownloadAuth(source); err != nil {
|
|
return err
|
|
}
|
|
name := titleFromMetadata(meta, id)
|
|
if n := jsonutil.StringFromAny(meta["name"]); n != "" {
|
|
name = n
|
|
}
|
|
base := m.Config.Session.Downloads.Folder
|
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
|
base = filepath.Join(base, jsonutil.TitleCase(source))
|
|
}
|
|
folder := m.safeJoin(base, naming.CleanName(name, naming.Config{RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters, TruncateTo: m.Config.Session.Filepaths.TruncateTo}))
|
|
tracksMap, ok := meta["tracks"].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("%s missing tracks data", strings.ToLower(kind))
|
|
}
|
|
rawItems, ok := anySlice(tracksMap["items"])
|
|
if !ok {
|
|
return fmt.Errorf("%s tracks missing items", strings.ToLower(kind))
|
|
}
|
|
ids := make([]string, 0, len(rawItems))
|
|
for _, raw := range rawItems {
|
|
itm, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if id := jsonutil.StringFromAny(itm["id"]); id != "" {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
m.logf("%s: %s (%d tracks)\n", kind, name, len(ids))
|
|
failures := m.runTrackWorkers(ctx, ids, func(pos int, trackID string) error {
|
|
opts := ripTrackOptions{albumFolder: folder, index: pos, total: len(ids)}
|
|
if playlistLike {
|
|
opts.forPlaylist = true
|
|
opts.playlistName = name
|
|
opts.playlistPos = pos
|
|
}
|
|
return m.ripTrack(ctx, p, source, trackID, "", opts)
|
|
})
|
|
if failures > 0 {
|
|
m.logf("%s done with %d failed track(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.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.markFailed(ctx, source, "video", videoID)
|
|
return fmt.Errorf("id=%s title=%q video download: %w", videoID, title, err)
|
|
}
|
|
|
|
if err = m.markDownloaded(ctx, source, videoID); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildCollectionAlbum(id string, meta map[string]any) collectionAlbum {
|
|
trackCount := jsonutil.IntFromAny(meta["tracks_count"])
|
|
if trackCount == 0 {
|
|
trackCount = jsonutil.IntFromAny(meta["numberOfTracks"])
|
|
}
|
|
return collectionAlbum{
|
|
ID: id,
|
|
Meta: meta,
|
|
Title: titleFromMetadata(meta, id),
|
|
AlbumArtist: jsonutil.NestedString(meta, "artist", "name"),
|
|
BitDepth: jsonutil.IntFromAny(meta["maximum_bit_depth"]),
|
|
Sampling: jsonutil.FloatFromAny(meta["maximum_sampling_rate"]),
|
|
Explicit: jsonutil.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
|
|
}
|
|
|
|
var items []any
|
|
if nested, isMap := albumsObj.(map[string]any); isMap {
|
|
items, _ = anySlice(nested["items"])
|
|
} else {
|
|
items, _ = anySlice(albumsObj)
|
|
}
|
|
|
|
out := make([]string, 0, len(items))
|
|
seen := map[string]struct{}{}
|
|
for _, raw := range items {
|
|
itm, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := jsonutil.StringFromAny(itm["id"])
|
|
if id == "" {
|
|
if nested, ok := itm["album"].(map[string]any); ok {
|
|
id = jsonutil.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 := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err := item.Rip(ctx); err != nil {
|
|
failures++
|
|
m.logf("media item failed: %v\n", err)
|
|
}
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return 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 {
|
|
if err := m.requireSourceDownloadAuth(source); err != nil {
|
|
return err
|
|
}
|
|
|
|
albumTitle := titleFromMetadata(albumMeta, albumID)
|
|
albumArtist := extractAlbumArtist(albumMeta)
|
|
releaseDate := jsonutil.StringFromAny(albumMeta["release_date_original"])
|
|
if releaseDate == "" {
|
|
releaseDate = jsonutil.StringFromAny(albumMeta["release_date"])
|
|
}
|
|
if releaseDate == "" {
|
|
releaseDate = jsonutil.StringFromAny(albumMeta["releaseDate"])
|
|
}
|
|
if releaseDate == "" {
|
|
releaseDate = jsonutil.StringFromAny(albumMeta["streamStartDate"])
|
|
}
|
|
year := naming.YearFromDate(releaseDate)
|
|
bitDepth := jsonutil.IntFromAny(albumMeta["maximum_bit_depth"])
|
|
sampling := jsonutil.StringFromAny(albumMeta["maximum_sampling_rate"])
|
|
|
|
tracksMap, ok := albumMeta["tracks"].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("album missing tracks data")
|
|
}
|
|
rawItems, ok := anySlice(tracksMap["items"])
|
|
if !ok {
|
|
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 := jsonutil.StringFromAny(itm["id"])
|
|
if id != "" {
|
|
trackIDs = append(trackIDs, id)
|
|
}
|
|
}
|
|
|
|
var prefetched *provider.Downloadable
|
|
if len(trackIDs) > 0 {
|
|
if d, dErr := p.GetDownloadable(ctx, trackIDs[0], m.qualityForSource(source)); dErr == nil {
|
|
prefetched = d
|
|
}
|
|
}
|
|
audioVals := m.folderAudioValues(source, bitDepth, sampling, prefetched)
|
|
folder := m.albumFolderPath(source, albumID, albumTitle, albumArtist, year, audioVals)
|
|
artRes, _ := artwork.Prepare(ctx, m.DL, folder, albumMeta, m.Config.Session.Artwork, false)
|
|
total := len(trackIDs)
|
|
discTotal := jsonutil.IntFromAny(albumMeta["media_count"])
|
|
if discTotal == 0 {
|
|
discTotal = jsonutil.IntFromAny(albumMeta["numberOfVolumes"])
|
|
}
|
|
// Providers that say their album payload embeds complete track objects let
|
|
// us skip one throttled API call per track.
|
|
var embeddedMeta map[string]map[string]any
|
|
if sd, ok := p.(albumTracksSelfDescribing); ok && sd.AlbumTracksAreComplete() {
|
|
embeddedMeta = albumTrackMetadata(albumMeta, rawItems)
|
|
}
|
|
|
|
m.logf("Album: %s (%d tracks)\n", albumTitle, total)
|
|
failures := m.runTrackWorkers(ctx, trackIDs, func(pos int, trackID string) error {
|
|
opts := ripTrackOptions{
|
|
albumFolder: folder,
|
|
albumEmbedCover: artRes.EmbedPath,
|
|
albumArtist: albumArtist,
|
|
index: pos,
|
|
total: total,
|
|
albumDiscTotal: discTotal,
|
|
prefetchedMeta: embeddedMeta[trackID],
|
|
}
|
|
// The first track's downloadable was already fetched to decide the
|
|
// album folder's audio values, so reuse it instead of refetching.
|
|
if pos == 1 {
|
|
opts.prefetched = prefetched
|
|
}
|
|
return m.ripTrack(ctx, p, source, trackID, "", opts)
|
|
})
|
|
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 {
|
|
if err := m.requireSourceDownloadAuth(source); err != nil {
|
|
return err
|
|
}
|
|
|
|
name := titleFromMetadata(playlistMeta, playlistID)
|
|
if n := jsonutil.StringFromAny(playlistMeta["name"]); n != "" {
|
|
name = n
|
|
}
|
|
base := m.Config.Session.Downloads.Folder
|
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
|
base = filepath.Join(base, jsonutil.TitleCase(source))
|
|
}
|
|
folder := m.safeJoin(base, 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 := anySlice(tracksMap["items"])
|
|
if !ok {
|
|
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 := jsonutil.StringFromAny(itm["id"])
|
|
if id == "" {
|
|
id = jsonutil.StringFromAny(itm["track_id"])
|
|
}
|
|
if id != "" {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
|
|
total := len(ids)
|
|
m.logf("Playlist: %s (%d tracks)\n", name, total)
|
|
failures := m.runTrackWorkers(ctx, ids, func(pos int, id string) error {
|
|
opts := ripTrackOptions{
|
|
albumFolder: folder,
|
|
index: pos,
|
|
total: total,
|
|
forPlaylist: true,
|
|
playlistName: name,
|
|
playlistPos: pos,
|
|
}
|
|
return m.ripTrack(ctx, p, source, id, "", opts)
|
|
})
|
|
|
|
if failures > 0 {
|
|
m.logf("Playlist done with %d failed track(s)\n", failures)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Main) ripPlaylistMixed(ctx context.Context, playlistID, name string, refs []PlaylistTrackRef) error {
|
|
requiredSources := map[string]struct{}{}
|
|
for _, ref := range refs {
|
|
s := strings.TrimSpace(ref.Source)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
requiredSources[s] = struct{}{}
|
|
}
|
|
for source := range requiredSources {
|
|
if err := m.requireSourceDownloadAuth(source); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
folder := m.safeJoin(m.Config.Session.Downloads.Folder, naming.CleanName(name, naming.Config{
|
|
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
|
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
|
|
}))
|
|
|
|
total := len(refs)
|
|
m.logf("Playlist: %s (%d tracks)\n", name, total)
|
|
failures := 0
|
|
|
|
providerCache := map[string]provider.Client{}
|
|
getProvider := func(source string) (provider.Client, error) {
|
|
if p, ok := providerCache[source]; ok {
|
|
return p, nil
|
|
}
|
|
p, err := m.GetLoggedInProvider(ctx, source)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
providerCache[source] = p
|
|
return p, nil
|
|
}
|
|
|
|
for i, ref := range refs {
|
|
p, err := getProvider(ref.Source)
|
|
if err != nil {
|
|
failures++
|
|
m.logf("track failed: id=%s source=%s reason=%v\n", ref.ID, ref.Source, err)
|
|
continue
|
|
}
|
|
opts := ripTrackOptions{
|
|
albumFolder: folder,
|
|
index: i + 1,
|
|
total: total,
|
|
forPlaylist: true,
|
|
playlistName: name,
|
|
playlistPos: i + 1,
|
|
}
|
|
if err = m.ripTrack(ctx, p, ref.Source, ref.ID, "", opts); err != nil {
|
|
failures++
|
|
m.logf("track failed: id=%s source=%s reason=%v\n", ref.ID, ref.Source, err)
|
|
}
|
|
}
|
|
|
|
if failures > 0 {
|
|
m.logf("Playlist done with %d failed track(s)\n", failures)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Main) requireSourceDownloadAuth(source string) error {
|
|
if source == "deezer" {
|
|
hasARL := strings.TrimSpace(m.Config.Session.Deezer.ARL) != ""
|
|
hasCreds := strings.TrimSpace(m.Config.Session.Deezer.Email) != "" && strings.TrimSpace(m.Config.Session.Deezer.Password) != ""
|
|
hasRefresh := strings.TrimSpace(m.Config.Session.Deezer.RefreshToken) != ""
|
|
if !hasARL && !hasCreds && !hasRefresh {
|
|
return fmt.Errorf("deezer native download requires deezer.arl, deezer.email+deezer.password, or deezer.refresh_token")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if !m.IgnoreDB {
|
|
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
|
|
if err == nil && 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
|
|
}
|
|
}
|
|
|
|
// A prefetched track object from the album payload saves one API call per
|
|
// track. Every call goes through the request-per-minute limiter, so on a
|
|
// large album this is the difference between the rip being gated by the
|
|
// API and being gated by the network.
|
|
meta := opts.prefetchedMeta
|
|
var err error
|
|
if !trackMetaUsable(meta) {
|
|
meta, err = p.GetMetadata(ctx, id, "track")
|
|
if err != nil {
|
|
m.markFailed(ctx, source, "track", id)
|
|
return fmt.Errorf("id=%s metadata: %w", id, err)
|
|
}
|
|
}
|
|
|
|
m.attachLyrics(ctx, p, meta, id)
|
|
|
|
title := titleFromMetadata(meta, id)
|
|
if title == id && fallbackTitle != "" {
|
|
title = fallbackTitle
|
|
}
|
|
|
|
if opts.forPlaylist {
|
|
applyPlaylistMetadataOverrides(meta, m.Config.Session.Metadata, opts.playlistName, opts.playlistPos)
|
|
}
|
|
|
|
d := opts.prefetched
|
|
if d == nil {
|
|
d, err = p.GetDownloadable(ctx, id, m.qualityForSource(source))
|
|
if err != nil {
|
|
m.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, d, 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))
|
|
}
|
|
downloadOnce := func() error {
|
|
if d.Source == "deezer" && strings.EqualFold(strings.TrimSpace(d.Cipher), "BF_CBC_STRIPE") {
|
|
trackID := d.TrackID
|
|
if strings.TrimSpace(trackID) == "" {
|
|
trackID = id
|
|
}
|
|
return m.DL.FileDeezerEncrypted(ctx, d.URL, outPath, trackID)
|
|
}
|
|
if d.Source == "yandex" && strings.EqualFold(strings.TrimSpace(d.Cipher), "AES_CTR") {
|
|
return m.DL.FileYandexEncrypted(ctx, d.URL, outPath, d.Key)
|
|
}
|
|
return m.DL.File(ctx, d.URL, outPath)
|
|
}
|
|
if err = downloadOnce(); err != nil {
|
|
// Never spend retries or a provider fallback on a cancelled context:
|
|
// otherwise one Ctrl-C costs three doomed attempts per remaining track.
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
m.markFailed(ctx, source, "track", id)
|
|
return fmt.Errorf("id=%s title=%q download: %w", id, title, err)
|
|
}
|
|
m.logf("retry: %s (%v)\n", filepath.Base(outPath), err)
|
|
if err = downloadOnce(); err != nil {
|
|
if fallbackProvider, ok := p.(trackFallbackDownloader); ok && ctx.Err() == nil {
|
|
m.logf("fallback: %s via provider backup flow\n", filepath.Base(outPath))
|
|
if fbErr := fallbackProvider.DownloadTrackFallback(ctx, id, m.qualityForSource(source), outPath); fbErr == nil {
|
|
goto downloaded
|
|
} else {
|
|
m.logf("fallback failed: %s (%v)\n", filepath.Base(outPath), fbErr)
|
|
}
|
|
}
|
|
m.markFailed(ctx, source, "track", id)
|
|
return fmt.Errorf("id=%s title=%q download: %w", id, title, err)
|
|
}
|
|
}
|
|
|
|
downloaded:
|
|
|
|
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 embedCoverPath == "" {
|
|
parent := opts.albumFolder
|
|
if parent == "" {
|
|
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(ctx, outPath, tagMeta, coverPath); err != nil {
|
|
if isFFmpegMissingError(err) {
|
|
m.markFailed(ctx, source, "track", id)
|
|
return fmt.Errorf("id=%s title=%q tag: %w", id, title, err)
|
|
}
|
|
m.logf("warning: tag failed for %s: %v\n", filepath.Base(outPath), err)
|
|
}
|
|
|
|
if m.Config.Session.Conversion.Enabled {
|
|
convertedPath, convErr := convert.Convert(ctx, outPath, m.Config.Session.Conversion)
|
|
if convErr != nil {
|
|
m.markFailed(ctx, source, "track", id)
|
|
return fmt.Errorf("id=%s title=%q convert: %w", id, title, convErr)
|
|
}
|
|
outPath = convertedPath
|
|
}
|
|
|
|
return m.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 "yandex":
|
|
return m.Config.Session.Yandex.Quality
|
|
case "beatport":
|
|
return m.Config.Session.Beatport.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"
|
|
}
|
|
case "yandex":
|
|
return 16, "44.1"
|
|
default:
|
|
return 16, "44.1"
|
|
}
|
|
}
|
|
|
|
func (m *Main) folderAudioValues(source string, metaBitDepth int, metaSampling string, d *provider.Downloadable) folderAudioValues {
|
|
vals := folderAudioValues{
|
|
container: "FLAC",
|
|
bitDepth: metaBitDepth,
|
|
quality: "Unknown",
|
|
codec: "Unknown",
|
|
}
|
|
if s := strings.TrimSpace(metaSampling); s != "" {
|
|
vals.samplingRate = s
|
|
}
|
|
|
|
if d != nil {
|
|
if c := strings.TrimSpace(d.Audio.Container); c != "" {
|
|
vals.container = strings.ToUpper(c)
|
|
} else if c := containerFromExtension(d.Extension); c != "" {
|
|
vals.container = c
|
|
}
|
|
if d.Audio.BitDepth > 0 {
|
|
vals.bitDepth = d.Audio.BitDepth
|
|
}
|
|
if s := strings.TrimSpace(d.Audio.SamplingRate); s != "" {
|
|
vals.samplingRate = s
|
|
}
|
|
if c := strings.TrimSpace(d.Audio.Codec); c != "" {
|
|
vals.codec = c
|
|
}
|
|
if q := strings.TrimSpace(d.Audio.Quality); q != "" {
|
|
vals.quality = q
|
|
}
|
|
if d.Audio.BitrateKbps > 0 {
|
|
vals.bitrateKbps = d.Audio.BitrateKbps
|
|
}
|
|
}
|
|
|
|
if vals.bitDepth == 0 || vals.samplingRate == "" {
|
|
fallbackBitDepth, fallbackSampling := m.qualityProfileForSource(source)
|
|
if vals.bitDepth == 0 {
|
|
vals.bitDepth = fallbackBitDepth
|
|
}
|
|
if vals.samplingRate == "" {
|
|
vals.samplingRate = fallbackSampling
|
|
}
|
|
}
|
|
if vals.codec == "Unknown" {
|
|
vals.codec = vals.container
|
|
}
|
|
return vals
|
|
}
|
|
|
|
func containerFromExtension(ext string) string {
|
|
switch strings.ToLower(strings.TrimSpace(ext)) {
|
|
case "flac":
|
|
return "FLAC"
|
|
case "mp3":
|
|
return "MP3"
|
|
case "m4a", "aac":
|
|
return "M4A"
|
|
case "mka":
|
|
return "MKA"
|
|
default:
|
|
v := strings.ToUpper(strings.TrimSpace(ext))
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
}
|
|
|
|
func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year string, audio folderAudioValues) string {
|
|
base := m.Config.Session.Downloads.Folder
|
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
|
base = filepath.Join(base, jsonutil.TitleCase(source))
|
|
}
|
|
|
|
bitrate := "Unknown"
|
|
if audio.bitrateKbps > 0 {
|
|
bitrate = strconv.Itoa(audio.bitrateKbps)
|
|
}
|
|
vals := map[string]string{
|
|
"albumartist": albumArtist,
|
|
"title": albumTitle,
|
|
"year": year,
|
|
"bit_depth": strconv.Itoa(audio.bitDepth),
|
|
"sampling_rate": audio.samplingRate,
|
|
"id": albumID,
|
|
"container": audio.container,
|
|
"codec": audio.codec,
|
|
"quality": audio.quality,
|
|
"bitrate": bitrate,
|
|
"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 m.safeJoin(base, folderName)
|
|
}
|
|
|
|
func (m *Main) trackOutputPath(source, id, title, ext string, d *provider.Downloadable, 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, jsonutil.TitleCase(source))
|
|
}
|
|
|
|
if albumFolder == "" && m.Config.Session.Filepaths.AddSinglesToFolder {
|
|
albumTitle := jsonutil.NestedString(trackMeta, "album", "title")
|
|
albumID := jsonutil.NestedString(trackMeta, "album", "id")
|
|
if albumID == "" {
|
|
albumID = id
|
|
}
|
|
albumArtist := extractAlbumArtist(trackMetaAlbum(trackMeta))
|
|
albumYear := naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date_original"]))
|
|
if albumYear == "Unknown" {
|
|
albumYear = naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date"]))
|
|
}
|
|
audioVals := m.folderAudioValues(source, jsonutil.IntFromAny(trackMeta["maximum_bit_depth"]), jsonutil.StringFromAny(trackMeta["maximum_sampling_rate"]), d)
|
|
albumFolder = m.albumFolderPath(source, albumID, albumTitle, albumArtist, albumYear, audioVals)
|
|
}
|
|
if albumFolder != "" {
|
|
base = albumFolder
|
|
if m.Config.Session.Downloads.DiscSubdirectories && albumDiscTotal > 1 {
|
|
discNumber := jsonutil.IntFromAny(trackMeta["media_number"])
|
|
if discNumber == 0 {
|
|
discNumber = jsonutil.IntFromAny(trackMeta["volumeNumber"])
|
|
}
|
|
if discNumber == 0 {
|
|
discNumber = jsonutil.IntFromAny(trackMeta["disk_number"])
|
|
}
|
|
if discNumber == 0 {
|
|
discNumber = 1
|
|
}
|
|
if discNumber > 0 {
|
|
base = filepath.Join(base, "Disc "+strconv.Itoa(discNumber))
|
|
}
|
|
}
|
|
}
|
|
|
|
trackNumber := jsonutil.IntFromAny(trackMeta["track_number"])
|
|
if trackNumber == 0 {
|
|
trackNumber = jsonutil.IntFromAny(trackMeta["trackNumber"])
|
|
}
|
|
explicit := ""
|
|
if jsonutil.BoolFromAny(trackMeta["parental_warning"]) || jsonutil.BoolFromAny(trackMeta["explicit"]) {
|
|
explicit = " (Explicit)"
|
|
}
|
|
artist := jsonutil.NestedString(trackMeta, "performer", "name")
|
|
if artist == "" {
|
|
artist = jsonutil.NestedString(trackMeta, "artist", "name")
|
|
}
|
|
albumArtist := jsonutil.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 m.safeJoin(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, jsonutil.TitleCase(source))
|
|
}
|
|
fileName := naming.CleanName(title, naming.Config{
|
|
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
|
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
|
|
})
|
|
if fileName == "" {
|
|
fileName = id
|
|
}
|
|
return m.safeJoin(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(jsonutil.StringFromAny(meta["version"]))
|
|
if version != "" && !isOriginalMix(version) {
|
|
return title + " (" + version + ")"
|
|
}
|
|
if title != "" {
|
|
return title
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func isOriginalMix(version string) bool {
|
|
return strings.EqualFold(strings.TrimSpace(version), "Original Mix")
|
|
}
|
|
|
|
func replaygainGainFromAny(v any) string {
|
|
s := strings.TrimSpace(jsonutil.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(jsonutil.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 := jsonutil.NestedString(trackMeta, "performer", "name")
|
|
if artist == "" {
|
|
artist = jsonutil.NestedString(trackMeta, "artist", "name")
|
|
}
|
|
artistNames := stringSliceFromAny(trackMeta["artist_names"])
|
|
albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
|
|
if albumArtist == "" {
|
|
albumArtist = artist
|
|
}
|
|
if strings.TrimSpace(opts.albumArtist) != "" {
|
|
albumArtist = strings.TrimSpace(opts.albumArtist)
|
|
}
|
|
trackNumber := jsonutil.IntFromAny(trackMeta["track_number"])
|
|
if trackNumber == 0 {
|
|
trackNumber = jsonutil.IntFromAny(trackMeta["trackNumber"])
|
|
}
|
|
discNumber := jsonutil.IntFromAny(trackMeta["media_number"])
|
|
if discNumber == 0 {
|
|
discNumber = jsonutil.IntFromAny(trackMeta["volumeNumber"])
|
|
}
|
|
if discNumber == 0 {
|
|
discNumber = jsonutil.IntFromAny(trackMeta["disk_number"])
|
|
}
|
|
date := jsonutil.StringFromAny(trackMeta["release_date_original"])
|
|
if date == "" {
|
|
date = jsonutil.StringFromAny(trackMeta["release_date"])
|
|
}
|
|
if date == "" {
|
|
date = jsonutil.StringFromAny(trackMeta["streamStartDate"])
|
|
}
|
|
album := jsonutil.NestedString(trackMeta, "album", "title")
|
|
if album == "" {
|
|
album = jsonutil.StringFromAny(trackMeta["title"])
|
|
}
|
|
trackTotal := jsonutil.IntFromAny(trackMeta["tracks_count"])
|
|
if trackTotal == 0 {
|
|
trackTotal = jsonutil.IntFromAny(trackMeta["numberOfTracks"])
|
|
}
|
|
if trackTotal == 0 {
|
|
trackTotal = jsonutil.IntFromAny(trackMeta["track_total"])
|
|
}
|
|
if trackTotal == 0 && opts.total > 0 {
|
|
trackTotal = opts.total
|
|
}
|
|
if opts.forPlaylist && opts.total > 0 {
|
|
trackTotal = opts.total
|
|
}
|
|
|
|
discTotal := jsonutil.IntFromAny(trackMeta["media_count"])
|
|
if discTotal == 0 {
|
|
discTotal = jsonutil.IntFromAny(trackMeta["numberOfVolumes"])
|
|
}
|
|
if !opts.forPlaylist && discTotal == 0 && opts.albumDiscTotal > 0 {
|
|
discTotal = opts.albumDiscTotal
|
|
}
|
|
if opts.forPlaylist {
|
|
discNumber = 0
|
|
discTotal = 0
|
|
}
|
|
if !opts.forPlaylist && discNumber == 0 {
|
|
discNumber = 1
|
|
}
|
|
|
|
genre := jsonutil.NestedString(trackMeta, "genre", "name")
|
|
if genre == "" {
|
|
genre = jsonutil.StringFromAny(trackMeta["genre"])
|
|
}
|
|
initialKey := jsonutil.FirstNonEmpty(
|
|
jsonutil.StringFromAny(trackMeta["key"]),
|
|
jsonutil.StringFromAny(trackMeta["initialkey"]),
|
|
)
|
|
initialKey = normalizeInitialKey(initialKey)
|
|
|
|
comment := jsonutil.StringFromAny(trackMeta["comment"])
|
|
description := jsonutil.StringFromAny(trackMeta["description"])
|
|
lyrics := jsonutil.StringFromAny(trackMeta["lyrics"])
|
|
if lrc := jsonutil.StringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
|
|
lyrics = lrc
|
|
}
|
|
trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"])
|
|
if trackGain == "" {
|
|
trackGain = replaygainGainFromAny(trackMeta["replayGain"])
|
|
}
|
|
if trackGain == "" {
|
|
trackGain = replaygainGainFromAny(trackMeta["gain"])
|
|
}
|
|
albumGain := replaygainGainFromAny(trackMeta["replaygain_album_gain"])
|
|
if albumGain == "" {
|
|
albumGain = replaygainGainFromAny(jsonutil.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(jsonutil.NestedAny(trackMeta, "album", "replaygain_album_peak"))
|
|
}
|
|
|
|
sourceAlbumID := jsonutil.NestedString(trackMeta, "album", "id")
|
|
if sourceAlbumID == "" {
|
|
sourceAlbumID = jsonutil.StringFromAny(trackMeta["source_album_id"])
|
|
}
|
|
sourceArtistID := jsonutil.NestedString(trackMeta, "artist", "id")
|
|
if sourceArtistID == "" {
|
|
sourceArtistID = jsonutil.NestedString(trackMeta, "performer", "id")
|
|
}
|
|
if sourceArtistID == "" {
|
|
sourceArtistID = jsonutil.StringFromAny(trackMeta["source_artist_id"])
|
|
}
|
|
sourceTrackID := trackID
|
|
if v := jsonutil.StringFromAny(trackMeta["source_track_id"]); v != "" {
|
|
sourceTrackID = v
|
|
}
|
|
|
|
return tag.Metadata{
|
|
Title: title,
|
|
Album: album,
|
|
Artist: artist,
|
|
Artists: artistNames,
|
|
AlbumArtist: albumArtist,
|
|
OmitDiscTags: opts.forPlaylist,
|
|
TrackNumber: trackNumber,
|
|
DiscNumber: discNumber,
|
|
TrackTotal: trackTotal,
|
|
DiscTotal: discTotal,
|
|
Date: date,
|
|
Genre: genre,
|
|
InitialKey: initialKey,
|
|
Comment: comment,
|
|
Description: description,
|
|
Lyrics: lyrics,
|
|
Copyright: jsonutil.StringFromAny(trackMeta["copyright"]),
|
|
ISRC: jsonutil.StringFromAny(trackMeta["isrc"]),
|
|
ReplaygainTrackGain: trackGain,
|
|
ReplaygainAlbumGain: albumGain,
|
|
ReplaygainTrackPeak: trackPeak,
|
|
ReplaygainAlbumPeak: albumPeak,
|
|
SourcePlatform: source,
|
|
SourceTrackID: sourceTrackID,
|
|
SourceAlbumID: sourceAlbumID,
|
|
SourceArtistID: sourceArtistID,
|
|
}
|
|
}
|
|
|
|
func stringSliceFromAny(v any) []string {
|
|
items, ok := v.([]string)
|
|
if ok {
|
|
return append([]string(nil), items...)
|
|
}
|
|
rawItems, ok := v.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(rawItems))
|
|
for _, raw := range rawItems {
|
|
if s := strings.TrimSpace(jsonutil.StringFromAny(raw)); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeInitialKey(in string) string {
|
|
s := strings.TrimSpace(in)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Fields(s)
|
|
if len(parts) >= 2 {
|
|
switch strings.ToLower(parts[1]) {
|
|
case "major", "maj":
|
|
if root := normalizeMajorKeyRoot(parts[0]); root != "" {
|
|
return root
|
|
}
|
|
case "minor", "min":
|
|
if root := normalizeMinorKeyRoot(parts[0]); root != "" {
|
|
return root + "m"
|
|
}
|
|
}
|
|
}
|
|
if strings.HasSuffix(s, "m") && len(s) > 1 {
|
|
root := normalizeMinorKeyRoot(strings.TrimSuffix(s, "m"))
|
|
if root != "" {
|
|
return root + "m"
|
|
}
|
|
}
|
|
if root := normalizeMajorKeyRoot(s); root != "" {
|
|
return root
|
|
}
|
|
return s
|
|
}
|
|
|
|
func normalizeMajorKeyRoot(root string) string {
|
|
s := normalizeKeyRootCase(root)
|
|
switch s {
|
|
case "C", "Db", "D", "Eb", "E", "F", "F#", "Gb", "G", "Ab", "A", "Bb", "B":
|
|
return s
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeMinorKeyRoot(root string) string {
|
|
s := normalizeKeyRootCase(root)
|
|
switch s {
|
|
case "C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B":
|
|
return s
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeKeyRootCase(root string) string {
|
|
s := strings.TrimSpace(root)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if len(s) >= 1 {
|
|
s = strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
|
}
|
|
return s
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
func isFFmpegMissingError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(err.Error()), "ffmpeg not found")
|
|
}
|
|
|
|
func extractAlbumArtist(albumMeta map[string]any) string {
|
|
if artistsRaw, ok := albumMeta["artists"].([]any); ok {
|
|
names := make([]string, 0, len(artistsRaw))
|
|
for _, a := range artistsRaw {
|
|
artist, ok := a.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if name := jsonutil.StringFromAny(artist["name"]); name != "" {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
if len(names) > 0 {
|
|
return jsonutil.DisplayArtistNames(names)
|
|
}
|
|
}
|
|
if names := stringSliceFromAny(albumMeta["artist_names"]); len(names) > 0 {
|
|
return jsonutil.DisplayArtistNames(names)
|
|
}
|
|
artist := jsonutil.NestedString(albumMeta, "artist", "name")
|
|
if artist != "" {
|
|
return artist
|
|
}
|
|
return "Unknown"
|
|
}
|