From f0355387786cf84f41196334cf6aa587f43a99d9 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:57:35 +0700 Subject: [PATCH] fix(library): expose failed scan files and reasons in app logs --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 12 ++++ .../com/zarz/spotiflac/MainActivitySafIo.kt | 15 +++-- .../com/zarz/spotiflac/MainActivitySafScan.kt | 67 ++++++++++--------- .../zarz/spotiflac/SafMetadataReadPolicy.kt | 11 ++- .../spotiflac/SafMetadataReadPolicyTest.kt | 23 +++++++ lib/providers/local_library_provider.dart | 28 +++++--- lib/services/network_library_scanner.dart | 13 +++- lib/services/platform_bridge.dart | 12 ++++ test/library_full_scan_test.dart | 39 +++++++++++ test/network_library_scanner_test.dart | 9 +++ 10 files changed, 177 insertions(+), 52 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 1292643a..dfba4e3a 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt index ad0e2e5f..3437631c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafIo.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index a1f6f34a..b1d4d78f 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -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() @@ -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++ diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafMetadataReadPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafMetadataReadPolicy.kt index 3b0d38a2..9cbfba7c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafMetadataReadPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafMetadataReadPolicy.kt @@ -9,10 +9,15 @@ internal fun 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 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 readLyricsWithSafCopy( diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/SafMetadataReadPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/SafMetadataReadPolicyTest.kt index f8d5df63..ecd56624 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/SafMetadataReadPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/SafMetadataReadPolicyTest.kt @@ -8,6 +8,29 @@ import org.junit.Test import java.io.File class SafMetadataReadPolicyTest { + @Test fun exhaustedReadsReportTheFailureOnce() { + val failures = mutableListOf() + val result = readSafMetadataWithFallback( + 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, diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 0e3b700d..764bf067 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -856,10 +856,15 @@ class LocalLibraryNotifier extends Notifier { ); 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 { 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, diff --git a/lib/services/network_library_scanner.dart b/lib/services/network_library_scanner.dart index 696e0354..7c2411f7 100644 --- a/lib/services/network_library_scanner.dart +++ b/lib/services/network_library_scanner.dart @@ -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); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 0b30974e..89e6e5b8 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -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({ @@ -2326,6 +2336,7 @@ class PlatformBridge { String treeUri, Map 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}, diff --git a/test/library_full_scan_test.dart b/test/library_full_scan_test.dart index 7a92ee2f..3ce42828 100644 --- a/test/library_full_scan_test.dart +++ b/test/library_full_scan_test.dart @@ -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(); + 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 scan({ bool force = false, diff --git a/test/network_library_scanner_test.dart b/test/network_library_scanner_test.dart index 95584a3a..f93b4c18 100644 --- a/test/network_library_scanner_test.dart +++ b/test/network_library_scanner_test.dart @@ -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')); }, );