fix(download): estimate sizes from track quality metadata

This commit is contained in:
zarzet
2026-09-06 22:11:37 +07:00
parent ba00807786
commit 174852b056
16 changed files with 195 additions and 84 deletions
+10 -7
View File
@@ -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
+1 -13
View File
@@ -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",
+1 -2
View File
@@ -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}"
}
+3
View File
@@ -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<String, dynamic> 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<String, dynamic>
+3
View File
@@ -321,6 +321,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
_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']),
+1 -1
View File
@@ -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;
+1 -2
View File
@@ -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<HomeTab>
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
duration: Duration(seconds: track.duration),
tracks: [track],
recommendedService:
trackState.searchExtensionId ?? trackState.searchSource,
onSelect: (quality, service) {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
-1
View File
@@ -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';
+3 -5
View File
@@ -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;
+56 -12
View File
@@ -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<Track> tracks) {
DownloadSizeEstimate? estimateDownloadSize({
required Duration? duration,
required QualityOption quality,
List<Track>? 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':
+10 -15
View File
@@ -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<Track> 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<Track> 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<DownloadServicePicker> {
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<DownloadServicePicker> {
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<DownloadServicePicker> {
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);
}
+2 -3
View File
@@ -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<void> queueTracksSkippingDownloaded(
context,
trackName: '${tracksToQueue.length} tracks',
artistName: artistNameForPicker,
duration: totalDownloadDuration(tracksToQueue),
tracks: tracksToQueue,
recommendedService: recommendedService,
onSelect: (quality, service) {
ref
+40 -8
View File
@@ -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<void> _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<void> _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',
);
+62 -13
View File
@@ -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);
},
);