Merge remote-tracking branch 'origin/main'

This commit is contained in:
zarzet committed 2026-09-25 01:03:00 +07:00
commit a470ed8d7c
36 files changed
+3690 -12

No files matched your search

+125
View File
@@ -4856,6 +4856,131 @@
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"downloadRedownloadFakeHiRes": "Re-download fake Hi-Res",
"@downloadRedownloadFakeHiRes": {
"description": "Setting title: automatically replace downloads that measure as fake Hi-Res"
},
"downloadRedownloadFakeHiResSubtitle": "Off by default. Check Hi-Res downloads and replace only when every audio sample is preserved. Keep the original if verification or processing fails",
"@downloadRedownloadFakeHiResSubtitle": {
"description": "Setting subtitle for the fake Hi-Res re-download toggle"
},
"hiResCheckVerdictFakeCertain": "Fake Hi-Res",
"@hiResCheckVerdictFakeCertain": {
"description": "Verdict: an exact fingerprint (padded bits or upsampling pattern) proves the file is not real Hi-Res"
},
"hiResCheckVerdictFakeSuspect": "Possibly a filtered master",
"@hiResCheckVerdictFakeSuspect": {
"description": "Verdict: fails the cutoff test but may be a genuine master low-pass filtered in mastering"
},
"hiResCheckReasonSampleHold": "Every sample is repeated: upsampled by duplication",
"@hiResCheckReasonSampleHold": {
"description": "Reason: sample-and-hold upsampling detected"
},
"hiResCheckReasonInterpolation": "In-between samples are linearly interpolated",
"@hiResCheckReasonInterpolation": {
"description": "Reason: linear-interpolation upsampling detected"
},
"hiResCheckReasonImaging": "Content above 22 kHz mirrors the audible band",
"@hiResCheckReasonImaging": {
"description": "Reason: spectral imaging from upsampling detected"
},
"hiResCheckMusicContent": "Musical content",
"@hiResCheckMusicContent": {
"description": "Label for the highest frequency that still moves with the music"
},
"hiResCheckUltrasonicNoise": "Above {cutoff} only steady noise (typical of DSD or analog tape transfers): a {rate} file would hold all the music",
"@hiResCheckUltrasonicNoise": {
"description": "Shown when the ultrasonic content of a Hi-Res file is noise rather than music",
"placeholders": {
"cutoff": {
"type": "String"
},
"rate": {
"type": "String"
}
}
},
"hiResCheckTitle": "Hi-Res Authenticity",
"@hiResCheckTitle": {
"description": "Title of the fake Hi-Res check card"
},
"hiResCheckDescription": "Detect upsampled or bit-padded fake Hi-Res files",
"@hiResCheckDescription": {
"description": "Explains what the Hi-Res authenticity check does"
},
"hiResCheckRun": "Check",
"@hiResCheckRun": {
"description": "Button that starts the Hi-Res authenticity check"
},
"hiResCheckChecking": "Checking Hi-Res authenticity...",
"@hiResCheckChecking": {
"description": "Loading text while the Hi-Res check runs"
},
"hiResCheckVerdictFake": "Likely fake Hi-Res",
"@hiResCheckVerdictFake": {
"description": "Verdict: the file claims Hi-Res but does not measure as it"
},
"hiResCheckVerdictGenuine": "Measures as genuine Hi-Res",
"@hiResCheckVerdictGenuine": {
"description": "Verdict: no upsampling or padded bit depth found"
},
"hiResCheckVerdictStandard": "Standard definition, nothing to flag",
"@hiResCheckVerdictStandard": {
"description": "Verdict: neither sample rate nor bit depth claims Hi-Res"
},
"hiResCheckVerdictInconclusive": "Inconclusive: the analyzed segment is too quiet or short",
"@hiResCheckVerdictInconclusive": {
"description": "Verdict: analysis could not reach a reliable conclusion"
},
"hiResCheckUnsupported": "Only FLAC and WAV files can be checked",
"@hiResCheckUnsupported": {
"description": "Shown when the file format cannot be decoded by the Hi-Res check"
},
"hiResCheckCutoff": "Content cutoff",
"@hiResCheckCutoff": {
"description": "Label for the highest frequency with real content"
},
"hiResCheckBitsInUse": "Bits in use",
"@hiResCheckBitsInUse": {
"description": "Label for effective vs declared bit depth"
},
"hiResCheckDisclaimer": "The spectrum stops early, but nothing proves it was upsampled: it may be a genuine master low-pass filtered in mastering. It is not replaced automatically.",
"@hiResCheckDisclaimer": {
"description": "Caveat shown under a fake Hi-Res verdict"
},
"hiResCheckFailed": "Hi-Res check failed: {error}",
"@hiResCheckFailed": {
"description": "Error shown when the Hi-Res check fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"hiResCheckReasonRate": "Declares {sampleRate} but content stops at ~{cutoff}",
"@hiResCheckReasonRate": {
"description": "Reason for a fake verdict caused by upsampling",
"placeholders": {
"sampleRate": {
"type": "String"
},
"cutoff": {
"type": "String"
}
}
},
"hiResCheckReasonDepth": "Declares {declared}-bit but only {effective} bits carry data",
"@hiResCheckReasonDepth": {
"description": "Reason for a fake verdict caused by padded bit depth",
"placeholders": {
"declared": {
"type": "int"
},
"effective": {
"type": "int"
}
}
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
+125
View File
@@ -4732,6 +4732,131 @@
"@audioAnalysisRescan": {
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
},
"downloadRedownloadFakeHiRes": "Riscarica i falsi Hi-Res",
"@downloadRedownloadFakeHiRes": {
"description": "Setting title: automatically replace downloads that measure as fake Hi-Res"
},
"downloadRedownloadFakeHiResSubtitle": "Disattivato per impostazione predefinita. Sostituisce i download Hi-Res solo se tutti i campioni audio sono preservati. Mantiene l’originale se la verifica o l’elaborazione non riesce",
"@downloadRedownloadFakeHiResSubtitle": {
"description": "Setting subtitle for the fake Hi-Res re-download toggle"
},
"hiResCheckVerdictFakeCertain": "Falso Hi-Res",
"@hiResCheckVerdictFakeCertain": {
"description": "Verdict: an exact fingerprint (padded bits or upsampling pattern) proves the file is not real Hi-Res"
},
"hiResCheckVerdictFakeSuspect": "Forse un master filtrato",
"@hiResCheckVerdictFakeSuspect": {
"description": "Verdict: fails the cutoff test but may be a genuine master low-pass filtered in mastering"
},
"hiResCheckReasonSampleHold": "Ogni campione è ripetuto: ricampionato per duplicazione",
"@hiResCheckReasonSampleHold": {
"description": "Reason: sample-and-hold upsampling detected"
},
"hiResCheckReasonInterpolation": "I campioni intermedi sono interpolati linearmente",
"@hiResCheckReasonInterpolation": {
"description": "Reason: linear-interpolation upsampling detected"
},
"hiResCheckReasonImaging": "Il contenuto sopra i 22 kHz è lo specchio della banda udibile",
"@hiResCheckReasonImaging": {
"description": "Reason: spectral imaging from upsampling detected"
},
"hiResCheckMusicContent": "Contenuto musicale",
"@hiResCheckMusicContent": {
"description": "Label for the highest frequency that still moves with the music"
},
"hiResCheckUltrasonicNoise": "Sopra {cutoff} solo rumore costante (tipico dei trasferimenti da DSD o nastro analogico): un file a {rate} conterrebbe tutta la musica",
"@hiResCheckUltrasonicNoise": {
"description": "Shown when the ultrasonic content of a Hi-Res file is noise rather than music",
"placeholders": {
"cutoff": {
"type": "String"
},
"rate": {
"type": "String"
}
}
},
"hiResCheckTitle": "Autenticità Hi-Res",
"@hiResCheckTitle": {
"description": "Title of the fake Hi-Res check card"
},
"hiResCheckDescription": "Rileva file Hi-Res falsi, ricampionati o con bit riempiti",
"@hiResCheckDescription": {
"description": "Explains what the Hi-Res authenticity check does"
},
"hiResCheckRun": "Verifica",
"@hiResCheckRun": {
"description": "Button that starts the Hi-Res authenticity check"
},
"hiResCheckChecking": "Verifica dell'autenticità Hi-Res...",
"@hiResCheckChecking": {
"description": "Loading text while the Hi-Res check runs"
},
"hiResCheckVerdictFake": "Probabile falso Hi-Res",
"@hiResCheckVerdictFake": {
"description": "Verdict: the file claims Hi-Res but does not measure as it"
},
"hiResCheckVerdictGenuine": "Risulta Hi-Res autentico",
"@hiResCheckVerdictGenuine": {
"description": "Verdict: no upsampling or padded bit depth found"
},
"hiResCheckVerdictStandard": "Definizione standard, niente da segnalare",
"@hiResCheckVerdictStandard": {
"description": "Verdict: neither sample rate nor bit depth claims Hi-Res"
},
"hiResCheckVerdictInconclusive": "Inconcludente: il segmento analizzato è troppo silenzioso o breve",
"@hiResCheckVerdictInconclusive": {
"description": "Verdict: analysis could not reach a reliable conclusion"
},
"hiResCheckUnsupported": "Si possono verificare solo file FLAC e WAV",
"@hiResCheckUnsupported": {
"description": "Shown when the file format cannot be decoded by the Hi-Res check"
},
"hiResCheckCutoff": "Taglio del contenuto",
"@hiResCheckCutoff": {
"description": "Label for the highest frequency with real content"
},
"hiResCheckBitsInUse": "Bit usati",
"@hiResCheckBitsInUse": {
"description": "Label for effective vs declared bit depth"
},
"hiResCheckDisclaimer": "Lo spettro si ferma presto, ma nulla prova che sia ricampionato: potrebbe essere un master autentico filtrato in mastering. Non viene sostituito automaticamente.",
"@hiResCheckDisclaimer": {
"description": "Caveat shown under a fake Hi-Res verdict"
},
"hiResCheckFailed": "Verifica Hi-Res non riuscita: {error}",
"@hiResCheckFailed": {
"description": "Error shown when the Hi-Res check fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"hiResCheckReasonRate": "Dichiara {sampleRate} ma il contenuto si ferma a ~{cutoff}",
"@hiResCheckReasonRate": {
"description": "Reason for a fake verdict caused by upsampling",
"placeholders": {
"sampleRate": {
"type": "String"
},
"cutoff": {
"type": "String"
}
}
},
"hiResCheckReasonDepth": "Dichiara {declared} bit ma solo {effective} contengono dati",
"@hiResCheckReasonDepth": {
"description": "Reason for a fake verdict caused by padded bit depth",
"placeholders": {
"declared": {
"type": "int"
},
"effective": {
"type": "int"
}
}
},
"audioAnalysisRescanning": "Re-analyzing audio...",
"@audioAnalysisRescanning": {
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
+5
View File
@@ -77,6 +77,8 @@ class AppSettings {
final String locale;
final String lyricsMode;
final bool autoConvertDownloads;
// Re-download a Hi-Res request at LOSSLESS when the file measures as fake.
final bool redownloadFakeHiRes;
final String autoConvertFormat; // 'mp3', 'aac' (M4A), or 'opus'
final String autoConvertBitrate; // '128k', '192k', '256k', or '320k'
final bool
@@ -172,6 +174,7 @@ class AppSettings {
this.locale = 'system',
this.lyricsMode = 'embed',
this.autoConvertDownloads = false,
this.redownloadFakeHiRes = false,
this.autoConvertFormat = 'mp3',
this.autoConvertBitrate = '320k',
this.useAllFilesAccess = false,
@@ -260,6 +263,7 @@ class AppSettings {
String? locale,
String? lyricsMode,
bool? autoConvertDownloads,
bool? redownloadFakeHiRes,
String? autoConvertFormat,
String? autoConvertBitrate,
bool? useAllFilesAccess,
@@ -353,6 +357,7 @@ class AppSettings {
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
autoConvertDownloads: autoConvertDownloads ?? this.autoConvertDownloads,
redownloadFakeHiRes: redownloadFakeHiRes ?? this.redownloadFakeHiRes,
autoConvertFormat: autoConvertFormat ?? this.autoConvertFormat,
autoConvertBitrate: autoConvertBitrate ?? this.autoConvertBitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
+2
View File
@@ -63,6 +63,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
autoConvertDownloads: json['autoConvertDownloads'] as bool? ?? false,
redownloadFakeHiRes: json['redownloadFakeHiRes'] as bool? ?? false,
autoConvertFormat: json['autoConvertFormat'] as String? ?? 'mp3',
autoConvertBitrate: json['autoConvertBitrate'] as String? ?? '320k',
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
@@ -152,6 +153,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'autoConvertDownloads': instance.autoConvertDownloads,
'redownloadFakeHiRes': instance.redownloadFakeHiRes,
'autoConvertFormat': instance.autoConvertFormat,
'autoConvertBitrate': instance.autoConvertBitrate,
'useAllFilesAccess': instance.useAllFilesAccess,
@@ -25,6 +25,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/download_request_payload.dart';
import 'package:spotiflac_android/services/download_motion_artwork_source.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/hires_check_service.dart';
import 'package:spotiflac_android/services/replaygain_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/verification_notification.dart';
@@ -57,6 +58,7 @@ part 'download_queue_provider_finalization.dart';
part 'download_queue_provider_replaygain.dart';
part 'download_queue_provider_embedding.dart';
part 'download_queue_provider_single_item.dart';
part 'download_queue_provider_hires_check.dart';
final _log = AppLogger('DownloadQueue');
@@ -0,0 +1,250 @@
part of 'download_queue_provider.dart';
// Per-track fake Hi-Res check (AppSettings.redownloadFakeHiRes), ported from
// SpotiFLAC-Module-Version's --redownload-fake-hires.
//
// Runs inside one _DownloadRun, right after the file is decoded and before
// anything is published, tagged or converted: the verdict decides which file
// the rest of the pipeline works on. A flagged file is set aside, the same
// track is downloaded again at LOSSLESS. The original is only deleted after
// full PCM verification and successful finalization, publication and history.
// If any step fails, including cancellation, the original is restored.
enum _FakeHiResOutcome {
/// Nothing to do, or the replacement failed: finish with the current file.
kept,
/// The verified replacement is already finalized and persisted.
completed,
/// The item was cancelled or paused mid-replacement; stop the run.
aborted,
}
/// Everything a replacement attempt may overwrite, so a failed one can put
/// the run back exactly as it was.
class _FakeHiResRunState {
_FakeHiResRunState(_DownloadRun run)
: result = Map<String, dynamic>.from(run.result),
quality = run.quality,
safOutputExt = run.safOutputExt,
safFileName = run.safFileName,
safBaseName = run.safBaseName,
finalSafFileName = run.finalSafFileName,
effectiveSafMode = run.effectiveSafMode,
effectiveOutputDir = run.effectiveOutputDir,
trackToDownload = run.trackToDownload,
appOutputDir = run.appOutputDir,
filePath = run.filePath,
wasExisting = run.wasExisting,
actualQuality = run.actualQuality,
resultOutputExt = run.resultOutputExt,
shouldPreserveNativeM4a = run.shouldPreserveNativeM4a,
decryptionDescriptor = run.decryptionDescriptor,
probedFinalMetadata = run.probedFinalMetadata,
externalLrcWritten = run.externalLrcWritten;
final Map<String, dynamic> result;
final String quality;
final String safOutputExt;
final String? safFileName;
final String? safBaseName;
final String? finalSafFileName;
final bool effectiveSafMode;
final String effectiveOutputDir;
final Track trackToDownload;
final String? appOutputDir;
final String? filePath;
final bool wasExisting;
final String actualQuality;
final String? resultOutputExt;
final bool shouldPreserveNativeM4a;
final DownloadDecryptionDescriptor? decryptionDescriptor;
final Map<String, dynamic>? probedFinalMetadata;
final bool externalLrcWritten;
void restore(_DownloadRun run) {
run.result = result;
run.quality = quality;
run.safOutputExt = safOutputExt;
run.safFileName = safFileName;
run.safBaseName = safBaseName;
run.finalSafFileName = finalSafFileName;
run.effectiveSafMode = effectiveSafMode;
run.effectiveOutputDir = effectiveOutputDir;
run.trackToDownload = trackToDownload;
run.appOutputDir = appOutputDir;
run.filePath = filePath;
run.wasExisting = wasExisting;
run.actualQuality = actualQuality;
run.resultOutputExt = resultOutputExt;
run.shouldPreserveNativeM4a = shouldPreserveNativeM4a;
run.decryptionDescriptor = decryptionDescriptor;
run.probedFinalMetadata = probedFinalMetadata;
run.externalLrcWritten = externalLrcWritten;
}
}
extension _DownloadRunFakeHiRes on _DownloadRun {
/// [requestTrack] is the track as it was sent to the backend, before the
/// first result was merged into it: the replacement gets the same brief
/// the first attempt did, not the first attempt's conclusions.
Future<_FakeHiResOutcome> _replaceFakeHiResIfNeeded(
Track requestTrack,
) async {
final path = filePath;
if (!settings.redownloadFakeHiRes ||
wasExisting ||
path == null ||
!HiResCheckService.isHiResQuality(quality)) {
return _FakeHiResOutcome.kept;
}
if (isContentUri(path)) {
// Already published to SAF: there is no local file to set aside, and
// a heuristic is not worth deleting the only copy for.
_log.d('[hires-check] skipped: output already published to SAF');
return _FakeHiResOutcome.kept;
}
if (!_serviceOffersFakeHiResFallback()) {
_log.d(
'[hires-check] skipped: ${item.service} has no '
'${HiResCheckService.fakeHiResFallbackQuality} quality',
);
return _FakeHiResOutcome.kept;
}
final HiResCheckResult check;
try {
check = await HiResCheckService.check(path);
} catch (e) {
// Never a reason to fail a finished download.
_log.w('[hires-check] skipped for ${item.track.name}: $e');
return _FakeHiResOutcome.kept;
}
if (!check.supported || !check.isFake) {
_log.d(
'[hires-check] ${item.track.name}: '
'${check.supported ? check.verdict : 'unsupported format'}',
);
return _FakeHiResOutcome.kept;
}
if (!check.redownloadSafe) {
// Evidence on one axis cannot justify discarding the other axis.
_log.w(
'[hires-check] ${item.track.name} may be fake Hi-Res '
'(${check.reason}); kept, not certain enough to replace',
);
return _FakeHiResOutcome.kept;
}
_log.w(
'[hires-check] ${item.track.name} looks like fake Hi-Res '
'(${check.reason}); re-downloading as '
'${HiResCheckService.fakeHiResFallbackQuality}',
);
final saved = _FakeHiResRunState(this);
var completed = false;
var aborted = false;
try {
completed = await HiResCheckService.withOriginalBackup(path, (
backup,
) async {
fakeHiResOriginalPath = backup;
trackToDownload = requestTrack;
result = <String, dynamic>{};
filePath = null;
probedFinalMetadata = null;
externalLrcWritten = false;
var finalized = false;
try {
await _useFakeHiResFallbackQuality();
n.updateItemStatus(item.id, DownloadStatus.downloading, progress: 0);
if (!await _downloadAndMaybeFallback()) {
aborted = true;
return false;
}
aborted = await _shouldAbort('during fake Hi-Res re-download');
if (aborted) return false;
final replacementPath = result['file_path'] as String?;
if (result['success'] != true ||
result['already_exists'] == true ||
normalizeOptionalString(replacementPath) == null ||
isContentUri(replacementPath!)) {
return false;
}
if (effectiveSafMode && result['saf_relative_dir'] is String) {
effectiveOutputDir = n._sanitizeSafRelativeDir(
result['saf_relative_dir'] as String,
);
}
finalized = await _handleDownloadSuccess();
return finalized;
} finally {
if (!finalized && result['already_exists'] != true) {
for (final failedPath in {
filePath,
result['file_path'] as String?,
}) {
if (failedPath != null && failedPath != backup) {
await deleteFile(failedPath);
}
}
}
}
});
} on HiResOriginalRestoreException {
rethrow;
} catch (e) {
_log.w('[hires-check] re-download of ${item.track.name} failed: $e');
} finally {
fakeHiResOriginalPath = null;
if (!completed) saved.restore(this);
}
if (completed) return _FakeHiResOutcome.completed;
if (aborted || await _shouldAbort('after fake Hi-Res rollback')) {
return _FakeHiResOutcome.aborted;
}
if (!await File(path).exists()) {
throw StateError('Could not restore the original Hi-Res file: $path');
}
n.updateItemStatus(item.id, DownloadStatus.downloading, progress: 1);
return _FakeHiResOutcome.kept;
}
/// Only LOSSLESS is a meaningful replacement. A provider that does not
/// list it would silently fall back to its default, which may be the very
/// Hi-Res tier that produced the fake.
bool _serviceOffersFakeHiResFallback() {
final service = item.service.trim().toLowerCase();
final extension = extensionState.extensions
.where((e) => e.id.toLowerCase() == service)
.firstOrNull;
final options = extension?.qualityOptions ?? const <QualityOption>[];
return options.isEmpty ||
options.any(
(o) =>
o.id.toUpperCase() == HiResCheckService.fakeHiResFallbackQuality,
);
}
/// Points the next backend request at LOSSLESS. The output folder stays
/// as resolved (including any storage fallback); only what depends on the
/// quality is rebuilt.
Future<void> _useFakeHiResFallbackQuality() async {
quality = HiResCheckService.fakeHiResFallbackQuality;
safOutputExt = n._determineOutputExt(quality, item.service);
if (effectiveSafMode) {
final name = await n._buildSafFileNameForItem(
item,
trackToDownload,
filenameFormat: effectiveFilenameFormat,
quality: quality,
outputExt: safOutputExt,
);
safFileName = name;
safBaseName = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
finalSafFileName = name;
}
}
}
@@ -869,6 +869,14 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
var quality = item.qualityOverride ?? state.audioQuality;
if (quality == 'DEFAULT') quality = state.audioQuality;
// The fake Hi-Res check and its LOSSLESS re-download live in the Dart
// run (_DownloadRun._replaceFakeHiResIfNeeded); the native finalizer has
// no equivalent, so these items stay on the Dart queue.
if (settings.redownloadFakeHiRes &&
HiResCheckService.isHiResQuality(quality)) {
return null;
}
final isSafMode = _isSafMode(settings);
final rawOutputDir = isSafMode
? _buildRelativeOutputDir(
@@ -132,6 +132,8 @@ class _DownloadRun {
Map<String, dynamic>? probedFinalMetadata;
bool externalLrcWritten = false;
final Map<String, String> _directoryScopes = {};
bool fakeHiResChecked = false;
String? fakeHiResOriginalPath;
Future<void> _run() async {
final normalizedService = n._normalizeQueuedService(item.service);
@@ -729,6 +731,7 @@ class _DownloadRun {
decryptionDescriptor = DownloadDecryptionDescriptor.fromDownloadResult(
result,
);
final requestTrack = trackToDownload;
trackToDownload = buildTrackForMetadataEmbedding(
trackToDownload,
result,
@@ -741,6 +744,18 @@ class _DownloadRun {
await _applyFormatHandling(actualService);
stageCompleted('format and metadata');
final originalHiResPath = fakeHiResOriginalPath;
if (originalHiResPath != null &&
(filePath == null ||
!await HiResCheckService.replacementPreservesAudio(
originalHiResPath,
filePath!,
))) {
throw StateError(
'Lossless replacement does not preserve the original audio',
);
}
if (await _shouldAbort(
'during finalization',
deleteFileOnAbort: filePath,
@@ -748,6 +763,19 @@ class _DownloadRun {
return false;
}
// At most once per run: the LOSSLESS replacement is not checked again.
if (!fakeHiResChecked) {
fakeHiResChecked = true;
switch (await _replaceFakeHiResIfNeeded(requestTrack)) {
case _FakeHiResOutcome.completed:
return true;
case _FakeHiResOutcome.aborted:
return false;
case _FakeHiResOutcome.kept:
break;
}
}
final deferredSafPublish =
effectiveSafMode &&
result['saf_deferred_publish'] == true &&
@@ -838,6 +866,23 @@ class _DownloadRun {
);
}
if (originalHiResPath != null &&
!await HiResCheckService.replacementPreservesAudio(
originalHiResPath,
filePath!,
)) {
throw StateError(
'Finalized replacement no longer preserves the original audio',
);
}
if (await _shouldAbort(
'before publishing finalized audio',
deleteFileOnAbort: wasExisting ? null : filePath,
)) {
return false;
}
if (deferredSafPublish && !await _publishDeferredSafOutputOnce()) {
throw StateError('Failed to publish deferred SAF output');
}
@@ -1762,13 +1807,19 @@ class _DownloadRun {
);
},
);
await n._notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: n._completedInSession,
totalCount: n._totalQueuedAtStart,
alreadyInLibrary: wasExisting,
);
try {
await n._notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: n._completedInSession,
totalCount: n._totalQueuedAtStart,
alreadyInLibrary: wasExisting,
);
} catch (e) {
// Audio and history are already committed. A notification failure
// must not roll back a valid replacement or its persisted history.
_log.w('Download completed but notification failed: $e');
}
n.removeItem(item.id);
}
}
+5
View File
@@ -792,6 +792,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setRedownloadFakeHiRes(bool enabled) {
state = state.copyWith(redownloadFakeHiRes: enabled);
_saveSettings();
}
void setAutoConvertFormat(String format) {
state = state.copyWith(
autoConvertFormat: normalizeAutoConvertFormat(format),
@@ -107,6 +107,15 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
showDivider: true,
),
],
SettingsSwitchItem(
icon: Icons.verified_outlined,
title: context.l10n.downloadRedownloadFakeHiRes,
subtitle: context.l10n.downloadRedownloadFakeHiResSubtitle,
value: settings.redownloadFakeHiRes,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setRedownloadFakeHiRes(value),
),
SettingsSwitchItem(
icon: Icons.auto_fix_high_outlined,
title: context.l10n.downloadAutoConvert,
@@ -330,6 +330,12 @@ class SettingsSearchCatalog {
title: l10n.downloadLossyFormat,
keywords: const ['mp3', 'aac', 'opus'],
),
SettingsSearchEntry(
icon: Icons.verified_outlined,
title: l10n.downloadRedownloadFakeHiRes,
subtitle: l10n.downloadRedownloadFakeHiResSubtitle,
keywords: const ['hi-res', 'hires', 'fake', 'upsampled', '24-bit'],
),
SettingsSearchEntry(
icon: Icons.auto_fix_high_outlined,
title: l10n.downloadAutoConvert,
+10
View File
@@ -88,6 +88,16 @@ extension _TrackMetadataCards on _TrackMetadataScreenState {
filePath: _filePath,
codecHint: _storedAudioFormat,
),
if (HiResCheckCard.isCandidate(
_filePath,
_storedAudioFormat,
)) ...[
const SizedBox(height: 16),
HiResCheckCard(
filePath: _filePath,
formatHint: _storedAudioFormat,
),
],
],
if (!context.isMornye) ...[
+1
View File
@@ -64,6 +64,7 @@ import 'package:spotiflac_android/widgets/mornye_context_menu.dart';
import 'package:spotiflac_android/widgets/mornye_metadata_row.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
import 'package:spotiflac_android/widgets/hires_check_card.dart';
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
import 'package:spotiflac_android/widgets/batch_convert_sheet.dart';
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
+162
View File
@@ -0,0 +1,162 @@
import 'dart:io';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HiResOriginalRestoreException implements Exception {
const HiResOriginalRestoreException(this.backupPath, this.cause);
final String backupPath;
final FileSystemException cause;
@override
String toString() => 'Original audio retained at $backupPath: $cause';
}
/// Evidence from the Rust Hi-Res checker's sampled analysis.
class HiResCheckResult {
final bool supported;
final String verdict;
final int declaredSampleRate;
final double cutoffFrequencyHz;
final int declaredBitDepth;
final int effectiveBitDepth;
final bool paddedBitDepth;
final String reason;
/// For a fake: 'certain', 'likely' or 'suspect' (may be a genuine master
/// low-pass filtered in mastering). Empty otherwise.
final String confidence;
/// 'sample_hold', 'linear_interpolation', 'imaging', or empty.
final String upsamplingArtifact;
/// Eligible to try a replacement. Full-file verification is still required.
final bool redownloadSafe;
/// Highest frequency whose level still moves with the music; 0 when not
/// measured.
final double musicCutoffHz;
/// True when the content past [musicCutoffHz] is steady noise (a DSD or
/// analog tape transfer), so [cutoffFrequencyHz] is where noise ends.
final bool ultrasonicNoiseOnly;
/// With [ultrasonicNoiseOnly]: the lowest standard rate that holds all
/// the music. Otherwise the declared rate.
final int usefulSampleRate;
const HiResCheckResult({
required this.supported,
this.verdict = '',
this.declaredSampleRate = 0,
this.cutoffFrequencyHz = 0,
this.declaredBitDepth = 0,
this.effectiveBitDepth = 0,
this.paddedBitDepth = false,
this.reason = '',
this.confidence = '',
this.upsamplingArtifact = '',
this.redownloadSafe = false,
this.musicCutoffHz = 0,
this.ultrasonicNoiseOnly = false,
this.usefulSampleRate = 0,
});
factory HiResCheckResult.fromJson(Map<String, dynamic> json) {
return HiResCheckResult(
supported: json['supported'] == true,
verdict: json['verdict'] as String? ?? '',
declaredSampleRate: (json['declared_sample_rate'] as num?)?.toInt() ?? 0,
cutoffFrequencyHz: (json['cutoff_frequency_hz'] as num?)?.toDouble() ?? 0,
declaredBitDepth: (json['declared_bit_depth'] as num?)?.toInt() ?? 0,
effectiveBitDepth: (json['effective_bit_depth'] as num?)?.toInt() ?? 0,
paddedBitDepth: json['padded_bit_depth'] == true,
reason: json['reason'] as String? ?? '',
confidence: json['confidence'] as String? ?? '',
upsamplingArtifact: json['upsampling_artifact'] as String? ?? '',
redownloadSafe: json['redownload_safe'] == true,
musicCutoffHz: (json['music_cutoff_hz'] as num?)?.toDouble() ?? 0,
ultrasonicNoiseOnly: json['ultrasonic_noise_only'] == true,
usefulSampleRate: (json['useful_sample_rate'] as num?)?.toInt() ?? 0,
);
}
bool get isFake => verdict == 'fake_hires';
bool get isCertain => isFake && confidence == 'certain';
bool get isSuspect => isFake && confidence == 'suspect';
/// Mirrors the analysis thresholds: a rate claim above 48 kHz whose content
/// stops below 28 kHz. Recomputed here so the reason can be localized.
bool get upsampled =>
isFake && declaredSampleRate > 48000 && cutoffFrequencyHz < 28000;
}
class HiResCheckService {
/// The requested qualities that actually claim Hi-Res. A LOSSLESS request
/// answered with CD-range audio is not a fake: it is what was asked for.
static const hiResQualities = {'HI_RES', 'HI_RES_LOSSLESS'};
/// Requested fallback tier. Its name does not guarantee audio equivalence.
static const fakeHiResFallbackQuality = 'LOSSLESS';
static bool isHiResQuality(String quality) =>
hiResQualities.contains(quality.trim().toUpperCase());
/// Runs the check; throws on failure. `supported == false` for formats
/// the Rust checker cannot decode (anything but FLAC and PCM WAV).
static Future<HiResCheckResult> check(String filePath) async {
final json = await PlatformBridge.checkHiResAuthenticity(filePath);
final error = json['error'];
if (error != null) throw StateError(error.toString());
return HiResCheckResult.fromJson(json);
}
/// Every sample, channel and frame must match after removing only zero-bit
/// padding or exact sample repetition. Missing/old backend support fails closed.
static Future<bool> replacementPreservesAudio(
String originalPath,
String replacementPath,
) async {
final result = await PlatformBridge.checkHiResAuthenticity(
originalPath,
options: {'verify_replacement_path': replacementPath},
);
return result['error'] == null && result['replacement_equivalent'] == true;
}
/// Keeps a unique backup on the same filesystem until [replaceAndFinalize]
/// has validated, processed, published and persisted the replacement.
/// False, cancellation or any exception restores the original atomically.
static Future<bool> withOriginalBackup(
String originalPath,
Future<bool> Function(String backupPath) replaceAndFinalize,
) async {
final original = File(originalPath);
final directory = await original.parent.createTemp('.fake-hires-');
final backup = File('${directory.path}/${original.uri.pathSegments.last}');
var moved = false;
var committed = false;
try {
await original.rename(backup.path);
moved = true;
committed = await replaceAndFinalize(backup.path);
return committed;
} finally {
if (moved && !committed) {
// If restoration fails, retain the backup and surface the error.
try {
await backup.rename(originalPath);
} on FileSystemException catch (error) {
throw HiResOriginalRestoreException(backup.path, error);
}
}
// Cleanup failure must not roll back a successfully published file.
try {
await directory.delete(recursive: true);
} on FileSystemException {
// A leftover backup is preferable to losing the completed download.
}
}
}
}
+12
View File
@@ -1156,6 +1156,18 @@ class PlatformBridge {
});
}
/// Runs the Rust Hi-Res check (spectral cutoff + padded bit depth) on
/// a FLAC or WAV file. Returns `{"supported": false}` for other formats.
static Future<Map<String, dynamic>> checkHiResAuthenticity(
String filePath, {
Map<String, dynamic>? options,
}) {
return _invokeMap('checkHiResAuthenticity', {
'file_path': filePath,
'options_json': options == null ? '' : jsonEncode(options),
});
}
/// Reads the tags and quality fields used for automatic Library display.
///
/// Android's readAudioMetadata implementation can inspect most SAF files
+308
View File
@@ -0,0 +1,308 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/hires_check_service.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
/// On-demand fake Hi-Res check for one FLAC/WAV file: spectral cutoff for
/// upsampling, and unused low bits for a 16-bit master padded to 24.
class HiResCheckCard extends StatefulWidget {
final String filePath;
final String? formatHint;
const HiResCheckCard({super.key, required this.filePath, this.formatHint});
/// Only FLAC and PCM WAV are decoded by the Go checker.
static bool isCandidate(String filePath, String? formatHint) {
final format = formatHint?.toLowerCase().trim() ?? '';
if (format == 'flac' || format == 'wav') return true;
final lower = filePath.toLowerCase();
return lower.endsWith('.flac') || lower.endsWith('.wav');
}
@override
State<HiResCheckCard> createState() => _HiResCheckCardState();
}
class _HiResCheckCardState extends State<HiResCheckCard> {
HiResCheckResult? _result;
bool _checking = false;
String? _error;
int _requestId = 0;
@override
void didUpdateWidget(covariant HiResCheckCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.filePath != widget.filePath) {
_requestId++;
_result = null;
_error = null;
_checking = false;
}
}
Future<void> _check() async {
final requestId = ++_requestId;
setState(() {
_checking = true;
_error = null;
});
String? error;
HiResCheckResult? result;
try {
result = await HiResCheckService.check(widget.filePath);
} on StateError catch (e) {
error = e.message;
} catch (e) {
error = e.toString();
}
if (!mounted || requestId != _requestId) return;
setState(() {
_checking = false;
_result = result;
_error = error;
});
}
@override
Widget build(BuildContext context) {
if (!HiResCheckCard.isCandidate(widget.filePath, widget.formatHint)) {
return const SizedBox.shrink();
}
final cs = Theme.of(context).colorScheme;
final l10n = context.l10n;
final result = _result;
return Card(
elevation: 0,
color: settingsGroupColor(context),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.verified_outlined, color: cs.primary, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.hiResCheckTitle,
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
if (_checking)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2.5),
)
else if (result != null || _error != null)
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: l10n.audioAnalysisRescan,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 48,
minHeight: 48,
),
color: cs.onSurfaceVariant,
onPressed: _check,
),
],
),
const SizedBox(height: 8),
if (_checking)
_secondaryText(l10n.hiResCheckChecking, cs)
else if (_error != null)
Text(
l10n.hiResCheckFailed(_error!),
style: TextStyle(color: cs.error, fontSize: 13),
)
else if (result == null) ...[
_secondaryText(l10n.hiResCheckDescription, cs),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: _check,
icon: const Icon(Icons.search, size: 18),
label: Text(l10n.hiResCheckRun),
),
] else if (!result.supported)
_secondaryText(l10n.hiResCheckUnsupported, cs)
else
..._buildResult(context, result, cs),
],
),
),
);
}
Widget _secondaryText(String text, ColorScheme cs) {
return Text(
text,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
);
}
List<Widget> _buildResult(
BuildContext context,
HiResCheckResult result,
ColorScheme cs,
) {
final l10n = context.l10n;
final (IconData icon, Color color, String label) = switch (result.verdict) {
'fake_hires' when result.isCertain => (
Icons.error_outline,
cs.error,
l10n.hiResCheckVerdictFakeCertain,
),
'fake_hires' when result.isSuspect => (
Icons.help_outline,
cs.tertiary,
l10n.hiResCheckVerdictFakeSuspect,
),
'fake_hires' => (
Icons.warning_amber_rounded,
cs.error,
l10n.hiResCheckVerdictFake,
),
'genuine_hires' => (
Icons.check_circle_outline,
cs.primary,
l10n.hiResCheckVerdictGenuine,
),
'standard_definition' => (
Icons.info_outline,
cs.onSurfaceVariant,
l10n.hiResCheckVerdictStandard,
),
_ => (
Icons.help_outline,
cs.onSurfaceVariant,
l10n.hiResCheckVerdictInconclusive,
),
};
final effectiveBits = result.effectiveBitDepth > 0
? result.effectiveBitDepth
: null;
final reasons = <String>[
if (result.upsamplingArtifact == 'sample_hold')
l10n.hiResCheckReasonSampleHold,
if (result.upsamplingArtifact == 'linear_interpolation')
l10n.hiResCheckReasonInterpolation,
if (result.upsamplingArtifact == 'imaging') l10n.hiResCheckReasonImaging,
if (result.upsampled)
l10n.hiResCheckReasonRate(
_formatKHz(result.declaredSampleRate.toDouble()),
_formatKHz(result.cutoffFrequencyHz),
),
if (result.isFake && result.paddedBitDepth)
l10n.hiResCheckReasonDepth(
result.declaredBitDepth,
result.effectiveBitDepth,
),
];
return [
Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
],
),
for (final reason in reasons) ...[
const SizedBox(height: 4),
_secondaryText('• $reason', cs),
],
if (result.ultrasonicNoiseOnly) ...[
const SizedBox(height: 4),
_secondaryText(
'• ${l10n.hiResCheckUltrasonicNoise(_formatKHz(result.musicCutoffHz), _formatKHz(result.usefulSampleRate.toDouble()))}',
cs,
),
],
if (result.verdict != 'inconclusive') ...[
const SizedBox(height: 8),
Wrap(
spacing: 16,
runSpacing: 4,
children: [
// Past the music only steady noise remains, so where it ends
// says nothing about the music: show the music's own edge.
if (result.ultrasonicNoiseOnly)
_detail(
l10n.hiResCheckMusicContent,
'~${_formatKHz(result.musicCutoffHz)}',
cs,
)
else
_detail(
l10n.hiResCheckCutoff,
'~${_formatKHz(result.cutoffFrequencyHz)}',
cs,
),
if (result.declaredBitDepth > 0)
_detail(
l10n.hiResCheckBitsInUse,
'${effectiveBits ?? '?'} / ${result.declaredBitDepth}-bit',
cs,
),
],
),
],
if (result.isSuspect) ...[
const SizedBox(height: 8),
Text(
l10n.hiResCheckDisclaimer,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 11,
fontStyle: FontStyle.italic,
),
),
],
];
}
Widget _detail(String label, String value, ColorScheme cs) {
return Text.rich(
TextSpan(
children: [
TextSpan(
text: '$label: ',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
),
TextSpan(
text: value,
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
String _formatKHz(double hz) => '${(hz / 1000).toStringAsFixed(1)} kHz';
}