diff --git a/docs/EXTENSION_DEVELOPMENT.md b/docs/EXTENSION_DEVELOPMENT.md index 0a15ebdb..f5b659b8 100644 --- a/docs/EXTENSION_DEVELOPMENT.md +++ b/docs/EXTENSION_DEVELOPMENT.md @@ -172,24 +172,27 @@ for custom IDs without resolving streams or triggering verification: For example, `"sizeEstimate": {"bitrateKbps": 256}`. - For compressed lossless audio, supply `bitDepth` and `sampleRate` in Hz. `channels` defaults to 2. Do not use this model for uncompressed PCM. -- Set `isMaximum: true` for a lossless tier that can return lower quality. Its - estimate uses the declared depth/rate and explicitly labels that assumption - (for example, “≈ 171.4 MB if 24-bit/192kHz”). It does not predict which quality - the provider will actually return. A capped bitrate alone remains unavailable. +- Set `isMaximum: true` for a lossless tier that can return lower quality. + The picker caps the declared depth/rate using each track's `audio_quality` + (for example, `16bit/44.1kHz` or `24bit/96kHz`). Metadata must belong to the + selected provider; missing or incomplete quality stays unavailable. + A capped bitrate alone remains unavailable. - Omitted parameters remain unknown. Older extensions retain estimates for the picker's legacy `LOSSLESS`, `HI_RES`, and `HI_RES_LOSSLESS` tiers and explicit codec/bitrate IDs or labels such as `opus_256` or `Opus 256kbps`. - Generic `best`, `high`, `low`, and spatial tiers need explicit parameters. + `best`, `default`, and `flac` options with `kind: "lossless"` can use track + quality directly. Other generic and spatial tiers need explicit parameters. Lossy estimates use duration × bitrate / 8. Lossless estimates use a rough -65% of uncompressed PCM size at the declared quality. This is a comparison +65% of uncompressed PCM size at the effective track quality. This is a comparison heuristic, not measured compression for the recording or a guaranteed size. Do not combine a CD-quality minimum with a hi-res maximum: that range hides the distinction between tiers and provides little useful size information. Estimates exclude artwork, tags, and container overhead. They describe the selected quality before automatic conversion, with a separate converted-size estimate when enabled. Fallback quality and intermediate transfers can change -both the final size and data usage. If any selected track lacks a duration, +both the final size and data usage. Collection estimates sum each track's +individual estimate. If any selected track lacks the required metadata, the picker does not show a partial sum as a complete total. ### Permissions diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d0766413..155c9764 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2137,19 +2137,7 @@ } }, "downloadSizeUnavailable": "Size estimate unavailable", - "downloadEstimatedSizeAtQuality": "≈ {size} if {quality}", - "@downloadEstimatedSizeAtQuality": { - "description": "Conditional size estimate for a tier with an unknown actual quality; quality is the assumed bit depth and sample rate", - "placeholders": { - "size": { - "type": "String" - }, - "quality": { - "type": "String" - } - } - }, - "downloadSizeEstimateNote": "Estimates assume the quality shown. Lower quality can produce smaller files; compression also affects size. Excludes artwork and tags. Data usage may be higher.", + "downloadSizeEstimateNote": "Lossless estimates use the track quality reported by the selected provider. Actual size depends on compression; artwork and tags are excluded. Data usage may be higher.", "downloadConvertedSizeEstimate": "After conversion to {format}: {size}", "@downloadConvertedSizeEstimate": { "description": "Estimated size after the user's automatic conversion, excluding tags and artwork", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 618d9e09..09936484 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6200,7 +6200,6 @@ "libraryFilterMetadataMissingIsrc": "Missing ISRC", "downloadEstimatedSize": "≈ {size}", "downloadSizeUnavailable": "Estimasi ukuran belum tersedia", - "downloadEstimatedSizeAtQuality": "≈ {size} jika {quality}", - "downloadSizeEstimateNote": "Estimasi memakai kualitas yang tertera. Kualitas lebih rendah bisa menghasilkan file lebih kecil; kompresi juga memengaruhi ukuran. Tanpa sampul dan tag. Penggunaan data bisa lebih besar.", + "downloadSizeEstimateNote": "Estimasi lossless memakai kualitas lagu dari provider yang dipilih. Ukuran sebenarnya bergantung pada kompresi; tanpa sampul dan tag. Penggunaan data bisa lebih besar.", "downloadConvertedSizeEstimate": "Setelah konversi ke {format}: {size}" } diff --git a/lib/providers/extension_models.dart b/lib/providers/extension_models.dart index 16d6d770..c2f43a30 100644 --- a/lib/providers/extension_models.dart +++ b/lib/providers/extension_models.dart @@ -689,6 +689,7 @@ class PostProcessingHook { class QualityOption { final String id; + final String? kind; final String label; final String? description; final QualitySizeEstimate? sizeEstimate; @@ -696,6 +697,7 @@ class QualityOption { const QualityOption({ required this.id, + this.kind, required this.label, this.description, this.sizeEstimate, @@ -705,6 +707,7 @@ class QualityOption { factory QualityOption.fromJson(Map json) { return QualityOption( id: json['id'] as String? ?? '', + kind: json['kind'] as String?, label: json['label'] as String? ?? '', description: json['description'] as String?, sizeEstimate: json['sizeEstimate'] is Map diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index cfc82f0a..9e33a12f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -321,6 +321,9 @@ class _AlbumScreenState extends ConsumerState _albumTotalTracks, composer: data['composer']?.toString(), audioQuality: data['audio_quality']?.toString(), + source: + (data['source'] ?? data['provider_id'])?.toString() ?? + _directMetadataProviderId(), audioModes: data['audio_modes']?.toString(), previewUrl: data['preview_url']?.toString(), explicit: parseExplicitFlag(data['explicit']), diff --git a/lib/screens/artist_screen_widgets.dart b/lib/screens/artist_screen_widgets.dart index 3d2ef6f2..98f05e98 100644 --- a/lib/screens/artist_screen_widgets.dart +++ b/lib/screens/artist_screen_widgets.dart @@ -583,7 +583,7 @@ extension _ArtistScreenSections on _ArtistScreenState { if (settings.askQualityBeforeDownload || settings.allowQualityVariants) { DownloadServicePicker.show( context, - duration: Duration(seconds: track.duration), + tracks: [track], recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { if (!mounted) return; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index ada48e2a..6addea81 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -27,7 +27,6 @@ import 'package:spotiflac_android/services/cover_download_service.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; -import 'package:spotiflac_android/utils/download_size_estimate.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/file_access.dart'; @@ -653,7 +652,7 @@ class _HomeTabState extends ConsumerState trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, - duration: Duration(seconds: track.duration), + tracks: [track], recommendedService: trackState.searchExtensionId ?? trackState.searchSource, onSelect: (quality, service) { diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index 025e605a..5352d9f1 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -479,7 +479,7 @@ extension _HomeTabExploreUI on _HomeTabState { trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, - duration: Duration(seconds: track.duration), + tracks: [track], onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) diff --git a/lib/screens/home_tab_import.dart b/lib/screens/home_tab_import.dart index 776f1e3f..3e17c439 100644 --- a/lib/screens/home_tab_import.dart +++ b/lib/screens/home_tab_import.dart @@ -197,7 +197,7 @@ extension _HomeTabCsvImport on _HomeTabState { this.context, trackName: l10n.csvImportTracks(tracksToQueue.length), artistName: l10n.dialogImportPlaylistTitle, - duration: totalDownloadDuration(tracksToQueue), + tracks: tracksToQueue, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 67d1a22c..e04c813a 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -16,7 +16,6 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; -import 'package:spotiflac_android/utils/download_size_estimate.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/utils/file_access.dart'; diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 9635d86d..a3afa666 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -253,11 +253,9 @@ extension _QueueTabSelectionActions on _QueueTabState { context, trackName: context.l10n.tracksCount(totalTracks), artistName: context.l10n.playlistsCount(selectedPlaylists.length), - duration: totalDownloadDuration( - selectedPlaylists.expand( - (playlist) => playlist.tracks.map((item) => item.track), - ), - ), + tracks: selectedPlaylists + .expand((playlist) => playlist.tracks.map((item) => item.track)) + .toList(), onSelect: (quality, service) { enqueueAll(qualityOverride: quality, service: service); if (!mounted) return; diff --git a/lib/utils/download_size_estimate.dart b/lib/utils/download_size_estimate.dart index 1da31a4d..a64cae50 100644 --- a/lib/utils/download_size_estimate.dart +++ b/lib/utils/download_size_estimate.dart @@ -1,16 +1,12 @@ +import 'dart:math' as math; + import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; class DownloadSizeEstimate { final int bytes; - final int? assumedBitDepth; - final int? assumedSampleRate; - const DownloadSizeEstimate({ - required this.bytes, - this.assumedBitDepth, - this.assumedSampleRate, - }); + const DownloadSizeEstimate({required this.bytes}); } /// Unknown durations must not make a batch estimate look like a complete total. @@ -26,6 +22,8 @@ Duration? totalDownloadDuration(Iterable tracks) { DownloadSizeEstimate? estimateDownloadSize({ required Duration? duration, required QualityOption quality, + List? tracks, + String? providerId, }) { if (duration == null || duration <= Duration.zero) return null; final parameters = quality.sizeEstimate ?? _legacyParameters(quality); @@ -38,6 +36,44 @@ DownloadSizeEstimate? estimateDownloadSize({ return DownloadSizeEstimate(bytes: bytes); } + if (tracks != null) { + if (tracks.isEmpty || providerId == null || providerId.isEmpty) return null; + var bytes = 0; + for (final track in tracks) { + // Quality from a metadata provider is not evidence for another catalog. + if (track.source != providerId || + track.isCollection || + track.duration <= 0) { + return null; + } + final match = _trackQualityPattern.firstMatch(track.audioQuality ?? ''); + if (match == null) return null; + final depth = int.parse(match.group(1)!); + final rate = + (double.parse(match.group(2)!) * + (match.group(3)!.toLowerCase() == 'khz' ? 1000 : 1)) + .round(); + if (depth <= 0 || rate <= 0) return null; + // Metadata reports the highest available quality of this track. A lower + // selection caps it; a higher selection must never upscale the estimate. + final estimate = estimateDownloadSize( + duration: Duration(seconds: track.duration), + quality: QualityOption( + id: quality.id, + label: quality.label, + sizeEstimate: QualitySizeEstimate( + bitDepth: math.min(parameters.bitDepth ?? depth, depth), + sampleRate: math.min(parameters.sampleRate ?? rate, rate), + channels: parameters.channels, + ), + ), + ); + if (estimate == null) return null; + bytes += estimate.bytes; + } + return DownloadSizeEstimate(bytes: bytes); + } + final depth = parameters.bitDepth; final rate = parameters.sampleRate; if (depth == null || @@ -47,22 +83,30 @@ DownloadSizeEstimate? estimateDownloadSize({ parameters.channels <= 0) { return null; } - // Use one comparison estimate at the selected tier, assuming 65% of PCM. + // Estimate compressed audio from the effective quality, assuming 65% of PCM. // This is a heuristic, not a measurement of this recording's compression. - // For capped tiers the UI must show the assumed depth/rate: a provider can - // return lower quality, so the tier's maximum is not the track's actual size. // Artwork, tags, container overhead and later conversion are excluded. return DownloadSizeEstimate( bytes: (seconds * depth * rate * parameters.channels / 8 * 0.65).round(), - assumedBitDepth: parameters.isMaximum ? depth : null, - assumedSampleRate: parameters.isMaximum ? rate : null, ); } +final _trackQualityPattern = RegExp( + r'^\s*(\d+)\s*-?\s*bit\s*/\s*(\d+(?:\.\d+)?)\s*(kHz|Hz)\s*$', + caseSensitive: false, +); + QualitySizeEstimate? _legacyParameters(QualityOption quality) { // These are the same legacy tiers already labelled by the picker. Custom // IDs (including best/high/low and spatial audio) need declared parameters. switch (quality.id.toUpperCase()) { + case 'BEST': + case 'DEFAULT': + case 'FLAC': + if (quality.kind == 'lossless' || quality.label.toUpperCase() == 'FLAC') { + return const QualitySizeEstimate(); + } + return null; case 'LOSSLESS': return const QualitySizeEstimate(bitDepth: 16, sampleRate: 44100); case 'HI_RES': diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 1fac4ba7..020e91af 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/utils/audio_format_utils.dart'; import 'package:spotiflac_android/utils/download_size_estimate.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; @@ -15,8 +16,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { final String? artistName; final String? coverUrl; - /// Combined duration for the tracks being queued; null if any is unknown. - final Duration? duration; + final List tracks; final void Function(String quality, String service) onSelect; final String? recommendedService; @@ -25,7 +25,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { this.trackName, this.artistName, this.coverUrl, - this.duration, + this.tracks = const [], required this.onSelect, this.recommendedService, }); @@ -39,7 +39,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { String? trackName, String? artistName, String? coverUrl, - Duration? duration, + List tracks = const [], String? recommendedService, required void Function(String quality, String service) onSelect, }) { @@ -57,7 +57,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { trackName: trackName, artistName: artistName, coverUrl: coverUrl, - duration: duration, + tracks: tracks, onSelect: onSelect, recommendedService: recommendedService, ), @@ -130,9 +130,10 @@ class _DownloadServicePickerState extends ConsumerState { final hasProviders = downloadExtensions.isNotEmpty; final qualityOptions = _getQualityOptions(downloadExtensions); final settings = ref.watch(settingsProvider); + final duration = totalDownloadDuration(widget.tracks); final convertedSize = settings.autoConvertDownloads ? estimateDownloadSize( - duration: widget.duration, + duration: duration, quality: QualityOption( id: 'converted', label: '', @@ -218,8 +219,10 @@ class _DownloadServicePickerState extends ConsumerState { estimatedSize: _sizeLabel( context, estimateDownloadSize( - duration: widget.duration, + duration: duration, quality: quality, + tracks: widget.tracks, + providerId: _selectedService, ), ), icon: _getQualityIcon(quality.id), @@ -261,14 +264,6 @@ class _DownloadServicePickerState extends ConsumerState { String _sizeLabel(BuildContext context, DownloadSizeEstimate? estimate) { if (estimate == null) return context.l10n.downloadSizeUnavailable; final size = formatBytes(estimate.bytes); - final depth = estimate.assumedBitDepth; - final rate = estimate.assumedSampleRate; - if (depth != null && rate != null) { - return context.l10n.downloadEstimatedSizeAtQuality( - size, - '$depth-bit/${formatSampleRateKHz(rate)}', - ); - } return context.l10n.downloadEstimatedSize(size); } diff --git a/lib/widgets/track_detail_actions.dart b/lib/widgets/track_detail_actions.dart index 7e809d9d..b4090865 100644 --- a/lib/widgets/track_detail_actions.dart +++ b/lib/widgets/track_detail_actions.dart @@ -7,7 +7,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; -import 'package:spotiflac_android/utils/download_size_estimate.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; @@ -36,7 +35,7 @@ void downloadSingleTrack( trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, - duration: Duration(seconds: track.duration), + tracks: [track], recommendedService: recommendedService, onSelect: (quality, service) { ref @@ -220,7 +219,7 @@ Future queueTracksSkippingDownloaded( context, trackName: '${tracksToQueue.length} tracks', artistName: artistNameForPicker, - duration: totalDownloadDuration(tracksToQueue), + tracks: tracksToQueue, recommendedService: recommendedService, onSelect: (quality, service) { ref diff --git a/test/download_service_picker_test.dart b/test/download_service_picker_test.dart index 2ebfa985..a679fcfa 100644 --- a/test/download_service_picker_test.dart +++ b/test/download_service_picker_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -56,6 +57,8 @@ class _PickerSettings extends SettingsNotifier { Future _openPicker( WidgetTester tester, { Duration? duration, + String? audioQuality = '16bit/44.1kHz', + String? source = 'provider-a', AppSettings settings = const AppSettings(), Locale locale = const Locale('en'), double textScale = 1, @@ -83,7 +86,17 @@ Future _openPicker( onPressed: () => DownloadServicePicker.show( context, trackName: 'Selected tracks', - duration: duration, + tracks: [ + Track( + id: 'sample', + name: 'Sample', + artistName: 'Artist', + albumName: 'Album', + duration: duration?.inSeconds ?? 0, + source: source, + audioQuality: audioQuality, + ), + ], onSelect: onSelect ?? (_, _) {}, ), child: const Text('Open'), @@ -107,9 +120,7 @@ void main() { duration: const Duration(minutes: 4), onSelect: (quality, service) => selected = (quality, service), ); - expect(find.text('≈ 26.2 MB'), findsOneWidget); - expect(find.text('≈ 85.7 MB if 24-bit/96kHz'), findsOneWidget); - expect(find.text('≈ 171.4 MB if 24-bit/192kHz'), findsOneWidget); + expect(find.text('≈ 26.2 MB'), findsNWidgets(3)); expect(find.textContaining('MB–'), findsNothing); await tester.tap(find.text('Audio B')); await tester.pumpAndSettle(); @@ -135,16 +146,37 @@ void main() { }, ); - testWidgets('capped estimates show their quality assumption in Indonesian', ( + testWidgets('metadata caps both hi-res tiers to the available sample rate', ( tester, ) async { await _openPicker( tester, duration: const Duration(minutes: 4), + audioQuality: '24bit/96kHz', locale: const Locale('id'), ); - expect(find.text('≈ 85.7 MB jika 24-bit/96kHz'), findsOneWidget); - expect(find.text('≈ 171.4 MB jika 24-bit/192kHz'), findsOneWidget); + expect(find.text('≈ 85.7 MB'), findsNWidgets(2)); + expect(find.text('≈ 26.2 MB'), findsOneWidget); + expect(find.textContaining('171.4'), findsNothing); + }); + + testWidgets('foreign or incomplete track quality stays unavailable', ( + tester, + ) async { + await _openPicker( + tester, + duration: const Duration(minutes: 4), + source: 'provider-b', + ); + expect(find.text('Size estimate unavailable'), findsNWidgets(3)); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + await _openPicker( + tester, + duration: const Duration(minutes: 4), + audioQuality: '24bit', + ); + expect(find.text('Size estimate unavailable'), findsNWidgets(3)); }); testWidgets( @@ -164,7 +196,7 @@ void main() { autoConvertBitrate: '256k', ), ); - expect(find.text('≈ 26.2 MB'), findsOneWidget); + expect(find.text('≈ 26.2 MB'), findsNWidgets(3)); final conversionNote = find.textContaining( 'After conversion to OPUS: ≈ 7.3 MB', ); diff --git a/test/download_size_estimate_test.dart b/test/download_size_estimate_test.dart index 0faad3cb..aa869b6f 100644 --- a/test/download_size_estimate_test.dart +++ b/test/download_size_estimate_test.dart @@ -3,18 +3,76 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/utils/download_size_estimate.dart'; -Track _track(int duration, {String? itemType}) => Track( +Track _track( + int duration, { + String? itemType, + String? audioQuality, + String? source = 'provider-a', +}) => Track( id: 'track', name: 'Track', artistName: 'Artist', albumName: 'Album', duration: duration, itemType: itemType, + audioQuality: audioQuality, + source: source, ); void main() { const duration = Duration(minutes: 4); + test('track metadata caps quality and sums each recording separately', () { + final cd = _track(240, audioQuality: '16bit/44.1kHz'); + final hiRes = _track(120, audioQuality: '24-bit/96000Hz'); + for (final id in ['LOSSLESS', 'HI_RES', 'HI_RES_LOSSLESS']) { + expect( + estimateDownloadSize( + duration: duration, + quality: QualityOption(id: id, label: ''), + tracks: [cd], + providerId: 'provider-a', + )!.bytes, + 27518400, + ); + } + final tracks = [cd, hiRes]; + expect( + estimateDownloadSize( + duration: totalDownloadDuration(tracks), + quality: QualityOption.fromJson({ + 'id': 'best', + 'label': 'Best', + 'kind': 'lossless', + }), + tracks: tracks, + providerId: 'provider-a', + )!.bytes, + 27518400 + 44928000, + ); + }); + + test( + 'lossless metadata must be complete and belong to selected provider', + () { + for (final track in [ + _track(240), + _track(240, audioQuality: '24bit'), + _track(240, audioQuality: '16bit/44.1kHz', source: 'provider-b'), + ]) { + expect( + estimateDownloadSize( + duration: duration, + quality: const QualityOption(id: 'LOSSLESS', label: ''), + tracks: [track], + providerId: 'provider-a', + ), + isNull, + ); + } + }, + ); + test('four minutes at 256 kbps is 7,680,000 bytes before overhead', () { for (final quality in [ const QualityOption(id: 'opus_256', label: 'Opus'), @@ -30,8 +88,6 @@ void main() { quality: quality, )!; expect(estimate.bytes, 7680000); - expect(estimate.assumedBitDepth, isNull); - expect(estimate.assumedSampleRate, isNull); } }); @@ -39,16 +95,15 @@ void main() { const cd = QualityOption(id: 'LOSSLESS', label: 'Lossless'); final cdSize = estimateDownloadSize(duration: duration, quality: cd)!; expect(cdSize.bytes, 27518400); - expect(cdSize.assumedSampleRate, isNull); final batchSize = estimateDownloadSize( duration: totalDownloadDuration([_track(240), _track(240)]), quality: cd, )!; expect(batchSize.bytes, cdSize.bytes * 2); - for (final (id, rate, bytes) in [ - ('HI_RES', 96000, 89856000), - ('HI_RES_LOSSLESS', 192000, 179712000), + for (final (id, bytes) in [ + ('HI_RES', 89856000), + ('HI_RES_LOSSLESS', 179712000), ]) { final size = estimateDownloadSize( duration: duration, @@ -56,8 +111,6 @@ void main() { )!; expect(size.bytes, bytes); expect(size.bytes, greaterThan(cdSize.bytes)); - expect(size.assumedBitDepth, 24); - expect(size.assumedSampleRate, rate); } }); @@ -74,8 +127,6 @@ void main() { quality: quality, )!; expect(estimate.bytes, 22464000); - expect(estimate.assumedBitDepth, isNull); - expect(estimate.assumedSampleRate, isNull); final capped = estimateDownloadSize( duration: duration, quality: QualityOption.fromJson({ @@ -89,8 +140,6 @@ void main() { }), )!; expect(capped.bytes, 179712000); - expect(capped.assumedBitDepth, 24); - expect(capped.assumedSampleRate, 192000); }, );