fix(download): name variants by measured quality

This commit is contained in:
zarzet
2026-07-16 09:20:35 +07:00
parent 0961c09a6d
commit b69f67a786
15 changed files with 904 additions and 61 deletions
+44 -6
View File
@@ -69,7 +69,7 @@ final _batchUniqueFilenameTokenPattern = RegExp(
caseSensitive: false,
);
final _qualityFilenameTokenPattern = RegExp(
r'\{quality\}',
r'\{quality_variant\}',
caseSensitive: false,
);
@@ -1143,6 +1143,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
outputExt,
),
allowQualityVariant: item.preserveQualityVariant,
qualityVariant: item.preserveQualityVariant
? qualityVariantStagingLabel(item.id)
: '',
songLinkRegion: settings.songLinkRegion,
);
}
@@ -2549,10 +2552,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_filenameMetadataForTrack(
trackToDownload,
quality: quality,
qualityVariant: item.preserveQualityVariant
? qualityVariantStagingLabel(item.id)
: '',
playlistPosition: _validPlaylistPosition(item),
),
);
safFileName = await _buildSafFileName(baseName, safOutputExt);
safFileName = await _buildSafFileName(
baseName,
safOutputExt,
qualityVariant: item.preserveQualityVariant
? qualityVariantStagingLabel(item.id)
: '',
);
safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
}
String? finalSafFileName = safFileName;
@@ -3625,6 +3637,36 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
if (filePath != null) {
final postProcessedPath = await _runPostProcessingHooks(
filePath,
trackToDownload,
);
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
filePath = postProcessedPath;
result['file_path'] = postProcessedPath;
}
}
if (filePath != null && item.preserveQualityVariant) {
final variantOutcome = await _finalizeQualityVariantFilename(
item: item,
result: result,
filePath: filePath,
storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: settings.downloadTreeUri,
safRelativeDir: effectiveOutputDir,
fileName: finalSafFileName ?? safFileName,
);
filePath = variantOutcome.filePath;
if (variantOutcome.fileName != null) {
finalSafFileName = variantOutcome.fileName;
}
if (variantOutcome.metadata != null) {
probedFinalMetadata = variantOutcome.metadata;
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
@@ -3657,10 +3699,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
if (filePath != null) {
await _runPostProcessingHooks(filePath, trackToDownload);
}
// Album ReplayGain: update the accumulator path to the final file
// location. For SAF downloads the metadata was embedded on a temp
// copy, so the stored path still points there. Replace it with the
@@ -11,6 +11,18 @@ class _DecryptOutcome {
const _DecryptOutcome(this.path, {this.newFileName, this.failStage});
}
class _QualityVariantFileOutcome {
final String filePath;
final String? fileName;
final Map<String, dynamic>? metadata;
const _QualityVariantFileOutcome({
required this.filePath,
this.fileName,
this.metadata,
});
}
/// AC-4 repair only applies to MP4 containers; decrypt can also emit raw
/// FLAC, which the native MP4 box parser would reject as corrupt.
bool _isMp4Container(String path) {
@@ -154,6 +166,33 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
}
}
Future<({String uri, String fileName})?> _writeTempToSafUnique({
required String treeUri,
required String relativeDir,
required String fileName,
required String mimeType,
required String srcPath,
String preservedSuffix = '',
}) async {
try {
final result = await PlatformBridge.createUniqueSafFileFromPath(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: fileName,
mimeType: mimeType,
srcPath: srcPath,
preservedSuffix: preservedSuffix,
);
final uri = (result['uri'] as String? ?? '').trim();
final publishedName = (result['file_name'] as String? ?? '').trim();
if (uri.isEmpty || publishedName.isEmpty) return null;
return (uri: uri, fileName: publishedName);
} catch (e) {
_log.w('Failed to write unique temp file to SAF: $e');
return null;
}
}
Future<void> _writeLrcToSaf({
required String treeUri,
required String relativeDir,
@@ -210,6 +249,9 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
void Function(String path) addCleanup,
)
op,
bool avoidOverwrite = false,
String preservedSuffix = '',
void Function(String fileName)? onPublishedFileName,
}) async {
final tempPath = await _copySafToTemp(uri);
if (tempPath == null) return null;
@@ -224,13 +266,32 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
final fileName = produced.$2;
final dotIndex = fileName.lastIndexOf('.');
final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : '';
final newUri = await _writeTempToSaf(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: fileName,
mimeType: _mimeTypeForExt(ext),
srcPath: outPath,
);
String? newUri;
if (avoidOverwrite) {
final published = await _writeTempToSafUnique(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: fileName,
mimeType: _mimeTypeForExt(ext),
srcPath: outPath,
preservedSuffix: preservedSuffix,
);
newUri = published?.uri;
if (published != null) {
onPublishedFileName?.call(published.fileName);
}
} else {
newUri = await _writeTempToSaf(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: fileName,
mimeType: _mimeTypeForExt(ext),
srcPath: outPath,
);
if (newUri != null) {
onPublishedFileName?.call(fileName);
}
}
if (newUri == null) return null;
if (newUri != uri) {
await _deleteSafFile(uri);
@@ -252,6 +313,174 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
}
}
Future<_QualityVariantFileOutcome> _finalizeQualityVariantFilename({
required DownloadItem item,
required Map<String, dynamic> result,
required String filePath,
required String storageMode,
String? downloadTreeUri,
String? safRelativeDir,
String? fileName,
}) async {
if (!item.preserveQualityVariant || result['already_exists'] == true) {
return _QualityVariantFileOutcome(filePath: filePath, fileName: fileName);
}
Map<String, dynamic>? metadata;
try {
metadata = await PlatformBridge.readFileMetadata(filePath);
if (metadata['error'] != null) metadata = null;
} catch (e) {
_log.d('Quality variant metadata probe failed for $filePath: $e');
}
final bitDepth = readPositiveInt(
metadata?['bit_depth'] ?? result['actual_bit_depth'],
);
final sampleRate = readPositiveInt(
metadata?['sample_rate'] ?? result['actual_sample_rate'],
);
final detectedFormat =
normalizeAudioFormatValue(
metadata?['audio_codec']?.toString() ??
metadata?['codec']?.toString() ??
metadata?['format']?.toString(),
) ??
normalizeAudioFormatValue(
result['audio_codec']?.toString() ?? result['format']?.toString(),
) ??
normalizeAudioFormatValue(
audioFormatForPath(filePath, fileName: fileName),
);
final bitrateKbps = readPositiveBitrateKbps(
metadata?['bitrate'] ??
metadata?['bit_rate'] ??
result['bitrate'] ??
result['actual_bitrate'],
);
final qualityLabel = buildQualityVariantFilenameLabel(
detectedFormat: detectedFormat,
bitDepth: bitDepth,
sampleRate: sampleRate,
bitrateKbps: bitrateKbps,
measuredQuality:
result['_native_actual_quality']?.toString() ??
result['quality']?.toString(),
);
if (qualityLabel == null) {
_log.w(
'Keeping collision-safe temporary quality label because the final '
'audio specification could not be measured: $filePath',
);
return _QualityVariantFileOutcome(
filePath: filePath,
fileName: fileName,
metadata: metadata,
);
}
if (bitDepth != null) result['actual_bit_depth'] = bitDepth;
if (sampleRate != null) result['actual_sample_rate'] = sampleRate;
if (detectedFormat != null) result['audio_codec'] = detectedFormat;
if (bitrateKbps != null && isLossyAudioFormat(detectedFormat)) {
result['bitrate'] = bitrateKbps;
}
final stagingLabel = qualityVariantStagingLabel(item.id);
final localPathSegments = File(filePath).uri.pathSegments;
final currentFileName = storageMode == 'saf' && isContentUri(filePath)
? (fileName ?? result['file_name']?.toString() ?? '')
: (localPathSegments.isEmpty ? '' : localPathSegments.last);
final preferredFileName = applyQualityVariantFilenameLabel(
fileName: currentFileName,
stagingLabel: stagingLabel,
qualityLabel: qualityLabel,
);
if (preferredFileName == currentFileName) {
return _QualityVariantFileOutcome(
filePath: filePath,
fileName: fileName,
metadata: metadata,
);
}
if (storageMode == 'saf' && isContentUri(filePath)) {
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
return _QualityVariantFileOutcome(
filePath: filePath,
fileName: fileName,
metadata: metadata,
);
}
String? publishedFileName;
final renamedUri = await _replaceSafFileVia(
uri: filePath,
treeUri: downloadTreeUri,
relativeDir: safRelativeDir ?? '',
avoidOverwrite: true,
preservedSuffix: qualityLabel,
onPublishedFileName: (name) => publishedFileName = name,
op: (tempPath, addCleanup) async => (tempPath, preferredFileName),
);
if (renamedUri == null) {
return _QualityVariantFileOutcome(
filePath: filePath,
fileName: fileName,
metadata: metadata,
);
}
final finalName = publishedFileName ?? preferredFileName;
result['file_path'] = renamedUri;
result['file_name'] = finalName;
return _QualityVariantFileOutcome(
filePath: renamedUri,
fileName: finalName,
metadata: metadata,
);
}
final source = File(filePath);
final parent = source.parent;
var target = File(
'${parent.path}${Platform.pathSeparator}$preferredFileName',
);
var counter = 2;
while (await target.exists() && target.path != source.path) {
final dotIndex = preferredFileName.lastIndexOf('.');
final hasExtension = dotIndex > 0;
final stem = hasExtension
? preferredFileName.substring(0, dotIndex)
: preferredFileName;
final extension = hasExtension
? preferredFileName.substring(dotIndex)
: '';
target = File(
'${parent.path}${Platform.pathSeparator}$stem ($counter)$extension',
);
counter++;
}
try {
final renamed = await source.rename(target.path);
result['file_path'] = renamed.path;
final renamedSegments = renamed.uri.pathSegments;
result['file_name'] = renamedSegments.isEmpty
? null
: renamedSegments.last;
return _QualityVariantFileOutcome(
filePath: renamed.path,
fileName: result['file_name'] as String?,
metadata: metadata,
);
} catch (e) {
_log.w('Failed to apply measured quality filename: $e');
return _QualityVariantFileOutcome(
filePath: filePath,
fileName: fileName,
metadata: metadata,
);
}
}
/// Shared decrypt finalize used by both the inline single-item pipeline
/// and the native-worker pipeline. Divergences captured as parameters:
/// [repairAc4] (inline repairs AC-4 containers using the still-encrypted
@@ -623,10 +623,19 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
_filenameMetadataForTrack(
item.track,
quality: quality,
qualityVariant: item.preserveQualityVariant
? qualityVariantStagingLabel(item.id)
: '',
playlistPosition: _validPlaylistPosition(item),
),
);
safFileName = await _buildSafFileName(baseName, safOutputExt);
safFileName = await _buildSafFileName(
baseName,
safOutputExt,
qualityVariant: item.preserveQualityVariant
? qualityVariantStagingLabel(item.id)
: '',
);
}
var trackForPayload = item.track;
@@ -921,14 +930,14 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
}
var actualQuality = context.quality;
final actualBitDepth = result['actual_bit_depth'] as int?;
final actualSampleRate = result['actual_sample_rate'] as int?;
final actualFormat =
var actualBitDepth = result['actual_bit_depth'] as int?;
var actualSampleRate = result['actual_sample_rate'] as int?;
var actualFormat =
normalizeAudioFormatValue(
result['audio_codec']?.toString() ?? result['format']?.toString(),
) ??
normalizeAudioFormatValue(audioFormatForPath(filePath));
final actualBitrate = isLossyAudioFormat(actualFormat)
var actualBitrate = isLossyAudioFormat(actualFormat)
? readPositiveBitrateKbps(result['bitrate'] ?? result['actual_bitrate'])
: null;
final resolvedQuality = resolveDisplayQuality(
@@ -994,6 +1003,53 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
}
filePath = convertedContainerPath;
final postProcessedPath = await _runPostProcessingHooks(
filePath,
trackToDownload,
);
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
filePath = postProcessedPath;
}
await _writeNativeWorkerReplayGain(
context: context,
settings: settings,
track: trackToDownload,
filePath: filePath,
);
if (item.preserveQualityVariant) {
final variantOutcome = await _finalizeQualityVariantFilename(
item: item,
result: result,
filePath: filePath,
storageMode: context.storageMode,
downloadTreeUri: context.downloadTreeUri,
safRelativeDir: context.safRelativeDir,
fileName: result['file_name'] as String? ?? context.safFileName,
);
filePath = variantOutcome.filePath;
actualBitDepth = readPositiveInt(result['actual_bit_depth']);
actualSampleRate = readPositiveInt(result['actual_sample_rate']);
actualFormat =
normalizeAudioFormatValue(result['audio_codec']?.toString()) ??
normalizeAudioFormatValue(audioFormatForPath(filePath));
actualBitrate = isLossyAudioFormat(actualFormat)
? readPositiveBitrateKbps(
result['bitrate'] ?? result['actual_bitrate'],
)
: null;
final finalQuality = resolveDisplayQuality(
filePath: filePath,
fileName: variantOutcome.fileName,
detectedFormat: actualFormat,
bitDepth: actualBitDepth,
sampleRate: actualSampleRate,
bitrateKbps: actualBitrate,
storedQuality: actualQuality,
);
if (finalQuality != null) actualQuality = finalQuality;
}
updateItemStatus(
item.id,
DownloadStatus.completed,
@@ -1024,19 +1080,6 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
onFetchError: (e) =>
_log.w('Failed to fetch native-worker external LRC: $e'),
);
final postProcessedPath = await _runPostProcessingHooks(
filePath,
trackToDownload,
);
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
filePath = postProcessedPath;
}
await _writeNativeWorkerReplayGain(
context: context,
settings: settings,
track: trackToDownload,
filePath: filePath,
);
_completedInSession++;
await _notificationService.showDownloadComplete(
@@ -207,10 +207,26 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
return parts.join('/');
}
Future<String> _buildSafFileName(String baseName, String outputExt) async {
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
Future<String> _buildSafFileName(
String baseName,
String outputExt, {
String qualityVariant = '',
}) async {
final extBytes = utf8.encode(outputExt).length;
final maxBaseBytes = max(1, _maxSafFilenameUtf8Bytes - extBytes);
if (qualityVariant.isNotEmpty && baseName.contains(qualityVariant)) {
final rawPrefix = baseName
.replaceAll(qualityVariant, '')
.replaceFirst(RegExp(r'[\s_-]+$'), '');
final sanitizedPrefix = await PlatformBridge.sanitizeFilename(rawPrefix);
final suffix = ' - $qualityVariant';
final prefixBytes = max(1, maxBaseBytes - utf8.encode(suffix).length);
final truncatedPrefix = _trimSafeName(
_truncateUtf8Bytes(sanitizedPrefix, prefixBytes),
);
return '$truncatedPrefix$suffix$outputExt';
}
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
final truncated = _truncateUtf8Bytes(sanitized, maxBaseBytes);
return '${_trimSafeName(truncated)}$outputExt';
}
@@ -541,9 +557,15 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
!_batchUniqueFilenameTokenPattern.hasMatch(effective)) {
effective = '$effective - {track:02} - {title}';
}
if (item.preserveQualityVariant) {
effective = effective.replaceAll(
RegExp(r'\{quality\}', caseSensitive: false),
'{quality_variant}',
);
}
if (item.preserveQualityVariant &&
!_qualityFilenameTokenPattern.hasMatch(effective)) {
effective = '$effective - {quality}';
effective = '$effective - {quality_variant}';
}
return effective;
}
@@ -551,6 +573,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
Map<String, dynamic> _filenameMetadataForTrack(
Track track, {
required String quality,
String qualityVariant = '',
int playlistPosition = 0,
}) {
return {
@@ -564,6 +587,7 @@ extension _DownloadQueuePaths on DownloadQueueNotifier {
'playlist_position': playlistPosition,
'playlistPosition': playlistPosition,
'quality': quality,
'quality_variant': qualityVariant,
};
}
}
@@ -49,6 +49,7 @@ class DownloadRequestPayload {
final bool deferSafPublish;
final bool requiresContainerConversion;
final bool allowQualityVariant;
final String qualityVariant;
final String songLinkRegion;
const DownloadRequestPayload({
@@ -100,6 +101,7 @@ class DownloadRequestPayload {
this.deferSafPublish = false,
this.requiresContainerConversion = false,
this.allowQualityVariant = false,
this.qualityVariant = '',
this.songLinkRegion = 'US',
});
@@ -153,6 +155,7 @@ class DownloadRequestPayload {
'defer_saf_publish': deferSafPublish,
'requires_container_conversion': requiresContainerConversion,
'allow_quality_variant': allowQualityVariant,
'quality_variant': qualityVariant,
'songlink_region': songLinkRegion,
};
}
@@ -210,6 +213,7 @@ class DownloadRequestPayload {
deferSafPublish: deferSafPublish,
requiresContainerConversion: requiresContainerConversion,
allowQualityVariant: allowQualityVariant,
qualityVariant: qualityVariant,
songLinkRegion: songLinkRegion,
);
}
+18
View File
@@ -642,6 +642,24 @@ class PlatformBridge {
return result as String?;
}
static Future<Map<String, dynamic>> createUniqueSafFileFromPath({
required String treeUri,
required String relativeDir,
required String fileName,
required String mimeType,
required String srcPath,
String preservedSuffix = '',
}) {
return _invokeMap('safCreateUniqueFromPath', {
'tree_uri': treeUri,
'relative_dir': relativeDir,
'file_name': fileName,
'mime_type': mimeType,
'src_path': srcPath,
'preserved_suffix': preservedSuffix,
});
}
static Future<void> openContentUri(String uri, {String mimeType = ''}) async {
await _channel.invokeMethod('openContentUri', {
'uri': uri,
+81
View File
@@ -77,6 +77,87 @@ bool isLossyAudioFormat(String? value) {
}.contains(normalizeAudioFormatValue(value));
}
/// Returns a provider-independent quality label suitable for a filename.
///
/// Requested labels such as LOSSLESS and HI_RES are intentionally ignored:
/// they describe provider intent, not the audio that was actually written.
String? buildQualityVariantFilenameLabel({
String? detectedFormat,
int? bitDepth,
int? sampleRate,
int? bitrateKbps,
String? measuredQuality,
}) {
if (isLossyAudioFormat(detectedFormat)) {
final effectiveBitrate =
bitrateKbps ?? _bitrateFromQuality(measuredQuality);
return effectiveBitrate != null && effectiveBitrate >= 16
? '${effectiveBitrate}kbps'
: null;
}
final measured = _losslessSpecsFromQuality(measuredQuality);
final effectiveBitDepth = bitDepth ?? measured?.$1;
final effectiveSampleRate = sampleRate ?? measured?.$2;
if (effectiveBitDepth == null ||
effectiveBitDepth <= 0 ||
effectiveSampleRate == null ||
effectiveSampleRate <= 0) {
return null;
}
return '${effectiveBitDepth}bit-${formatSampleRateKHz(effectiveSampleRate)}';
}
int? _bitrateFromQuality(String? quality) {
final match = RegExp(
r'\b(\d+)\s*kbps\b',
caseSensitive: false,
).firstMatch(quality ?? '');
final value = int.tryParse(match?.group(1) ?? '');
return value != null && value >= 16 ? value : null;
}
(int, int)? _losslessSpecsFromQuality(String? quality) {
final match = RegExp(
r'\b(\d+)\s*(?:-|\s)?bit\s*[/_-]\s*(\d+(?:\.\d+)?)\s*k?hz\b',
caseSensitive: false,
).firstMatch(quality ?? '');
final bitDepth = int.tryParse(match?.group(1) ?? '');
final rate = double.tryParse(match?.group(2) ?? '');
if (bitDepth == null || bitDepth <= 0 || rate == null || rate <= 0) {
return null;
}
final sampleRate = rate < 1000 ? (rate * 1000).round() : rate.round();
return (bitDepth, sampleRate);
}
String qualityVariantStagingLabel(String itemId) {
var hash = 0x811c9dc5;
for (final byte in itemId.codeUnits) {
hash ^= byte;
hash = (hash * 0x01000193) & 0xffffffff;
}
return 'qv_${hash.toRadixString(16).padLeft(8, '0')}';
}
String applyQualityVariantFilenameLabel({
required String fileName,
required String stagingLabel,
required String qualityLabel,
}) {
if (stagingLabel.isNotEmpty && fileName.contains(stagingLabel)) {
return fileName.replaceAll(stagingLabel, qualityLabel);
}
if (fileName.contains(qualityLabel)) {
return fileName;
}
final dotIndex = fileName.lastIndexOf('.');
final hasExtension = dotIndex > 0;
final stem = hasExtension ? fileName.substring(0, dotIndex) : fileName;
final extension = hasExtension ? fileName.substring(dotIndex) : '';
return '$stem - $qualityLabel$extension';
}
String lossyFormatForSetting(String value) {
final normalized = value.trim().toLowerCase();
if (normalized.startsWith('opus')) return 'opus';