Files
streamrip-go/internal/app/app.go
2026-06-10 12:58:04 +02:00

1417 lines
40 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"os/exec"
"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/jsonutil"
"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"
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
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(path string, meta tag.Metadata, coverPath string) error
}
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{
"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{},
}
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...)
}
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) AddPlaylistByTrackIDs(ctx context.Context, source, playlistID, playlistName string, trackIDs []string) error {
p, err := m.GetLoggedInProvider(ctx, source)
if err != nil {
return err
}
if strings.TrimSpace(playlistName) == "" {
playlistName = playlistID
}
ids := make([]string, 0, len(trackIDs))
for _, id := range trackIDs {
id = strings.TrimSpace(id)
if id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return fmt.Errorf("playlist %q has no track ids", playlistName)
}
pending := media.PendingFunc{
ResolveFn: func(context.Context) (media.Media, error) {
metaItems := make([]any, 0, len(ids))
for _, id := range ids {
metaItems = append(metaItems, map[string]any{"id": id})
}
playlistMeta := map[string]any{
"name": playlistName,
"tracks": map[string]any{"items": metaItems},
}
return media.MediaFunc{RipFn: func(ctx context.Context) error {
if m.Config.Session.CLI.TextOutput {
m.logf("Downloading playlist: %s\n", playlistName)
}
return m.ripPlaylist(ctx, p, source, playlistID, playlistMeta)
}}, 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) 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 := 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
}
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 := 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 := 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 {
if err := m.requireSourceDownloadAuth(source); err != nil {
return err
}
albumTitle := titleFromMetadata(albumMeta, albumID)
albumArtist := jsonutil.NestedString(albumMeta, "artist", "name")
if albumArtist == "" {
albumArtist = "Unknown"
}
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 := 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 := 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"])
}
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 i == 0 {
opts.prefetched = prefetched
}
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 idx == 1 {
opts.prefetched = prefetched
}
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 {
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 := filepath.Join(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 := 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 := 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 := 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) 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 := filepath.Join(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 !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
}
}
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 := opts.prefetched
if d == nil {
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, 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 {
m.logf("retry: %s (%v)\n", filepath.Base(outPath), err)
if err = downloadOnce(); err != nil {
if fallbackProvider, ok := p.(trackFallbackDownloader); ok {
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.Store.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 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 {
if isFFmpegMissingError(err) {
_ = m.Store.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(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 "yandex":
return m.Config.Session.Yandex.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 filepath.Join(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 := jsonutil.NestedString(trackMeta, "album", "artist", "name")
if albumArtist == "" {
albumArtist = jsonutil.NestedString(trackMeta, "performer", "name")
}
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 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, 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 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(jsonutil.StringFromAny(meta["version"]))
if version != "" {
return title + " (" + version + ")"
}
if title != "" {
return title
}
}
return fallback
}
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")
}
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 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"])
}
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,
AlbumArtist: albumArtist,
TrackNumber: trackNumber,
DiscNumber: discNumber,
TrackTotal: trackTotal,
DiscTotal: discTotal,
Date: date,
Genre: genre,
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 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")
}