mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
unify folder naming with resolved audio profiles across providers
This commit is contained in:
@@ -48,6 +48,7 @@ type ripTrackOptions struct {
|
|||||||
albumFolder string
|
albumFolder string
|
||||||
albumEmbedCover string
|
albumEmbedCover string
|
||||||
albumArtist string
|
albumArtist string
|
||||||
|
prefetched *provider.Downloadable
|
||||||
index int
|
index int
|
||||||
total int
|
total int
|
||||||
albumDiscTotal int
|
albumDiscTotal int
|
||||||
@@ -56,6 +57,15 @@ type ripTrackOptions struct {
|
|||||||
playlistPos int
|
playlistPos int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type folderAudioValues struct {
|
||||||
|
container string
|
||||||
|
codec string
|
||||||
|
quality string
|
||||||
|
bitDepth int
|
||||||
|
samplingRate string
|
||||||
|
bitrateKbps int
|
||||||
|
}
|
||||||
|
|
||||||
type collectionAlbum struct {
|
type collectionAlbum struct {
|
||||||
ID string
|
ID string
|
||||||
Meta map[string]any
|
Meta map[string]any
|
||||||
@@ -535,15 +545,6 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
|
|||||||
year := naming.YearFromDate(releaseDate)
|
year := naming.YearFromDate(releaseDate)
|
||||||
bitDepth := jsonutil.IntFromAny(albumMeta["maximum_bit_depth"])
|
bitDepth := jsonutil.IntFromAny(albumMeta["maximum_bit_depth"])
|
||||||
sampling := jsonutil.StringFromAny(albumMeta["maximum_sampling_rate"])
|
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)
|
tracksMap, ok := albumMeta["tracks"].(map[string]any)
|
||||||
if !ok {
|
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)
|
artRes, _ := artwork.Prepare(ctx, m.DL, folder, albumMeta, m.Config.Session.Artwork, false)
|
||||||
total := len(trackIDs)
|
total := len(trackIDs)
|
||||||
discTotal := jsonutil.IntFromAny(albumMeta["media_count"])
|
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 {
|
if !m.Config.Session.Downloads.Concurrency || m.Config.Session.Downloads.MaxConnections == 1 {
|
||||||
for i, trackID := range trackIDs {
|
for i, trackID := range trackIDs {
|
||||||
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, albumArtist: albumArtist, index: i + 1, total: total, albumDiscTotal: discTotal}
|
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 {
|
if err := m.ripTrack(ctx, p, source, trackID, "", opts); err != nil {
|
||||||
failures++
|
failures++
|
||||||
m.logf("track failed: id=%s reason=%v\n", trackID, err)
|
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 wg.Done()
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
opts := ripTrackOptions{albumFolder: folder, albumEmbedCover: artRes.EmbedPath, albumArtist: albumArtist, index: idx, total: total, albumDiscTotal: discTotal}
|
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 {
|
if err := m.ripTrack(ctx, p, source, tid, "", opts); err != nil {
|
||||||
errCh <- err
|
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)
|
applyPlaylistMetadataOverrides(meta, m.Config.Session.Metadata, opts.playlistName, opts.playlistPos)
|
||||||
}
|
}
|
||||||
|
|
||||||
d, err := p.GetDownloadable(ctx, id, m.qualityForSource(source))
|
d := opts.prefetched
|
||||||
if err != nil {
|
if d == nil {
|
||||||
_ = m.Store.MarkFailed(ctx, source, "track", id)
|
d, err = p.GetDownloadable(ctx, id, m.qualityForSource(source))
|
||||||
return fmt.Errorf("id=%s title=%q get_downloadable: %w", id, title, err)
|
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()) {
|
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))
|
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
|
base := m.Config.Session.Downloads.Folder
|
||||||
if m.Config.Session.Downloads.SourceSubdirectories {
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
||||||
base = filepath.Join(base, jsonutil.TitleCase(source))
|
base = filepath.Join(base, jsonutil.TitleCase(source))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bitrate := "Unknown"
|
||||||
|
if audio.bitrateKbps > 0 {
|
||||||
|
bitrate = strconv.Itoa(audio.bitrateKbps)
|
||||||
|
}
|
||||||
vals := map[string]string{
|
vals := map[string]string{
|
||||||
"albumartist": albumArtist,
|
"albumartist": albumArtist,
|
||||||
"title": albumTitle,
|
"title": albumTitle,
|
||||||
"year": year,
|
"year": year,
|
||||||
"bit_depth": strconv.Itoa(bitDepth),
|
"bit_depth": strconv.Itoa(audio.bitDepth),
|
||||||
"sampling_rate": samplingRate,
|
"sampling_rate": audio.samplingRate,
|
||||||
"id": albumID,
|
"id": albumID,
|
||||||
"container": "FLAC",
|
"container": audio.container,
|
||||||
|
"codec": audio.codec,
|
||||||
|
"quality": audio.quality,
|
||||||
|
"bitrate": bitrate,
|
||||||
"albumcomposer": "Unknown",
|
"albumcomposer": "Unknown",
|
||||||
}
|
}
|
||||||
folderName := naming.FormatTemplate(m.Config.Session.Filepaths.FolderFormat, vals)
|
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)
|
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
|
base := m.Config.Session.Downloads.Folder
|
||||||
if m.Config.Session.Downloads.SourceSubdirectories {
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
||||||
base = filepath.Join(base, jsonutil.TitleCase(source))
|
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" {
|
if albumYear == "Unknown" {
|
||||||
albumYear = naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date"]))
|
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 != "" {
|
if albumFolder != "" {
|
||||||
base = albumFolder
|
base = albumFolder
|
||||||
|
|||||||
@@ -64,22 +64,34 @@ type fakeVideoProvider struct {
|
|||||||
url string
|
url string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeResolvedAlbumProvider struct {
|
||||||
|
url string
|
||||||
|
downloadable provider.Downloadable
|
||||||
|
downloadableHits int
|
||||||
|
}
|
||||||
|
|
||||||
type fakeFailProvider struct{}
|
type fakeFailProvider struct{}
|
||||||
|
|
||||||
func (f *fakeAlbumProvider) Source() string { return "qobuz" }
|
func (f *fakeAlbumProvider) Source() string { return "qobuz" }
|
||||||
func (f *fakePlaylistProvider) Source() string { return "qobuz" }
|
func (f *fakePlaylistProvider) Source() string { return "qobuz" }
|
||||||
func (f *fakeVideoProvider) Source() string { return "tidal" }
|
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 *fakeAlbumProvider) Login(context.Context) error { return nil }
|
||||||
func (f *fakePlaylistProvider) Login(context.Context) error {
|
func (f *fakePlaylistProvider) Login(context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeVideoProvider) Login(context.Context) error { return nil }
|
func (f *fakeVideoProvider) Login(context.Context) error { return nil }
|
||||||
func (f *fakeAlbumProvider) LoggedIn() bool { return true }
|
func (f *fakeResolvedAlbumProvider) Login(context.Context) error {
|
||||||
func (f *fakePlaylistProvider) LoggedIn() bool { return true }
|
return nil
|
||||||
func (f *fakeVideoProvider) LoggedIn() bool { return true }
|
}
|
||||||
func (f *fakeAlbumProvider) Close() error { return nil }
|
func (f *fakeAlbumProvider) LoggedIn() bool { return true }
|
||||||
func (f *fakePlaylistProvider) Close() error { return nil }
|
func (f *fakePlaylistProvider) LoggedIn() bool { return true }
|
||||||
func (f *fakeVideoProvider) Close() error { return nil }
|
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) {
|
func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
|
||||||
return nil, nil
|
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) {
|
func (f *fakeVideoProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
|
||||||
return nil, nil
|
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) {
|
func (f *fakeAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
|
||||||
if mediaType == "album" {
|
if mediaType == "album" {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
@@ -161,6 +176,22 @@ func (f *fakeVideoProvider) GetMetadata(_ context.Context, id string, mediaType
|
|||||||
}
|
}
|
||||||
return nil, nil
|
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) {
|
func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
|
||||||
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
|
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) {
|
func (f *fakeVideoProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
|
||||||
return nil, nil
|
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) {
|
func (f *fakeVideoProvider) GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) {
|
||||||
return &provider.Downloadable{URL: f.url, Extension: "mp4", Source: "tidal"}, nil
|
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"},
|
"performer": map[string]any{"name": "Dido"},
|
||||||
"album": map[string]any{"id": "a", "title": "Greatest Hits", "artist": 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)) {
|
if !strings.Contains(path, string(filepath.Separator)+"Disc 1"+string(filepath.Separator)) {
|
||||||
t.Fatalf("expected Disc 1 subdir in path, got %q", path)
|
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"},
|
"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 {
|
if got, want := filepath.Dir(out), filepath.Join(tmp, "album-123"); got != want {
|
||||||
t.Fatalf("trackOutputPath() dir=%q want %q", 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) {
|
func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) {
|
||||||
meta := map[string]any{
|
meta := map[string]any{
|
||||||
"replayGain": float64(-7.25),
|
"replayGain": float64(-7.25),
|
||||||
|
|||||||
@@ -308,7 +308,14 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
|
|||||||
if trackID == "" {
|
if trackID == "" {
|
||||||
trackID = strings.TrimSpace(item)
|
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) {
|
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 {
|
func findStringByKey(v any, wantedKey string) string {
|
||||||
w := strings.ToLower(strings.TrimSpace(wantedKey))
|
w := strings.ToLower(strings.TrimSpace(wantedKey))
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ func TestGetDownloadableNativeCipher(t *testing.T) {
|
|||||||
if d.Cipher != "BF_CBC_STRIPE" || d.Extension != "flac" || d.TrackID != "42" {
|
if d.Cipher != "BF_CBC_STRIPE" || d.Extension != "flac" || d.TrackID != "42" {
|
||||||
t.Fatalf("unexpected downloadable: %+v", d)
|
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) {
|
func TestGetDownloadableRequiresARL(t *testing.T) {
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ type Downloadable struct {
|
|||||||
Source string
|
Source string
|
||||||
Cipher string
|
Cipher string
|
||||||
TrackID string
|
TrackID string
|
||||||
|
Audio AudioProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioProfile struct {
|
||||||
|
Container string
|
||||||
|
Codec string
|
||||||
|
Quality string
|
||||||
|
BitDepth int
|
||||||
|
SamplingRate string
|
||||||
|
BitrateKbps int
|
||||||
}
|
}
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
|
|||||||
@@ -275,11 +275,13 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, quality int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
ext := qobuzDownloadExtension(resp, quality, streamURL)
|
ext := qobuzDownloadExtension(resp, quality, streamURL)
|
||||||
|
profile := qobuzAudioProfile(resp, quality, ext)
|
||||||
|
|
||||||
return &provider.Downloadable{
|
return &provider.Downloadable{
|
||||||
URL: streamURL,
|
URL: streamURL,
|
||||||
Extension: ext,
|
Extension: ext,
|
||||||
Source: "qobuz",
|
Source: "qobuz",
|
||||||
|
Audio: profile,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +320,81 @@ func qobuzDownloadExtension(resp map[string]any, quality int, streamURL string)
|
|||||||
return "mp3"
|
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 {
|
func (c *Client) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,6 +366,9 @@ func TestGetDownloadableUsesReturnedURLExtension(t *testing.T) {
|
|||||||
if d.Extension != "mp3" {
|
if d.Extension != "mp3" {
|
||||||
t.Fatalf("extension = %q, want mp3", d.Extension)
|
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 {
|
func qobuzSecretSig(requestTS, secret string) string {
|
||||||
|
|||||||
@@ -288,13 +288,30 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
|
|||||||
if ext == "" {
|
if ext == "" {
|
||||||
ext = "m4a"
|
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 {
|
func (c *Client) Close() error {
|
||||||
return nil
|
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) {
|
func (c *Client) trackInfo(ctx context.Context, item string) (map[string]any, error) {
|
||||||
if strings.TrimSpace(item) == "" {
|
if strings.TrimSpace(item) == "" {
|
||||||
return nil, errors.New("empty soundcloud item")
|
return nil, errors.New("empty soundcloud item")
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) {
|
|||||||
if d.URL != "https://cdn.example/audio.m4a" || d.Extension != "m4a" {
|
if d.URL != "https://cdn.example/audio.m4a" || d.Extension != "m4a" {
|
||||||
t.Fatalf("unexpected downloadable: %+v", d)
|
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) {
|
func TestGetPlaylistMetadata(t *testing.T) {
|
||||||
|
|||||||
@@ -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 {
|
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") {
|
} else if strings.Contains(codec, "ec-3") || strings.Contains(codec, "eac3") || strings.Contains(codec, "joc") || strings.Contains(codec, "atmos") {
|
||||||
ext = "mka"
|
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 {
|
func bestHLSVariantURL(masterURL, playlist string) string {
|
||||||
|
|||||||
@@ -315,4 +315,7 @@ func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T)
|
|||||||
if d.Extension != "flac" {
|
if d.Extension != "flac" {
|
||||||
t.Fatalf("extension = %q, want flac", d.Extension)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user