Files
streamrip-go/internal/app/app.go
Joren b2688ce949 add CLI parity flags and expand provider support
This brings the Go CLI closer to upstream behavior with global flag handling and clearer resolve failures, while adding Tidal video downloads plus initial Deezer and SoundCloud no-account flows for broader end-to-end coverage.
2026-04-20 00:56:10 +02:00

1132 lines
30 KiB
Go

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