mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
fix(library): expose failed scan files and reasons in app logs
This commit is contained in:
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'));
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user