From 7d330fb2ecbdbbbb30015a32d4745568e104e35c Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 6 Apr 2026 01:58:36 +0700 Subject: [PATCH] fix: preserve composer metadata across qobuz and history --- go_backend/qobuz.go | 9 +++++ go_backend/qobuz_test.go | 39 +++++++++++++++++- lib/providers/download_queue_provider.dart | 47 +++++++++++++++++++--- lib/screens/track_metadata_screen.dart | 1 + lib/services/history_database.dart | 14 ++++++- 5 files changed, 101 insertions(+), 9 deletions(-) diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index d4bf27e..a1330fe 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -55,6 +55,7 @@ const ( qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/" qobuzStoreBaseURL = "https://www.qobuz.com/us-en" qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download" + qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz" qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId=" qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId=" qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/" @@ -105,6 +106,10 @@ type QobuzTrack struct { ID int64 `json:"id"` Name string `json:"name"` } `json:"performer"` + Composer struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"composer"` } type qobuzImageSet struct { @@ -349,6 +354,7 @@ func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata { AlbumID: qobuzPrefixedID(track.Album.ID), ArtistID: qobuzTrackArtistID(track), AlbumType: qobuzTrackAlbumType(track), + Composer: strings.TrimSpace(track.Composer.Name), } } @@ -373,6 +379,7 @@ func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata { AlbumID: qobuzPrefixedID(track.Album.ID), AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)), AlbumType: qobuzTrackAlbumType(track), + Composer: strings.TrimSpace(track.Composer.Name), } } @@ -1133,6 +1140,7 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP func (q *QobuzDownloader) GetAvailableAPIs() []string { return []string{ qobuzDownloadAPIURL, + qobuzZarzDownloadAPIURL, qobuzDabMusicAPIURL, qobuzDeebAPIURL, qobuzAfkarAPIURL, @@ -1154,6 +1162,7 @@ const ( func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider { return []qobuzAPIProvider{ {Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, + {Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL}, {Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard}, {Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard}, {Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard}, diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index d888116..cf7106a 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -241,12 +241,13 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) { func TestQobuzAvailableProviders(t *testing.T) { providers := NewQobuzDownloader().GetAvailableProviders() - if len(providers) != 5 { - t.Fatalf("expected 5 Qobuz providers, got %d", len(providers)) + if len(providers) != 6 { + t.Fatalf("expected 6 Qobuz providers, got %d", len(providers)) } want := map[string]string{ "musicdl": qobuzAPIKindMusicDL, + "zarz": qobuzAPIKindMusicDL, "dabmusic": qobuzAPIKindStandard, "deeb": qobuzAPIKindStandard, "qbz": qobuzAPIKindStandard, @@ -518,3 +519,37 @@ func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) { t.Fatal("expected SongLink Qobuz source to bypass artist/title verification") } } + +func TestQobuzTrackMetadataIncludesComposer(t *testing.T) { + track := &QobuzTrack{ + ID: 40681594, + Title: "Sign of the Times", + ISRC: "USSM11703595", + Duration: 340, + TrackNumber: 1, + MediaNumber: 1, + } + track.Performer.ID = 729886 + track.Performer.Name = "Harry Styles" + track.Composer.ID = 729886 + track.Composer.Name = "Harry Styles" + track.Album.ID = "0886446451985" + track.Album.Title = "Harry Styles" + track.Album.ReleaseDate = "2017-05-12" + track.Album.TracksCount = 10 + track.Album.ReleaseType = "album" + track.Album.ProductType = "album" + track.Album.Artist.ID = 729886 + track.Album.Artist.Name = "Harry Styles" + track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}} + + trackMeta := qobuzTrackToTrackMetadata(track) + if trackMeta.Composer != "Harry Styles" { + t.Fatalf("track composer = %q", trackMeta.Composer) + } + + albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track) + if albumTrackMeta.Composer != "Harry Styles" { + t.Fatalf("album track composer = %q", albumTrackMeta.Composer) + } +} diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index dbd5c13..782eea7 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -59,6 +59,7 @@ class DownloadHistoryItem { final int? bitDepth; final int? sampleRate; final String? genre; + final String? composer; final String? label; final String? copyright; @@ -87,6 +88,7 @@ class DownloadHistoryItem { this.bitDepth, this.sampleRate, this.genre, + this.composer, this.label, this.copyright, }); @@ -116,6 +118,7 @@ class DownloadHistoryItem { 'bitDepth': bitDepth, 'sampleRate': sampleRate, 'genre': genre, + 'composer': composer, 'label': label, 'copyright': copyright, }; @@ -146,6 +149,7 @@ class DownloadHistoryItem { bitDepth: json['bitDepth'] as int?, sampleRate: json['sampleRate'] as int?, genre: json['genre'] as String?, + composer: json['composer'] as String?, label: json['label'] as String?, copyright: json['copyright'] as String?, ); @@ -172,6 +176,7 @@ class DownloadHistoryItem { int? bitDepth, int? sampleRate, String? genre, + String? composer, String? label, String? copyright, }) { @@ -200,6 +205,7 @@ class DownloadHistoryItem { bitDepth: bitDepth ?? this.bitDepth, sampleRate: sampleRate ?? this.sampleRate, genre: genre ?? this.genre, + composer: composer ?? this.composer, label: label ?? this.label, copyright: copyright ?? this.copyright, ); @@ -578,12 +584,17 @@ class DownloadHistoryNotifier extends Notifier { trimmedPath.startsWith('content://')); if (hasResolvedSpecs && !isPlaceholderQualityLabel(item.quality)) { - return false; + final needsComposerBackfill = + normalizeOptionalString(item.composer) == null; + return needsComposerBackfill; } + final needsComposerBackfill = + normalizeOptionalString(item.composer) == null; return needsLosslessSpecProbe || isPlaceholderQualityLabel(item.quality) || - normalizeOptionalString(item.quality) == null; + normalizeOptionalString(item.quality) == null || + needsComposerBackfill; } Future?> _probeAudioMetadata( @@ -607,8 +618,12 @@ class DownloadHistoryNotifier extends Notifier { sampleRate: sampleRate, storedQuality: fallbackQuality, ); + final composer = normalizeOptionalString(result['composer']?.toString()); - if (quality == null && bitDepth == null && sampleRate == null) { + if (quality == null && + bitDepth == null && + sampleRate == null && + composer == null) { return null; } @@ -616,6 +631,7 @@ class DownloadHistoryNotifier extends Notifier { 'quality': quality, 'bitDepth': bitDepth, 'sampleRate': sampleRate, + 'composer': composer, }; } catch (e) { _historyLog.d('Audio metadata probe failed for $filePath: $e'); @@ -682,6 +698,9 @@ class DownloadHistoryNotifier extends Notifier { ); final resolvedBitDepth = probed['bitDepth'] as int?; final resolvedSampleRate = probed['sampleRate'] as int?; + final resolvedComposer = normalizeOptionalString( + probed['composer'] as String?, + ); final qualityChanged = resolvedQuality != null && resolvedQuality != item.quality; @@ -689,8 +708,13 @@ class DownloadHistoryNotifier extends Notifier { resolvedBitDepth != null && resolvedBitDepth != item.bitDepth; final sampleRateChanged = resolvedSampleRate != null && resolvedSampleRate != item.sampleRate; + final composerChanged = + resolvedComposer != null && resolvedComposer != item.composer; - if (!qualityChanged && !bitDepthChanged && !sampleRateChanged) { + if (!qualityChanged && + !bitDepthChanged && + !sampleRateChanged && + !composerChanged) { continue; } @@ -698,6 +722,7 @@ class DownloadHistoryNotifier extends Notifier { quality: resolvedQuality, bitDepth: resolvedBitDepth, sampleRate: resolvedSampleRate, + composer: resolvedComposer, ); updatedItems ??= [...items]; updatedItems[index] = updated; @@ -746,6 +771,9 @@ class DownloadHistoryNotifier extends Notifier { genre: normalizeOptionalString(item.genre) ?? normalizeOptionalString(existing.genre), + composer: + normalizeOptionalString(item.composer) ?? + normalizeOptionalString(existing.composer), label: normalizeOptionalString(item.label) ?? normalizeOptionalString(existing.label), @@ -851,6 +879,7 @@ class DownloadHistoryNotifier extends Notifier { int? discNumber, String? releaseDate, String? genre, + String? composer, String? label, String? copyright, }) async { @@ -868,6 +897,7 @@ class DownloadHistoryNotifier extends Notifier { discNumber: discNumber, releaseDate: releaseDate, genre: genre, + composer: composer, label: label, copyright: copyright, ); @@ -3224,6 +3254,9 @@ class DownloadQueueNotifier extends Notifier { final backendAlbumArtist = normalizeOptionalString( backendResult['album_artist'] as String?, ); + final backendComposer = normalizeOptionalString( + backendResult['composer']?.toString(), + ); final hasOverrides = backendTrackNum != null || @@ -3232,7 +3265,8 @@ class DownloadQueueNotifier extends Notifier { backendAlbum != null || backendIsrc != null || backendCoverUrl != null || - backendAlbumArtist != null; + backendAlbumArtist != null || + backendComposer != null; if (!hasOverrides) { return baseTrack; @@ -3257,7 +3291,7 @@ class DownloadQueueNotifier extends Notifier { availability: baseTrack.availability, albumType: baseTrack.albumType, totalTracks: baseTrack.totalTracks, - composer: baseTrack.composer, + composer: backendComposer ?? baseTrack.composer, source: baseTrack.source, ); } @@ -5511,6 +5545,7 @@ class DownloadQueueNotifier extends Notifier { bitDepth: historyBitDepth, sampleRate: historySampleRate, genre: effectiveGenre, + composer: trackToDownload.composer, label: effectiveLabel, copyright: effectiveCopyright, ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index d6464c4..1887b31 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -2770,6 +2770,7 @@ class _TrackMetadataScreenState extends ConsumerState { discNumber: discNumber, releaseDate: normalizedOrNull(releaseDate), genre: normalizedOrNull(genre), + composer: normalizedOrNull(composer), label: normalizedOrNull(label), copyright: normalizedOrNull(copyright), ); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index b6ba580..4d9caea 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -31,7 +31,7 @@ class HistoryDatabase { return await openDatabase( path, - version: 3, + version: 4, onConfigure: (db) async { await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); @@ -70,6 +70,7 @@ class HistoryDatabase { bit_depth INTEGER, sample_rate INTEGER, genre TEXT, + composer TEXT, label TEXT, copyright TEXT ) @@ -98,6 +99,15 @@ class HistoryDatabase { if (oldVersion < 3) { await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER'); } + if (oldVersion < 4) { + final columns = await db.rawQuery('PRAGMA table_info(history)'); + final hasComposer = columns.any( + (row) => (row['name']?.toString().toLowerCase() ?? '') == 'composer', + ); + if (!hasComposer) { + await db.execute('ALTER TABLE history ADD COLUMN composer TEXT'); + } + } } static final _iosContainerPattern = RegExp( @@ -265,6 +275,7 @@ class HistoryDatabase { 'bit_depth': json['bitDepth'], 'sample_rate': json['sampleRate'], 'genre': json['genre'], + 'composer': json['composer'], 'label': json['label'], 'copyright': json['copyright'], }; @@ -296,6 +307,7 @@ class HistoryDatabase { 'bitDepth': row['bit_depth'], 'sampleRate': row['sample_rate'], 'genre': row['genre'], + 'composer': row['composer'], 'label': row['label'], 'copyright': row['copyright'], };