diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 47435e52..75d8178d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/download_queue_state.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; @@ -29,6 +30,7 @@ import 'package:spotiflac_android/utils/progress_stream_poller.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; export 'package:spotiflac_android/providers/download_history_provider.dart'; +export 'package:spotiflac_android/providers/download_queue_state.dart'; export 'package:spotiflac_android/services/history_database.dart' show HistoryLookupRequest, HistoryBatchLookupRequest; @@ -85,72 +87,6 @@ final _qualityFilenameTokenPattern = RegExp( caseSensitive: false, ); -class DownloadQueueState { - static const Object _noChange = Object(); - final List items; - final DownloadQueueLookup lookup; - final DownloadItem? currentDownload; - final bool isProcessing; - final bool isPaused; - final String outputDir; - final String filenameFormat; - final String singleFilenameFormat; - final String audioQuality; - final bool autoFallback; - - const DownloadQueueState({ - this.items = const [], - this.lookup = const DownloadQueueLookup.empty(), - this.currentDownload, - this.isProcessing = false, - this.isPaused = false, - this.outputDir = '', - this.filenameFormat = '{artist} - {title}', - this.singleFilenameFormat = '{title} - {artist}', - this.audioQuality = 'LOSSLESS', - this.autoFallback = true, - }); - - DownloadQueueState copyWith({ - List? items, - DownloadQueueLookup? lookup, - Object? currentDownload = _noChange, - bool? isProcessing, - bool? isPaused, - String? outputDir, - String? filenameFormat, - String? singleFilenameFormat, - String? audioQuality, - bool? autoFallback, - }) { - final resolvedItems = items ?? this.items; - return DownloadQueueState( - items: resolvedItems, - lookup: - lookup ?? - (items != null - ? DownloadQueueLookup.fromItems(resolvedItems) - : this.lookup), - currentDownload: identical(currentDownload, _noChange) - ? this.currentDownload - : currentDownload as DownloadItem?, - isProcessing: isProcessing ?? this.isProcessing, - isPaused: isPaused ?? this.isPaused, - outputDir: outputDir ?? this.outputDir, - filenameFormat: filenameFormat ?? this.filenameFormat, - singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat, - audioQuality: audioQuality ?? this.audioQuality, - autoFallback: autoFallback ?? this.autoFallback, - ); - } - - int get queuedCount => items.isEmpty ? 0 : lookup.queuedCount; - int get completedCount => items.isEmpty ? 0 : lookup.completedCount; - int get failedCount => items.isEmpty ? 0 : lookup.failedCount; - int get activeDownloadsCount => - items.isEmpty ? 0 : lookup.activeDownloadsCount; -} - class _ProgressUpdate { final DownloadStatus status; final double progress; @@ -4056,177 +3992,6 @@ final downloadQueueProvider = DownloadQueueNotifier.new, ); -class DownloadQueueLookup { - final Map byTrackId; - final Map byItemId; - final Map indexByItemId; - final List itemIds; - final int queuedCount; - final int completedCount; - final int failedCount; - final int activeDownloadsCount; - final int finalizingCount; - - const DownloadQueueLookup.empty() - : byTrackId = const {}, - byItemId = const {}, - indexByItemId = const {}, - itemIds = const [], - queuedCount = 0, - completedCount = 0, - failedCount = 0, - activeDownloadsCount = 0, - finalizingCount = 0; - - DownloadQueueLookup._({ - required this.byTrackId, - required this.byItemId, - required this.indexByItemId, - required this.itemIds, - required this.queuedCount, - required this.completedCount, - required this.failedCount, - required this.activeDownloadsCount, - required this.finalizingCount, - }); - - factory DownloadQueueLookup.fromItems(List items) { - final byTrackId = {}; - final byItemId = {}; - final indexByItemId = {}; - final itemIds = []; - var queuedCount = 0; - var completedCount = 0; - var failedCount = 0; - var activeDownloadsCount = 0; - var finalizingCount = 0; - for (var index = 0; index < items.length; index++) { - final item = items[index]; - byTrackId.putIfAbsent(item.track.id, () => item); - byItemId[item.id] = item; - indexByItemId[item.id] = index; - itemIds.add(item.id); - if (_countsAsQueued(item.status)) queuedCount++; - if (item.status == DownloadStatus.completed) completedCount++; - if (item.status == DownloadStatus.failed) failedCount++; - if (item.status == DownloadStatus.downloading) activeDownloadsCount++; - if (item.status == DownloadStatus.finalizing) finalizingCount++; - } - return DownloadQueueLookup._( - byTrackId: Map.unmodifiable(byTrackId), - byItemId: Map.unmodifiable(byItemId), - indexByItemId: Map.unmodifiable(indexByItemId), - itemIds: List.unmodifiable(itemIds), - queuedCount: queuedCount, - completedCount: completedCount, - failedCount: failedCount, - activeDownloadsCount: activeDownloadsCount, - finalizingCount: finalizingCount, - ); - } - - static bool _countsAsQueued(DownloadStatus status) => - status == DownloadStatus.queued || - status == DownloadStatus.downloading || - status == DownloadStatus.finalizing; - - static int _deltaForStatus({ - required DownloadStatus previous, - required DownloadStatus next, - required bool Function(DownloadStatus status) predicate, - }) { - final had = predicate(previous); - final has = predicate(next); - if (had == has) return 0; - return has ? 1 : -1; - } - - DownloadQueueLookup updatedForIndices({ - required List previousItems, - required List nextItems, - required Iterable changedIndices, - }) { - if (previousItems.length != nextItems.length || - itemIds.length != nextItems.length || - indexByItemId.length != nextItems.length) { - return DownloadQueueLookup.fromItems(nextItems); - } - - final normalizedChanged = []; - for (final index in changedIndices) { - if (index < 0 || index >= nextItems.length) { - return DownloadQueueLookup.fromItems(nextItems); - } - normalizedChanged.add(index); - } - if (normalizedChanged.isEmpty) return this; - - var nextQueuedCount = queuedCount; - var nextCompletedCount = completedCount; - var nextFailedCount = failedCount; - var nextActiveDownloadsCount = activeDownloadsCount; - var nextFinalizingCount = finalizingCount; - Map? nextByItemId; - Map? nextByTrackId; - - for (final index in normalizedChanged) { - final previous = previousItems[index]; - final next = nextItems[index]; - if (previous.id != next.id || previous.track.id != next.track.id) { - return DownloadQueueLookup.fromItems(nextItems); - } - - nextByItemId ??= Map.from(byItemId); - nextByItemId[next.id] = next; - if (byTrackId[next.track.id]?.id == previous.id) { - nextByTrackId ??= Map.from(byTrackId); - nextByTrackId[next.track.id] = next; - } - nextQueuedCount += _deltaForStatus( - previous: previous.status, - next: next.status, - predicate: _countsAsQueued, - ); - nextCompletedCount += _deltaForStatus( - previous: previous.status, - next: next.status, - predicate: (status) => status == DownloadStatus.completed, - ); - nextFailedCount += _deltaForStatus( - previous: previous.status, - next: next.status, - predicate: (status) => status == DownloadStatus.failed, - ); - nextActiveDownloadsCount += _deltaForStatus( - previous: previous.status, - next: next.status, - predicate: (status) => status == DownloadStatus.downloading, - ); - nextFinalizingCount += _deltaForStatus( - previous: previous.status, - next: next.status, - predicate: (status) => status == DownloadStatus.finalizing, - ); - } - - return DownloadQueueLookup._( - byTrackId: nextByTrackId == null - ? byTrackId - : Map.unmodifiable(nextByTrackId), - byItemId: nextByItemId == null - ? byItemId - : Map.unmodifiable(nextByItemId), - indexByItemId: indexByItemId, - itemIds: itemIds, - queuedCount: nextQueuedCount, - completedCount: nextCompletedCount, - failedCount: nextFailedCount, - activeDownloadsCount: nextActiveDownloadsCount, - finalizingCount: nextFinalizingCount, - ); - } -} - final downloadQueueLookupProvider = Provider((ref) { return ref.watch(downloadQueueProvider.select((s) => s.lookup)); }); diff --git a/lib/providers/download_queue_state.dart b/lib/providers/download_queue_state.dart new file mode 100644 index 00000000..1a1b56df --- /dev/null +++ b/lib/providers/download_queue_state.dart @@ -0,0 +1,247 @@ +import 'package:spotiflac_android/models/download_item.dart'; + +/// Immutable queue state shared by the notifier and read-only UI consumers. +/// +/// Keeping this model outside the notifier implementation makes queue state +/// transitions testable without importing the download orchestration pipeline. +class DownloadQueueState { + static const Object _noChange = Object(); + + final List items; + final DownloadQueueLookup lookup; + final DownloadItem? currentDownload; + final bool isProcessing; + final bool isPaused; + final String outputDir; + final String filenameFormat; + final String singleFilenameFormat; + final String audioQuality; + final bool autoFallback; + + const DownloadQueueState({ + this.items = const [], + this.lookup = const DownloadQueueLookup.empty(), + this.currentDownload, + this.isProcessing = false, + this.isPaused = false, + this.outputDir = '', + this.filenameFormat = '{artist} - {title}', + this.singleFilenameFormat = '{title} - {artist}', + this.audioQuality = 'LOSSLESS', + this.autoFallback = true, + }); + + DownloadQueueState copyWith({ + List? items, + DownloadQueueLookup? lookup, + Object? currentDownload = _noChange, + bool? isProcessing, + bool? isPaused, + String? outputDir, + String? filenameFormat, + String? singleFilenameFormat, + String? audioQuality, + bool? autoFallback, + }) { + final resolvedItems = items ?? this.items; + return DownloadQueueState( + items: resolvedItems, + lookup: + lookup ?? + (items != null + ? DownloadQueueLookup.fromItems(resolvedItems) + : this.lookup), + currentDownload: identical(currentDownload, _noChange) + ? this.currentDownload + : currentDownload as DownloadItem?, + isProcessing: isProcessing ?? this.isProcessing, + isPaused: isPaused ?? this.isPaused, + outputDir: outputDir ?? this.outputDir, + filenameFormat: filenameFormat ?? this.filenameFormat, + singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat, + audioQuality: audioQuality ?? this.audioQuality, + autoFallback: autoFallback ?? this.autoFallback, + ); + } + + int get queuedCount => items.isEmpty ? 0 : lookup.queuedCount; + int get completedCount => items.isEmpty ? 0 : lookup.completedCount; + int get failedCount => items.isEmpty ? 0 : lookup.failedCount; + int get activeDownloadsCount => + items.isEmpty ? 0 : lookup.activeDownloadsCount; +} + +/// Precomputed queue indexes and counters. +/// +/// The lookup can update in O(changed items) when item identities are stable, +/// avoiding full queue scans for every progress event. +class DownloadQueueLookup { + final Map byTrackId; + final Map byItemId; + final Map indexByItemId; + final List itemIds; + final int queuedCount; + final int completedCount; + final int failedCount; + final int activeDownloadsCount; + final int finalizingCount; + + const DownloadQueueLookup.empty() + : byTrackId = const {}, + byItemId = const {}, + indexByItemId = const {}, + itemIds = const [], + queuedCount = 0, + completedCount = 0, + failedCount = 0, + activeDownloadsCount = 0, + finalizingCount = 0; + + DownloadQueueLookup._({ + required this.byTrackId, + required this.byItemId, + required this.indexByItemId, + required this.itemIds, + required this.queuedCount, + required this.completedCount, + required this.failedCount, + required this.activeDownloadsCount, + required this.finalizingCount, + }); + + factory DownloadQueueLookup.fromItems(List items) { + final byTrackId = {}; + final byItemId = {}; + final indexByItemId = {}; + final itemIds = []; + var queuedCount = 0; + var completedCount = 0; + var failedCount = 0; + var activeDownloadsCount = 0; + var finalizingCount = 0; + for (var index = 0; index < items.length; index++) { + final item = items[index]; + byTrackId.putIfAbsent(item.track.id, () => item); + byItemId[item.id] = item; + indexByItemId[item.id] = index; + itemIds.add(item.id); + if (_countsAsQueued(item.status)) queuedCount++; + if (item.status == DownloadStatus.completed) completedCount++; + if (item.status == DownloadStatus.failed) failedCount++; + if (item.status == DownloadStatus.downloading) activeDownloadsCount++; + if (item.status == DownloadStatus.finalizing) finalizingCount++; + } + return DownloadQueueLookup._( + byTrackId: Map.unmodifiable(byTrackId), + byItemId: Map.unmodifiable(byItemId), + indexByItemId: Map.unmodifiable(indexByItemId), + itemIds: List.unmodifiable(itemIds), + queuedCount: queuedCount, + completedCount: completedCount, + failedCount: failedCount, + activeDownloadsCount: activeDownloadsCount, + finalizingCount: finalizingCount, + ); + } + + static bool _countsAsQueued(DownloadStatus status) => + status == DownloadStatus.queued || + status == DownloadStatus.downloading || + status == DownloadStatus.finalizing; + + static int _deltaForStatus({ + required DownloadStatus previous, + required DownloadStatus next, + required bool Function(DownloadStatus status) predicate, + }) { + final had = predicate(previous); + final has = predicate(next); + if (had == has) return 0; + return has ? 1 : -1; + } + + DownloadQueueLookup updatedForIndices({ + required List previousItems, + required List nextItems, + required Iterable changedIndices, + }) { + if (previousItems.length != nextItems.length || + itemIds.length != nextItems.length || + indexByItemId.length != nextItems.length) { + return DownloadQueueLookup.fromItems(nextItems); + } + + final normalizedChanged = []; + for (final index in changedIndices) { + if (index < 0 || index >= nextItems.length) { + return DownloadQueueLookup.fromItems(nextItems); + } + normalizedChanged.add(index); + } + if (normalizedChanged.isEmpty) return this; + + var nextQueuedCount = queuedCount; + var nextCompletedCount = completedCount; + var nextFailedCount = failedCount; + var nextActiveDownloadsCount = activeDownloadsCount; + var nextFinalizingCount = finalizingCount; + Map? nextByItemId; + Map? nextByTrackId; + + for (final index in normalizedChanged) { + final previous = previousItems[index]; + final next = nextItems[index]; + if (previous.id != next.id || previous.track.id != next.track.id) { + return DownloadQueueLookup.fromItems(nextItems); + } + + nextByItemId ??= Map.from(byItemId); + nextByItemId[next.id] = next; + if (byTrackId[next.track.id]?.id == previous.id) { + nextByTrackId ??= Map.from(byTrackId); + nextByTrackId[next.track.id] = next; + } + nextQueuedCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: _countsAsQueued, + ); + nextCompletedCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: (status) => status == DownloadStatus.completed, + ); + nextFailedCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: (status) => status == DownloadStatus.failed, + ); + nextActiveDownloadsCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: (status) => status == DownloadStatus.downloading, + ); + nextFinalizingCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: (status) => status == DownloadStatus.finalizing, + ); + } + + return DownloadQueueLookup._( + byTrackId: nextByTrackId == null + ? byTrackId + : Map.unmodifiable(nextByTrackId), + byItemId: nextByItemId == null + ? byItemId + : Map.unmodifiable(nextByItemId), + indexByItemId: indexByItemId, + itemIds: itemIds, + queuedCount: nextQueuedCount, + completedCount: nextCompletedCount, + failedCount: nextFailedCount, + activeDownloadsCount: nextActiveDownloadsCount, + finalizingCount: nextFinalizingCount, + ); + } +} diff --git a/lib/screens/home_search_logic.dart b/lib/screens/home_search_logic.dart new file mode 100644 index 00000000..91602bdb --- /dev/null +++ b/lib/screens/home_search_logic.dart @@ -0,0 +1,261 @@ +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; + +bool looksLikeUrlOrSpotifyUri(String text) => + text.startsWith('http') || text.startsWith('spotify:'); + +class HomeSearchResultBuckets { + final List realTracks; + final List realTrackIndexes; + final List albumItems; + final List playlistItems; + final List artistItems; + + const HomeSearchResultBuckets({ + required this.realTracks, + required this.realTrackIndexes, + required this.albumItems, + required this.playlistItems, + required this.artistItems, + }); +} + +HomeSearchResultBuckets bucketHomeSearchResults(List tracks) { + final realTracks = []; + final realTrackIndexes = []; + final albumItems = []; + final playlistItems = []; + final artistItems = []; + + for (var index = 0; index < tracks.length; index++) { + final track = tracks[index]; + if (!track.isCollection) { + realTracks.add(track); + realTrackIndexes.add(index); + } + if (track.isAlbumItem) albumItems.add(track); + if (track.isPlaylistItem) playlistItems.add(track); + if (track.isArtistItem) artistItems.add(track); + } + + return HomeSearchResultBuckets( + realTracks: realTracks, + realTrackIndexes: realTrackIndexes, + albumItems: albumItems, + playlistItems: playlistItems, + artistItems: artistItems, + ); +} + +enum HomeSearchSortOption { + defaultOrder, + titleAsc, + titleDesc, + artistAsc, + artistDesc, + durationAsc, + durationDesc, + dateAsc, + dateDesc, +} + +List sortHomeSearchItems({ + required List items, + required HomeSearchSortOption option, + required String Function(T) nameOf, + required String Function(T) artistOf, + required int Function(T) durationOf, + required String? Function(T) dateOf, +}) { + if (option == HomeSearchSortOption.defaultOrder) return items; + + final sorted = List.of(items); + switch (option) { + case HomeSearchSortOption.defaultOrder: + break; + case HomeSearchSortOption.titleAsc: + sorted.sort( + (a, b) => nameOf(a).toLowerCase().compareTo(nameOf(b).toLowerCase()), + ); + case HomeSearchSortOption.titleDesc: + sorted.sort( + (a, b) => nameOf(b).toLowerCase().compareTo(nameOf(a).toLowerCase()), + ); + case HomeSearchSortOption.artistAsc: + sorted.sort( + (a, b) => + artistOf(a).toLowerCase().compareTo(artistOf(b).toLowerCase()), + ); + case HomeSearchSortOption.artistDesc: + sorted.sort( + (a, b) => + artistOf(b).toLowerCase().compareTo(artistOf(a).toLowerCase()), + ); + case HomeSearchSortOption.durationAsc: + sorted.sort((a, b) => durationOf(a).compareTo(durationOf(b))); + case HomeSearchSortOption.durationDesc: + sorted.sort((a, b) => durationOf(b).compareTo(durationOf(a))); + case HomeSearchSortOption.dateAsc: + sorted.sort((a, b) => (dateOf(a) ?? '').compareTo(dateOf(b) ?? '')); + case HomeSearchSortOption.dateDesc: + sorted.sort((a, b) => (dateOf(b) ?? '').compareTo(dateOf(a) ?? '')); + } + return sorted; +} + +/// Provider and filter selection rules shared by the home search surface and +/// its provider dropdown. +class HomeSearchProviderPolicy { + const HomeSearchProviderPolicy._(); + + static Extension? defaultExtension(List extensions) { + return extensions + .where( + (extension) => + extension.enabled && + extension.hasCustomSearch && + extension.searchBehavior?.primary == true, + ) + .firstOrNull ?? + extensions + .where( + (extension) => extension.enabled && extension.hasCustomSearch, + ) + .firstOrNull; + } + + static String? resolveProvider( + String? explicitSearchProvider, + List extensions, + ) { + final explicit = explicitSearchProvider?.trim(); + if (explicit != null && + explicit.isNotEmpty && + extensions.any( + (extension) => + extension.enabled && + extension.hasCustomSearch && + extension.id == explicit, + )) { + return explicit; + } + return defaultExtension(extensions)?.id; + } + + static bool hasProvider( + String? explicitSearchProvider, + List extensions, + ) { + final explicit = explicitSearchProvider?.trim(); + if (explicit != null && + explicit.isNotEmpty && + extensions.any( + (extension) => + extension.enabled && + extension.hasCustomSearch && + extension.id == explicit, + )) { + return true; + } + + return extensions.any( + (extension) => extension.enabled && extension.hasCustomSearch, + ); + } + + static String? sanitizeFilter( + String? filter, + String? currentSearchProvider, + List extensions, + ) { + if (filter == null || filter.isEmpty) { + return null; + } + + final canonicalFilter = canonicalFilterId(filter); + if (currentSearchProvider == null || currentSearchProvider.isEmpty) { + return switch (canonicalFilter) { + 'track' || 'artist' || 'album' || 'playlist' => canonicalFilter, + _ => null, + }; + } + + final extension = extensions + .where( + (candidate) => + candidate.id == currentSearchProvider && candidate.enabled, + ) + .firstOrNull; + final filters = extension?.searchBehavior?.filters; + if (filters == null || filters.isEmpty) { + return null; + } + + return filters + .where( + (candidate) => + canonicalFilterId(candidate.id) == canonicalFilter || + (candidate.label != null && + canonicalFilterId(candidate.label!) == canonicalFilter) || + (candidate.icon != null && + canonicalFilterId(candidate.icon!) == canonicalFilter), + ) + .firstOrNull + ?.id; + } + + static String canonicalFilterId(String value) { + final normalized = value.trim().toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]+'), + '', + ); + return switch (normalized) { + 'track' || 'tracks' || 'song' || 'songs' || 'music' => 'track', + 'artist' || 'artists' => 'artist', + 'album' || 'albums' => 'album', + 'playlist' || 'playlists' => 'playlist', + _ => normalized, + }; + } + + static String? preferredFilter( + String preferredSearchTab, + String? currentSearchProvider, + List extensions, + ) { + final preferred = switch (preferredSearchTab) { + 'track' => 'track', + 'artist' => 'artist', + 'album' => 'album', + 'playlist' => 'playlist', + _ => null, + }; + + return sanitizeFilter(preferred, currentSearchProvider, extensions); + } + + static String displayFilterSelection( + String? selectedSearchFilter, + String preferredSearchTab, + String? currentSearchProvider, + List extensions, + ) { + if (selectedSearchFilter == 'all') { + return 'all'; + } + if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) { + return sanitizeFilter( + selectedSearchFilter, + currentSearchProvider, + extensions, + ) ?? + 'all'; + } + return preferredFilter( + preferredSearchTab, + currentSearchProvider, + extensions, + ) ?? + 'all'; + } +} diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index a766fa10..6ac5c304 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/screens/home_search_logic.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -42,9 +43,6 @@ import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; part 'home_tab_helpers.dart'; part 'home_tab_widgets.dart'; -bool _looksLikeUrlOrSpotifyUri(String text) => - text.startsWith('http') || text.startsWith('spotify:'); - class HomeTab extends ConsumerStatefulWidget { const HomeTab({super.key}); @override @@ -85,11 +83,11 @@ class _HomeTabState extends ConsumerState Map? _thumbnailSizesCache; List? _searchBucketsSourceTracks; - _SearchResultBuckets? _searchBucketsCache; - _SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder; + HomeSearchResultBuckets? _searchBucketsCache; + HomeSearchSortOption _searchSortOption = HomeSearchSortOption.defaultOrder; List? _sortedTracksSource; List? _sortedTrackIndexesSource; - _SearchSortOption? _sortedTracksMode; + HomeSearchSortOption? _sortedTracksMode; List? _sortedTracksCache; List? _sortedTrackIndexesCache; @@ -252,7 +250,7 @@ class _HomeTabState extends ConsumerState String? currentSearchProvider, List extensions, ) { - final resolvedSearchProvider = _resolveSearchProvider( + final resolvedSearchProvider = HomeSearchProviderPolicy.resolveProvider( currentSearchProvider, extensions, ); @@ -295,203 +293,13 @@ class _HomeTabState extends ConsumerState ]; } - Extension? _defaultSearchExtension(List extensions) { - return extensions - .where( - (ext) => - ext.enabled && - ext.hasCustomSearch && - ext.searchBehavior?.primary == true, - ) - .firstOrNull ?? - extensions - .where((ext) => ext.enabled && ext.hasCustomSearch) - .firstOrNull; - } - - String? _resolveSearchProvider( - String? explicitSearchProvider, - List extensions, - ) { - final explicit = explicitSearchProvider?.trim(); - if (explicit != null && - explicit.isNotEmpty && - extensions.any( - (ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit, - )) { - return explicit; - } - return _defaultSearchExtension(extensions)?.id; - } - - bool _hasSearchProvider( - String? explicitSearchProvider, - List extensions, - ) { - final explicit = explicitSearchProvider?.trim(); - if (explicit != null && explicit.isNotEmpty) { - if (extensions.any( - (ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit, - )) { - return true; - } - } - - return extensions.any((ext) => ext.enabled && ext.hasCustomSearch); - } - - String? _sanitizeSearchFilterForProvider( - String? filter, - String? currentSearchProvider, - List extensions, - ) { - if (filter == null || filter.isEmpty) { - return null; - } - - final canonicalFilter = _canonicalSearchFilterId(filter); - - if (currentSearchProvider == null || currentSearchProvider.isEmpty) { - switch (canonicalFilter) { - case 'track': - case 'artist': - case 'album': - case 'playlist': - return canonicalFilter; - default: - return null; - } - } - - final extension = extensions - .where((e) => e.id == currentSearchProvider && e.enabled) - .firstOrNull; - final filters = extension?.searchBehavior?.filters; - if (filters == null || filters.isEmpty) { - return null; - } - - final match = filters - .where( - (candidate) => - _canonicalSearchFilterId(candidate.id) == canonicalFilter || - (candidate.label != null && - _canonicalSearchFilterId(candidate.label!) == - canonicalFilter) || - (candidate.icon != null && - _canonicalSearchFilterId(candidate.icon!) == canonicalFilter), - ) - .firstOrNull; - return match?.id; - } - - String _canonicalSearchFilterId(String value) { - final normalized = value.trim().toLowerCase().replaceAll( - RegExp(r'[^a-z0-9]+'), - '', - ); - switch (normalized) { - case 'track': - case 'tracks': - case 'song': - case 'songs': - case 'music': - return 'track'; - case 'artist': - case 'artists': - return 'artist'; - case 'album': - case 'albums': - return 'album'; - case 'playlist': - case 'playlists': - return 'playlist'; - default: - return normalized; - } - } - - String? _preferredSearchFilter( - String preferredSearchTab, - String? currentSearchProvider, - List extensions, - ) { - final preferred = switch (preferredSearchTab) { - 'track' => 'track', - 'artist' => 'artist', - 'album' => 'album', - 'playlist' => 'playlist', - _ => null, - }; - - return _sanitizeSearchFilterForProvider( - preferred, - currentSearchProvider, - extensions, - ); - } - - String _displaySearchFilterSelection( - String? selectedSearchFilter, - String preferredSearchTab, - String? currentSearchProvider, - List extensions, - ) { - if (selectedSearchFilter == 'all') { - return 'all'; - } - if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) { - return _sanitizeSearchFilterForProvider( - selectedSearchFilter, - currentSearchProvider, - extensions, - ) ?? - 'all'; - } - return _preferredSearchFilter( - preferredSearchTab, - currentSearchProvider, - extensions, - ) ?? - 'all'; - } - - _SearchResultBuckets _getSearchResultBuckets(List tracks) { + HomeSearchResultBuckets _getSearchResultBuckets(List tracks) { final cached = _searchBucketsCache; if (cached != null && identical(tracks, _searchBucketsSourceTracks)) { return cached; } - final realTracks = []; - final realTrackIndexes = []; - final albumItems = []; - final playlistItems = []; - final artistItems = []; - - for (int i = 0; i < tracks.length; i++) { - final track = tracks[i]; - if (!track.isCollection) { - realTracks.add(track); - realTrackIndexes.add(i); - } - if (track.isAlbumItem) { - albumItems.add(track); - } - if (track.isPlaylistItem) { - playlistItems.add(track); - } - if (track.isArtistItem) { - artistItems.add(track); - } - } - - final buckets = _SearchResultBuckets( - realTracks: realTracks, - realTrackIndexes: realTrackIndexes, - albumItems: albumItems, - playlistItems: playlistItems, - artistItems: artistItems, - ); + final buckets = bucketHomeSearchResults(tracks); _searchBucketsSourceTracks = tracks; _searchBucketsCache = buckets; return buckets; @@ -530,7 +338,7 @@ class _HomeTabState extends ConsumerState final extState = ref.read(extensionProvider); if (!extState.isInitialized && extState.error == null) return true; - final searchProvider = _resolveSearchProvider( + final searchProvider = HomeSearchProviderPolicy.resolveProvider( settings.searchProvider, extState.extensions, ); @@ -563,7 +371,7 @@ class _HomeTabState extends ConsumerState } if (_isLiveSearchEnabled() && text.length >= _minLiveSearchChars) { - if (_looksLikeUrlOrSpotifyUri(text)) return; + if (looksLikeUrlOrSpotifyUri(text)) return; _liveSearchDebounce?.cancel(); _liveSearchDebounce = Timer(_liveSearchDelay, () { @@ -611,26 +419,26 @@ class _HomeTabState extends ConsumerState } final settings = ref.read(settingsProvider); - final searchProvider = _resolveSearchProvider( + final searchProvider = HomeSearchProviderPolicy.resolveProvider( settings.searchProvider, extState.extensions, ); final storedFilter = ref.read(trackProvider).selectedSearchFilter; final selectedFilter = switch (filterOverride) { 'all' => null, - final explicit? => _sanitizeSearchFilterForProvider( + final explicit? => HomeSearchProviderPolicy.sanitizeFilter( explicit, searchProvider, extState.extensions, ), null => switch (storedFilter) { 'all' => null, - final stored? => _sanitizeSearchFilterForProvider( + final stored? => HomeSearchProviderPolicy.sanitizeFilter( stored, searchProvider, extState.extensions, ), - null => _preferredSearchFilter( + null => HomeSearchProviderPolicy.preferredFilter( settings.defaultSearchTab, searchProvider, extState.extensions, @@ -648,7 +456,7 @@ class _HomeTabState extends ConsumerState } _lastSearchQuery = searchKey; _activeSearchInput = query; - _searchSortOption = _SearchSortOption.defaultOrder; + _searchSortOption = HomeSearchSortOption.defaultOrder; _invalidateSearchSortCaches(); ref.read(trackProvider.notifier).setSearchText(query.trim().isNotEmpty); @@ -688,7 +496,7 @@ class _HomeTabState extends ConsumerState if (data?.text != null) { _urlController.text = data!.text!; final text = data.text!.trim(); - if (_looksLikeUrlOrSpotifyUri(text)) { + if (looksLikeUrlOrSpotifyUri(text)) { _fetchMetadata(); } } @@ -720,7 +528,7 @@ class _HomeTabState extends ConsumerState Future _fetchMetadata() async { final url = _urlController.text.trim(); if (url.isEmpty) return; - if (_looksLikeUrlOrSpotifyUri(url)) { + if (looksLikeUrlOrSpotifyUri(url)) { await ref.read(trackProvider.notifier).fetchFromUrl(url); final trackState = ref.read(trackProvider); if (trackState.error != null && mounted) { @@ -1200,7 +1008,7 @@ class _HomeTabState extends ConsumerState !isLoading; final isSearchProviderLoading = !extensionReadiness.isInitialized && extensionReadiness.error == null; - final hasSearchProvider = _hasSearchProvider( + final hasSearchProvider = HomeSearchProviderPolicy.hasProvider( explicitSearchProvider, extensions, ); @@ -1236,7 +1044,7 @@ class _HomeTabState extends ConsumerState final text = _urlController.text.trim(); if (text.isEmpty || text.length < _minLiveSearchChars) return; - if (_looksLikeUrlOrSpotifyUri(text)) return; + if (looksLikeUrlOrSpotifyUri(text)) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -1355,7 +1163,7 @@ class _HomeTabState extends ConsumerState return SliverToBoxAdapter( child: _buildSearchFilterBar( searchFilters, - _displaySearchFilterSelection( + HomeSearchProviderPolicy.displayFilterSelection( selectedSearchFilter, defaultSearchTab, currentSearchProvider, @@ -2690,25 +2498,25 @@ class _HomeTabState extends ConsumerState ); } - String _sortOptionLabel(_SearchSortOption option) { + String _sortOptionLabel(HomeSearchSortOption option) { switch (option) { - case _SearchSortOption.defaultOrder: + case HomeSearchSortOption.defaultOrder: return context.l10n.searchSortDefault; - case _SearchSortOption.titleAsc: + case HomeSearchSortOption.titleAsc: return context.l10n.searchSortTitleAZ; - case _SearchSortOption.titleDesc: + case HomeSearchSortOption.titleDesc: return context.l10n.searchSortTitleZA; - case _SearchSortOption.artistAsc: + case HomeSearchSortOption.artistAsc: return context.l10n.searchSortArtistAZ; - case _SearchSortOption.artistDesc: + case HomeSearchSortOption.artistDesc: return context.l10n.searchSortArtistZA; - case _SearchSortOption.durationAsc: + case HomeSearchSortOption.durationAsc: return context.l10n.searchSortDurationShort; - case _SearchSortOption.durationDesc: + case HomeSearchSortOption.durationDesc: return context.l10n.searchSortDurationLong; - case _SearchSortOption.dateAsc: + case HomeSearchSortOption.dateAsc: return context.l10n.searchSortDateOldest; - case _SearchSortOption.dateDesc: + case HomeSearchSortOption.dateDesc: return context.l10n.searchSortDateNewest; } } @@ -2754,7 +2562,7 @@ class _HomeTabState extends ConsumerState const Spacer(), TextButton( onPressed: () => setSheetState( - () => tempSort = _SearchSortOption.defaultOrder, + () => tempSort = HomeSearchSortOption.defaultOrder, ), child: Text(context.l10n.libraryFilterReset), ), @@ -2764,7 +2572,7 @@ class _HomeTabState extends ConsumerState Wrap( spacing: 8, runSpacing: 8, - children: _SearchSortOption.values.map((option) { + children: HomeSearchSortOption.values.map((option) { return FilterChip( label: Text(_sortOptionLabel(option)), selected: tempSort == option, @@ -2798,63 +2606,12 @@ class _HomeTabState extends ConsumerState ); } - List _applySortToList( - List items, - String Function(T) getName, - String Function(T) getArtist, - int Function(T) getDuration, - String? Function(T) getDate, - ) { - if (_searchSortOption == _SearchSortOption.defaultOrder) return items; - final sorted = List.of(items); - switch (_searchSortOption) { - case _SearchSortOption.defaultOrder: - break; - case _SearchSortOption.titleAsc: - sorted.sort( - (a, b) => - getName(a).toLowerCase().compareTo(getName(b).toLowerCase()), - ); - case _SearchSortOption.titleDesc: - sorted.sort( - (a, b) => - getName(b).toLowerCase().compareTo(getName(a).toLowerCase()), - ); - case _SearchSortOption.artistAsc: - sorted.sort( - (a, b) => - getArtist(a).toLowerCase().compareTo(getArtist(b).toLowerCase()), - ); - case _SearchSortOption.artistDesc: - sorted.sort( - (a, b) => - getArtist(b).toLowerCase().compareTo(getArtist(a).toLowerCase()), - ); - case _SearchSortOption.durationAsc: - sorted.sort((a, b) => getDuration(a).compareTo(getDuration(b))); - case _SearchSortOption.durationDesc: - sorted.sort((a, b) => getDuration(b).compareTo(getDuration(a))); - case _SearchSortOption.dateAsc: - sorted.sort((a, b) { - final da = getDate(a) ?? ''; - final db = getDate(b) ?? ''; - return da.compareTo(db); - }); - case _SearchSortOption.dateDesc: - sorted.sort((a, b) { - final da = getDate(a) ?? ''; - final db = getDate(b) ?? ''; - return db.compareTo(da); - }); - } - return sorted; - } - ({List tracks, List indexes}) _sortTrackResults( List tracks, List indexes, ) { - if (tracks.isEmpty || _searchSortOption == _SearchSortOption.defaultOrder) { + if (tracks.isEmpty || + _searchSortOption == HomeSearchSortOption.defaultOrder) { return (tracks: tracks, indexes: indexes); } if (identical(tracks, _sortedTracksSource) && @@ -2869,12 +2626,13 @@ class _HomeTabState extends ConsumerState (i) => (tracks[i], indexes[i]), growable: false, ); - final sortedPairs = _applySortToList<(Track, int)>( - paired, - (p) => p.$1.name, - (p) => p.$1.artistName, - (p) => p.$1.duration, - (p) => p.$1.releaseDate, + final sortedPairs = sortHomeSearchItems<(Track, int)>( + items: paired, + option: _searchSortOption, + nameOf: (p) => p.$1.name, + artistOf: (p) => p.$1.artistName, + durationOf: (p) => p.$1.duration, + dateOf: (p) => p.$1.releaseDate, ); final sortedTracks = sortedPairs.map((p) => p.$1).toList(growable: false); final sortedIndexes = sortedPairs.map((p) => p.$2).toList(growable: false); @@ -3067,17 +2825,19 @@ class _HomeTabState extends ConsumerState icon: Icon( Icons.swap_vert, size: 18, - color: _searchSortOption != _SearchSortOption.defaultOrder + color: + _searchSortOption != HomeSearchSortOption.defaultOrder ? colorScheme.primary : colorScheme.onSurfaceVariant, ), label: Text( - _searchSortOption != _SearchSortOption.defaultOrder + _searchSortOption != HomeSearchSortOption.defaultOrder ? _sortOptionLabel(_searchSortOption) : context.l10n.libraryFilterSort, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: - _searchSortOption != _SearchSortOption.defaultOrder + _searchSortOption != + HomeSearchSortOption.defaultOrder ? colorScheme.primary : colorScheme.onSurfaceVariant, ), @@ -3234,7 +2994,7 @@ class _HomeTabState extends ConsumerState String _getSearchHint() { final settings = ref.read(settingsProvider); final extState = ref.read(extensionProvider); - final searchProvider = _resolveSearchProvider( + final searchProvider = HomeSearchProviderPolicy.resolveProvider( settings.searchProvider, extState.extensions, ); @@ -3328,7 +3088,7 @@ class _HomeTabState extends ConsumerState void _triggerSearchWithFilter(String? filter) { final text = _urlController.text.trim(); if (text.isEmpty || text.length < _minLiveSearchChars) return; - if (_looksLikeUrlOrSpotifyUri(text)) return; + if (looksLikeUrlOrSpotifyUri(text)) return; _lastSearchQuery = null; _performSearch(text, filterOverride: filter); @@ -3412,7 +3172,7 @@ class _HomeTabState extends ConsumerState final text = _urlController.text.trim(); if (text.isEmpty) return; - if (_looksLikeUrlOrSpotifyUri(text)) { + if (looksLikeUrlOrSpotifyUri(text)) { _fetchMetadata(); _searchFocusNode.unfocus(); return; diff --git a/lib/screens/home_tab_helpers.dart b/lib/screens/home_tab_helpers.dart index fb6a2321..9914d8b1 100644 --- a/lib/screens/home_tab_helpers.dart +++ b/lib/screens/home_tab_helpers.dart @@ -31,34 +31,6 @@ class _CsvImportOptions { }); } -class _SearchResultBuckets { - final List realTracks; - final List realTrackIndexes; - final List albumItems; - final List playlistItems; - final List artistItems; - - const _SearchResultBuckets({ - required this.realTracks, - required this.realTrackIndexes, - required this.albumItems, - required this.playlistItems, - required this.artistItems, - }); -} - -enum _SearchSortOption { - defaultOrder, - titleAsc, - titleDesc, - artistAsc, - artistDesc, - durationAsc, - durationDesc, - dateAsc, - dateDesc, -} - const _homeHistoryPreviewLimit = 48; class _HomeHistoryPreview { diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index f83b575b..acfc7462 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -6,20 +6,6 @@ class _SearchProviderDropdown extends ConsumerWidget { const _SearchProviderDropdown({this.onProviderChanged}); - Extension? _defaultSearchExtension(List extensions) { - return extensions - .where( - (ext) => - ext.enabled && - ext.hasCustomSearch && - ext.searchBehavior?.primary == true, - ) - .firstOrNull ?? - extensions - .where((ext) => ext.enabled && ext.hasCustomSearch) - .firstOrNull; - } - @override Widget build(BuildContext context, WidgetRef ref) { final rawCurrentProvider = ref.watch( @@ -71,7 +57,7 @@ class _SearchProviderDropdown extends ConsumerWidget { rawCurrentProvider.isNotEmpty && searchProviders.any((e) => e.id == rawCurrentProvider) ? rawCurrentProvider - : _defaultSearchExtension(searchProviders)?.id; + : HomeSearchProviderPolicy.defaultExtension(searchProviders)?.id; final currentProvider = resolvedCurrentProvider != null && resolvedCurrentProvider.isNotEmpty ? resolvedCurrentProvider diff --git a/lib/services/audio_metadata_mapper.dart b/lib/services/audio_metadata_mapper.dart new file mode 100644 index 00000000..3be6ac50 --- /dev/null +++ b/lib/services/audio_metadata_mapper.dart @@ -0,0 +1,380 @@ +import 'package:spotiflac_android/utils/artist_utils.dart'; + +/// Pure metadata-key conversion used by the different audio containers. +/// +/// Keeping these mappings independent from FFmpeg execution makes the tag +/// contract directly testable and prevents container-specific policy from +/// being buried in the conversion orchestration. +class AudioMetadataMapper { + const AudioMetadataMapper._(); + + static String? extractLyricsForId3(Map? metadata) { + if (metadata == null) return null; + + String? fallback; + for (final entry in metadata.entries) { + final key = _normalizeKey(entry.key); + if (key != 'UNSYNCEDLYRICS' && key != 'LYRICS') continue; + + final value = entry.value; + if (value.trim().isEmpty) continue; + if (key == 'UNSYNCEDLYRICS') return value; + fallback ??= value; + } + + return fallback; + } + + /// Maps Vorbis-comment keys to the fields consumed by the native WAV/AIFF + /// metadata writer. + static Map vorbisToNativeChunkFields( + Map metadata, + ) { + final fields = {}; + + void setIndexPair(String numberKey, String totalKey, String value) { + final normalized = value.trim(); + if (normalized.isEmpty || normalized == '0') return; + if (normalized.contains('/')) { + final parts = normalized.split('/'); + fields[numberKey] = parts[0].trim(); + if (parts.length > 1 && parts[1].trim().isNotEmpty) { + fields[totalKey] = parts[1].trim(); + } + } else { + fields[numberKey] = normalized; + } + } + + for (final entry in metadata.entries) { + final key = _normalizeKey(entry.key); + final value = entry.value; + if (value.trim().isEmpty) continue; + + switch (key) { + case 'TITLE': + fields['title'] = value; + case 'ARTIST': + fields['artist'] = value; + case 'ALBUM': + fields['album'] = value; + case 'ALBUMARTIST': + fields['album_artist'] = value; + case 'TRACKNUMBER': + case 'TRACK': + case 'TRCK': + setIndexPair('track_number', 'track_total', value); + case 'TRACKTOTAL': + case 'TOTALTRACKS': + if (value.trim() != '0') fields['track_total'] = value.trim(); + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + setIndexPair('disc_number', 'disc_total', value); + case 'DISCTOTAL': + case 'TOTALDISCS': + if (value.trim() != '0') fields['disc_total'] = value.trim(); + case 'DATE': + fields['date'] = value; + case 'YEAR': + if ((fields['date'] ?? '').isEmpty) fields['date'] = value; + case 'ISRC': + fields['isrc'] = value; + case 'GENRE': + fields['genre'] = value; + case 'COMPOSER': + fields['composer'] = value; + case 'ORGANIZATION': + case 'LABEL': + case 'PUBLISHER': + fields['label'] = value; + case 'COPYRIGHT': + fields['copyright'] = value; + case 'COMMENT': + case 'DESCRIPTION': + fields['comment'] = value; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + fields['lyrics'] = value; + case 'REPLAYGAINTRACKGAIN': + fields['replaygain_track_gain'] = value; + case 'REPLAYGAINTRACKPEAK': + fields['replaygain_track_peak'] = value; + case 'REPLAYGAINALBUMGAIN': + fields['replaygain_album_gain'] = value; + case 'REPLAYGAINALBUMPEAK': + fields['replaygain_album_peak'] = value; + } + } + + return fields; + } + + /// Normalizes arbitrary tag spellings to standard Vorbis comment names. + static Map normalizeToVorbisComments( + Map metadata, + ) { + final vorbis = {}; + + for (final entry in metadata.entries) { + final key = _normalizeKey(entry.key); + final value = entry.value; + + switch (key) { + case 'TITLE': + vorbis['TITLE'] = value; + case 'ARTIST': + vorbis['ARTIST'] = value; + case 'ALBUM': + vorbis['ALBUM'] = value; + case 'ALBUMARTIST': + vorbis['ALBUMARTIST'] = value; + case 'TRACKNUMBER': + case 'TRACKNBR': + case 'TRACK': + case 'TRCK': + if (value != '0') vorbis['TRACKNUMBER'] = value; + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + if (value != '0') vorbis['DISCNUMBER'] = value; + case 'DATE': + vorbis['DATE'] = value; + final yearMatch = RegExp(r'^(\d{4})').firstMatch(value); + if (yearMatch != null && + (!vorbis.containsKey('YEAR') || vorbis['YEAR']!.isEmpty)) { + vorbis['YEAR'] = yearMatch.group(1)!; + } + case 'YEAR': + vorbis['YEAR'] = value; + if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) { + vorbis['DATE'] = value; + } + case 'GENRE': + vorbis['GENRE'] = value; + case 'ISRC': + vorbis['ISRC'] = value; + case 'LABEL': + case 'ORGANIZATION': + vorbis['ORGANIZATION'] = value; + case 'COPYRIGHT': + vorbis['COPYRIGHT'] = value; + case 'COMPOSER': + vorbis['COMPOSER'] = value; + case 'COMMENT': + vorbis['COMMENT'] = value; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + vorbis['LYRICS'] = value; + vorbis['UNSYNCEDLYRICS'] = value; + case 'REPLAYGAINTRACKGAIN': + vorbis['REPLAYGAIN_TRACK_GAIN'] = value; + case 'REPLAYGAINTRACKPEAK': + vorbis['REPLAYGAIN_TRACK_PEAK'] = value; + case 'REPLAYGAINALBUMGAIN': + vorbis['REPLAYGAIN_ALBUM_GAIN'] = value; + case 'REPLAYGAINALBUMPEAK': + vorbis['REPLAYGAIN_ALBUM_PEAK'] = value; + case 'R128TRACKGAIN': + vorbis['R128_TRACK_GAIN'] = value; + case 'R128ALBUMGAIN': + vorbis['R128_ALBUM_GAIN'] = value; + } + } + + return vorbis; + } + + static void appendVorbisMetadataArguments( + List arguments, + Map metadata, { + String artistTagMode = artistTagModeJoined, + }) { + for (final entry in buildVorbisMetadataEntries( + metadata, + artistTagMode: artistTagMode, + )) { + arguments + ..add('-metadata') + ..add('${entry.key}=${entry.value}'); + } + } + + static void appendMappedMetadataArguments( + List arguments, + Map metadata, + ) { + for (final entry in metadata.entries) { + arguments + ..add('-metadata') + ..add('${entry.key}=${entry.value}'); + } + } + + static List> buildVorbisMetadataEntries( + Map metadata, { + String artistTagMode = artistTagModeJoined, + }) { + final vorbis = normalizeToVorbisComments(metadata); + final entries = >[]; + + for (final entry in vorbis.entries) { + if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') { + continue; + } + entries.add(entry); + } + + _appendVorbisArtistEntries( + entries, + 'ARTIST', + vorbis['ARTIST'], + artistTagMode: artistTagMode, + ); + _appendVorbisArtistEntries( + entries, + 'ALBUMARTIST', + vorbis['ALBUMARTIST'], + artistTagMode: artistTagMode, + ); + + return entries; + } + + /// Maps generic metadata keys to M4A/MP4 tag names understood by FFmpeg. + static Map convertToM4aTags(Map metadata) { + final m4a = {}; + + for (final entry in metadata.entries) { + final key = _normalizeKey(entry.key); + final value = entry.value; + + switch (key) { + case 'TITLE': + m4a['title'] = value; + case 'ARTIST': + m4a['artist'] = value; + case 'ALBUM': + m4a['album'] = value; + case 'ALBUMARTIST': + m4a['album_artist'] = value; + case 'TRACKNUMBER': + case 'TRACK': + case 'TRCK': + m4a['track'] = value; + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + m4a['disc'] = value; + case 'DATE': + m4a['date'] = value; + case 'YEAR': + if (!m4a.containsKey('date') || m4a['date']!.isEmpty) { + m4a['date'] = value; + } + case 'GENRE': + m4a['genre'] = value; + case 'ISRC': + m4a['isrc'] = value; + case 'COMPOSER': + m4a['composer'] = value; + case 'COMMENT': + m4a['comment'] = value; + case 'COPYRIGHT': + m4a['copyright'] = value; + case 'LABEL': + case 'ORGANIZATION': + m4a['organization'] = value; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + m4a['lyrics'] = value; + } + } + + return m4a; + } + + /// Maps generic metadata keys to ID3 names understood by FFmpeg. + static Map convertToId3Tags(Map metadata) { + final id3 = {}; + + for (final entry in metadata.entries) { + final originalKey = entry.key.toUpperCase(); + final key = _normalizeKey(originalKey); + final value = entry.value; + + switch (key) { + case 'TITLE': + id3['title'] = value; + case 'ARTIST': + id3['artist'] = value; + case 'ALBUM': + id3['album'] = value; + case 'ALBUMARTIST': + id3['album_artist'] = value; + case 'TRACKNUMBER': + case 'TRACK': + case 'TRCK': + if (value != '0') id3['track'] = value; + case 'DISCNUMBER': + case 'DISC': + case 'TPOS': + if (value != '0') id3['disc'] = value; + case 'DATE': + id3['date'] = value; + case 'YEAR': + if (!id3.containsKey('date') || id3['date']!.isEmpty) { + id3['date'] = value; + } + case 'ISRC': + id3['TSRC'] = value; + case 'LYRICS': + case 'UNSYNCEDLYRICS': + id3['lyrics'] = value; + case 'COMPOSER': + id3['composer'] = value; + case 'COMMENT': + id3['comment'] = value; + case 'REPLAYGAINTRACKGAIN': + id3['REPLAYGAIN_TRACK_GAIN'] = value; + case 'REPLAYGAINTRACKPEAK': + id3['REPLAYGAIN_TRACK_PEAK'] = value; + case 'REPLAYGAINALBUMGAIN': + id3['REPLAYGAIN_ALBUM_GAIN'] = value; + case 'REPLAYGAINALBUMPEAK': + id3['REPLAYGAIN_ALBUM_PEAK'] = value; + default: + id3[originalKey.toLowerCase()] = value; + } + } + + return id3; + } + + static void _appendVorbisArtistEntries( + List> entries, + String key, + String? rawValue, { + String artistTagMode = artistTagModeJoined, + }) { + if (rawValue == null) return; + final value = rawValue.trim(); + if (value.isEmpty) { + // Preserve an empty entry so FFmpeg clears an existing tag. + entries.add(MapEntry(key, '')); + return; + } + + if (!shouldSplitVorbisArtistTags(artistTagMode)) { + entries.add(MapEntry(key, value)); + return; + } + + for (final artist in splitArtistTagValues(value)) { + entries.add(MapEntry(key, artist)); + } + } + + static String _normalizeKey(String key) => + key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); +} diff --git a/lib/services/ffmpeg_models.dart b/lib/services/ffmpeg_models.dart new file mode 100644 index 00000000..7d9a947d --- /dev/null +++ b/lib/services/ffmpeg_models.dart @@ -0,0 +1,181 @@ +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; + +/// Describes an extension-requested decryption step without coupling callers +/// to the FFmpeg execution service. +class DownloadDecryptionDescriptor { + final String strategy; + final String key; + final String? iv; + final String? inputFormat; + final String? outputExtension; + final Map options; + + const DownloadDecryptionDescriptor({ + required this.strategy, + required this.key, + this.iv, + this.inputFormat, + this.outputExtension, + this.options = const {}, + }); + + factory DownloadDecryptionDescriptor.fromJson(Map json) { + final rawOptions = json['options']; + return DownloadDecryptionDescriptor( + strategy: (json['strategy'] as String? ?? '').trim(), + key: (json['key'] as String? ?? '').trim(), + iv: (json['iv'] as String?)?.trim(), + inputFormat: (json['input_format'] as String?)?.trim(), + outputExtension: (json['output_extension'] as String?)?.trim(), + options: rawOptions is Map + ? Map.from(rawOptions) + : const {}, + ); + } + + Map toJson() { + final json = {'strategy': strategy, 'key': key}; + if (iv != null && iv!.isNotEmpty) { + json['iv'] = iv; + } + if (inputFormat != null && inputFormat!.isNotEmpty) { + json['input_format'] = inputFormat; + } + if (outputExtension != null && outputExtension!.isNotEmpty) { + json['output_extension'] = outputExtension; + } + if (options.isNotEmpty) { + json['options'] = options; + } + return json; + } + + static DownloadDecryptionDescriptor? fromDownloadResult( + Map result, + ) { + final rawDecryption = result['decryption']; + if (rawDecryption is Map) { + final descriptorJson = Map.from(rawDecryption); + descriptorJson['output_extension'] ??= result['output_extension']; + final descriptor = DownloadDecryptionDescriptor.fromJson(descriptorJson); + if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' && + descriptor.key.isNotEmpty) { + return descriptor; + } + } + + final legacyKey = (result['decryption_key'] as String?)?.trim() ?? ''; + if (legacyKey.isEmpty) { + return null; + } + + return DownloadDecryptionDescriptor( + strategy: 'ffmpeg.mov_key', + key: legacyKey, + inputFormat: 'mov', + outputExtension: (result['output_extension'] as String?)?.trim(), + ); + } + + String get normalizedStrategy { + switch (strategy.trim().toLowerCase()) { + case '': + case 'ffmpeg.mov_key': + case 'ffmpeg_mov_key': + case 'mov_decryption_key': + case 'mp4_decryption_key': + case 'ffmpeg.mp4_decryption_key': + return 'ffmpeg.mov_key'; + default: + return strategy.trim(); + } + } + + String? get normalizedOutputExtension { + final trimmed = (outputExtension ?? '').trim().toLowerCase(); + if (trimmed.isEmpty) return null; + return trimmed.startsWith('.') ? trimmed : '.$trimmed'; + } +} + +class CueSplitTrackInfo { + final int number; + final String title; + final String artist; + final String isrc; + final String composer; + final double startSec; + final double endSec; + + CueSplitTrackInfo({ + required this.number, + required this.title, + required this.artist, + this.isrc = '', + this.composer = '', + required this.startSec, + required this.endSec, + }); + + factory CueSplitTrackInfo.fromJson(Map json) { + return CueSplitTrackInfo( + number: json['number'] as int? ?? 0, + title: json['title'] as String? ?? '', + artist: json['artist'] as String? ?? '', + isrc: json['isrc'] as String? ?? '', + composer: json['composer'] as String? ?? '', + startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0, + endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0, + ); + } +} + +class FFmpegResult { + final bool success; + final int returnCode; + final String output; + + FFmpegResult({ + required this.success, + required this.returnCode, + required this.output, + }); +} + +class LiveDecryptedStreamResult { + final String localUrl; + final String format; + final FFmpegSession session; + + LiveDecryptedStreamResult({ + required this.localUrl, + required this.format, + required this.session, + }); +} + +/// Result of an EBU R128 loudness scan, used to compute ReplayGain tags. +class ReplayGainResult { + /// Track gain in dB, e.g. "-6.50 dB". + final String trackGain; + + /// Track peak as a linear ratio, e.g. "0.988831". + final String trackPeak; + + /// Raw integrated loudness in LUFS (needed for album gain computation). + final double integratedLufs; + + /// Raw true peak as linear ratio (needed for album peak computation). + final double truePeakLinear; + + const ReplayGainResult({ + required this.trackGain, + required this.trackPeak, + required this.integratedLufs, + required this.truePeakLinear, + }); + + @override + String toString() => + 'ReplayGainResult(trackGain: $trackGain, trackPeak: $trackPeak)'; +} diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 640dfb11..78665f5c 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -9,109 +9,18 @@ import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; +import 'package:spotiflac_android/services/ffmpeg_models.dart'; +import 'package:spotiflac_android/services/id3v23_lyrics.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; +export 'package:spotiflac_android/services/ffmpeg_models.dart'; + final _log = AppLogger('FFmpeg'); -class DownloadDecryptionDescriptor { - final String strategy; - final String key; - final String? iv; - final String? inputFormat; - final String? outputExtension; - final Map options; - - const DownloadDecryptionDescriptor({ - required this.strategy, - required this.key, - this.iv, - this.inputFormat, - this.outputExtension, - this.options = const {}, - }); - - factory DownloadDecryptionDescriptor.fromJson(Map json) { - final rawOptions = json['options']; - return DownloadDecryptionDescriptor( - strategy: (json['strategy'] as String? ?? '').trim(), - key: (json['key'] as String? ?? '').trim(), - iv: (json['iv'] as String?)?.trim(), - inputFormat: (json['input_format'] as String?)?.trim(), - outputExtension: (json['output_extension'] as String?)?.trim(), - options: rawOptions is Map - ? Map.from(rawOptions) - : const {}, - ); - } - - Map toJson() { - final json = {'strategy': strategy, 'key': key}; - if (iv != null && iv!.isNotEmpty) { - json['iv'] = iv; - } - if (inputFormat != null && inputFormat!.isNotEmpty) { - json['input_format'] = inputFormat; - } - if (outputExtension != null && outputExtension!.isNotEmpty) { - json['output_extension'] = outputExtension; - } - if (options.isNotEmpty) { - json['options'] = options; - } - return json; - } - - static DownloadDecryptionDescriptor? fromDownloadResult( - Map result, - ) { - final rawDecryption = result['decryption']; - if (rawDecryption is Map) { - final descriptorJson = Map.from(rawDecryption); - descriptorJson['output_extension'] ??= result['output_extension']; - final descriptor = DownloadDecryptionDescriptor.fromJson(descriptorJson); - if (descriptor.normalizedStrategy == 'ffmpeg.mov_key' && - descriptor.key.isNotEmpty) { - return descriptor; - } - } - - final legacyKey = (result['decryption_key'] as String?)?.trim() ?? ''; - if (legacyKey.isEmpty) { - return null; - } - - return DownloadDecryptionDescriptor( - strategy: 'ffmpeg.mov_key', - key: legacyKey, - inputFormat: 'mov', - outputExtension: (result['output_extension'] as String?)?.trim(), - ); - } - - String get normalizedStrategy { - switch (strategy.trim().toLowerCase()) { - case '': - case 'ffmpeg.mov_key': - case 'ffmpeg_mov_key': - case 'mov_decryption_key': - case 'mp4_decryption_key': - case 'ffmpeg.mp4_decryption_key': - return 'ffmpeg.mov_key'; - default: - return strategy.trim(); - } - } - - String? get normalizedOutputExtension { - final trimmed = (outputExtension ?? '').trim().toLowerCase(); - if (trimmed.isEmpty) return null; - return trimmed.startsWith('.') ? trimmed : '.$trimmed'; - } -} - class _ResolvedLosslessConversionQuality { final int? targetBitDepth; final int? targetSampleRate; @@ -1764,7 +1673,7 @@ class FFmpegService { ..add('copy'); if (metadata != null) { - _appendVorbisMetadataToArguments( + AudioMetadataMapper.appendVorbisMetadataArguments( arguments, metadata, artistTagMode: artistTagMode, @@ -1810,7 +1719,7 @@ class FFmpegService { }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3'); - final lyrics = _extractLyricsForId3(metadata); + final lyrics = AudioMetadataMapper.extractLyricsForId3(metadata); // Try with -c:a copy first (fastest, preserves original codec) var result = await _runMp3Embed( @@ -1928,7 +1837,10 @@ class FFmpegService { } if (metadata != null) { - _appendMappedMetadataToArguments(arguments, _convertToId3Tags(metadata)); + AudioMetadataMapper.appendMappedMetadataArguments( + arguments, + AudioMetadataMapper.convertToId3Tags(metadata), + ); } arguments @@ -1958,23 +1870,6 @@ class FFmpegService { return mp3Path; } - static String? _extractLyricsForId3(Map? metadata) { - if (metadata == null) return null; - - String? fallback; - for (final entry in metadata.entries) { - final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); - if (key != 'UNSYNCEDLYRICS' && key != 'LYRICS') continue; - - final value = entry.value; - if (value.trim().isEmpty) continue; - if (key == 'UNSYNCEDLYRICS') return value; - fallback ??= value; - } - - return fallback; - } - static Future _ensureMp3UnsyncedLyricsFrame( String mp3Path, String lyrics, @@ -1984,7 +1879,7 @@ class FFmpegService { if (!await file.exists()) return; final bytes = await file.readAsBytes(); - final updated = _writeId3v23UnsyncedLyrics(bytes, lyrics); + final updated = Id3v23Lyrics.writeUnsyncedLyrics(bytes, lyrics); if (updated == null) { _log.w('Skipping MP3 USLT lyrics frame update: unsupported ID3 tag'); return; @@ -1997,165 +1892,6 @@ class FFmpegService { } } - static Uint8List? _writeId3v23UnsyncedLyrics(Uint8List bytes, String lyrics) { - final lyricsFrame = _buildId3v23UnsyncedLyricsFrame(lyrics); - - if (!_hasId3Header(bytes)) { - final builder = BytesBuilder(copy: false) - ..add(_buildId3v23Tag(lyricsFrame)) - ..add(bytes); - return builder.toBytes(); - } - - if (bytes.length < 10 || bytes[3] != 3) { - return null; - } - - final flags = bytes[5]; - const unsupportedFlags = 0x80 | 0x40 | 0x20; - if ((flags & unsupportedFlags) != 0) { - return null; - } - - final tagSize = _readSynchsafeInt(bytes, 6); - if (tagSize == null) return null; - - final tagEnd = 10 + tagSize; - if (tagEnd < 10 || tagEnd > bytes.length) { - return null; - } - - final tagPayload = bytes.sublist(10, tagEnd); - final preservedFrames = _removeId3v23Frames(tagPayload, {'USLT'}); - final newPayload = BytesBuilder(copy: false) - ..add(preservedFrames) - ..add(lyricsFrame); - - final newTag = _buildId3v23Tag(newPayload.toBytes()); - final builder = BytesBuilder(copy: false) - ..add(newTag) - ..add(bytes.sublist(tagEnd)); - return builder.toBytes(); - } - - static bool _hasId3Header(Uint8List bytes) { - return bytes.length >= 10 && - bytes[0] == 0x49 && - bytes[1] == 0x44 && - bytes[2] == 0x33; - } - - static Uint8List _removeId3v23Frames( - Uint8List tagPayload, - Set frameIds, - ) { - final builder = BytesBuilder(copy: false); - var offset = 0; - - while (offset + 10 <= tagPayload.length) { - final idBytes = tagPayload.sublist(offset, offset + 4); - if (idBytes.every((byte) => byte == 0)) break; - - final frameId = ascii.decode(idBytes, allowInvalid: true); - if (!RegExp(r'^[A-Z0-9]{4}$').hasMatch(frameId)) break; - - final frameSize = _readUint32(tagPayload, offset + 4); - if (frameSize <= 0 || offset + 10 + frameSize > tagPayload.length) { - break; - } - - if (!frameIds.contains(frameId)) { - builder.add(tagPayload.sublist(offset, offset + 10 + frameSize)); - } - - offset += 10 + frameSize; - } - - return builder.toBytes(); - } - - static Uint8List _buildId3v23Tag(Uint8List payload) { - final header = Uint8List(10) - ..[0] = 0x49 - ..[1] = 0x44 - ..[2] = 0x33 - ..[3] = 3; - - final size = _writeSynchsafeInt(payload.length); - header.setRange(6, 10, size); - - final builder = BytesBuilder(copy: false) - ..add(header) - ..add(payload); - return builder.toBytes(); - } - - static Uint8List _buildId3v23UnsyncedLyricsFrame(String lyrics) { - final payload = BytesBuilder(copy: false) - ..add(const [0x01, 0x65, 0x6e, 0x67]) - ..add(const [0xff, 0xfe, 0x00, 0x00]) - ..add(_utf16LeWithBom(lyrics)); - - return _buildId3v23Frame('USLT', payload.toBytes()); - } - - static Uint8List _buildId3v23Frame(String frameId, Uint8List payload) { - final header = Uint8List(10); - header.setRange(0, 4, ascii.encode(frameId)); - final size = _writeUint32(payload.length); - header.setRange(4, 8, size); - - final builder = BytesBuilder(copy: false) - ..add(header) - ..add(payload); - return builder.toBytes(); - } - - static Uint8List _utf16LeWithBom(String value) { - final bytes = BytesBuilder(copy: false)..add(const [0xff, 0xfe]); - for (final codeUnit in value.codeUnits) { - bytes.add([codeUnit & 0xff, (codeUnit >> 8) & 0xff]); - } - return bytes.toBytes(); - } - - static int? _readSynchsafeInt(Uint8List bytes, int offset) { - if (offset + 4 > bytes.length) return null; - - final b0 = bytes[offset]; - final b1 = bytes[offset + 1]; - final b2 = bytes[offset + 2]; - final b3 = bytes[offset + 3]; - if ((b0 | b1 | b2 | b3) & 0x80 != 0) return null; - - return (b0 << 21) | (b1 << 14) | (b2 << 7) | b3; - } - - static Uint8List _writeSynchsafeInt(int value) { - return Uint8List.fromList([ - (value >> 21) & 0x7f, - (value >> 14) & 0x7f, - (value >> 7) & 0x7f, - value & 0x7f, - ]); - } - - static int _readUint32(Uint8List bytes, int offset) { - return (bytes[offset] << 24) | - (bytes[offset + 1] << 16) | - (bytes[offset + 2] << 8) | - bytes[offset + 3]; - } - - static Uint8List _writeUint32(int value) { - return Uint8List.fromList([ - (value >> 24) & 0xff, - (value >> 16) & 0xff, - (value >> 8) & 0xff, - value & 0xff, - ]); - } - static Future embedMetadataToOpus({ required String opusPath, String? coverPath, @@ -2183,7 +1919,7 @@ class FFmpegService { ]; if (metadata != null) { - _appendVorbisMetadataToArguments( + AudioMetadataMapper.appendVorbisMetadataArguments( arguments, metadata, artistTagMode: artistTagMode, @@ -2290,9 +2026,9 @@ class FFmpegService { } if (metadata != null) { - _appendMappedMetadataToArguments( + AudioMetadataMapper.appendMappedMetadataArguments( arguments, - _convertToM4aTags(metadata), + AudioMetadataMapper.convertToM4aTags(metadata), ); } @@ -2668,9 +2404,12 @@ class FFmpegService { ..add(isAlac ? '-1' : '0'); if (isAlac) { - _appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata)); + AudioMetadataMapper.appendMappedMetadataArguments( + arguments, + AudioMetadataMapper.convertToM4aTags(metadata), + ); } else { - _appendVorbisMetadataToArguments( + AudioMetadataMapper.appendVorbisMetadataArguments( arguments, metadata, artistTagMode: artistTagMode, @@ -2796,7 +2535,9 @@ class FFmpegService { Map vorbisMetadata, String? coverPath, ) async { - final fields = _vorbisToNativeChunkFields(vorbisMetadata); + final fields = AudioMetadataMapper.vorbisToNativeChunkFields( + vorbisMetadata, + ); if (coverPath != null && coverPath.trim().isNotEmpty) { fields['cover_path'] = coverPath; } @@ -2810,293 +2551,6 @@ class FFmpegService { } } - /// Maps Vorbis-comment style metadata (UPPERCASE keys) to the lowercase field - /// names consumed by the Go EditFileMetadata native WAV/AIFF tag writer. - static Map _vorbisToNativeChunkFields( - Map metadata, - ) { - final out = {}; - - void setIndexPair(String numberKey, String totalKey, String value) { - final v = value.trim(); - if (v.isEmpty || v == '0') return; - if (v.contains('/')) { - final parts = v.split('/'); - out[numberKey] = parts[0].trim(); - if (parts.length > 1 && parts[1].trim().isNotEmpty) { - out[totalKey] = parts[1].trim(); - } - } else { - out[numberKey] = v; - } - } - - for (final entry in metadata.entries) { - final normalizedKey = entry.key.toUpperCase().replaceAll( - RegExp(r'[^A-Z0-9]'), - '', - ); - final value = entry.value; - if (value.trim().isEmpty) continue; - - switch (normalizedKey) { - case 'TITLE': - out['title'] = value; - break; - case 'ARTIST': - out['artist'] = value; - break; - case 'ALBUM': - out['album'] = value; - break; - case 'ALBUMARTIST': - out['album_artist'] = value; - break; - case 'TRACKNUMBER': - case 'TRACK': - case 'TRCK': - setIndexPair('track_number', 'track_total', value); - break; - case 'TRACKTOTAL': - case 'TOTALTRACKS': - if (value.trim() != '0') out['track_total'] = value.trim(); - break; - case 'DISCNUMBER': - case 'DISC': - case 'TPOS': - setIndexPair('disc_number', 'disc_total', value); - break; - case 'DISCTOTAL': - case 'TOTALDISCS': - if (value.trim() != '0') out['disc_total'] = value.trim(); - break; - case 'DATE': - out['date'] = value; - break; - case 'YEAR': - if ((out['date'] ?? '').isEmpty) out['date'] = value; - break; - case 'ISRC': - out['isrc'] = value; - break; - case 'GENRE': - out['genre'] = value; - break; - case 'COMPOSER': - out['composer'] = value; - break; - case 'ORGANIZATION': - case 'LABEL': - case 'PUBLISHER': - out['label'] = value; - break; - case 'COPYRIGHT': - out['copyright'] = value; - break; - case 'COMMENT': - case 'DESCRIPTION': - out['comment'] = value; - break; - case 'LYRICS': - case 'UNSYNCEDLYRICS': - out['lyrics'] = value; - break; - case 'REPLAYGAINTRACKGAIN': - out['replaygain_track_gain'] = value; - break; - case 'REPLAYGAINTRACKPEAK': - out['replaygain_track_peak'] = value; - break; - case 'REPLAYGAINALBUMGAIN': - out['replaygain_album_gain'] = value; - break; - case 'REPLAYGAINALBUMPEAK': - out['replaygain_album_peak'] = value; - break; - } - } - - return out; - } - - /// Normalize metadata keys to standard Vorbis comment names, filtering out - /// technical fields (bit_depth, sample_rate, duration, etc.). - static Map _normalizeToVorbisComments( - Map metadata, - ) { - final vorbis = {}; - - for (final entry in metadata.entries) { - final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); - final value = entry.value; - - switch (key) { - case 'TITLE': - vorbis['TITLE'] = value; - break; - case 'ARTIST': - vorbis['ARTIST'] = value; - break; - case 'ALBUM': - vorbis['ALBUM'] = value; - break; - case 'ALBUMARTIST': - vorbis['ALBUMARTIST'] = value; - break; - case 'TRACKNUMBER': - case 'TRACKNBR': - case 'TRACK': - case 'TRCK': - if (value != '0') vorbis['TRACKNUMBER'] = value; - break; - case 'DISCNUMBER': - case 'DISC': - case 'TPOS': - if (value != '0') vorbis['DISCNUMBER'] = value; - break; - case 'DATE': - vorbis['DATE'] = value; - final yearMatch = RegExp(r'^(\d{4})').firstMatch(value); - if (yearMatch != null && - (!vorbis.containsKey('YEAR') || vorbis['YEAR']!.isEmpty)) { - vorbis['YEAR'] = yearMatch.group(1)!; - } - break; - case 'YEAR': - vorbis['YEAR'] = value; - if (!vorbis.containsKey('DATE') || vorbis['DATE']!.isEmpty) { - vorbis['DATE'] = value; - } - break; - case 'GENRE': - vorbis['GENRE'] = value; - break; - case 'ISRC': - vorbis['ISRC'] = value; - break; - case 'LABEL': - case 'ORGANIZATION': - vorbis['ORGANIZATION'] = value; - break; - case 'COPYRIGHT': - vorbis['COPYRIGHT'] = value; - break; - case 'COMPOSER': - vorbis['COMPOSER'] = value; - break; - case 'COMMENT': - vorbis['COMMENT'] = value; - break; - case 'LYRICS': - case 'UNSYNCEDLYRICS': - vorbis['LYRICS'] = value; - vorbis['UNSYNCEDLYRICS'] = value; - break; - case 'REPLAYGAINTRACKGAIN': - vorbis['REPLAYGAIN_TRACK_GAIN'] = value; - break; - case 'REPLAYGAINTRACKPEAK': - vorbis['REPLAYGAIN_TRACK_PEAK'] = value; - break; - case 'REPLAYGAINALBUMGAIN': - vorbis['REPLAYGAIN_ALBUM_GAIN'] = value; - break; - case 'REPLAYGAINALBUMPEAK': - vorbis['REPLAYGAIN_ALBUM_PEAK'] = value; - break; - case 'R128TRACKGAIN': - vorbis['R128_TRACK_GAIN'] = value; - break; - case 'R128ALBUMGAIN': - vorbis['R128_ALBUM_GAIN'] = value; - break; - } - } - - return vorbis; - } - - static void _appendVorbisMetadataToArguments( - List arguments, - Map metadata, { - String artistTagMode = artistTagModeJoined, - }) { - for (final entry in _buildVorbisMetadataEntries( - metadata, - artistTagMode: artistTagMode, - )) { - arguments - ..add('-metadata') - ..add('${entry.key}=${entry.value}'); - } - } - - static void _appendMappedMetadataToArguments( - List arguments, - Map metadata, - ) { - for (final entry in metadata.entries) { - arguments - ..add('-metadata') - ..add('${entry.key}=${entry.value}'); - } - } - - static List> _buildVorbisMetadataEntries( - Map metadata, { - String artistTagMode = artistTagModeJoined, - }) { - final vorbis = _normalizeToVorbisComments(metadata); - final entries = >[]; - - for (final entry in vorbis.entries) { - if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') { - continue; - } - entries.add(entry); - } - - _appendVorbisArtistEntries( - entries, - 'ARTIST', - vorbis['ARTIST'], - artistTagMode: artistTagMode, - ); - _appendVorbisArtistEntries( - entries, - 'ALBUMARTIST', - vorbis['ALBUMARTIST'], - artistTagMode: artistTagMode, - ); - - return entries; - } - - static void _appendVorbisArtistEntries( - List> entries, - String key, - String? rawValue, { - String artistTagMode = artistTagModeJoined, - }) { - if (rawValue == null) return; - final value = rawValue.trim(); - if (value.isEmpty) { - // Emit an empty entry so that with preserveMetadata the old tag is - // overridden (cleared) by FFmpeg's `-metadata key=""`. - entries.add(MapEntry(key, '')); - return; - } - - if (!shouldSplitVorbisArtistTags(artistTagMode)) { - entries.add(MapEntry(key, value)); - return; - } - - for (final artist in splitArtistTagValues(value)) { - entries.add(MapEntry(key, artist)); - } - } - /// Writes ISRC and label into an M4A/MP4 file natively (iTunes freeform /// atoms), since FFmpeg's MP4 muxer drops these keys. Only keys present in /// [metadata] are written; an empty value clears the corresponding tag. @@ -3125,153 +2579,6 @@ class FFmpegService { } } - /// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg. - static Map _convertToM4aTags(Map metadata) { - final m4aMap = {}; - - for (final entry in metadata.entries) { - final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), ''); - final value = entry.value; - - switch (key) { - case 'TITLE': - m4aMap['title'] = value; - break; - case 'ARTIST': - m4aMap['artist'] = value; - break; - case 'ALBUM': - m4aMap['album'] = value; - break; - case 'ALBUMARTIST': - m4aMap['album_artist'] = value; - break; - case 'TRACKNUMBER': - case 'TRACK': - case 'TRCK': - m4aMap['track'] = value; - break; - case 'DISCNUMBER': - case 'DISC': - case 'TPOS': - m4aMap['disc'] = value; - break; - case 'DATE': - m4aMap['date'] = value; - break; - case 'YEAR': - if (!m4aMap.containsKey('date') || m4aMap['date']!.isEmpty) { - m4aMap['date'] = value; - } - break; - case 'GENRE': - m4aMap['genre'] = value; - break; - case 'ISRC': - m4aMap['isrc'] = value; - break; - case 'COMPOSER': - m4aMap['composer'] = value; - break; - case 'COMMENT': - m4aMap['comment'] = value; - break; - case 'COPYRIGHT': - m4aMap['copyright'] = value; - break; - case 'LABEL': - case 'ORGANIZATION': - m4aMap['organization'] = value; - break; - case 'LYRICS': - case 'UNSYNCEDLYRICS': - m4aMap['lyrics'] = value; - break; - } - } - - return m4aMap; - } - - static Map _convertToId3Tags( - Map vorbisMetadata, - ) { - final id3Map = {}; - - for (final entry in vorbisMetadata.entries) { - final key = entry.key.toUpperCase(); - final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), ''); - final value = entry.value; - - switch (normalizedKey) { - case 'TITLE': - id3Map['title'] = value; - break; - case 'ARTIST': - id3Map['artist'] = value; - break; - case 'ALBUM': - id3Map['album'] = value; - break; - case 'ALBUMARTIST': - id3Map['album_artist'] = value; - break; - case 'TRACKNUMBER': - case 'TRACK': - case 'TRCK': - if (value != '0') { - id3Map['track'] = value; - } - break; - case 'DISCNUMBER': - case 'DISC': - case 'TPOS': - if (value != '0') { - id3Map['disc'] = value; - } - break; - case 'DATE': - id3Map['date'] = value; - break; - case 'YEAR': - if (!id3Map.containsKey('date') || id3Map['date']!.isEmpty) { - id3Map['date'] = value; - } - break; - case 'ISRC': - id3Map['TSRC'] = value; - break; - case 'LYRICS': - case 'UNSYNCEDLYRICS': - id3Map['lyrics'] = value; - break; - case 'COMPOSER': - id3Map['composer'] = value; - break; - case 'COMMENT': - id3Map['comment'] = value; - break; - // FFmpeg writes these as TXXX frames automatically with uppercase keys - case 'REPLAYGAINTRACKGAIN': - id3Map['REPLAYGAIN_TRACK_GAIN'] = value; - break; - case 'REPLAYGAINTRACKPEAK': - id3Map['REPLAYGAIN_TRACK_PEAK'] = value; - break; - case 'REPLAYGAINALBUMGAIN': - id3Map['REPLAYGAIN_ALBUM_GAIN'] = value; - break; - case 'REPLAYGAINALBUMPEAK': - id3Map['REPLAYGAIN_ALBUM_PEAK'] = value; - break; - default: - id3Map[key.toLowerCase()] = value; - } - } - - return id3Map; - } - /// Split a CUE+audio file into individual track files using FFmpeg. /// Each track is extracted with `-c copy` (no re-encoding) and metadata is embedded. /// [audioPath] is the source audio file (FLAC, WAV, etc.) @@ -3368,7 +2675,7 @@ class FFmpegService { if (track.isrc.isNotEmpty) addMeta('ISRC', track.isrc); if (track.composer.isNotEmpty) addMeta('COMPOSER', track.composer); - _appendMappedMetadataToArguments(arguments, cueMetadata); + AudioMetadataMapper.appendMappedMetadataArguments(arguments, cueMetadata); arguments ..add(outputPath) ..add('-y'); @@ -3404,86 +2711,4 @@ class FFmpegService { } } -class CueSplitTrackInfo { - final int number; - final String title; - final String artist; - final String isrc; - final String composer; - final double startSec; - final double endSec; - - CueSplitTrackInfo({ - required this.number, - required this.title, - required this.artist, - this.isrc = '', - this.composer = '', - required this.startSec, - required this.endSec, - }); - - factory CueSplitTrackInfo.fromJson(Map json) { - return CueSplitTrackInfo( - number: json['number'] as int? ?? 0, - title: json['title'] as String? ?? '', - artist: json['artist'] as String? ?? '', - isrc: json['isrc'] as String? ?? '', - composer: json['composer'] as String? ?? '', - startSec: (json['start_sec'] as num?)?.toDouble() ?? 0.0, - endSec: (json['end_sec'] as num?)?.toDouble() ?? -1.0, - ); - } -} - -class FFmpegResult { - final bool success; - final int returnCode; - final String output; - - FFmpegResult({ - required this.success, - required this.returnCode, - required this.output, - }); -} - enum _LiveDecryptFormat { flac, m4a } - -class LiveDecryptedStreamResult { - final String localUrl; - final String format; - final FFmpegSession session; - - LiveDecryptedStreamResult({ - required this.localUrl, - required this.format, - required this.session, - }); -} - -/// Result of an EBU R128 loudness scan, used to compute ReplayGain tags. -class ReplayGainResult { - /// Track gain in dB, e.g. "-6.50 dB" - final String trackGain; - - /// Track peak as a linear ratio, e.g. "0.988831" - final String trackPeak; - - /// Raw integrated loudness in LUFS (needed for album gain computation) - final double integratedLufs; - - /// Raw true peak as linear ratio (needed for album peak computation) - final double truePeakLinear; - - const ReplayGainResult({ - required this.trackGain, - required this.trackPeak, - required this.integratedLufs, - required this.truePeakLinear, - }); - - @override - String toString() => - 'ReplayGainResult(trackGain: $trackGain, trackPeak: $trackPeak)'; -} diff --git a/lib/services/id3v23_lyrics.dart b/lib/services/id3v23_lyrics.dart new file mode 100644 index 00000000..deab1445 --- /dev/null +++ b/lib/services/id3v23_lyrics.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// Minimal ID3v2.3 USLT writer used after FFmpeg metadata embedding. +/// +/// It intentionally supports only plain ID3v2.3 tags without extended, +/// compressed, or unsynchronized headers. Unsupported tags are left unchanged. +class Id3v23Lyrics { + const Id3v23Lyrics._(); + + static Uint8List? writeUnsyncedLyrics(Uint8List bytes, String lyrics) { + final lyricsFrame = _buildUnsyncedLyricsFrame(lyrics); + + if (!_hasId3Header(bytes)) { + final builder = BytesBuilder(copy: false) + ..add(_buildTag(lyricsFrame)) + ..add(bytes); + return builder.toBytes(); + } + + if (bytes.length < 10 || bytes[3] != 3) { + return null; + } + + final flags = bytes[5]; + const unsupportedFlags = 0x80 | 0x40 | 0x20; + if ((flags & unsupportedFlags) != 0) { + return null; + } + + final tagSize = _readSynchsafeInt(bytes, 6); + if (tagSize == null) return null; + + final tagEnd = 10 + tagSize; + if (tagEnd < 10 || tagEnd > bytes.length) { + return null; + } + + final tagPayload = bytes.sublist(10, tagEnd); + final preservedFrames = _removeFrames(tagPayload, {'USLT'}); + final newPayload = BytesBuilder(copy: false) + ..add(preservedFrames) + ..add(lyricsFrame); + + final newTag = _buildTag(newPayload.toBytes()); + final builder = BytesBuilder(copy: false) + ..add(newTag) + ..add(bytes.sublist(tagEnd)); + return builder.toBytes(); + } + + static bool _hasId3Header(Uint8List bytes) { + return bytes.length >= 10 && + bytes[0] == 0x49 && + bytes[1] == 0x44 && + bytes[2] == 0x33; + } + + static Uint8List _removeFrames(Uint8List tagPayload, Set frameIds) { + final builder = BytesBuilder(copy: false); + var offset = 0; + + while (offset + 10 <= tagPayload.length) { + final idBytes = tagPayload.sublist(offset, offset + 4); + if (idBytes.every((byte) => byte == 0)) break; + + final frameId = ascii.decode(idBytes, allowInvalid: true); + if (!RegExp(r'^[A-Z0-9]{4}$').hasMatch(frameId)) break; + + final frameSize = _readUint32(tagPayload, offset + 4); + if (frameSize <= 0 || offset + 10 + frameSize > tagPayload.length) { + break; + } + + if (!frameIds.contains(frameId)) { + builder.add(tagPayload.sublist(offset, offset + 10 + frameSize)); + } + + offset += 10 + frameSize; + } + + return builder.toBytes(); + } + + static Uint8List _buildTag(Uint8List payload) { + final header = Uint8List(10) + ..[0] = 0x49 + ..[1] = 0x44 + ..[2] = 0x33 + ..[3] = 3; + + final size = _writeSynchsafeInt(payload.length); + header.setRange(6, 10, size); + + final builder = BytesBuilder(copy: false) + ..add(header) + ..add(payload); + return builder.toBytes(); + } + + static Uint8List _buildUnsyncedLyricsFrame(String lyrics) { + final payload = BytesBuilder(copy: false) + ..add(const [0x01, 0x65, 0x6e, 0x67]) + ..add(const [0xff, 0xfe, 0x00, 0x00]) + ..add(_utf16LeWithBom(lyrics)); + + return _buildFrame('USLT', payload.toBytes()); + } + + static Uint8List _buildFrame(String frameId, Uint8List payload) { + final header = Uint8List(10); + header.setRange(0, 4, ascii.encode(frameId)); + final size = _writeUint32(payload.length); + header.setRange(4, 8, size); + + final builder = BytesBuilder(copy: false) + ..add(header) + ..add(payload); + return builder.toBytes(); + } + + static Uint8List _utf16LeWithBom(String value) { + final bytes = BytesBuilder(copy: false)..add(const [0xff, 0xfe]); + for (final codeUnit in value.codeUnits) { + bytes.add([codeUnit & 0xff, (codeUnit >> 8) & 0xff]); + } + return bytes.toBytes(); + } + + static int? _readSynchsafeInt(Uint8List bytes, int offset) { + if (offset + 4 > bytes.length) return null; + + final b0 = bytes[offset]; + final b1 = bytes[offset + 1]; + final b2 = bytes[offset + 2]; + final b3 = bytes[offset + 3]; + if ((b0 | b1 | b2 | b3) & 0x80 != 0) return null; + + return (b0 << 21) | (b1 << 14) | (b2 << 7) | b3; + } + + static Uint8List _writeSynchsafeInt(int value) { + return Uint8List.fromList([ + (value >> 21) & 0x7f, + (value >> 14) & 0x7f, + (value >> 7) & 0x7f, + value & 0x7f, + ]); + } + + static int _readUint32(Uint8List bytes, int offset) { + return (bytes[offset] << 24) | + (bytes[offset + 1] << 16) | + (bytes[offset + 2] << 8) | + bytes[offset + 3]; + } + + static Uint8List _writeUint32(int value) { + return Uint8List.fromList([ + (value >> 24) & 0xff, + (value >> 16) & 0xff, + (value >> 8) & 0xff, + value & 0xff, + ]); + } +} diff --git a/test/maintainability_contracts_test.dart b/test/maintainability_contracts_test.dart new file mode 100644 index 00000000..bf82845d --- /dev/null +++ b/test/maintainability_contracts_test.dart @@ -0,0 +1,337 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_state.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/screens/home_search_logic.dart'; +import 'package:spotiflac_android/services/audio_metadata_mapper.dart'; +import 'package:spotiflac_android/services/ffmpeg_models.dart'; +import 'package:spotiflac_android/services/id3v23_lyrics.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; + +void main() { + Track track({ + required String id, + required String name, + String artist = 'Artist', + int duration = 180, + String? releaseDate, + String? itemType, + }) { + return Track( + id: id, + name: name, + artistName: artist, + albumName: 'Album', + duration: duration, + releaseDate: releaseDate, + itemType: itemType, + ); + } + + group('home search logic', () { + test('separates playable tracks from collection result types', () { + final results = [ + track(id: 'track', name: 'Track'), + track(id: 'album', name: 'Album', itemType: 'album'), + track(id: 'playlist', name: 'Playlist', itemType: 'playlist'), + track(id: 'artist', name: 'Artist', itemType: 'artist'), + ]; + + final buckets = bucketHomeSearchResults(results); + + expect(buckets.realTracks.map((item) => item.id), ['track']); + expect(buckets.realTrackIndexes, [0]); + expect(buckets.albumItems.single.id, 'album'); + expect(buckets.playlistItems.single.id, 'playlist'); + expect(buckets.artistItems.single.id, 'artist'); + }); + + test('sorts a copy and preserves default result identity', () { + final results = [ + track( + id: 'later', + name: 'Zulu', + artist: 'Beta', + duration: 220, + releaseDate: '2026-02-01', + ), + track( + id: 'earlier', + name: 'Alpha', + artist: 'Alpha', + duration: 120, + releaseDate: '2025-01-01', + ), + ]; + + final unchanged = sortHomeSearchItems( + items: results, + option: HomeSearchSortOption.defaultOrder, + nameOf: (item) => item.name, + artistOf: (item) => item.artistName, + durationOf: (item) => item.duration, + dateOf: (item) => item.releaseDate, + ); + final sorted = sortHomeSearchItems( + items: results, + option: HomeSearchSortOption.titleAsc, + nameOf: (item) => item.name, + artistOf: (item) => item.artistName, + durationOf: (item) => item.duration, + dateOf: (item) => item.releaseDate, + ); + + expect(identical(unchanged, results), isTrue); + expect(identical(sorted, results), isFalse); + expect(sorted.map((item) => item.id), ['earlier', 'later']); + expect(results.first.id, 'later'); + }); + + test('recognizes URLs and Spotify URIs without treating text as a URL', () { + expect(looksLikeUrlOrSpotifyUri('https://example.test/track'), isTrue); + expect(looksLikeUrlOrSpotifyUri('spotify:track:123'), isTrue); + expect(looksLikeUrlOrSpotifyUri('artist track title'), isFalse); + }); + + test('uses the primary enabled extension as the provider fallback', () { + final secondary = _searchExtension(id: 'secondary'); + final primary = _searchExtension(id: 'primary', primary: true); + final disabled = _searchExtension(id: 'disabled', enabled: false); + final extensions = [secondary, primary, disabled]; + + expect( + HomeSearchProviderPolicy.resolveProvider(null, extensions), + 'primary', + ); + expect( + HomeSearchProviderPolicy.resolveProvider('secondary', extensions), + 'secondary', + ); + expect( + HomeSearchProviderPolicy.resolveProvider('disabled', extensions), + 'primary', + ); + expect( + HomeSearchProviderPolicy.hasProvider('missing', extensions), + isTrue, + ); + }); + + test('canonicalizes built-in and extension search filters', () { + final extension = _searchExtension( + id: 'provider', + filters: const [ + SearchFilter(id: 'songs', label: 'Tracks', icon: 'music'), + SearchFilter(id: 'records', label: 'Albums', icon: 'album'), + ], + ); + + expect( + HomeSearchProviderPolicy.sanitizeFilter('Music', null, const []), + 'track', + ); + expect( + HomeSearchProviderPolicy.sanitizeFilter('track', 'provider', [ + extension, + ]), + 'songs', + ); + expect( + HomeSearchProviderPolicy.displayFilterSelection( + null, + 'album', + 'provider', + [extension], + ), + 'records', + ); + expect( + HomeSearchProviderPolicy.sanitizeFilter('podcast', 'provider', [ + extension, + ]), + isNull, + ); + }); + }); + + group('FFmpeg value models', () { + test('normalizes structured and legacy decryption descriptors', () { + final structured = DownloadDecryptionDescriptor.fromDownloadResult({ + 'output_extension': 'M4A', + 'decryption': { + 'strategy': 'ffmpeg_mov_key', + 'key': ' secret ', + 'options': {'repair_ac4': true}, + }, + }); + final legacy = DownloadDecryptionDescriptor.fromDownloadResult({ + 'decryption_key': 'legacy', + 'output_extension': '.flac', + }); + + expect(structured, isNotNull); + expect(structured!.normalizedStrategy, 'ffmpeg.mov_key'); + expect(structured.normalizedOutputExtension, '.m4a'); + expect(structured.key, 'secret'); + expect(structured.options['repair_ac4'], isTrue); + expect(legacy!.inputFormat, 'mov'); + expect(legacy.normalizedOutputExtension, '.flac'); + }); + + test('rejects empty or unsupported structured decryption requests', () { + expect( + DownloadDecryptionDescriptor.fromDownloadResult({ + 'decryption': {'strategy': 'custom', 'key': 'secret'}, + }), + isNull, + ); + expect( + DownloadDecryptionDescriptor.fromDownloadResult({ + 'decryption': {'strategy': 'ffmpeg.mov_key', 'key': ' '}, + }), + isNull, + ); + }); + }); + + group('audio metadata modules', () { + test('maps shared tags consistently for native, M4A, and ID3 writers', () { + final metadata = { + 'ALBUM_ARTIST': 'Album Artist', + 'TRACKNUMBER': '3/12', + 'DISCNUMBER': '1/2', + 'ISRC': 'TEST12345678', + 'UNSYNCEDLYRICS': 'Lyrics', + 'REPLAYGAIN_TRACK_GAIN': '-5.00 dB', + 'BIT_DEPTH': '24', + }; + + final native = AudioMetadataMapper.vorbisToNativeChunkFields(metadata); + final m4a = AudioMetadataMapper.convertToM4aTags(metadata); + final id3 = AudioMetadataMapper.convertToId3Tags(metadata); + + expect(native['album_artist'], 'Album Artist'); + expect(native['track_number'], '3'); + expect(native['track_total'], '12'); + expect(native['disc_number'], '1'); + expect(native['disc_total'], '2'); + expect(native['replaygain_track_gain'], '-5.00 dB'); + expect(m4a['isrc'], 'TEST12345678'); + expect(m4a['lyrics'], 'Lyrics'); + expect(id3['TSRC'], 'TEST12345678'); + expect(id3['REPLAYGAIN_TRACK_GAIN'], '-5.00 dB'); + expect(native, isNot(contains('bit_depth'))); + }); + + test('builds split Vorbis artist entries without losing other tags', () { + final entries = AudioMetadataMapper.buildVorbisMetadataEntries({ + 'ARTIST': 'First & Second', + 'TITLE': 'Track', + }, artistTagMode: artistTagModeSplitVorbis); + + expect( + entries.where((entry) => entry.key == 'ARTIST').map((e) => e.value), + ['First', 'Second'], + ); + expect( + entries.where((entry) => entry.key == 'TITLE').single.value, + 'Track', + ); + }); + + test('writes one ID3v2.3 USLT frame and replaces it on update', () { + final audio = Uint8List.fromList([0xff, 0xfb, 0x90, 0x64]); + final first = Id3v23Lyrics.writeUnsyncedLyrics(audio, 'First lyrics'); + final second = Id3v23Lyrics.writeUnsyncedLyrics( + first!, + 'Replacement lyrics', + ); + + expect(second, isNotNull); + expect(second!.sublist(0, 4), [0x49, 0x44, 0x33, 0x03]); + expect(_sequenceCount(second, ascii.encode('USLT')), 1); + expect(second.sublist(second.length - audio.length), audio); + }); + + test('leaves unsupported ID3 versions unchanged', () { + final id3v24 = Uint8List.fromList([ + 0x49, + 0x44, + 0x33, + 0x04, + 0, + 0, + 0, + 0, + 0, + 0, + ]); + + expect(Id3v23Lyrics.writeUnsyncedLyrics(id3v24, 'Lyrics'), isNull); + }); + }); + + test('queue state owns lookup rebuilding independently of notifier', () { + final queued = DownloadItem( + id: 'one', + track: track(id: 'track-one', name: 'One'), + service: 'extension.test', + createdAt: DateTime.utc(2026), + ); + final completed = queued.copyWith(status: DownloadStatus.completed); + + final initial = const DownloadQueueState().copyWith(items: [queued]); + final next = initial.copyWith( + items: [completed], + currentDownload: completed, + ); + final cleared = next.copyWith(currentDownload: null); + + expect(initial.queuedCount, 1); + expect(next.completedCount, 1); + expect(next.lookup.byItemId['one']?.status, DownloadStatus.completed); + expect(next.currentDownload, completed); + expect(cleared.currentDownload, isNull); + }); +} + +int _sequenceCount(Uint8List bytes, List sequence) { + var count = 0; + for (var index = 0; index <= bytes.length - sequence.length; index++) { + var matches = true; + for (var offset = 0; offset < sequence.length; offset++) { + if (bytes[index + offset] != sequence[offset]) { + matches = false; + break; + } + } + if (matches) count++; + } + return count; +} + +Extension _searchExtension({ + required String id, + bool enabled = true, + bool primary = false, + List filters = const [], +}) { + return Extension( + id: id, + name: id, + displayName: id, + version: '1.0.0', + description: 'test', + enabled: enabled, + status: 'loaded', + searchBehavior: SearchBehavior( + enabled: true, + primary: primary, + filters: filters, + ), + ); +}