fix(library): expose failed scan files and reasons in app logs

This commit is contained in:
zarzet committed 2026-10-03 17:57:35 +07:00
1 parent 6a4054d75e
commit f035538778
10 files changed
+177 -52

No files matched your search

@@ -885,6 +885,18 @@ class MainActivity: FlutterFragmentActivity() {
channel.invokeMethod("extensionSessionGrantCompleted", payload)
}
internal fun reportLibraryScanError(path: String, operation: String, message: String) {
android.util.Log.e("SpotiFLAC", "Library scan: $operation [$path]: $message")
val payload = mapOf(
"path" to path,
"operation" to operation,
"message" to message,
)
runOnUiThread {
backendChannel?.invokeMethod("libraryScanError", payload)
}
}
/**
* Opens a short-lived descriptor lease for zero-copy SAF playback. The
* returned proc path identifies the lease. The Android audio plugin must
@@ -36,6 +36,7 @@ import org.json.JSONTokener
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.security.MessageDigest
import java.util.Locale
@@ -274,6 +275,7 @@ private fun MainActivity.readMetadataFromUri(
fallbackExt: String? = null,
acceptRead: (JSONObject) -> Boolean = { true },
retryDirectOnFailure: Boolean = false,
onFailure: (String) -> Unit = {},
read: (String, String) -> JSONObject?,
): JSONObject? {
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
@@ -294,6 +296,9 @@ private fun MainActivity.readMetadataFromUri(
},
accept = acceptRead,
retryDirectOnFailure = retryDirectOnFailure,
onFailure = { error ->
onFailure(error?.message ?: "No readable audio metadata for $displayName")
},
)
}
@@ -302,19 +307,18 @@ internal fun MainActivity.readAudioMetadataFromUri(
displayNameHint: String? = null,
fallbackExt: String? = null,
coverCacheKey: String = "",
onFailure: (String) -> Unit = {},
): JSONObject? = readMetadataFromUri(
uri, displayNameHint, fallbackExt,
acceptRead = {
val fromFilename = it.optBoolean("metadataFromFilename", false)
if (fromFilename) {
android.util.Log.w(
"SpotiFLAC",
"SAF metadata tags unreadable; ignoring filename-derived metadata",
)
throw IOException("Audio tags unreadable; only filename-derived metadata available")
}
!fromFilename
},
retryDirectOnFailure = true,
onFailure = onFailure,
) { path, name ->
if (name.endsWith(".dsf", true) || name.endsWith(".dff", true) || name.endsWith(".wv", true)) {
DsdSource.open(path)?.use { source ->
@@ -334,7 +338,8 @@ internal fun MainActivity.readAudioMetadataFromUri(
val obj = JSONObject(coreBackend.readAudioMetadata(
path, name, coverCacheKey,
))
obj.takeUnless { it.has("error") }
if (obj.has("error")) throw IOException(obj.optString("error", "Audio metadata read failed"))
obj
}
/** The Hi-Res check reads only a window from the middle of the file, so a
@@ -956,10 +956,7 @@ internal fun MainActivity.scanSafTree(
if (children == null) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF scan: failed listing directory $dirUri: ${listing.exceptionOrNull()?.message}",
)
reportLibraryScanError(dirUri, "List folder", listing.exceptionOrNull()?.message ?: "No directory listing")
continue
}
rememberCueDirectoryListing(dir, children, safChildLookupCache)
@@ -991,10 +988,7 @@ internal fun MainActivity.scanSafTree(
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF scan: skipped child under $dirUri: ${e.message}",
)
reportLibraryScanError(child.doc.uri.toString(), "Inspect entry", e.message ?: e.javaClass.simpleName)
}
}
}
@@ -1100,7 +1094,7 @@ internal fun MainActivity.scanSafTree(
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue", cueName)
if (tempCuePath == null) {
errors++
android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy CUE ${cueDoc.uri}")
reportLibraryScanError(cueUri, "Read CUE", "Failed to copy CUE file")
scanned++
continue
}
@@ -1115,7 +1109,7 @@ internal fun MainActivity.scanSafTree(
)
if (audioDoc == null) {
android.util.Log.w("SpotiFLAC", "SAF scan: no audio file found for CUE $cueName")
reportLibraryScanError(cueUri, "Resolve CUE audio", "No audio file found for $cueName")
errors++
scanned++
continue
@@ -1139,7 +1133,7 @@ internal fun MainActivity.scanSafTree(
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
if (tempAudioPath == null) {
android.util.Log.w("SpotiFLAC", "SAF scan: failed to copy audio for CUE $cueName")
reportLibraryScanError(audioDoc.uri.toString(), "Read CUE audio", "Failed to copy audio for $cueName")
errors++
scanned++
continue
@@ -1171,7 +1165,7 @@ internal fun MainActivity.scanSafTree(
} catch (e: Exception) {
errors++
android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}")
reportLibraryScanError(cueUri, "Scan CUE", e.message ?: e.javaClass.simpleName)
} finally {
try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {}
try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
@@ -1191,6 +1185,7 @@ internal fun MainActivity.scanSafTree(
val name: String,
val lastModified: Long,
val metadata: JSONObject?,
val error: String?,
)
val pendingAudio = mutableListOf<SafAudioEntry>()
@@ -1234,31 +1229,33 @@ internal fun MainActivity.scanSafTree(
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified)
var error: String? = null
val metadata = try {
readAudioMetadataFromUri(
doc.uri,
name,
fallbackExt,
coverCacheKey,
onFailure = { error = it },
)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF scan: metadata read failed for $stableUri: ${e.message}",
)
error = e.message ?: e.javaClass.simpleName
null
}
SafAudioScanOutcome(stableUri, name, lastModified, metadata)
SafAudioScanOutcome(stableUri, name, lastModified, metadata, error)
},
) { _, result ->
) { audio, result ->
val outcome = result.getOrNull()
if (outcome == null) {
errors++
val error = result.exceptionOrNull()
reportLibraryScanError(audio.doc.uri.toString(), "Read metadata", error?.message ?: "Metadata task failed")
} else {
updateSafScanProgress { it.currentFile = outcome.name }
val metadataObj = outcome.metadata
if (metadataObj == null) {
errors++
reportLibraryScanError(outcome.uri, "Read metadata", outcome.error ?: "No readable audio metadata for ${outcome.name}")
} else {
try {
metadataObj.put("id", buildStableLibraryId(outcome.uri))
@@ -1268,8 +1265,9 @@ internal fun MainActivity.scanSafTree(
// Flush before recording the checkpoint to avoid losing the row.
ndjsonWriter?.flush()
recordCheckpoint(outcome.uri, outcome.lastModified)
} catch (_: Exception) {
} catch (error: Exception) {
errors++
reportLibraryScanError(outcome.uri, "Index track", error.message ?: error.javaClass.simpleName)
}
}
}
@@ -1401,10 +1399,7 @@ internal fun MainActivity.scanSafTreeIncremental(
if (children == null) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF incremental scan: failed listing directory $dirUri: ${listing.exceptionOrNull()?.message}",
)
reportLibraryScanError(dirUri, "List folder", listing.exceptionOrNull()?.message ?: "No directory listing")
continue
}
rememberCueDirectoryListing(dir, children, safChildLookupCache)
@@ -1463,10 +1458,7 @@ internal fun MainActivity.scanSafTreeIncremental(
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF incremental scan: skipped child under $dirUri: ${e.message}",
)
reportLibraryScanError(child.doc.uri.toString(), "Inspect entry", e.message ?: e.javaClass.simpleName)
}
}
}
@@ -1534,7 +1526,7 @@ internal fun MainActivity.scanSafTreeIncremental(
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue", cueName)
if (tempCuePath == null) {
errors++
android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy CUE ${cueDoc.uri}")
reportLibraryScanError(cueDoc.uri.toString(), "Read CUE", "Failed to copy CUE file")
scanned++
continue
}
@@ -1549,7 +1541,7 @@ internal fun MainActivity.scanSafTreeIncremental(
)
if (audioDoc == null) {
android.util.Log.w("SpotiFLAC", "SAF incremental scan: no audio file found for CUE $cueName")
reportLibraryScanError(cueDoc.uri.toString(), "Resolve CUE audio", "No audio file found for $cueName")
errors++
scanned++
continue
@@ -1569,7 +1561,7 @@ internal fun MainActivity.scanSafTreeIncremental(
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
if (tempAudioPath == null) {
android.util.Log.w("SpotiFLAC", "SAF incremental scan: failed to copy audio for CUE $cueName")
reportLibraryScanError(audioDoc.uri.toString(), "Read CUE audio", "Failed to copy audio for $cueName")
errors++
scanned++
continue
@@ -1602,7 +1594,7 @@ internal fun MainActivity.scanSafTreeIncremental(
} catch (e: Exception) {
errors++
android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}")
reportLibraryScanError(cueDoc.uri.toString(), "Scan CUE", e.message ?: e.javaClass.simpleName)
} finally {
try { tempCuePath?.let { File(it).delete() } } catch (_: Exception) {}
try { tempAudioPath?.let { File(it).delete() } } catch (_: Exception) {}
@@ -1693,12 +1685,20 @@ internal fun MainActivity.scanSafTreeIncremental(
audio.name,
fallbackExt,
buildLibraryCoverCacheKey(audio.doc.uri.toString(), audio.lastModified),
onFailure = { message ->
reportLibraryScanError(audio.doc.uri.toString(), "Read metadata", message)
},
)
},
) { audio, result ->
updateSafScanProgress { it.currentFile = audio.name }
// A failed read aborts the incremental scan, as it did when serial.
val metadataObj = result.getOrThrow()
val metadataObj = try {
result.getOrThrow()
} catch (error: Exception) {
reportLibraryScanError(audio.doc.uri.toString(), "Read metadata", error.message ?: error.javaClass.simpleName)
throw error
}
if (metadataObj == null) {
errors++
} else {
@@ -1709,8 +1709,9 @@ internal fun MainActivity.scanSafTreeIncremental(
metadataObj.put("fileModTime", audio.lastModified)
metadataObj.put("lastModified", audio.lastModified)
putFile(metadataObj)
} catch (_: Exception) {
} catch (error: Exception) {
errors++
reportLibraryScanError(audio.doc.uri.toString(), "Index track", error.message ?: error.javaClass.simpleName)
}
}
scanned++
@@ -9,10 +9,15 @@ internal fun <T> readSafMetadataWithFallback(
fallbackRead: () -> T?,
accept: (T) -> Boolean = { true },
retryDirectOnFailure: Boolean = false,
onFailure: (Exception?) -> Unit = {},
): T? {
var lastError: Exception? = null
fun attempt(read: () -> T?): T? = try {
read()?.takeIf(accept)
} catch (_: Exception) { null }
} catch (error: Exception) {
lastError = error
null
}
val direct = attempt(directRead)
if (direct != null) return direct
@@ -20,7 +25,9 @@ internal fun <T> readSafMetadataWithFallback(
if (copied != null) return copied
// A remote provider may have completed its download/cache by now. Reopen
// once; never repeatedly copy whole songs or publish filename guesses.
return if (retryDirectOnFailure) attempt(directRead) else null
val retried = if (retryDirectOnFailure) attempt(directRead) else null
if (retried == null) onFailure(lastError)
return retried
}
internal fun <T> readLyricsWithSafCopy(
@@ -8,6 +8,29 @@ import org.junit.Test
import java.io.File
class SafMetadataReadPolicyTest {
@Test fun exhaustedReadsReportTheFailureOnce() {
val failures = mutableListOf<String?>()
val result = readSafMetadataWithFallback<String>(
directRead = { throw java.io.IOException("Invalid audio header") },
fallbackRead = { null },
retryDirectOnFailure = true,
onFailure = { failures.add(it?.message) },
)
assertNull(result)
assertEquals(listOf("Invalid audio header"), failures)
}
@Test fun recoveredReadsDoNotReportAScanError() {
var failures = 0
val result = readSafMetadataWithFallback(
directRead = { throw SecurityException("Descriptor unavailable") },
fallbackRead = { "valid metadata" },
onFailure = { failures++ },
)
assertEquals("valid metadata", result)
assertEquals(0, failures)
}
@Test fun rejectsTemporaryWorkspaceMetadataAndRetriesDescriptorOnce() {
val guessed = mapOf(
"metadataFromFilename" to true,
+19 -9
View File
@@ -856,10 +856,15 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
await _pruneLibraryCoverCache();
_log.i(
'Full scan complete: ${state.totalCount} tracks found, '
'$skippedDownloads already in downloads',
);
final summary =
'Full scan complete: ${state.totalCount} tracks found, '
'$skippedDownloads already in downloads, '
'${state.scanErrorCount} errors';
if (state.scanErrorCount > 0) {
_log.e(summary);
} else {
_log.i(summary);
}
await _showScanCompleteNotification(
totalTracks: state.totalCount,
excludedDownloadedCount: skippedDownloads,
@@ -1028,11 +1033,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
excludedDownloadedCount: skippedDownloads,
);
_log.i(
'Incremental scan complete: ${state.totalCount} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
);
final summary =
'Incremental scan complete: ${state.totalCount} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads, '
'${state.scanErrorCount} errors)';
if (state.scanErrorCount > 0) {
_log.e(summary);
} else {
_log.i(summary);
}
await _showScanCompleteNotification(
totalTracks: state.totalCount,
excludedDownloadedCount: skippedDownloads,
+10 -3
View File
@@ -7,6 +7,9 @@ import 'package:spotiflac_android/services/network_metadata_service.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary');
/// A single tag read at a time; only NDJSON and artwork are stored locally.
/// Failed listings never count as empty directories. Partial scans retain old
@@ -81,8 +84,9 @@ class NetworkLibraryScanner {
entries = folder == root
? first
: await _storage.list(connection, folder);
} catch (_) {
} catch (error) {
errors++;
_log.e('Network folder listing failed [$folder]', error);
onProgress(processed, errors, folder);
continue;
}
@@ -103,7 +107,9 @@ class NetworkLibraryScanner {
try {
final tags = await _readMetadata(trackSource);
if (tags['error'] != null || tags['metadataFromFilename'] == true) {
throw StateError('Network tags unavailable');
throw StateError(
tags['error']?.toString() ?? 'Network tags unavailable',
);
}
final id = 'network_${sha256.convert(utf8.encode(trackSource))}';
String? coverPath;
@@ -129,10 +135,11 @@ class NetworkLibraryScanner {
..['coverPath'] = coverPath;
await output.writeString('${jsonEncode(row)}\n');
written++;
} catch (_) {
} catch (error) {
// Cancellation must abort the whole staging operation.
await checkpoint();
errors++;
_log.e('Network track scan failed [$trackSource]', error);
}
processed++;
onProgress(processed, errors, entry.name);
+12
View File
@@ -259,6 +259,15 @@ class PlatformBridge {
_backendEventHandlerInstalled = true;
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'libraryScanError':
final args = call.arguments;
if (args is Map) {
final path = args['path']?.toString() ?? '';
final operation = args['operation']?.toString() ?? 'scan';
final message = args['message']?.toString() ?? 'Unknown error';
AppLogger('LocalLibrary').e('$operation [$path]: $message');
}
return null;
case 'extensionSessionGrantCompleted':
final args = call.arguments;
if (args is Map) {
@@ -2243,6 +2252,7 @@ class PlatformBridge {
bool Function()? isCancelled,
}) async {
// Stable support-directory path lets native SAF scans resume after process death.
_ensureBackendEventHandler();
final scanDir = await getApplicationSupportDirectory();
await scanDir.create(recursive: true);
final identity = jsonEncode(<String, dynamic>{
@@ -2326,6 +2336,7 @@ class PlatformBridge {
String treeUri,
Map<String, int> existingFiles,
) async {
_ensureBackendEventHandler();
final result = await _channel.invokeMethod('scanSafTreeIncremental', {
'tree_uri': treeUri,
'existing_files': jsonEncode(existingFiles),
@@ -2337,6 +2348,7 @@ class PlatformBridge {
String treeUri,
String snapshotPath,
) async {
_ensureBackendEventHandler();
final result = await _channel.invokeMethod(
'scanSafTreeIncrementalFromSnapshot',
{'tree_uri': treeUri, 'snapshot_path': snapshotPath},
+39
View File
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -22,6 +24,43 @@ void main() {
await root.delete(recursive: true);
});
test('SAF scan failures reach Logs even with logging disabled', () async {
final previousLogging = LogBuffer.loggingEnabled;
LogBuffer.loggingEnabled = false;
addTearDown(() => LogBuffer.loggingEnabled = previousLogging);
LogBuffer().clear();
messenger.setMockMethodCallHandler(backend, (call) async {
if (call.method != 'scanSafTreeToNDJSONFile') return null;
final delivered = Completer<void>();
messenger.handlePlatformMessage(
backend.name,
const StandardMethodCodec().encodeMethodCall(
const MethodCall('libraryScanError', {
'path': 'content://library/document/unreadable.flac',
'operation': 'Read metadata',
'message': 'Invalid audio header',
}),
),
(_) => delivered.complete(),
);
await delivered.future;
final output = File((call.arguments as Map)['output_path'] as String);
await output.writeAsString('');
return {'path': output.path, 'count': 0, 'error_count': 1};
});
final scan = await PlatformBridge.scanSafTreeToNDJSONFile(
'content://library/tree/music',
);
expect(scan.errorCount, 1);
final error = LogBuffer().entries.single;
expect(error.level, 'ERROR');
expect(error.tag, 'LocalLibrary');
expect(error.message, contains('unreadable.flac'));
expect(error.message, contains('Read metadata'));
expect(error.message, contains('Invalid audio header'));
});
for (final saf in [false, true]) {
Future<LibraryScanNDJSONFile> scan({
bool force = false,
+9
View File
@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/network_library_scanner.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
class _Storage extends NetworkStorageService {
_Storage(this.tree, {this.failedFolder});
@@ -119,6 +120,7 @@ void main() {
test(
'failed folders and tags report partial results instead of deleting old rows',
() async {
LogBuffer().clear();
final storage = _Storage({
'': [
const NetworkEntry('offline/', 'Offline', directory: true),
@@ -140,6 +142,13 @@ void main() {
expect(scan.errorCount, 2);
expect(scan.expectedCount, 1);
expect((await scan.rows().toList()).single['trackName'], 'Valid');
final errors = LogBuffer().entries.where(
(entry) => entry.level == 'ERROR',
);
expect(errors, hasLength(2));
expect(errors.any((entry) => entry.message.contains('bad.flac')), isTrue);
expect(errors.any((entry) => entry.message.contains('offline/')), isTrue);
expect(errors.first.error, contains('Network tags unavailable'));
},
);