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