From 49c3c66f15a0cdedba23d958262aae3bccf8a33c Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:03:49 +0700 Subject: [PATCH] fix(metadata): preserve Track fields during enrichment Apply resolved metadata with copyWith and retain the existing field precedence. Keep UPC, preview URLs, and source-only fields when enrichment updates other tags. Extract the pure merge helpers and add regression tests for field preservation. --- .../download_queue_provider_embedding.dart | 436 ++++++++---------- ...download_queue_provider_native_worker.dart | 2 +- .../download_queue_provider_single_item.dart | 11 +- test/download_track_metadata_merge_test.dart | 110 +++++ 4 files changed, 316 insertions(+), 243 deletions(-) create mode 100644 test/download_track_metadata_merge_test.dart diff --git a/lib/providers/download_queue_provider_embedding.dart b/lib/providers/download_queue_provider_embedding.dart index 0b24a6b5..eb32d9dc 100644 --- a/lib/providers/download_queue_provider_embedding.dart +++ b/lib/providers/download_queue_provider_embedding.dart @@ -32,12 +32,6 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { return albumArtist; } - static final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$'); - - bool _isValidISRC(String value) { - return _isrcRegex.hasMatch(value.toUpperCase()); - } - /// Returns true if any enabled extension matching [source] or [service] /// declares `skipLyrics: true` in its manifest. bool _shouldSkipLyrics( @@ -129,64 +123,6 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { return null; } - Track _copyTrackWithResolvedMetadata( - Track track, { - String? resolvedIsrc, - int? trackNumber, - int? totalTracks, - int? discNumber, - int? totalDiscs, - String? releaseDate, - String? deezerId, - String? composer, - }) { - final normalizedIsrc = normalizeOptionalString(resolvedIsrc); - final normalizedComposer = normalizeOptionalString(composer); - - return Track( - id: track.id, - name: track.name, - artistName: track.artistName, - albumName: track.albumName, - albumArtist: track.albumArtist, - artistId: track.artistId, - albumId: track.albumId, - coverUrl: normalizeCoverReference(track.coverUrl), - duration: track.duration, - isrc: (normalizedIsrc != null && _isValidISRC(normalizedIsrc)) - ? normalizedIsrc - : track.isrc, - trackNumber: (track.trackNumber != null && track.trackNumber! > 0) - ? track.trackNumber - : trackNumber, - discNumber: (track.discNumber != null && track.discNumber! > 0) - ? track.discNumber - : discNumber, - totalDiscs: (track.totalDiscs != null && track.totalDiscs! > 0) - ? track.totalDiscs - : totalDiscs, - releaseDate: track.releaseDate ?? normalizeOptionalString(releaseDate), - deezerId: deezerId ?? track.deezerId, - availability: track.availability, - source: track.source, - albumType: track.albumType, - totalTracks: (track.totalTracks != null && track.totalTracks! > 0) - ? track.totalTracks - : totalTracks, - composer: (track.composer != null && track.composer!.isNotEmpty) - ? track.composer - : normalizedComposer, - genre: track.genre, - label: track.label, - copyright: track.copyright, - comment: track.comment, - itemType: track.itemType, - audioQuality: track.audioQuality, - audioModes: track.audioModes, - explicit: track.explicit, - ); - } - Future<_DeezerLookupPreparation> _resolveProviderTrackForDeezerLookup( Track track, String itemId, @@ -231,7 +167,7 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { 'Resolved ISRC from ${effectiveProvider.isEmpty ? provider : effectiveProvider}: $resolvedIsrc', ); - final updatedTrack = _copyTrackWithResolvedMetadata( + final updatedTrack = copyTrackWithResolvedMetadata( track, resolvedIsrc: resolvedIsrc, releaseDate: trackData['release_date'] as String?, @@ -251,7 +187,7 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { return _DeezerLookupPreparation( track: deezerTrackId == null ? updatedTrack - : _copyTrackWithResolvedMetadata( + : copyTrackWithResolvedMetadata( updatedTrack, deezerId: deezerTrackId, ), @@ -321,7 +257,7 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { deezerTrackId != null; final updatedTrack = needsEnrich - ? _copyTrackWithResolvedMetadata( + ? copyTrackWithResolvedMetadata( track, resolvedIsrc: deezerIsrc, releaseDate: trackData['release_date'] as String?, @@ -351,7 +287,7 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { deezerTrackId = deezerData['id'].toString(); _log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId'); return _DeezerLookupPreparation( - track: _copyTrackWithResolvedMetadata(track, deezerId: deezerTrackId), + track: copyTrackWithResolvedMetadata(track, deezerId: deezerTrackId), deezerTrackId: deezerTrackId, ); } @@ -451,172 +387,6 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { return _loadDeezerExtendedMetadata(deezerTrackId); } - bool _isUsableIndex(int? number, int? total) { - if (number == null || number <= 0) return false; - return total == null || total <= 0 || number <= total; - } - - int? _resolvePositiveMetadataInt(int? sourceValue, int? backendValue) { - if (sourceValue != null && sourceValue > 0) return sourceValue; - return backendValue; - } - - int? _resolveMetadataIndex({ - required int? sourceValue, - required int? backendValue, - required int? total, - }) { - if (_isUsableIndex(sourceValue, total)) return sourceValue; - if (_isUsableIndex(backendValue, total)) return backendValue; - return sourceValue != null && sourceValue > 0 ? sourceValue : backendValue; - } - - String? _resolveMetadataText(String? sourceValue, String? backendValue) { - return normalizeOptionalString(sourceValue) ?? - normalizeOptionalString(backendValue); - } - - Track _buildTrackForMetadataEmbedding( - Track baseTrack, - Map backendResult, - String? resolvedAlbumArtist, - ) { - final backendTrackNum = readPositiveInt(backendResult['track_number']); - final backendDiscNum = readPositiveInt(backendResult['disc_number']); - final backendTotalTracks = readPositiveInt(backendResult['total_tracks']); - final backendTotalDiscs = readPositiveInt(backendResult['total_discs']); - final backendYear = normalizeOptionalString( - backendResult['release_date'] as String?, - ); - final backendAlbum = normalizeOptionalString( - backendResult['album'] as String?, - ); - final backendIsrc = normalizeOptionalString( - backendResult['isrc'] as String?, - ); - final backendCoverUrl = normalizeCoverReference( - backendResult['cover_url']?.toString(), - ); - final baseCoverUrl = normalizeCoverReference(baseTrack.coverUrl); - final resolvedCoverUrl = baseCoverUrl ?? backendCoverUrl; - final backendAlbumArtist = normalizeOptionalString( - backendResult['album_artist'] as String?, - ); - final backendComposer = normalizeOptionalString( - backendResult['composer']?.toString(), - ); - final sourceAlbumName = normalizeOptionalString(baseTrack.albumName); - final sourceAlbumArtist = normalizeOptionalString(baseTrack.albumArtist); - final sourceIsrc = normalizeOptionalString(baseTrack.isrc); - final sourceReleaseDate = normalizeOptionalString(baseTrack.releaseDate); - final sourceComposer = normalizeOptionalString(baseTrack.composer); - final sourceAlbumType = normalizeOptionalString(baseTrack.albumType); - final sourceGenre = normalizeOptionalString(baseTrack.genre); - final sourceLabel = normalizeOptionalString(baseTrack.label); - final sourceCopyright = normalizeOptionalString(baseTrack.copyright); - final sourceComment = normalizeOptionalString(baseTrack.comment); - final sourceUpc = normalizeOptionalString(baseTrack.upc); - final backendGenre = normalizeOptionalString( - backendResult['genre']?.toString(), - ); - final backendLabel = normalizeOptionalString( - backendResult['label']?.toString(), - ); - final backendCopyright = normalizeOptionalString( - backendResult['copyright']?.toString(), - ); - final backendComment = normalizeOptionalString( - backendResult['comment']?.toString(), - ); - final resolvedComment = backendComment ?? sourceComment; - final backendAlbumType = normalizeOptionalString( - backendResult['album_type']?.toString(), - ); - final backendUpc = normalizeOptionalString( - (backendResult['upc'] ?? backendResult['barcode'])?.toString(), - ); - final backendExplicit = backendResult['explicit'] == true; - final resolvedTotalTracks = _resolvePositiveMetadataInt( - baseTrack.totalTracks, - backendTotalTracks, - ); - final resolvedTotalDiscs = _resolvePositiveMetadataInt( - baseTrack.totalDiscs, - backendTotalDiscs, - ); - final resolvedTrackNumber = _resolveMetadataIndex( - sourceValue: baseTrack.trackNumber, - backendValue: backendTrackNum, - total: resolvedTotalTracks, - ); - final resolvedDiscNumber = _resolveMetadataIndex( - sourceValue: baseTrack.discNumber, - backendValue: backendDiscNum, - total: resolvedTotalDiscs, - ); - - final hasOverrides = - resolvedTrackNumber != baseTrack.trackNumber || - resolvedDiscNumber != baseTrack.discNumber || - resolvedTotalTracks != baseTrack.totalTracks || - resolvedTotalDiscs != baseTrack.totalDiscs || - resolvedAlbumArtist != sourceAlbumArtist || - (sourceReleaseDate == null && backendYear != null) || - (sourceAlbumName == null && backendAlbum != null) || - (sourceIsrc == null && backendIsrc != null) || - (baseCoverUrl == null && backendCoverUrl != null) || - (sourceAlbumArtist == null && - resolvedAlbumArtist == null && - backendAlbumArtist != null) || - (sourceComposer == null && backendComposer != null) || - (sourceAlbumType == null && backendAlbumType != null) || - (sourceGenre == null && backendGenre != null) || - (sourceLabel == null && backendLabel != null) || - (sourceCopyright == null && backendCopyright != null) || - resolvedComment != sourceComment || - (baseTrack.explicit != true && backendExplicit) || - (sourceUpc == null && backendUpc != null); - - if (!hasOverrides) { - return baseTrack; - } - - return Track( - id: baseTrack.id, - name: baseTrack.name, - artistName: baseTrack.artistName, - albumName: sourceAlbumName ?? backendAlbum ?? baseTrack.albumName, - albumArtist: - resolvedAlbumArtist ?? sourceAlbumArtist ?? backendAlbumArtist, - artistId: baseTrack.artistId, - albumId: baseTrack.albumId, - coverUrl: resolvedCoverUrl, - duration: baseTrack.duration, - isrc: sourceIsrc ?? backendIsrc, - trackNumber: resolvedTrackNumber, - discNumber: resolvedDiscNumber, - totalDiscs: resolvedTotalDiscs, - releaseDate: sourceReleaseDate ?? backendYear, - deezerId: baseTrack.deezerId, - availability: baseTrack.availability, - albumType: sourceAlbumType ?? backendAlbumType, - totalTracks: resolvedTotalTracks, - composer: sourceComposer ?? backendComposer, - genre: sourceGenre ?? backendGenre, - label: sourceLabel ?? backendLabel, - copyright: sourceCopyright ?? backendCopyright, - comment: resolvedComment, - source: baseTrack.source, - itemType: baseTrack.itemType, - audioQuality: baseTrack.audioQuality, - audioModes: baseTrack.audioModes, - explicit: baseTrack.explicit == true || backendExplicit - ? true - : baseTrack.explicit, - upc: sourceUpc ?? backendUpc, - ); - } - /// Unified metadata, cover, lyrics, and ReplayGain embedding for all formats. /// /// [format] must be one of `'flac'`, `'m4a'`, `'mp3'`, or `'opus'`. @@ -1079,3 +849,201 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { } } } + +final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$'); + +bool _isValidISRC(String value) { + return _isrcRegex.hasMatch(value.toUpperCase()); +} + +/// Fills resolved identifiers and missing metadata without dropping other tags. +Track copyTrackWithResolvedMetadata( + Track track, { + String? resolvedIsrc, + int? trackNumber, + int? totalTracks, + int? discNumber, + int? totalDiscs, + String? releaseDate, + String? deezerId, + String? composer, +}) { + final normalizedIsrc = normalizeOptionalString(resolvedIsrc); + final normalizedComposer = normalizeOptionalString(composer); + + return track.copyWith( + coverUrl: normalizeCoverReference(track.coverUrl), + isrc: (normalizedIsrc != null && _isValidISRC(normalizedIsrc)) + ? normalizedIsrc + : track.isrc, + trackNumber: (track.trackNumber != null && track.trackNumber! > 0) + ? track.trackNumber + : trackNumber, + discNumber: (track.discNumber != null && track.discNumber! > 0) + ? track.discNumber + : discNumber, + totalDiscs: (track.totalDiscs != null && track.totalDiscs! > 0) + ? track.totalDiscs + : totalDiscs, + releaseDate: track.releaseDate ?? normalizeOptionalString(releaseDate), + deezerId: deezerId ?? track.deezerId, + totalTracks: (track.totalTracks != null && track.totalTracks! > 0) + ? track.totalTracks + : totalTracks, + composer: (track.composer != null && track.composer!.isNotEmpty) + ? track.composer + : normalizedComposer, + ); +} + +bool _isUsableIndex(int? number, int? total) { + if (number == null || number <= 0) return false; + return total == null || total <= 0 || number <= total; +} + +int? _resolvePositiveMetadataInt(int? sourceValue, int? backendValue) { + if (sourceValue != null && sourceValue > 0) return sourceValue; + return backendValue; +} + +int? _resolveMetadataIndex({ + required int? sourceValue, + required int? backendValue, + required int? total, +}) { + if (_isUsableIndex(sourceValue, total)) return sourceValue; + if (_isUsableIndex(backendValue, total)) return backendValue; + return sourceValue != null && sourceValue > 0 ? sourceValue : backendValue; +} + +String? _resolveMetadataText(String? sourceValue, String? backendValue) { + return normalizeOptionalString(sourceValue) ?? + normalizeOptionalString(backendValue); +} + +/// Applies backend metadata fallbacks while preserving source-only fields. +Track buildTrackForMetadataEmbedding( + Track baseTrack, + Map backendResult, + String? resolvedAlbumArtist, +) { + final backendTrackNum = readPositiveInt(backendResult['track_number']); + final backendDiscNum = readPositiveInt(backendResult['disc_number']); + final backendTotalTracks = readPositiveInt(backendResult['total_tracks']); + final backendTotalDiscs = readPositiveInt(backendResult['total_discs']); + final backendYear = normalizeOptionalString( + backendResult['release_date'] as String?, + ); + final backendAlbum = normalizeOptionalString( + backendResult['album'] as String?, + ); + final backendIsrc = normalizeOptionalString(backendResult['isrc'] as String?); + final backendCoverUrl = normalizeCoverReference( + backendResult['cover_url']?.toString(), + ); + final baseCoverUrl = normalizeCoverReference(baseTrack.coverUrl); + final resolvedCoverUrl = baseCoverUrl ?? backendCoverUrl; + final backendAlbumArtist = normalizeOptionalString( + backendResult['album_artist'] as String?, + ); + final backendComposer = normalizeOptionalString( + backendResult['composer']?.toString(), + ); + final sourceAlbumName = normalizeOptionalString(baseTrack.albumName); + final sourceAlbumArtist = normalizeOptionalString(baseTrack.albumArtist); + final sourceIsrc = normalizeOptionalString(baseTrack.isrc); + final sourceReleaseDate = normalizeOptionalString(baseTrack.releaseDate); + final sourceComposer = normalizeOptionalString(baseTrack.composer); + final sourceAlbumType = normalizeOptionalString(baseTrack.albumType); + final sourceGenre = normalizeOptionalString(baseTrack.genre); + final sourceLabel = normalizeOptionalString(baseTrack.label); + final sourceCopyright = normalizeOptionalString(baseTrack.copyright); + final sourceComment = normalizeOptionalString(baseTrack.comment); + final sourceUpc = normalizeOptionalString(baseTrack.upc); + final backendGenre = normalizeOptionalString( + backendResult['genre']?.toString(), + ); + final backendLabel = normalizeOptionalString( + backendResult['label']?.toString(), + ); + final backendCopyright = normalizeOptionalString( + backendResult['copyright']?.toString(), + ); + final backendComment = normalizeOptionalString( + backendResult['comment']?.toString(), + ); + final resolvedComment = backendComment ?? sourceComment; + final backendAlbumType = normalizeOptionalString( + backendResult['album_type']?.toString(), + ); + final backendUpc = normalizeOptionalString( + (backendResult['upc'] ?? backendResult['barcode'])?.toString(), + ); + final backendExplicit = backendResult['explicit'] == true; + final resolvedTotalTracks = _resolvePositiveMetadataInt( + baseTrack.totalTracks, + backendTotalTracks, + ); + final resolvedTotalDiscs = _resolvePositiveMetadataInt( + baseTrack.totalDiscs, + backendTotalDiscs, + ); + final resolvedTrackNumber = _resolveMetadataIndex( + sourceValue: baseTrack.trackNumber, + backendValue: backendTrackNum, + total: resolvedTotalTracks, + ); + final resolvedDiscNumber = _resolveMetadataIndex( + sourceValue: baseTrack.discNumber, + backendValue: backendDiscNum, + total: resolvedTotalDiscs, + ); + + final hasOverrides = + resolvedTrackNumber != baseTrack.trackNumber || + resolvedDiscNumber != baseTrack.discNumber || + resolvedTotalTracks != baseTrack.totalTracks || + resolvedTotalDiscs != baseTrack.totalDiscs || + resolvedAlbumArtist != sourceAlbumArtist || + (sourceReleaseDate == null && backendYear != null) || + (sourceAlbumName == null && backendAlbum != null) || + (sourceIsrc == null && backendIsrc != null) || + (baseCoverUrl == null && backendCoverUrl != null) || + (sourceAlbumArtist == null && + resolvedAlbumArtist == null && + backendAlbumArtist != null) || + (sourceComposer == null && backendComposer != null) || + (sourceAlbumType == null && backendAlbumType != null) || + (sourceGenre == null && backendGenre != null) || + (sourceLabel == null && backendLabel != null) || + (sourceCopyright == null && backendCopyright != null) || + resolvedComment != sourceComment || + (baseTrack.explicit != true && backendExplicit) || + (sourceUpc == null && backendUpc != null); + + if (!hasOverrides) { + return baseTrack; + } + + return baseTrack.copyWith( + albumName: sourceAlbumName ?? backendAlbum ?? baseTrack.albumName, + albumArtist: resolvedAlbumArtist ?? sourceAlbumArtist ?? backendAlbumArtist, + coverUrl: resolvedCoverUrl, + isrc: sourceIsrc ?? backendIsrc, + trackNumber: resolvedTrackNumber, + discNumber: resolvedDiscNumber, + totalDiscs: resolvedTotalDiscs, + releaseDate: sourceReleaseDate ?? backendYear, + albumType: sourceAlbumType ?? backendAlbumType, + totalTracks: resolvedTotalTracks, + composer: sourceComposer ?? backendComposer, + genre: sourceGenre ?? backendGenre, + label: sourceLabel ?? backendLabel, + copyright: sourceCopyright ?? backendCopyright, + comment: resolvedComment, + explicit: baseTrack.explicit == true || backendExplicit + ? true + : baseTrack.explicit, + upc: sourceUpc ?? backendUpc, + ); +} diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 04c97c0b..1c76a29c 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -1337,7 +1337,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { item.track, settings, ); - final trackToDownload = _buildTrackForMetadataEmbedding( + final trackToDownload = buildTrackForMetadataEmbedding( item.track, result, resolvedAlbumArtist, diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index db25a6df..6294c835 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -327,7 +327,7 @@ class _DownloadRun { final enrichedComposer = normalizeOptionalString( data['composer']?.toString(), ); - trackToDownload = Track( + trackToDownload = trackToDownload.copyWith( id: (data['spotify_id'] as String?) ?? trackToDownload.id, name: (data['name'] as String?) ?? trackToDownload.name, artistName: @@ -350,7 +350,6 @@ class _DownloadRun { totalDiscs: enrichedTotalDiscs ?? trackToDownload.totalDiscs, releaseDate: data['release_date'] as String?, deezerId: rawId, - availability: trackToDownload.availability, albumType: (data['album_type'] as String?) ?? trackToDownload.albumType, totalTracks: enrichedTotalTracks ?? trackToDownload.totalTracks, @@ -360,10 +359,6 @@ class _DownloadRun { copyright: data['copyright']?.toString() ?? trackToDownload.copyright, comment: data['comment']?.toString() ?? trackToDownload.comment, - source: trackToDownload.source, - itemType: trackToDownload.itemType, - audioQuality: trackToDownload.audioQuality, - audioModes: trackToDownload.audioModes, explicit: parseExplicitFlag(data['explicit']) ?? trackToDownload.explicit, @@ -708,7 +703,7 @@ class _DownloadRun { decryptionDescriptor = DownloadDecryptionDescriptor.fromDownloadResult( result, ); - trackToDownload = n._buildTrackForMetadataEmbedding( + trackToDownload = buildTrackForMetadataEmbedding( trackToDownload, result, resolvedAlbumArtist, @@ -1617,7 +1612,7 @@ class _DownloadRun { bool rebuildTrack = true, }) async { final track = rebuildTrack - ? n._buildTrackForMetadataEmbedding( + ? buildTrackForMetadataEmbedding( trackToDownload, result, resolvedAlbumArtist, diff --git a/test/download_track_metadata_merge_test.dart b/test/download_track_metadata_merge_test.dart new file mode 100644 index 00000000..e3faf9ad --- /dev/null +++ b/test/download_track_metadata_merge_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; + +void main() { + const track = Track( + id: 'extension:123', + name: 'Song', + artistName: 'Artist', + albumName: 'Album', + duration: 100, + albumArtist: 'Album Artist', + artistId: 'artist-id', + albumId: 'album-id', + coverUrl: 'https://example.test/cover', + previewUrl: 'https://example.test/preview', + upc: '012345678905', + source: 'extension', + audioQuality: 'LOSSLESS', + audioModes: 'STEREO', + explicit: false, + genre: 'Rock', + label: 'Label', + copyright: 'Copyright', + comment: 'Comment', + availability: ServiceAvailability(deezer: true), + ); + + test('identifier enrichment preserves every untouched Track field', () { + final resolved = copyTrackWithResolvedMetadata( + track, + resolvedIsrc: ' USAAA2400001 ', + deezerId: '123', + composer: ' Composer ', + ); + expect(resolved.toJson(), { + ...track.toJson(), + 'isrc': 'USAAA2400001', + 'deezerId': '123', + 'composer': 'Composer', + }); + }); + + test('existing numbering wins, missing numbering is filled', () { + final original = track.copyWith( + trackNumber: 2, + discNumber: 0, + totalTracks: 12, + composer: 'Original composer', + isrc: 'USAAA2400001', + ); + final resolved = copyTrackWithResolvedMetadata( + original, + resolvedIsrc: 'invalid', + trackNumber: 9, + totalTracks: 20, + discNumber: 1, + composer: 'Replacement', + ); + expect(resolved.toJson(), {...original.toJson(), 'discNumber': 1}); + }); + + test('embedding fallbacks retain preview, UPC, and source metadata', () { + final merged = buildTrackForMetadataEmbedding(track, { + 'track_number': 3, + 'total_tracks': 10, + 'isrc': 'USAAA2400001', + 'composer': 'Composer', + 'upc': '999999999999', + 'comment': 'Backend comment', + }, track.albumArtist); + expect(merged.toJson(), { + ...track.toJson(), + 'trackNumber': 3, + 'totalTracks': 10, + 'isrc': 'USAAA2400001', + 'composer': 'Composer', + 'comment': 'Backend comment', + }); + }); + + test( + 'embedding preserves valid source numbering and fixes invalid indices', + () { + final original = track.copyWith( + trackNumber: 2, + totalTracks: 10, + discNumber: 9, + totalDiscs: 2, + ); + final merged = buildTrackForMetadataEmbedding(original, { + 'track_number': 5, + 'disc_number': 1, + 'total_tracks': 20, + 'total_discs': 3, + }, original.albumArtist); + expect(merged.toJson(), {...original.toJson(), 'discNumber': 1}); + }, + ); + + test('embedding without overrides reuses the original track', () { + expect( + identical( + buildTrackForMetadataEmbedding(track, {}, track.albumArtist), + track, + ), + isTrue, + ); + }); +}