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") }