unify folder naming with resolved audio profiles across providers

This commit is contained in:
2026-04-24 00:35:38 +02:00
parent d5b336ca4e
commit 232901f3eb
11 changed files with 550 additions and 33 deletions

View File

@@ -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

View File

@@ -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),