diff --git a/internal/app/app.go b/internal/app/app.go index b0d847c..58a76f3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -48,6 +48,7 @@ type ripTrackOptions struct { albumFolder string albumEmbedCover string albumArtist string + prefetched *provider.Downloadable index int total int albumDiscTotal int @@ -56,6 +57,15 @@ type ripTrackOptions struct { 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 @@ -535,15 +545,6 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID year := naming.YearFromDate(releaseDate) bitDepth := jsonutil.IntFromAny(albumMeta["maximum_bit_depth"]) sampling := jsonutil.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 { @@ -571,7 +572,14 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID } } - folder := m.albumFolderPath(source, albumID, albumTitle, albumArtist, year, bitDepth, sampling) + 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"]) @@ -584,6 +592,9 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID 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) @@ -609,6 +620,9 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID 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 } @@ -834,13 +848,16 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall 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) + 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, meta, opts.albumFolder, opts.albumDiscTotal) + 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)) } @@ -953,20 +970,95 @@ func (m *Main) qualityProfileForSource(source string) (int, string) { } } -func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year string, bitDepth int, samplingRate string) string { +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(bitDepth), - "sampling_rate": samplingRate, + "bit_depth": strconv.Itoa(audio.bitDepth), + "sampling_rate": audio.samplingRate, "id": albumID, - "container": "FLAC", + "container": audio.container, + "codec": audio.codec, + "quality": audio.quality, + "bitrate": bitrate, "albumcomposer": "Unknown", } folderName := naming.FormatTemplate(m.Config.Session.Filepaths.FolderFormat, vals) @@ -978,7 +1070,7 @@ func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year st return filepath.Join(base, folderName) } -func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[string]any, albumFolder string, albumDiscTotal int) string { +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)) @@ -998,7 +1090,8 @@ func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[stri if albumYear == "Unknown" { albumYear = naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date"])) } - albumFolder = m.albumFolderPath(source, albumID, albumTitle, albumArtist, albumYear, jsonutil.IntFromAny(trackMeta["maximum_bit_depth"]), jsonutil.StringFromAny(trackMeta["maximum_sampling_rate"])) + 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 diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 31db4dd..b6dc359 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -64,22 +64,34 @@ type fakeVideoProvider struct { url string } +type fakeResolvedAlbumProvider struct { + url string + downloadable provider.Downloadable + downloadableHits int +} + type fakeFailProvider struct{} func (f *fakeAlbumProvider) Source() string { return "qobuz" } func (f *fakePlaylistProvider) Source() string { return "qobuz" } func (f *fakeVideoProvider) Source() string { return "tidal" } +func (f *fakeResolvedAlbumProvider) Source() string { return "qobuz" } func (f *fakeAlbumProvider) Login(context.Context) error { return nil } func (f *fakePlaylistProvider) Login(context.Context) error { return nil } func (f *fakeVideoProvider) Login(context.Context) error { return nil } -func (f *fakeAlbumProvider) LoggedIn() bool { return true } -func (f *fakePlaylistProvider) LoggedIn() bool { return true } -func (f *fakeVideoProvider) LoggedIn() bool { return true } -func (f *fakeAlbumProvider) Close() error { return nil } -func (f *fakePlaylistProvider) Close() error { return nil } -func (f *fakeVideoProvider) Close() error { return nil } +func (f *fakeResolvedAlbumProvider) Login(context.Context) error { + return nil +} +func (f *fakeAlbumProvider) LoggedIn() bool { return true } +func (f *fakePlaylistProvider) LoggedIn() bool { return true } +func (f *fakeVideoProvider) LoggedIn() bool { return true } +func (f *fakeResolvedAlbumProvider) LoggedIn() bool { return true } +func (f *fakeAlbumProvider) Close() error { return nil } +func (f *fakePlaylistProvider) Close() error { return nil } +func (f *fakeVideoProvider) Close() error { return nil } +func (f *fakeResolvedAlbumProvider) Close() error { return nil } func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } @@ -89,6 +101,9 @@ func (f *fakePlaylistProvider) Search(context.Context, string, string, int) ([]m func (f *fakeVideoProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } +func (f *fakeResolvedAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { + return nil, nil +} func (f *fakeAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { if mediaType == "album" { return map[string]any{ @@ -161,6 +176,22 @@ func (f *fakeVideoProvider) GetMetadata(_ context.Context, id string, mediaType } return nil, nil } +func (f *fakeResolvedAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { + if mediaType == "album" { + return map[string]any{ + "title": "Fallback Album", + "release_date_original": "2020-01-01", + "artist": map[string]any{"name": "Fallback Artist"}, + "tracks": map[string]any{"items": []any{map[string]any{"id": "t1"}}}, + }, nil + } + return map[string]any{ + "title": "Fallback Song", + "track_number": float64(1), + "performer": map[string]any{"name": "Fallback Artist"}, + "album": map[string]any{"title": "Fallback Album", "artist": map[string]any{"name": "Fallback Artist"}}, + }, nil +} func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil } @@ -173,6 +204,14 @@ func (f *fakePlaylistProvider) GetDownloadable(context.Context, string, int) (*p func (f *fakeVideoProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { return nil, nil } +func (f *fakeResolvedAlbumProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { + f.downloadableHits++ + d := f.downloadable + if d.URL == "" { + d.URL = f.url + } + return &d, nil +} func (f *fakeVideoProvider) GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) { return &provider.Downloadable{URL: f.url, Extension: "mp4", Source: "tidal"}, nil } @@ -466,7 +505,7 @@ func TestTrackOutputPathFallsBackToDisc1(t *testing.T) { "performer": map[string]any{"name": "Dido"}, "album": map[string]any{"id": "a", "title": "Greatest Hits", "artist": map[string]any{"name": "Dido"}}, } - path := m.trackOutputPath("tidal", "1", "Song", "flac", meta, filepath.Join(tmp, "Album"), 2) + path := m.trackOutputPath("tidal", "1", "Song", "flac", nil, meta, filepath.Join(tmp, "Album"), 2) if !strings.Contains(path, string(filepath.Separator)+"Disc 1"+string(filepath.Separator)) { t.Fatalf("expected Disc 1 subdir in path, got %q", path) } @@ -657,12 +696,107 @@ func TestTrackOutputPathSinglesUsesAlbumID(t *testing.T) { "performer": map[string]any{"name": "Artist"}, } - out := m.trackOutputPath("qobuz", "track-999", "Song", "flac", meta, "", 0) + out := m.trackOutputPath("qobuz", "track-999", "Song", "flac", nil, meta, "", 0) if got, want := filepath.Dir(out), filepath.Join(tmp, "album-123"); got != want { t.Fatalf("trackOutputPath() dir=%q want %q", got, want) } } +func TestTrackOutputPathSinglesUsesResolvedAudioProfile(t *testing.T) { + tmp := t.TempDir() + d := config.DefaultConfigData() + d.Downloads.Folder = tmp + d.Downloads.SourceSubdirectories = false + d.Filepaths.AddSinglesToFolder = true + d.Filepaths.FolderFormat = "{container}-{bit_depth}-{sampling_rate}" + d.Filepaths.TrackFormat = "{title}" + d.Filepaths.RestrictCharacters = false + + m := &Main{Config: &config.Config{File: d, Session: d}} + meta := map[string]any{ + "album": map[string]any{ + "id": "album-123", + "title": "Album", + "artist": map[string]any{"name": "Artist"}, + }, + "performer": map[string]any{"name": "Artist"}, + } + dl := &provider.Downloadable{Extension: "mp3", Audio: provider.AudioProfile{Container: "MP3", Codec: "MP3", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}} + + out := m.trackOutputPath("qobuz", "track-999", "Song", "mp3", dl, meta, "", 0) + if got, want := filepath.Dir(out), filepath.Join(tmp, "MP3-16-44.1"); got != want { + t.Fatalf("trackOutputPath() dir=%q want %q", got, want) + } +} + +func TestRipAlbumUsesResolvedAudioProfileForFolderName(t *testing.T) { + tmp := t.TempDir() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("audio-bytes")) + })) + defer ts.Close() + + d := config.DefaultConfigData() + d.Downloads.Folder = tmp + d.Downloads.SourceSubdirectories = false + d.Downloads.Concurrency = false + d.Filepaths.RestrictCharacters = false + cfg := &config.Config{File: d, Session: d} + + sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) + if err != nil { + t.Fatalf("NewSQLite() error = %v", err) + } + defer func() { _ = sqlite.Close() }() + + fake := &fakeResolvedAlbumProvider{ + url: ts.URL, + downloadable: provider.Downloadable{ + URL: ts.URL, + Extension: "mp3", + Source: "qobuz", + Audio: provider.AudioProfile{ + Container: "MP3", + Codec: "MP3", + Quality: "HIGH", + BitDepth: 16, + SamplingRate: "44.1", + BitrateKbps: 320, + }, + }, + } + + m := &Main{ + Config: cfg, + Providers: map[string]provider.Client{ + "qobuz": fake, + }, + Store: sqlite, + DL: download.NewWithOptions(true, false), + Tagger: noopTagger{}, + } + + ctx := context.Background() + if err = m.AddByID(ctx, "qobuz", "album", "a1"); err != nil { + t.Fatalf("AddByID() error = %v", err) + } + if err = m.Resolve(ctx); err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if err = m.Rip(ctx); err != nil { + t.Fatalf("Rip() error = %v", err) + } + + folder := filepath.Join(tmp, "Fallback Artist - Fallback Album (2020) [MP3] [16B-44.1kHz]") + if _, err = os.Stat(filepath.Join(folder, "01. Fallback Artist - Fallback Song.mp3")); err != nil { + t.Fatalf("missing track in resolved-quality folder: %v", err) + } + if fake.downloadableHits != 1 { + t.Fatalf("GetDownloadable() calls=%d, want 1 (prefetched reuse)", fake.downloadableHits) + } +} + func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) { meta := map[string]any{ "replayGain": float64(-7.25), diff --git a/internal/provider/deezer/client.go b/internal/provider/deezer/client.go index eb2192b..009af3a 100644 --- a/internal/provider/deezer/client.go +++ b/internal/provider/deezer/client.go @@ -308,7 +308,14 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov if trackID == "" { trackID = strings.TrimSpace(item) } - return &provider.Downloadable{URL: media.URL, Extension: ext, Source: "deezer", Cipher: media.Cipher, TrackID: trackID}, nil + return &provider.Downloadable{ + URL: media.URL, + Extension: ext, + Source: "deezer", + Cipher: media.Cipher, + TrackID: trackID, + Audio: audioProfileForFormat(media.Format), + }, nil } func (c *Client) apiGet(ctx context.Context, path string, params url.Values) (map[string]any, error) { @@ -1223,6 +1230,55 @@ func extensionForFormat(format string) string { } } +func audioProfileForFormat(format string) provider.AudioProfile { + profile := provider.AudioProfile{} + switch strings.ToUpper(strings.TrimSpace(format)) { + case "FLAC": + profile.Container = "FLAC" + profile.Codec = "FLAC" + profile.Quality = "LOSSLESS" + profile.BitDepth = 16 + profile.SamplingRate = "44.1" + case "MP3_320": + profile.Container = "MP3" + profile.Codec = "MP3" + profile.Quality = "HIGH" + profile.BitrateKbps = 320 + profile.BitDepth = 16 + profile.SamplingRate = "44.1" + case "MP3_128": + profile.Container = "MP3" + profile.Codec = "MP3" + profile.Quality = "LOW" + profile.BitrateKbps = 128 + profile.BitDepth = 16 + profile.SamplingRate = "44.1" + case "MP3_64", "MP3_MISC": + profile.Container = "MP3" + profile.Codec = "MP3" + profile.Quality = "LOW" + profile.BitrateKbps = 64 + profile.BitDepth = 16 + profile.SamplingRate = "44.1" + default: + if ext := extensionForFormat(format); ext == "flac" { + profile.Container = "FLAC" + profile.Codec = "FLAC" + profile.Quality = "LOSSLESS" + profile.BitDepth = 16 + profile.SamplingRate = "44.1" + } else { + profile.Container = "MP3" + profile.Codec = "MP3" + profile.Quality = "LOW" + profile.BitrateKbps = 128 + profile.BitDepth = 16 + profile.SamplingRate = "44.1" + } + } + return profile +} + func findStringByKey(v any, wantedKey string) string { w := strings.ToLower(strings.TrimSpace(wantedKey)) switch x := v.(type) { diff --git a/internal/provider/deezer/client_test.go b/internal/provider/deezer/client_test.go index b8911ae..e47deba 100644 --- a/internal/provider/deezer/client_test.go +++ b/internal/provider/deezer/client_test.go @@ -139,6 +139,9 @@ func TestGetDownloadableNativeCipher(t *testing.T) { if d.Cipher != "BF_CBC_STRIPE" || d.Extension != "flac" || d.TrackID != "42" { t.Fatalf("unexpected downloadable: %+v", d) } + if d.Audio.Container != "FLAC" || d.Audio.Quality != "LOSSLESS" { + t.Fatalf("unexpected audio profile: %+v", d.Audio) + } } func TestGetDownloadableRequiresARL(t *testing.T) { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a8362b1..cf82f72 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -8,6 +8,16 @@ type Downloadable struct { Source string Cipher string TrackID string + Audio AudioProfile +} + +type AudioProfile struct { + Container string + Codec string + Quality string + BitDepth int + SamplingRate string + BitrateKbps int } type Client interface { diff --git a/internal/provider/qobuz/client.go b/internal/provider/qobuz/client.go index c0ca300..a648b92 100644 --- a/internal/provider/qobuz/client.go +++ b/internal/provider/qobuz/client.go @@ -275,11 +275,13 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) } ext := qobuzDownloadExtension(resp, quality, streamURL) + profile := qobuzAudioProfile(resp, quality, ext) return &provider.Downloadable{ URL: streamURL, Extension: ext, Source: "qobuz", + Audio: profile, }, nil } @@ -318,6 +320,81 @@ func qobuzDownloadExtension(resp map[string]any, quality int, streamURL string) return "mp3" } +func qobuzAudioProfile(resp map[string]any, requestedQuality int, ext string) provider.AudioProfile { + if formatID, ok := intValue(resp["format_id"]); ok { + switch formatID { + case 5: + return provider.AudioProfile{ + Container: "MP3", + Codec: "MP3", + Quality: "HIGH", + BitDepth: 16, + SamplingRate: "44.1", + BitrateKbps: 320, + } + case 6: + return provider.AudioProfile{ + Container: "FLAC", + Codec: "FLAC", + Quality: "LOSSLESS", + BitDepth: 16, + SamplingRate: "44.1", + } + case 7: + return provider.AudioProfile{ + Container: "FLAC", + Codec: "FLAC", + Quality: "HI_RES", + BitDepth: 24, + SamplingRate: "96", + } + case 27: + return provider.AudioProfile{ + Container: "FLAC", + Codec: "FLAC", + Quality: "HI_RES", + BitDepth: 24, + SamplingRate: "192", + } + } + } + + if strings.EqualFold(ext, "mp3") { + bitrate := 128 + if requestedQuality >= 1 { + bitrate = 320 + } + return provider.AudioProfile{ + Container: "MP3", + Codec: "MP3", + Quality: "HIGH", + BitDepth: 16, + SamplingRate: "44.1", + BitrateKbps: bitrate, + } + } + + quality := "LOSSLESS" + bitDepth := 16 + sampling := "44.1" + if requestedQuality >= 4 { + quality = "HI_RES" + bitDepth = 24 + sampling = "192" + } else if requestedQuality >= 3 { + quality = "HI_RES" + bitDepth = 24 + sampling = "96" + } + return provider.AudioProfile{ + Container: "FLAC", + Codec: "FLAC", + Quality: quality, + BitDepth: bitDepth, + SamplingRate: sampling, + } +} + func (c *Client) Close() error { return nil } diff --git a/internal/provider/qobuz/client_test.go b/internal/provider/qobuz/client_test.go index d430d2b..64e12a2 100644 --- a/internal/provider/qobuz/client_test.go +++ b/internal/provider/qobuz/client_test.go @@ -366,6 +366,9 @@ func TestGetDownloadableUsesReturnedURLExtension(t *testing.T) { if d.Extension != "mp3" { t.Fatalf("extension = %q, want mp3", d.Extension) } + if d.Audio.Container != "MP3" || d.Audio.Codec != "MP3" { + t.Fatalf("unexpected audio profile: %+v", d.Audio) + } } func qobuzSecretSig(requestTS, secret string) string { diff --git a/internal/provider/soundcloud/client.go b/internal/provider/soundcloud/client.go index ab7d335..541d208 100644 --- a/internal/provider/soundcloud/client.go +++ b/internal/provider/soundcloud/client.go @@ -288,13 +288,30 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov if ext == "" { ext = "m4a" } - return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "soundcloud"}, nil + return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "soundcloud", Audio: soundcloudAudioProfile(ext)}, nil } func (c *Client) Close() error { return nil } +func soundcloudAudioProfile(ext string) provider.AudioProfile { + switch strings.ToLower(strings.TrimSpace(ext)) { + case "mp3": + return provider.AudioProfile{Container: "MP3", Codec: "MP3", Quality: "LOSSY", BitDepth: 16, SamplingRate: "44.1"} + case "flac": + return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"} + case "m4a", "aac": + return provider.AudioProfile{Container: "M4A", Codec: "AAC", Quality: "LOSSY", BitDepth: 16, SamplingRate: "44.1"} + default: + container := strings.ToUpper(strings.TrimSpace(ext)) + if container == "" { + container = "M4A" + } + return provider.AudioProfile{Container: container, Codec: container, Quality: "LOSSY", BitDepth: 16, SamplingRate: "44.1"} + } +} + func (c *Client) trackInfo(ctx context.Context, item string) (map[string]any, error) { if strings.TrimSpace(item) == "" { return nil, errors.New("empty soundcloud item") diff --git a/internal/provider/soundcloud/client_test.go b/internal/provider/soundcloud/client_test.go index ed1d0b4..57778e9 100644 --- a/internal/provider/soundcloud/client_test.go +++ b/internal/provider/soundcloud/client_test.go @@ -43,6 +43,9 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) { if d.URL != "https://cdn.example/audio.m4a" || d.Extension != "m4a" { t.Fatalf("unexpected downloadable: %+v", d) } + if d.Audio.Container != "M4A" || d.Audio.Codec != "AAC" { + t.Fatalf("unexpected audio profile: %+v", d.Audio) + } } func TestGetPlaylistMetadata(t *testing.T) { diff --git a/internal/provider/tidal/client.go b/internal/provider/tidal/client.go index a946d80..9b9492c 100644 --- a/internal/provider/tidal/client.go +++ b/internal/provider/tidal/client.go @@ -531,7 +531,11 @@ func (c *Client) getDownloadableFromTrackManifestForFormats(ctx context.Context, } } - return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal"}, nil + profile := tidalAudioProfileFromFormats(attrFormats) + if profile.Container == "" { + profile = tidalAudioProfileFromExtension(ext) + } + return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal", Audio: profile}, nil } func formatsForQuality(quality int, preferAtmos bool) []string { @@ -638,7 +642,121 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl } else if strings.Contains(codec, "ec-3") || strings.Contains(codec, "eac3") || strings.Contains(codec, "joc") || strings.Contains(codec, "atmos") { ext = "mka" } - return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal"} + profile := tidalAudioProfileFromCodec(codec) + if profile.Container == "" { + profile = tidalAudioProfileFromExtension(ext) + } + audioQuality := strings.ToUpper(strings.TrimSpace(stringify(resp["audioQuality"]))) + if audioQuality == "" { + audioQuality = strings.ToUpper(strings.TrimSpace(stringify(manifest["audioQuality"]))) + } + if audioQuality != "" { + profile = applyTidalAudioQuality(profile, audioQuality) + } + if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") { + profile = tidalAtmosAudioProfile() + } + return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal", Audio: profile} +} + +func tidalAudioProfileFromFormats(formats []any) provider.AudioProfile { + best := provider.AudioProfile{} + for _, raw := range formats { + f := strings.ToUpper(strings.TrimSpace(stringify(raw))) + switch { + case strings.Contains(f, "EAC3") || strings.Contains(f, "JOC") || strings.Contains(f, "ATMOS"): + return tidalAtmosAudioProfile() + case strings.Contains(f, "FLAC_HIRES"): + best = provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "HI_RES_LOSSLESS", BitDepth: 24} + case strings.Contains(f, "FLAC"): + if best.Container == "" { + best = provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"} + } + case strings.Contains(f, "AACLC"): + if best.Container == "" { + best = provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320} + } + case strings.Contains(f, "HEAAC"): + if best.Container == "" { + best = provider.AudioProfile{Container: "M4A", Codec: "HEAACV1", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 96} + } + } + } + return best +} + +func tidalAudioProfileFromCodec(codec string) provider.AudioProfile { + c := strings.ToLower(strings.TrimSpace(codec)) + switch { + case strings.Contains(c, "ec-3") || strings.Contains(c, "eac3") || strings.Contains(c, "joc") || strings.Contains(c, "atmos"): + return tidalAtmosAudioProfile() + case strings.Contains(c, "flac"): + return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"} + case strings.Contains(c, "mp4a.40.5") || strings.Contains(c, "mp4a.40.29"): + return provider.AudioProfile{Container: "M4A", Codec: "HEAACV1", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 96} + case strings.Contains(c, "mp4a") || strings.Contains(c, "aac"): + return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320} + default: + return provider.AudioProfile{} + } +} + +func tidalAtmosAudioProfile() provider.AudioProfile { + return provider.AudioProfile{Container: "MKA", Codec: "EAC3_JOC", Quality: "ATMOS", BitDepth: 24, SamplingRate: "48"} +} + +func tidalAudioProfileFromExtension(ext string) provider.AudioProfile { + switch strings.ToLower(strings.TrimSpace(ext)) { + case "flac": + return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"} + case "mka": + return tidalAtmosAudioProfile() + case "m4a": + return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320} + default: + container := strings.ToUpper(strings.TrimSpace(ext)) + if container == "" { + container = "M4A" + } + return provider.AudioProfile{Container: container, Codec: container} + } +} + +func applyTidalAudioQuality(profile provider.AudioProfile, audioQuality string) provider.AudioProfile { + aq := strings.ToUpper(strings.TrimSpace(audioQuality)) + if aq == "" { + return profile + } + profile.Quality = aq + switch aq { + case "HI_RES", "HI_RES_LOSSLESS": + if strings.EqualFold(profile.Container, "FLAC") { + if profile.BitDepth < 24 { + profile.BitDepth = 24 + } + } + case "LOSSLESS": + if strings.EqualFold(profile.Container, "FLAC") { + if profile.BitDepth == 0 { + profile.BitDepth = 16 + } + if profile.SamplingRate == "" { + profile.SamplingRate = "44.1" + } + } + case "HIGH": + if strings.EqualFold(profile.Container, "M4A") && profile.BitrateKbps == 0 { + profile.BitrateKbps = 320 + } + case "LOW": + if strings.EqualFold(profile.Container, "M4A") { + profile.Codec = "HEAACV1" + if profile.BitrateKbps == 0 { + profile.BitrateKbps = 96 + } + } + } + return profile } func bestHLSVariantURL(masterURL, playlist string) string { diff --git a/internal/provider/tidal/client_test.go b/internal/provider/tidal/client_test.go index 7433710..c109086 100644 --- a/internal/provider/tidal/client_test.go +++ b/internal/provider/tidal/client_test.go @@ -315,4 +315,7 @@ func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T) if d.Extension != "flac" { t.Fatalf("extension = %q, want flac", d.Extension) } + if d.Audio.Container != "FLAC" || d.Audio.Quality != "LOSSLESS" { + t.Fatalf("unexpected audio profile: %+v", d.Audio) + } }