From 60624436a64922faff8870ee95b71dc018e20ac1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 22 Aug 2026 11:15:42 +0700 Subject: [PATCH] feat(library): support multiple storage folders --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 77 +++- lib/l10n/app_localizations.dart | 48 +++ lib/l10n/app_localizations_de.dart | 27 ++ lib/l10n/app_localizations_en.dart | 27 ++ lib/l10n/app_localizations_es.dart | 27 ++ lib/l10n/app_localizations_fr.dart | 27 ++ lib/l10n/app_localizations_id.dart | 27 ++ lib/l10n/app_localizations_ja.dart | 27 ++ lib/l10n/app_localizations_ko.dart | 27 ++ lib/l10n/app_localizations_pt.dart | 27 ++ lib/l10n/app_localizations_ru.dart | 27 ++ lib/l10n/app_localizations_tr.dart | 27 ++ lib/l10n/app_localizations_uk.dart | 27 ++ lib/l10n/arb/app_en.arb | 32 ++ lib/l10n/arb/app_id.arb | 8 + lib/main.dart | 16 +- lib/providers/local_library_provider.dart | 403 ++++++++++++++++-- lib/screens/local_album_screen.dart | 12 +- lib/screens/queue_tab_batch_actions.dart | 12 +- .../settings/library_settings_page.dart | 287 +++++++++++-- lib/services/library_database.dart | 363 ++++++++++++++-- lib/services/library_database_models.dart | 66 +++ lib/services/library_database_queue_sql.dart | 12 +- lib/services/platform_bridge.dart | 10 + test/models_and_utils_test.dart | 21 + 25 files changed, 1530 insertions(+), 134 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 8a5d0c70..34b60f74 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1,11 +1,15 @@ package com.zarz.spotiflac import android.app.Activity +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.storage.StorageManager +import android.provider.DocumentsContract import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile @@ -74,6 +78,7 @@ class MainActivity: FlutterFragmentActivity() { private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var backendChannel: MethodChannel? = null + private var libraryStorageReceiver: BroadcastReceiver? = null private val pendingSessionGrantEvents = mutableListOf>() private var pendingSafTreeResult: MethodChannel.Result? = null internal val safScanLock = Any() @@ -118,9 +123,38 @@ class MainActivity: FlutterFragmentActivity() { val payload = JSONObject() payload.put("tree_uri", uri.toString()) payload.put("display_name", resolveSafDisplayPath(uri)) + val storageId = resolveSafStorageId(uri) + val volume = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + getSystemService(StorageManager::class.java).storageVolumes.firstOrNull { + (storageId == "primary" && it.isPrimary) || + (!it.uuid.isNullOrBlank() && it.uuid.equals(storageId, ignoreCase = true)) + } + } else { + null + } + payload.put("volume_id", storageId ?: JSONObject.NULL) + payload.put( + "is_removable", + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + volume?.isRemovable ?: (storageId != null && storageId != "primary") + } else { + storageId != null && storageId != "primary" + }, + ) result.success(payload.toString()) } + private fun resolveSafStorageId(treeUri: Uri): String? { + return try { + DocumentsContract.getTreeDocumentId(treeUri) + ?.substringBefore(':') + ?.trim() + ?.takeIf { it.isNotEmpty() } + } catch (_: Exception) { + null + } + } + /** * Resolve a SAF tree URI to a human-readable path. * e.g. "content://...tree/primary%3AMusic" -> "/storage/emulated/0/Music" @@ -138,7 +172,14 @@ class MainActivity: FlutterFragmentActivity() { val prefix = if (storageId == "primary") { "/storage/emulated/0" } else { - "SD Card" + val volumeName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + getSystemService(StorageManager::class.java).storageVolumes + .firstOrNull { it.uuid.equals(storageId, ignoreCase = true) } + ?.getDescription(this) + } else { + null + } + volumeName?.takeIf { it.isNotBlank() } ?: "External storage" } return if (subPath.isEmpty()) prefix else "$prefix/$subPath" @@ -791,6 +832,12 @@ class MainActivity: FlutterFragmentActivity() { } override fun onDestroy() { + libraryStorageReceiver?.let { + try { + unregisterReceiver(it) + } catch (_: Exception) {} + } + libraryStorageReceiver = null try { Gobackend.cleanupExtensions() } catch (e: Exception) { @@ -856,6 +903,7 @@ class MainActivity: FlutterFragmentActivity() { val channel = MethodChannel(messenger, CHANNEL) backendChannel = channel + registerLibraryStorageReceiver() if (pendingSessionGrantEvents.isNotEmpty()) { val events = pendingSessionGrantEvents.toList() pendingSessionGrantEvents.clear() @@ -2483,4 +2531,31 @@ class MainActivity: FlutterFragmentActivity() { } } } + + private fun registerLibraryStorageReceiver() { + if (libraryStorageReceiver != null) return + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + backendChannel?.invokeMethod( + "libraryStorageChanged", + mapOf("action" to (intent?.action ?: "")), + ) + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_MEDIA_MOUNTED) + addAction(Intent.ACTION_MEDIA_UNMOUNTED) + addAction(Intent.ACTION_MEDIA_EJECT) + addAction(Intent.ACTION_MEDIA_REMOVED) + addAction(Intent.ACTION_MEDIA_BAD_REMOVAL) + addDataScheme("file") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + registerReceiver(receiver, filter) + } + libraryStorageReceiver = receiver + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1e567da1..cadae656 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3400,6 +3400,54 @@ abstract class AppLocalizations { /// **'Tap to select folder'** String get libraryFolderHint; + /// Action to add another local library source + /// + /// In en, this message translates to: + /// **'Add library folder'** + String get libraryAddFolder; + + /// Supported storage locations for local library sources + /// + /// In en, this message translates to: + /// **'Internal storage, SD card, SSD, or another external drive'** + String get libraryAddFolderSubtitle; + + /// Library source is connected and accessible + /// + /// In en, this message translates to: + /// **'Online'** + String get librarySourceOnline; + + /// Library source is temporarily disconnected + /// + /// In en, this message translates to: + /// **'Offline. Reconnect the storage to restore these tracks'** + String get librarySourceOffline; + + /// Library source has been disabled by the user + /// + /// In en, this message translates to: + /// **'Disabled'** + String get librarySourceDisabled; + + /// Label for a removable or external library source + /// + /// In en, this message translates to: + /// **'External storage'** + String get libraryExternalStorage; + + /// Action to remove one indexed library source + /// + /// In en, this message translates to: + /// **'Remove library folder'** + String get libraryRemoveFolder; + + /// Confirmation shown before removing a library source + /// + /// In en, this message translates to: + /// **'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'** + String get libraryRemoveFolderMessage; + /// Toggle for duplicate indicator in search /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 31b09af6..d3e18173 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1901,6 +1901,33 @@ class AppLocalizationsDe extends AppLocalizations { @override String get libraryFolderHint => 'Tippe um Ordner auszuwählen'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => 'Duplikat Indikator anzeigen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 09e140ef..386e864e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1876,6 +1876,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get libraryFolderHint => 'Tap to select folder'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 2efd104c..775a2b80 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1876,6 +1876,33 @@ class AppLocalizationsEs extends AppLocalizations { @override String get libraryFolderHint => 'Tap to select folder'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index cdfd84f1..a4dfa7cf 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1927,6 +1927,33 @@ class AppLocalizationsFr extends AppLocalizations { @override String get libraryFolderHint => 'Appuyez pour sélectionner un dossier'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => 'Afficher l\'indicateur de doublons'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index e620271e..b251b866 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1885,6 +1885,33 @@ class AppLocalizationsId extends AppLocalizations { @override String get libraryFolderHint => 'Tap to select folder'; + @override + String get libraryAddFolder => 'Tambah folder library'; + + @override + String get libraryAddFolderSubtitle => + 'Penyimpanan internal, kartu SD, SSD, atau drive eksternal lain'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Sambungkan kembali storage untuk memulihkan lagu'; + + @override + String get librarySourceDisabled => 'Dinonaktifkan'; + + @override + String get libraryExternalStorage => 'Storage eksternal'; + + @override + String get libraryRemoveFolder => 'Hapus folder library'; + + @override + String get libraryRemoveFolderMessage => + 'Hapus folder ini dan indeks lagunya dari SpotiFLAC Mobile? File audio di storage tidak akan dihapus.'; + @override String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index bef89963..1dbf6c84 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1865,6 +1865,33 @@ class AppLocalizationsJa extends AppLocalizations { @override String get libraryFolderHint => 'タップでフォルダを選択'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a90eebcb..cd304012 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1836,6 +1836,33 @@ class AppLocalizationsKo extends AppLocalizations { @override String get libraryFolderHint => '탭하여 폴더를 선택하세요'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => '중복 표시기 표시'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 516c0e96..62efa9b7 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1876,6 +1876,33 @@ class AppLocalizationsPt extends AppLocalizations { @override String get libraryFolderHint => 'Tap to select folder'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => 'Show Duplicate Indicator'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 24404f1d..4be46537 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1896,6 +1896,33 @@ class AppLocalizationsRu extends AppLocalizations { @override String get libraryFolderHint => 'Нажмите, чтобы выбрать папку'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => 'Показать индикатор дубликатов'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 2e6b202a..cf8297d5 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1899,6 +1899,33 @@ class AppLocalizationsTr extends AppLocalizations { @override String get libraryFolderHint => 'Klasör seçmek için dokunun'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => 'Kopya Belirtecini Göster'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index da224306..42d7e997 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -1905,6 +1905,33 @@ class AppLocalizationsUk extends AppLocalizations { @override String get libraryFolderHint => 'Натисніть, щоб вибрати папку'; + @override + String get libraryAddFolder => 'Add library folder'; + + @override + String get libraryAddFolderSubtitle => + 'Internal storage, SD card, SSD, or another external drive'; + + @override + String get librarySourceOnline => 'Online'; + + @override + String get librarySourceOffline => + 'Offline. Reconnect the storage to restore these tracks'; + + @override + String get librarySourceDisabled => 'Disabled'; + + @override + String get libraryExternalStorage => 'External storage'; + + @override + String get libraryRemoveFolder => 'Remove library folder'; + + @override + String get libraryRemoveFolderMessage => + 'Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.'; + @override String get libraryShowDuplicateIndicator => 'Показати індикатор дублікатів'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 1f222d68..953e750b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2497,6 +2497,38 @@ "@libraryFolderHint": { "description": "Placeholder when no folder selected" }, + "libraryAddFolder": "Add library folder", + "@libraryAddFolder": { + "description": "Action to add another local library source" + }, + "libraryAddFolderSubtitle": "Internal storage, SD card, SSD, or another external drive", + "@libraryAddFolderSubtitle": { + "description": "Supported storage locations for local library sources" + }, + "librarySourceOnline": "Online", + "@librarySourceOnline": { + "description": "Library source is connected and accessible" + }, + "librarySourceOffline": "Offline. Reconnect the storage to restore these tracks", + "@librarySourceOffline": { + "description": "Library source is temporarily disconnected" + }, + "librarySourceDisabled": "Disabled", + "@librarySourceDisabled": { + "description": "Library source has been disabled by the user" + }, + "libraryExternalStorage": "External storage", + "@libraryExternalStorage": { + "description": "Label for a removable or external library source" + }, + "libraryRemoveFolder": "Remove library folder", + "@libraryRemoveFolder": { + "description": "Action to remove one indexed library source" + }, + "libraryRemoveFolderMessage": "Remove this folder and its indexed tracks from SpotiFLAC Mobile? Audio files on the storage will not be deleted.", + "@libraryRemoveFolderMessage": { + "description": "Confirmation shown before removing a library source" + }, "libraryShowDuplicateIndicator": "Show Duplicate Indicator", "@libraryShowDuplicateIndicator": { "description": "Toggle for duplicate indicator in search" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index e32db072..63a42b20 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -3309,6 +3309,14 @@ "description": "Tutorial extensions tip 2" }, "libraryFolder": "Library Folder", + "libraryAddFolder": "Tambah folder library", + "libraryAddFolderSubtitle": "Penyimpanan internal, kartu SD, SSD, atau drive eksternal lain", + "librarySourceOnline": "Online", + "librarySourceOffline": "Offline. Sambungkan kembali storage untuk memulihkan lagu", + "librarySourceDisabled": "Dinonaktifkan", + "libraryExternalStorage": "Storage eksternal", + "libraryRemoveFolder": "Hapus folder library", + "libraryRemoveFolderMessage": "Hapus folder ini dan indeks lagunya dari SpotiFLAC Mobile? File audio di storage tidak akan dihapus.", "@metadataProviderPriorityTitle": { "description": "Metadata priority page title" }, diff --git a/lib/main.dart b/lib/main.dart index aae344e2..0590313e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -261,6 +261,13 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _maybeAutoScanLocalLibrary(); + if (ref.exists(localLibraryProvider)) { + unawaited( + ref + .read(localLibraryProvider.notifier) + .refreshSourceAvailability(scanReconnected: true), + ); + } if (ref.exists(downloadQueueProvider)) { ref .read(downloadQueueProvider.notifier) @@ -343,7 +350,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> final settings = ref.read(settingsProvider); if (!settings.localLibraryEnabled) return; - if (settings.localLibraryPath.isEmpty) return; if (settings.localLibraryAutoScan == 'off') return; final libraryState = ref.read(localLibraryProvider); @@ -371,13 +377,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> } } - final iosBookmark = settings.localLibraryBookmark; - ref - .read(localLibraryProvider.notifier) - .startScan( - settings.localLibraryPath, - iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, - ); + await ref.read(localLibraryProvider.notifier).scanAllSources(); } Future _initializeAppServices() async { diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 2814327c..be841377 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/notification_service.dart'; @@ -30,6 +31,8 @@ class LocalLibraryState { final int totalCount; final int loadedIndexVersion; final DateTime? lastScannedAt; + final List sources; + final String? scanningSourceId; final int excludedDownloadedCount; final Set _trackKeySet; final Set _isrcSet; @@ -46,6 +49,8 @@ class LocalLibraryState { this.totalCount = 0, this.loadedIndexVersion = 0, this.lastScannedAt, + this.sources = const [], + this.scanningSourceId, this.excludedDownloadedCount = 0, Set? trackKeySet, Set? isrcSet, @@ -81,6 +86,10 @@ class LocalLibraryState { int? totalCount, int? loadedIndexVersion, DateTime? lastScannedAt, + bool clearLastScannedAt = false, + List? sources, + String? scanningSourceId, + bool clearScanningSourceId = false, int? excludedDownloadedCount, Set? trackKeySet, Set? isrcSet, @@ -96,7 +105,13 @@ class LocalLibraryState { scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled, totalCount: totalCount ?? this.totalCount, loadedIndexVersion: loadedIndexVersion ?? this.loadedIndexVersion, - lastScannedAt: lastScannedAt ?? this.lastScannedAt, + lastScannedAt: clearLastScannedAt + ? null + : lastScannedAt ?? this.lastScannedAt, + sources: sources ?? this.sources, + scanningSourceId: clearScanningSourceId + ? null + : scanningSourceId ?? this.scanningSourceId, excludedDownloadedCount: excludedDownloadedCount ?? this.excludedDownloadedCount, trackKeySet: trackKeySet ?? _trackKeySet, @@ -132,6 +147,8 @@ class LocalLibraryNotifier extends Notifier { Future? _loadFuture; bool _scanCancelRequested = false; bool _scanInProgress = false; + StreamSubscription? _storageEventsSubscription; + Timer? _storageEventDebounce; static const _scanNotificationHeartbeat = Duration(seconds: 4); int _lastScanNotificationPercent = -1; int _lastScanNotificationTotalFiles = -1; @@ -139,7 +156,25 @@ class LocalLibraryNotifier extends Notifier { @override LocalLibraryState build() { - ref.onDispose(_progressPoller.stop); + ref.onDispose(() { + _progressPoller.stop(); + _storageEventsSubscription?.cancel(); + _storageEventDebounce?.cancel(); + }); + + if (Platform.isAndroid) { + _storageEventsSubscription = PlatformBridge.libraryStorageEvents().listen( + (_) { + _storageEventDebounce?.cancel(); + _storageEventDebounce = Timer( + const Duration(milliseconds: 900), + () => refreshSourceAvailability(scanReconnected: true), + ); + }, + onError: (Object e) => + _log.w('Library storage event stream failed: $e'), + ); + } Future.microtask(_ensureLoadedFromDatabase); return LocalLibraryState(); @@ -160,11 +195,15 @@ class LocalLibraryNotifier extends Notifier { _isLoaded = true; try { + await _migrateLegacySource(); + final reconnectedSources = await _refreshSourceAvailabilityInDatabase(); final countFuture = _db.getCount(); final indexFuture = _db.getLookupIndex(); + final sourcesFuture = _db.getSources(); final prefsFuture = _prefs; final count = await countFuture; final lookupIndex = await indexFuture; + final sources = await sourcesFuture; DateTime? lastScannedAt; var excludedDownloadedCount = 0; @@ -182,6 +221,7 @@ class LocalLibraryNotifier extends Notifier { loadedIndexVersion: state.loadedIndexVersion + 1, lastScannedAt: lastScannedAt, excludedDownloadedCount: excludedDownloadedCount, + sources: sources, trackKeySet: lookupIndex.matchKeys, isrcSet: lookupIndex.isrcs, ); @@ -190,6 +230,13 @@ class LocalLibraryNotifier extends Notifier { '$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount', ); _hasLoadedFromDatabase = true; + if (reconnectedSources.isNotEmpty) { + unawaited( + Future.microtask( + () => _scanSourcesSequentially(reconnectedSources), + ), + ); + } } catch (e, stack) { _isLoaded = false; _log.e('Failed to load library from database: $e', e, stack); @@ -205,19 +252,171 @@ class LocalLibraryNotifier extends Notifier { await _ensureLoadedFromDatabase(); } + Future addSource({ + required String path, + required String displayName, + String? bookmark, + String? volumeId, + bool isRemovable = false, + }) async { + final trimmedPath = path.trim(); + if (trimmedPath.isEmpty) { + throw const FormatException('Library folder path is empty'); + } + final existing = await _db.getSourceByPath(trimmedPath); + if (existing != null) { + await _db.updateSourceState( + existing.id, + enabled: true, + available: true, + lastSeenAt: DateTime.now(), + ); + await _refreshSummaryFromStorage(); + return existing.copyWith( + enabled: true, + available: true, + lastSeenAt: DateTime.now(), + ); + } + + final source = LocalLibrarySource( + id: _newSourceId(trimmedPath), + path: trimmedPath, + displayName: displayName.trim().isEmpty + ? _displayNameForPath(trimmedPath) + : displayName.trim(), + bookmark: bookmark?.trim().isEmpty == true ? null : bookmark, + volumeId: volumeId?.trim().isEmpty == true ? null : volumeId, + isRemovable: isRemovable || _looksLikeRemovablePath(trimmedPath), + enabled: true, + available: true, + lastSeenAt: DateTime.now(), + ); + await _db.upsertSource(source); + await _refreshSummaryFromStorage(); + return source; + } + + String _newSourceId(String path) { + const offset = 0xcbf29ce484222325; + const prime = 0x100000001b3; + const mask = 0xffffffffffffffff; + var hash = offset; + for (final unit in path.codeUnits) { + hash = ((hash ^ unit) * prime) & mask; + } + return 'source_${hash.toRadixString(16).padLeft(16, '0')}'; + } + + Future setSourceEnabled(String sourceId, bool enabled) async { + final source = state.sources + .where((entry) => entry.id == sourceId) + .firstOrNull; + await _db.updateSourceState(sourceId, enabled: enabled); + await _refreshSummaryFromStorage(); + if (enabled && + source?.available == true && + source?.isIndexed == true && + !_scanInProgress) { + unawaited(startSourceScan(sourceId)); + } + } + + Future removeSource(String sourceId) async { + await _db.removeSource(sourceId); + await _refreshSummaryFromStorage(); + } + + Future refreshSourceAvailability({bool scanReconnected = false}) async { + await _ensureLoadedFromDatabase(); + final before = await _db.getSources(); + final reconnected = []; + for (final source in before) { + final available = await _isPathAvailable( + source.path, + bookmark: source.bookmark, + ); + if (available != source.available) { + await _db.updateSourceState( + source.id, + available: available, + lastSeenAt: available ? DateTime.now() : null, + ); + if (available && source.enabled && source.isIndexed) { + reconnected.add(source.id); + } + } + } + await _refreshSummaryFromStorage(); + + // Existing rows become visible as soon as availability flips. The + // incremental pass then verifies changes made while storage was detached + // without re-reading metadata for unchanged files. + if (scanReconnected && reconnected.isNotEmpty && !_scanInProgress) { + unawaited(_scanSourcesSequentially(reconnected)); + } + } + + Future _scanSourcesSequentially( + Iterable sourceIds, { + bool forceFullScan = false, + }) async { + for (final sourceId in sourceIds) { + await startSourceScan(sourceId, forceFullScan: forceFullScan); + if (_scanCancelRequested) break; + } + } + + Future scanAllSources({bool forceFullScan = false}) async { + await _ensureLoadedFromDatabase(); + final sourceIds = state.sources + .where((source) => source.enabled && source.available) + .map((source) => source.id) + .toList(growable: false); + await _scanSourcesSequentially(sourceIds, forceFullScan: forceFullScan); + } + + Future startSourceScan( + String sourceId, { + bool forceFullScan = false, + }) async { + await _ensureLoadedFromDatabase(); + final source = (await _db.getSources()) + .where((entry) => entry.id == sourceId) + .firstOrNull; + if (source == null || !source.enabled || !source.available) return; + await startScan( + source.path, + forceFullScan: forceFullScan, + iosBookmark: source.bookmark, + sourceId: source.id, + ); + } + Future _refreshSummaryFromStorage({ DateTime? lastScannedAt, int? excludedDownloadedCount, }) async { final countFuture = _db.getCount(); final indexFuture = _db.getLookupIndex(); + final sourcesFuture = _db.getSources(); final count = await countFuture; final index = await indexFuture; + final sources = await sourcesFuture; + final latestSourceScan = sources + .map((source) => source.lastScannedAt) + .whereType() + .fold( + null, + (latest, value) => + latest == null || value.isAfter(latest) ? value : latest, + ); state = state.copyWith( totalCount: count, loadedIndexVersion: state.loadedIndexVersion + 1, - lastScannedAt: lastScannedAt, + lastScannedAt: lastScannedAt ?? latestSourceScan, excludedDownloadedCount: excludedDownloadedCount, + sources: sources, trackKeySet: index.matchKeys, isrcSet: index.isrcs, ); @@ -225,6 +424,102 @@ class LocalLibraryNotifier extends Notifier { _isLoaded = true; } + Future _migrateLegacySource() async { + final settings = ref.read(settingsProvider); + final path = settings.localLibraryPath.trim(); + if (path.isEmpty) return; + if (await _db.getSourceByPath(path) != null) return; + DateTime? legacyLastScannedAt; + try { + legacyLastScannedAt = readLocalLibraryLastScannedAt(await _prefs); + } catch (_) {} + await _db.migrateLegacySource( + path: path, + displayName: _displayNameForPath(path), + bookmark: settings.localLibraryBookmark.trim().isEmpty + ? null + : settings.localLibraryBookmark, + isRemovable: _looksLikeRemovablePath(path), + available: await _isPathAvailable( + path, + bookmark: settings.localLibraryBookmark, + ), + lastScannedAt: legacyLastScannedAt, + ); + } + + String _displayNameForPath(String path) { + if (path.startsWith('content://')) { + try { + final decoded = Uri.decodeComponent(Uri.parse(path).pathSegments.last); + final relative = decoded.contains(':') + ? decoded.substring(decoded.indexOf(':') + 1) + : decoded; + if (relative.trim().isNotEmpty) { + return relative.split('/').last; + } + } catch (_) {} + return 'Music'; + } + final normalized = path + .replaceAll('\\', '/') + .replaceAll(RegExp(r'/+$'), ''); + final name = normalized.split('/').last; + return name.isEmpty ? path : name; + } + + bool _looksLikeRemovablePath(String path) { + if (path.startsWith('content://')) { + try { + final decoded = Uri.decodeComponent(Uri.parse(path).pathSegments.last); + return !decoded.startsWith('primary:'); + } catch (_) { + return false; + } + } + final normalized = path.replaceAll('\\', '/').toLowerCase(); + return normalized.startsWith('/storage/') && + !normalized.startsWith('/storage/emulated/'); + } + + Future _isPathAvailable(String path, {String? bookmark}) async { + if (Platform.isAndroid && path.startsWith('content://')) { + return PlatformBridge.isSafTreeAccessible(path); + } + if (Platform.isIOS && bookmark != null && bookmark.trim().isNotEmpty) { + final access = await PlatformBridge.startAccessingIosBookmark(bookmark); + if (access == null) return false; + try { + return await Directory(access.path).exists(); + } finally { + await PlatformBridge.stopAccessingIosBookmark(access); + } + } + return await Directory(path).exists(); + } + + Future> _refreshSourceAvailabilityInDatabase() async { + final sources = await _db.getSources(); + final reconnected = []; + for (final source in sources) { + final available = await _isPathAvailable( + source.path, + bookmark: source.bookmark, + ); + if (available != source.available) { + await _db.updateSourceState( + source.id, + available: available, + lastSeenAt: available ? DateTime.now() : null, + ); + if (available && source.enabled && source.isIndexed) { + reconnected.add(source.id); + } + } + } + return reconnected; + } + bool _isDownloadedPath(String? filePath, Set downloadedPathKeys) { if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) { return false; @@ -239,6 +534,7 @@ class LocalLibraryNotifier extends Notifier { } Future<({int inserted, int skipped})?> _replaceFromFullScanStream({ + required String sourceId, required String folderPath, required bool isSaf, required Set downloadedPathKeys, @@ -283,7 +579,7 @@ class LocalLibraryNotifier extends Notifier { } } - final inserted = await _db.replaceAllStream(filteredRows()); + final inserted = await _db.replaceSourceStream(sourceId, filteredRows()); _log.i( 'Stream-ingested $inserted/${scanFile.expectedCount} scan rows ' '($skipped downloads excluded)', @@ -298,12 +594,34 @@ class LocalLibraryNotifier extends Notifier { String folderPath, { bool forceFullScan = false, String? iosBookmark, + String? sourceId, }) async { if (_scanInProgress || state.isScanning) { _log.w('Scan already in progress'); return; } + var activeSourceId = sourceId; + if (activeSourceId == null) { + final existingSource = await _db.getSourceByPath(folderPath); + activeSourceId = existingSource?.id; + if (activeSourceId == null) { + final source = await addSource( + path: folderPath, + displayName: _displayNameForPath(folderPath), + bookmark: iosBookmark, + isRemovable: _looksLikeRemovablePath(folderPath), + ); + activeSourceId = source.id; + } + } + + if (!await _isPathAvailable(folderPath, bookmark: iosBookmark)) { + await _db.updateSourceState(activeSourceId, available: false); + await _refreshSummaryFromStorage(); + return; + } + _scanInProgress = true; _scanCancelRequested = false; _log.i( @@ -318,6 +636,7 @@ class LocalLibraryNotifier extends Notifier { scannedFiles: 0, scanErrorCount: 0, scanWasCancelled: false, + scanningSourceId: activeSourceId, ); _resetScanNotificationTracking(); if (_shouldShowScanProgressNotification( @@ -384,9 +703,11 @@ class LocalLibraryNotifier extends Notifier { '(${downloadedPathKeys.length} path keys)', ); - final useStreamingFullScan = forceFullScan || await _db.getCount() == 0; + final useStreamingFullScan = + forceFullScan || await _db.getSourceCount(activeSourceId) == 0; if (useStreamingFullScan) { final scanResult = await _replaceFromFullScanStream( + sourceId: activeSourceId, folderPath: effectiveFolderPath, isSaf: isSaf, downloadedPathKeys: downloadedPathKeys, @@ -414,6 +735,13 @@ class LocalLibraryNotifier extends Notifier { } final now = DateTime.now(); + await _db.updateSourceState( + activeSourceId, + available: true, + lastSeenAt: now, + lastScannedAt: now, + clearLastScanError: true, + ); try { final prefs = await SharedPreferences.getInstance(); await writeLocalLibraryLastScannedAt(prefs, now); @@ -447,7 +775,9 @@ class LocalLibraryNotifier extends Notifier { errorCount: state.scanErrorCount, ); } else { - final existingFiles = await _db.getFileModTimes(); + final existingFiles = await _db.getFileModTimes( + sourceId: activeSourceId, + ); _log.i( 'Incremental scan: ${existingFiles.length} existing files in database', ); @@ -465,7 +795,7 @@ class LocalLibraryNotifier extends Notifier { final useSnapshotBridge = Platform.isAndroid && existingFiles.isNotEmpty; final snapshotPath = useSnapshotBridge - ? await _db.writeFileModTimesSnapshot() + ? await _db.writeFileModTimesSnapshot(sourceId: activeSourceId) : null; Map result; @@ -566,7 +896,10 @@ class LocalLibraryNotifier extends Notifier { updatedItems.add(item); } if (updatedItems.isNotEmpty) { - await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList()); + await _db.upsertBatch( + updatedItems.map((e) => e.toJson()).toList(), + sourceId: activeSourceId, + ); _log.i('Upserted ${updatedItems.length} items'); } if (skippedDownloads > 0) { @@ -582,6 +915,13 @@ class LocalLibraryNotifier extends Notifier { } final now = DateTime.now(); + await _db.updateSourceState( + activeSourceId, + available: true, + lastSeenAt: now, + lastScannedAt: now, + clearLastScanError: true, + ); try { final prefs = await SharedPreferences.getInstance(); await writeLocalLibraryLastScannedAt(prefs, now); @@ -627,6 +967,8 @@ class LocalLibraryNotifier extends Notifier { return; } _log.e('Library scan failed: $e', e, stack); + await _db.updateSourceState(activeSourceId, lastScanError: e.toString()); + await _refreshSummaryFromStorage(); state = state.copyWith( isScanning: false, scanIsFinalizing: false, @@ -640,6 +982,7 @@ class LocalLibraryNotifier extends Notifier { } _stopProgressPolling(); _scanInProgress = false; + state = state.copyWith(clearScanningSourceId: true); } } @@ -821,28 +1164,29 @@ class LocalLibraryNotifier extends Notifier { } Future cleanupMissingFiles({String? iosBookmark}) async { - IosSecurityScopedAccess? securityAccess; - if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { - securityAccess = await PlatformBridge.startAccessingIosBookmark( - iosBookmark, - ); - if (securityAccess == null) { - throw const FileSystemException( - 'Cannot clean the library without folder access', - ); - } - } - try { - final removed = await _db.cleanupMissingFiles(); - if (removed > 0) { - await _refreshSummaryFromStorage(); - } - return removed; - } finally { - if (securityAccess != null) { - await PlatformBridge.stopAccessingIosBookmark(securityAccess); + await refreshSourceAvailability(); + var removed = 0; + for (final source in state.sources) { + if (!source.enabled || !source.available) continue; + IosSecurityScopedAccess? securityAccess; + try { + if (Platform.isIOS && + source.bookmark != null && + source.bookmark!.isNotEmpty) { + securityAccess = await PlatformBridge.startAccessingIosBookmark( + source.bookmark!, + ); + if (securityAccess == null) continue; + } + removed += await _db.cleanupMissingFiles(sourceId: source.id); + } finally { + if (securityAccess != null) { + await PlatformBridge.stopAccessingIosBookmark(securityAccess); + } } } + if (removed > 0) await _refreshSummaryFromStorage(); + return removed; } Future clearLibrary() async { @@ -856,7 +1200,8 @@ class LocalLibraryNotifier extends Notifier { _log.w('Failed to clear lastScannedAt: $e'); } - state = LocalLibraryState(loadedIndexVersion: state.loadedIndexVersion + 1); + await _refreshSummaryFromStorage(); + state = state.copyWith(clearLastScannedAt: true); _log.i('Library cleared'); } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 63b3afa9..30fd52c6 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -747,17 +747,9 @@ class _LocalAlbumScreenState extends ConsumerState if (!mounted) return; if (!cancelled) BatchProgressDialog.dismiss(context); - final localLibraryPath = settings.localLibraryPath.trim(); - final iosBookmark = settings.localLibraryBookmark; try { - if (localLibraryPath.isNotEmpty && - !ref.read(localLibraryProvider).isScanning) { - await ref - .read(localLibraryProvider.notifier) - .startScan( - localLibraryPath, - iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, - ); + if (!ref.read(localLibraryProvider).isScanning) { + await ref.read(localLibraryProvider.notifier).scanAllSources(); } else { await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } diff --git a/lib/screens/queue_tab_batch_actions.dart b/lib/screens/queue_tab_batch_actions.dart index a1f3615a..cc113630 100644 --- a/lib/screens/queue_tab_batch_actions.dart +++ b/lib/screens/queue_tab_batch_actions.dart @@ -325,17 +325,9 @@ extension _QueueTabBatchActions on _QueueTabState { if (!mounted) return; if (!cancelled) BatchProgressDialog.dismiss(context); - final localLibraryPath = settings.localLibraryPath.trim(); - final iosBookmark = settings.localLibraryBookmark; try { - if (localLibraryPath.isNotEmpty && - !ref.read(localLibraryProvider).isScanning) { - await ref - .read(localLibraryProvider.notifier) - .startScan( - localLibraryPath, - iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, - ); + if (!ref.read(localLibraryProvider).isScanning) { + await ref.read(localLibraryProvider.notifier).scanAllSources(); } else { await ref.read(localLibraryProvider.notifier).reloadFromStorage(); } diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index bc8f6d15..d9505585 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -8,9 +8,11 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_sliver_header.dart'; @@ -115,7 +117,19 @@ class _LibrarySettingsPageState extends ConsumerState { if (result != null) { final treeUri = result['tree_uri'] as String? ?? ''; if (treeUri.isNotEmpty) { - ref.read(settingsProvider.notifier).setLocalLibraryPath(treeUri); + final source = await ref + .read(localLibraryProvider.notifier) + .addSource( + path: treeUri, + displayName: + result['display_name'] as String? ?? + _getDisplayPath(treeUri), + volumeId: result['volume_id'] as String?, + isRemovable: result['is_removable'] == true, + ); + await ref + .read(localLibraryProvider.notifier) + .startSourceScan(source.id); } } } else { @@ -143,49 +157,42 @@ class _LibrarySettingsPageState extends ConsumerState { return; } if (picked != null) { - ref - .read(settingsProvider.notifier) - .setLocalLibraryPathAndBookmark(picked.path, picked.bookmark); + final source = await ref + .read(localLibraryProvider.notifier) + .addSource( + path: picked.path, + displayName: picked.path, + bookmark: picked.bookmark, + ); + await ref + .read(localLibraryProvider.notifier) + .startSourceScan(source.id); } return; } final result = await FilePicker.getDirectoryPath(); if (result != null) { - ref.read(settingsProvider.notifier).setLocalLibraryPath(result); + final source = await ref + .read(localLibraryProvider.notifier) + .addSource(path: result, displayName: result); + await ref + .read(localLibraryProvider.notifier) + .startSourceScan(source.id); } } } Future _startScan({bool forceFullScan = false}) async { - final settings = ref.read(settingsProvider); - final libraryPath = settings.localLibraryPath; - final iosBookmark = settings.localLibraryBookmark; - - if (libraryPath.isEmpty) { + final sources = ref.read(localLibraryProvider).sources; + if (sources.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.libraryScanSelectFolderFirst)), ); return; } - - if (Platform.isIOS && iosBookmark.isNotEmpty) { - } else if (!libraryPath.startsWith('content://') && - !await Directory(libraryPath).exists()) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.libraryFolderNotExist)), - ); - } - return; - } - await ref .read(localLibraryProvider.notifier) - .startScan( - libraryPath, - forceFullScan: forceFullScan, - iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, - ); + .scanAllSources(forceFullScan: forceFullScan); } Future _cancelScan() async { @@ -225,12 +232,9 @@ class _LibrarySettingsPageState extends ConsumerState { } Future _cleanupMissingFiles() async { - final iosBookmark = ref.read(settingsProvider).localLibraryBookmark; final removed = await ref .read(localLibraryProvider.notifier) - .cleanupMissingFiles( - iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, - ); + .cleanupMissingFiles(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -240,6 +244,37 @@ class _LibrarySettingsPageState extends ConsumerState { } } + Future _removeSource(LocalLibrarySource source) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.libraryRemoveFolder), + content: Text(context.l10n.libraryRemoveFolderMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + ), + child: Text(context.l10n.dialogRemove), + ), + ], + ), + ); + if (confirmed != true) return; + await ref.read(localLibraryProvider.notifier).removeSource(source.id); + final settings = ref.read(settingsProvider); + if (settings.localLibraryPath == source.path) { + ref + .read(settingsProvider.notifier) + .setLocalLibraryPathAndBookmark('', ''); + } + } + String _getAutoScanLabel(BuildContext context, String mode) { switch (mode) { case 'on_open': @@ -473,6 +508,12 @@ class _LibrarySettingsPageState extends ConsumerState { @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); + final librarySources = ref.watch( + localLibraryProvider.select((state) => state.sources), + ); + final scanningSourceId = ref.watch( + localLibraryProvider.select((state) => state.scanningSourceId), + ); final colorScheme = Theme.of(context).colorScheme; return Scaffold( @@ -553,14 +594,30 @@ class _LibrarySettingsPageState extends ConsumerState { .read(settingsProvider.notifier) .setLocalLibraryEnabled(value), ), + for (final source in librarySources) + _LibrarySourceSettingsItem( + source: source, + isScanning: scanningSourceId == source.id, + enabled: settings.localLibraryEnabled, + onEnabledChanged: (value) => ref + .read(localLibraryProvider.notifier) + .setSourceEnabled(source.id, value), + onScan: () => ref + .read(localLibraryProvider.notifier) + .startSourceScan(source.id), + onFullScan: () => ref + .read(localLibraryProvider.notifier) + .startSourceScan(source.id, forceFullScan: true), + onRemove: () => _removeSource(source), + ), Opacity( opacity: settings.localLibraryEnabled ? 1.0 : 0.5, child: SettingsItem( - icon: Icons.folder_outlined, - title: context.l10n.libraryFolder, - subtitle: settings.localLibraryPath.isEmpty + icon: Icons.create_new_folder_outlined, + title: context.l10n.libraryAddFolder, + subtitle: librarySources.isEmpty ? context.l10n.libraryFolderHint - : _getDisplayPath(settings.localLibraryPath), + : context.l10n.libraryAddFolderSubtitle, onTap: settings.localLibraryEnabled ? _pickLibraryFolder : null, @@ -681,29 +738,25 @@ class _LibrarySettingsPageState extends ConsumerState { ) else ...[ Opacity( - opacity: settings.localLibraryPath.isNotEmpty - ? 1.0 - : 0.5, + opacity: librarySources.isNotEmpty ? 1.0 : 0.5, child: SettingsItem( icon: Icons.refresh, title: context.l10n.libraryScan, - subtitle: settings.localLibraryPath.isEmpty + subtitle: librarySources.isEmpty ? context.l10n.libraryScanSelectFolderFirst : context.l10n.libraryScanSubtitle, - onTap: settings.localLibraryPath.isNotEmpty + onTap: librarySources.isNotEmpty ? _startScan : null, ), ), Opacity( - opacity: settings.localLibraryPath.isNotEmpty - ? 1.0 - : 0.5, + opacity: librarySources.isNotEmpty ? 1.0 : 0.5, child: SettingsItem( icon: Icons.sync, title: context.l10n.libraryForceFullScan, subtitle: context.l10n.libraryForceFullScanSubtitle, - onTap: settings.localLibraryPath.isNotEmpty + onTap: librarySources.isNotEmpty ? () => _startScan(forceFullScan: true) : null, ), @@ -845,6 +898,152 @@ class _LibrarySettingsPageState extends ConsumerState { } } +class _LibrarySourceSettingsItem extends StatelessWidget { + final LocalLibrarySource source; + final bool isScanning; + final bool enabled; + final ValueChanged onEnabledChanged; + final VoidCallback onScan; + final VoidCallback onFullScan; + final VoidCallback onRemove; + + const _LibrarySourceSettingsItem({ + required this.source, + required this.isScanning, + required this.enabled, + required this.onEnabledChanged, + required this.onScan, + required this.onFullScan, + required this.onRemove, + }); + + String _title() { + final normalized = source.displayName + .replaceAll('\\', '/') + .replaceAll(RegExp(r'/+$'), ''); + final name = normalized.split('/').last.trim(); + return name.isEmpty ? source.displayName : name; + } + + String _lastScanned(BuildContext context) { + final value = source.lastScannedAt; + if (value == null) return context.l10n.libraryLastScannedNever; + final now = DateTime.now(); + final diff = now.difference(value); + if (diff.inMinutes < 1) return context.l10n.timeJustNow; + if (diff.inHours < 1) return context.l10n.timeMinutesAgo(diff.inMinutes); + if (diff.inDays < 1) return context.l10n.timeHoursAgo(diff.inHours); + if (diff.inDays < 7) return context.l10n.dateDaysAgo(diff.inDays); + return '${value.day}/${value.month}/${value.year}'; + } + + Future _showActions(BuildContext context) async { + final canScan = + enabled && source.enabled && source.available && !isScanning; + final action = await showAppBottomSheet( + context: context, + useRootNavigator: true, + title: _title(), + builder: (context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + enabled: canScan, + leading: const Icon(Icons.refresh_rounded), + title: Text(context.l10n.libraryScan), + subtitle: Text(context.l10n.libraryScanSubtitle), + onTap: canScan ? () => Navigator.pop(context, 'scan') : null, + ), + ListTile( + enabled: canScan, + leading: const Icon(Icons.sync_rounded), + title: Text(context.l10n.libraryForceFullScan), + subtitle: Text(context.l10n.libraryForceFullScanSubtitle), + onTap: canScan ? () => Navigator.pop(context, 'full_scan') : null, + ), + ListTile( + leading: Icon( + Icons.delete_outline, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + context.l10n.libraryRemoveFolder, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + onTap: () => Navigator.pop(context, 'remove'), + ), + const SizedBox(height: 8), + ], + ), + ); + switch (action) { + case 'scan': + onScan(); + case 'full_scan': + onFullScan(); + case 'remove': + onRemove(); + } + } + + @override + Widget build(BuildContext context) { + final title = _title(); + final status = !source.enabled + ? context.l10n.librarySourceDisabled + : !source.available + ? context.l10n.librarySourceOffline + : isScanning + ? context.l10n.libraryScanning + : source.lastScanError?.trim().isNotEmpty == true + ? context.l10n.notifLibraryScanFailed + : context.l10n.librarySourceOnline; + final pathLine = source.displayName == title ? null : source.displayName; + final details = [ + status, + context.l10n.libraryTracksUnit(source.trackCount), + context.l10n.libraryLastScanned(_lastScanned(context)), + ].join(' · '); + + return Opacity( + opacity: enabled ? 1 : 0.5, + child: SettingsItem( + icon: source.isRemovable ? Icons.usb_rounded : Icons.folder_outlined, + title: title, + titleTrailing: source.isRemovable + ? Tooltip( + message: context.l10n.libraryExternalStorage, + child: const Icon(Icons.sd_storage_outlined, size: 16), + ) + : null, + subtitle: pathLine == null ? details : '$pathLine\n$details', + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isScanning) + const Padding( + padding: EdgeInsets.only(right: 8), + child: SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + Switch.adaptive( + value: source.enabled, + onChanged: enabled ? onEnabledChanged : null, + ), + IconButton( + tooltip: MaterialLocalizations.of(context).moreButtonTooltip, + onPressed: () => _showActions(context), + icon: const Icon(Icons.more_vert), + ), + ], + ), + ), + ); + } +} + class _LibraryHeroCard extends StatelessWidget { final int itemCount; final int excludedDownloadedCount; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 57603d9c..0aa4c162 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -16,7 +16,9 @@ final _log = AppLogger('LibraryDatabase'); class LibraryDatabase { static final LibraryDatabase instance = LibraryDatabase._init(); - static const int schemaVersion = 10; + static const int schemaVersion = 11; + static const String legacySourceId = LocalLibraryItem.legacySourceId; + static const String visibleLibraryView = 'library_visible'; static const int audioMetadataScanVersion = 1; static final sqlite.SingleFlightInitializer _database = sqlite.SingleFlightInitializer(); @@ -58,6 +60,7 @@ class LibraryDatabase { await db.execute(''' CREATE TABLE library ( id TEXT PRIMARY KEY, + source_id TEXT NOT NULL DEFAULT '$legacySourceId', track_name TEXT NOT NULL, artist_name TEXT NOT NULL, album_name TEXT NOT NULL, @@ -108,6 +111,7 @@ class LibraryDatabase { await _createNormalizedIndexes(db); await _createQueueIndexes(db); await _createPathKeyTable(db); + await _createLibrarySources(db); _log.i('Library database schema created with indexes'); } @@ -187,6 +191,52 @@ class LibraryDatabase { await _createQueueIndexes(db); _log.i('Added persisted queue sort/search columns'); } + if (oldVersion < 11) { + await sqlite.addColumnIfMissing( + db, + 'library', + 'source_id', + "TEXT NOT NULL DEFAULT '$legacySourceId'", + ); + await _createLibrarySources(db); + _log.i('Added multiple local library sources'); + } + } + + Future _createLibrarySources(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS library_sources ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + bookmark TEXT, + volume_id TEXT, + is_removable INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + available INTEGER NOT NULL DEFAULT 1, + last_scanned_at TEXT, + last_seen_at TEXT, + last_scan_error TEXT + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_library_source_id ON library(source_id)', + ); + await db.insert('library_sources', { + 'id': legacySourceId, + 'path': 'legacy://local-library', + 'display_name': 'Music', + 'enabled': 1, + 'available': 1, + }, conflictAlgorithm: ConflictAlgorithm.ignore); + await db.execute('DROP VIEW IF EXISTS $visibleLibraryView'); + await db.execute(''' + CREATE VIEW $visibleLibraryView AS + SELECT l.* + FROM library l + JOIN library_sources s ON s.id = l.source_id + WHERE s.enabled = 1 AND s.available = 1 + '''); } Future _createPathKeyTable(DatabaseExecutor db) => @@ -378,11 +428,15 @@ class LibraryDatabase { await batch.commit(noResult: true); } - Map _jsonToDbRow(Map json) { + Map _jsonToDbRow( + Map json, { + String? sourceId, + }) { final fileModTime = (json['fileModTime'] as num?)?.toInt(); final scannedAt = json['scannedAt'] as String?; final row = { 'id': json['id'], + 'source_id': sourceId ?? json['sourceId'] ?? legacySourceId, 'track_name': json['trackName'], 'artist_name': json['artistName'], 'album_name': json['albumName'], @@ -436,6 +490,7 @@ class LibraryDatabase { Map _dbRowToJson(Map row) { return { 'id': row['id'], + 'sourceId': row['source_id'] ?? legacySourceId, 'trackName': row['track_name'], 'artistName': row['artist_name'], 'albumName': row['album_name'], @@ -462,12 +517,12 @@ class LibraryDatabase { }; } - Future upsert(Map json) async { + Future upsert(Map json, {String? sourceId}) async { final db = await database; await db.transaction((txn) async { await txn.insert( 'library', - _jsonToDbRow(json), + _jsonToDbRow(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); final batch = txn.batch(); @@ -480,7 +535,10 @@ class LibraryDatabase { }); } - Future upsertBatch(List> items) async { + Future upsertBatch( + List> items, { + String? sourceId, + }) async { if (items.isEmpty) return; final db = await database; await db.transaction((txn) async { @@ -488,7 +546,7 @@ class LibraryDatabase { for (final json in items) { batch.insert( 'library', - _jsonToDbRow(json), + _jsonToDbRow(json, sourceId: sourceId), conflictAlgorithm: ConflictAlgorithm.replace, ); _putPathKeysInBatch( @@ -575,10 +633,237 @@ class LibraryDatabase { return inserted; } + /// Atomically replaces only one source. Other folders, including temporarily + /// disconnected removable storage, retain their index rows. + Future replaceSourceStream( + String sourceId, + Stream> items, { + int batchSize = 300, + }) async { + if (batchSize <= 0) { + throw ArgumentError.value(batchSize, 'batchSize', 'Must be positive'); + } + final db = await database; + var inserted = 0; + await db.transaction((txn) async { + await txn.rawDelete( + 'DELETE FROM library_path_keys WHERE item_id IN ' + '(SELECT id FROM library WHERE source_id = ?)', + [sourceId], + ); + await txn.delete( + 'library', + where: 'source_id = ?', + whereArgs: [sourceId], + ); + + var batch = txn.batch(); + var pending = 0; + Future flush() async { + if (pending == 0) return; + await batch.commit(noResult: true); + batch = txn.batch(); + pending = 0; + } + + await for (final json in items) { + final id = json['id'] as String?; + if (id == null || id.trim().isEmpty) { + throw const FormatException('Library scan row has no valid id'); + } + batch.insert( + 'library', + _jsonToDbRow(json, sourceId: sourceId), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + _putPathKeysInBatch(batch, id, json['filePath'] as String?); + inserted++; + pending++; + if (pending >= batchSize) await flush(); + } + await flush(); + }); + _log.i('Stream-replaced library source $sourceId with $inserted items'); + return inserted; + } + + Future> getSources() async { + final db = await database; + final rows = await db.rawQuery(''' + SELECT s.*, COUNT(l.id) AS track_count + FROM library_sources s + LEFT JOIN library l ON l.source_id = s.id + GROUP BY s.id + HAVING s.path != 'legacy://local-library' OR COUNT(l.id) > 0 + ORDER BY s.is_removable, LOWER(s.display_name), LOWER(s.path) + '''); + return rows.map(_sourceFromRow).toList(growable: false); + } + + Future getSourceByPath(String path) async { + final db = await database; + final rows = await db.rawQuery( + ''' + SELECT s.*, COUNT(l.id) AS track_count + FROM library_sources s + LEFT JOIN library l ON l.source_id = s.id + WHERE s.path = ? + GROUP BY s.id + LIMIT 1 + ''', + [path], + ); + return rows.isEmpty ? null : _sourceFromRow(rows.first); + } + + LocalLibrarySource _sourceFromRow(Map row) { + DateTime? parseDate(Object? value) => + value is String ? DateTime.tryParse(value) : null; + return LocalLibrarySource( + id: row['id'] as String, + path: row['path'] as String, + displayName: row['display_name'] as String, + bookmark: row['bookmark'] as String?, + volumeId: row['volume_id'] as String?, + isRemovable: (row['is_removable'] as num?)?.toInt() == 1, + enabled: (row['enabled'] as num?)?.toInt() != 0, + available: (row['available'] as num?)?.toInt() != 0, + trackCount: (row['track_count'] as num?)?.toInt() ?? 0, + lastScannedAt: parseDate(row['last_scanned_at']), + lastSeenAt: parseDate(row['last_seen_at']), + lastScanError: row['last_scan_error'] as String?, + ); + } + + Future upsertSource(LocalLibrarySource source) async { + final db = await database; + final values = { + 'path': source.path, + 'display_name': source.displayName, + 'bookmark': source.bookmark, + 'volume_id': source.volumeId, + 'is_removable': source.isRemovable ? 1 : 0, + 'enabled': source.enabled ? 1 : 0, + 'available': source.available ? 1 : 0, + 'last_scanned_at': source.lastScannedAt?.toIso8601String(), + 'last_seen_at': source.lastSeenAt?.toIso8601String(), + 'last_scan_error': source.lastScanError, + }; + final updated = await db.update( + 'library_sources', + values, + where: 'id = ?', + whereArgs: [source.id], + ); + if (updated == 0) { + await db.insert('library_sources', {'id': source.id, ...values}); + } + } + + Future updateSourceState( + String sourceId, { + bool? enabled, + bool? available, + DateTime? lastScannedAt, + DateTime? lastSeenAt, + String? lastScanError, + bool clearLastScanError = false, + }) async { + final values = {}; + if (enabled != null) values['enabled'] = enabled ? 1 : 0; + if (available != null) values['available'] = available ? 1 : 0; + if (lastScannedAt != null) { + values['last_scanned_at'] = lastScannedAt.toIso8601String(); + } + if (lastSeenAt != null) { + values['last_seen_at'] = lastSeenAt.toIso8601String(); + } + if (lastScanError != null || clearLastScanError) { + values['last_scan_error'] = clearLastScanError ? null : lastScanError; + } + if (values.isEmpty) return; + final db = await database; + await db.update( + 'library_sources', + values, + where: 'id = ?', + whereArgs: [sourceId], + ); + } + + Future migrateLegacySource({ + required String path, + required String displayName, + String? bookmark, + String? volumeId, + bool isRemovable = false, + bool available = true, + DateTime? lastScannedAt, + }) async { + if (path.trim().isEmpty) return; + final db = await database; + await db.update( + 'library_sources', + { + 'path': path, + 'display_name': displayName, + 'bookmark': bookmark, + 'volume_id': volumeId, + 'is_removable': isRemovable ? 1 : 0, + 'available': available ? 1 : 0, + 'last_scanned_at': lastScannedAt?.toIso8601String(), + 'last_seen_at': available ? DateTime.now().toIso8601String() : null, + }, + where: 'id = ?', + whereArgs: [legacySourceId], + ); + } + + Future removeSource(String sourceId) async { + final db = await database; + await db.transaction((txn) async { + await txn.rawDelete( + 'DELETE FROM library_path_keys WHERE item_id IN ' + '(SELECT id FROM library WHERE source_id = ?)', + [sourceId], + ); + await txn.delete( + 'library', + where: 'source_id = ?', + whereArgs: [sourceId], + ); + if (sourceId == legacySourceId) { + await txn.update( + 'library_sources', + { + 'path': 'legacy://local-library', + 'display_name': 'Music', + 'bookmark': null, + 'volume_id': null, + 'is_removable': 0, + 'enabled': 1, + 'available': 1, + 'last_scanned_at': null, + 'last_seen_at': null, + 'last_scan_error': null, + }, + where: 'id = ?', + whereArgs: [sourceId], + ); + } else { + await txn.delete( + 'library_sources', + where: 'id = ?', + whereArgs: [sourceId], + ); + } + }); + } + Future>> getAll({int? limit, int? offset}) async { final db = await database; final rows = await db.query( - 'library', + visibleLibraryView, orderBy: 'album_artist, album_name, disc_number, track_number', limit: limit, offset: offset, @@ -688,10 +973,10 @@ class LibraryDatabase { COUNT(*) AS all_count, COUNT(DISTINCT CASE WHEN grouped.track_count > 1 THEN l.album_key END) AS album_count, COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count - FROM library l + FROM $visibleLibraryView l JOIN ( SELECT album_key, COUNT(*) AS track_count - FROM library candidate + FROM $visibleLibraryView candidate WHERE NOT EXISTS ( SELECT 1 FROM library_path_keys lpk @@ -768,7 +1053,7 @@ class LibraryDatabase { COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count FROM ( SELECT l.album_key, COUNT(*) AS track_count - FROM library l + FROM $visibleLibraryView l WHERE NOT EXISTS ( SELECT 1 FROM library_path_keys lpk @@ -845,7 +1130,7 @@ class LibraryDatabase { ) async { final db = await database; final rows = await db.query( - 'library', + visibleLibraryView, where: "LOWER(album_name) = ? AND LOWER(COALESCE(NULLIF(album_artist, ''), artist_name)) = ?", whereArgs: [albumName.toLowerCase(), artistName.toLowerCase()], @@ -860,7 +1145,7 @@ class LibraryDatabase { ) async { final db = await database; final rows = await db.query( - 'library', + visibleLibraryView, where: 'album_key = ?', whereArgs: [albumKey], orderBy: @@ -872,7 +1157,7 @@ class LibraryDatabase { Future?> getById(String id) async { final db = await database; final rows = await db.query( - 'library', + visibleLibraryView, where: 'id = ?', whereArgs: [id], limit: 1, @@ -884,7 +1169,7 @@ class LibraryDatabase { Future?> getByIsrc(String isrc) async { final db = await database; final rows = await db.query( - 'library', + visibleLibraryView, where: 'isrc = ?', whereArgs: [isrc], limit: 1, @@ -899,7 +1184,7 @@ class LibraryDatabase { ) async { final db = await database; final rows = await db.query( - 'library', + visibleLibraryView, where: 'match_key = ?', whereArgs: [matchKeyFor(trackName, artistName)], ); @@ -912,7 +1197,7 @@ class LibraryDatabase { ) async { final db = await database; final rows = await db.query( - 'library', + visibleLibraryView, where: 'match_key = ?', whereArgs: [matchKeyFor(trackName, artistName)], orderBy: _orderByForSort(LocalLibrarySortMode.album), @@ -958,7 +1243,7 @@ class LibraryDatabase { ) { return sqlite.loadRowsByColumn( db, - table: 'library', + table: visibleLibraryView, column: column, rawValues: rawValues, destination: destination, @@ -991,7 +1276,9 @@ class LibraryDatabase { Future getLookupIndex() async { final db = await database; - final rows = await db.rawQuery('SELECT isrc, match_key FROM library'); + final rows = await db.rawQuery( + 'SELECT isrc, match_key FROM $visibleLibraryView', + ); final isrcs = {}; final matchKeys = {}; for (final row in rows) { @@ -1042,7 +1329,7 @@ class LibraryDatabase { SELECT l.id AS id, 'local' AS source, l.track_name, l.artist_name, l.album_name, l.file_path, UPPER(TRIM(l.isrc)) AS isrc_key, l.bit_depth, l.sample_rate, l.bitrate, l.format - FROM library l + FROM $visibleLibraryView l WHERE l.isrc IS NOT NULL AND TRIM(l.isrc) != '' AND NOT EXISTS ( SELECT 1 @@ -1224,9 +1511,14 @@ class LibraryDatabase { }); } - Future cleanupMissingFiles() async { + Future cleanupMissingFiles({String? sourceId}) async { final db = await database; - final rows = await db.query('library', columns: ['id', 'file_path']); + final rows = await db.query( + 'library', + columns: ['id', 'file_path'], + where: sourceId == null ? null : 'source_id = ?', + whereArgs: sourceId == null ? null : [sourceId], + ); final missingIds = []; const checkChunkSize = 16; @@ -1282,13 +1574,28 @@ class LibraryDatabase { await db.transaction((txn) async { await txn.delete('library_path_keys'); await txn.delete('library'); + await txn.update('library_sources', { + 'last_scanned_at': null, + 'last_scan_error': null, + }); }); _log.i('Cleared all library data'); } Future getCount() async { final db = await database; - final result = await db.rawQuery('SELECT COUNT(*) as count FROM library'); + final result = await db.rawQuery( + 'SELECT COUNT(*) as count FROM $visibleLibraryView', + ); + return Sqflite.firstIntValue(result) ?? 0; + } + + Future getSourceCount(String sourceId) async { + final db = await database; + final result = await db.rawQuery( + 'SELECT COUNT(*) as count FROM library WHERE source_id = ?', + [sourceId], + ); return Sqflite.firstIntValue(result) ?? 0; } @@ -1299,11 +1606,13 @@ class LibraryDatabase { _historyAttached = false; } - Future> getFileModTimes() async { + Future> getFileModTimes({String? sourceId}) async { final db = await database; final rows = await db.rawQuery( 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time, ' - 'audio_metadata_scan_version FROM library', + 'audio_metadata_scan_version FROM library ' + '${sourceId == null ? '' : 'WHERE source_id = ?'}', + sourceId == null ? const [] : [sourceId], ); final result = {}; for (final row in rows) { @@ -1321,11 +1630,13 @@ class LibraryDatabase { return result; } - Future writeFileModTimesSnapshot() async { + Future writeFileModTimesSnapshot({String? sourceId}) async { final db = await database; final rows = await db.rawQuery( 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time, ' - 'audio_metadata_scan_version FROM library', + 'audio_metadata_scan_version FROM library ' + '${sourceId == null ? '' : 'WHERE source_id = ?'}', + sourceId == null ? const [] : [sourceId], ); final tempDir = await getTemporaryDirectory(); final file = File( diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart index fcfd473e..f7f69501 100644 --- a/lib/services/library_database_models.dart +++ b/lib/services/library_database_models.dart @@ -12,7 +12,10 @@ int libraryIncrementalSnapshotModTime({ } class LocalLibraryItem { + static const legacySourceId = 'legacy'; + final String id; + final String sourceId; final String trackName; final String artistName; final String albumName; @@ -39,6 +42,7 @@ class LocalLibraryItem { const LocalLibraryItem({ required this.id, + this.sourceId = legacySourceId, required this.trackName, required this.artistName, required this.albumName, @@ -66,6 +70,7 @@ class LocalLibraryItem { Map toJson() => { 'id': id, + 'sourceId': sourceId, 'trackName': trackName, 'artistName': artistName, 'albumName': albumName, @@ -94,6 +99,7 @@ class LocalLibraryItem { factory LocalLibraryItem.fromJson(Map json) => LocalLibraryItem( id: json['id'] as String, + sourceId: json['sourceId'] as String? ?? legacySourceId, trackName: json['trackName'] as String, artistName: json['artistName'] as String, albumName: json['albumName'] as String, @@ -128,6 +134,7 @@ class LocalLibraryItem { }) { return LocalLibraryItem( id: id, + sourceId: sourceId, trackName: trackName, artistName: artistName, albumName: albumName, @@ -160,6 +167,65 @@ class LocalLibraryItem { '${LibraryDatabase.normalizeLookupText(albumName)}|${LibraryDatabase.normalizeLookupText(albumArtist ?? artistName)}'; } +class LocalLibrarySource { + final String id; + final String path; + final String displayName; + final String? bookmark; + final String? volumeId; + final bool isRemovable; + final bool enabled; + final bool available; + final int trackCount; + final DateTime? lastScannedAt; + final DateTime? lastSeenAt; + final String? lastScanError; + + const LocalLibrarySource({ + required this.id, + required this.path, + required this.displayName, + this.bookmark, + this.volumeId, + this.isRemovable = false, + this.enabled = true, + this.available = true, + this.trackCount = 0, + this.lastScannedAt, + this.lastSeenAt, + this.lastScanError, + }); + + bool get isIndexed => lastScannedAt != null || trackCount > 0; + + LocalLibrarySource copyWith({ + bool? enabled, + bool? available, + int? trackCount, + DateTime? lastScannedAt, + DateTime? lastSeenAt, + String? lastScanError, + bool clearLastScanError = false, + }) { + return LocalLibrarySource( + id: id, + path: path, + displayName: displayName, + bookmark: bookmark, + volumeId: volumeId, + isRemovable: isRemovable, + enabled: enabled ?? this.enabled, + available: available ?? this.available, + trackCount: trackCount ?? this.trackCount, + lastScannedAt: lastScannedAt ?? this.lastScannedAt, + lastSeenAt: lastSeenAt ?? this.lastSeenAt, + lastScanError: clearLastScanError + ? null + : lastScanError ?? this.lastScanError, + ); + } +} + enum LocalLibrarySortMode { album, title, artist, latest, quality } enum LocalLibraryFilterMode { all, albums, singles } diff --git a/lib/services/library_database_queue_sql.dart b/lib/services/library_database_queue_sql.dart index 8f2b1319..25d9424f 100644 --- a/lib/services/library_database_queue_sql.dart +++ b/lib/services/library_database_queue_sql.dart @@ -105,12 +105,12 @@ extension _LibraryDbQueueSql on LibraryDatabase { where.add(''' l.album_key IN ( SELECT album_key - FROM library + FROM ${LibraryDatabase.visibleLibraryView} candidate WHERE NOT EXISTS ( SELECT 1 FROM library_path_keys lpk JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key - WHERE lpk.item_id = library.id + WHERE lpk.item_id = candidate.id ) GROUP BY album_key HAVING COUNT(*) = 1 @@ -162,7 +162,7 @@ extension _LibraryDbQueueSql on LibraryDatabase { l.sort_genre, l.sort_release, l.sort_added - FROM library l + FROM ${LibraryDatabase.visibleLibraryView} l ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} '''; parts.add( @@ -306,18 +306,18 @@ extension _LibraryDbQueueSql on LibraryDatabase { MIN(l.album_artist_norm) AS sort_artist, COALESCE(MAX(l.release_date), '') AS sort_release, COALESCE(MAX(l.sort_genre), '') AS sort_genre - FROM library l + FROM ${LibraryDatabase.visibleLibraryView} l JOIN ( SELECT album_key, COUNT(*) AS track_count, MAX(COALESCE(sort_added, 0)) AS latest_added - FROM library + FROM ${LibraryDatabase.visibleLibraryView} candidate WHERE NOT EXISTS ( SELECT 1 FROM library_path_keys lpk JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key - WHERE lpk.item_id = library.id + WHERE lpk.item_id = candidate.id ) GROUP BY album_key HAVING COUNT(*) > 1 diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index de6a9e14..3e9110c2 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -179,6 +179,8 @@ class PlatformBridge { static final StreamController _extensionSessionGrantEvents = StreamController.broadcast(); + static final StreamController _libraryStorageEvents = + StreamController.broadcast(); static bool _backendEventHandlerInstalled = false; static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS; @@ -191,6 +193,11 @@ class PlatformBridge { return _extensionSessionGrantEvents.stream; } + static Stream libraryStorageEvents() { + _ensureBackendEventHandler(); + return _libraryStorageEvents.stream; + } + static void _ensureBackendEventHandler() { if (_backendEventHandlerInstalled) return; _backendEventHandlerInstalled = true; @@ -210,6 +217,9 @@ class PlatformBridge { } } return null; + case 'libraryStorageChanged': + _libraryStorageEvents.add(null); + return null; default: return null; } diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index a8d95bca..a2a8ed36 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -120,6 +120,7 @@ void main() { test('keeps a local item current after an audio metadata probe', () { final item = LocalLibraryItem( id: 'local-1', + sourceId: 'external-ssd', trackName: 'Song', artistName: 'Artist', albumName: 'Album', @@ -136,6 +137,26 @@ void main() { expect(updated.sampleRate, 96000); expect(updated.trackName, 'Song'); expect(updated.filePath, '/music/song.flac'); + expect(updated.sourceId, 'external-ssd'); + expect( + LocalLibraryItem.fromJson(updated.toJson()).sourceId, + 'external-ssd', + ); + }); + + test('recognizes a retained external source index', () { + final offlineSource = LocalLibrarySource( + id: 'external-ssd', + path: 'content://music/tree/SSD%3AMusic', + displayName: 'SSD/Music', + isRemovable: true, + available: false, + trackCount: 1200, + ); + + expect(offlineSource.isIndexed, isTrue); + expect(offlineSource.available, isFalse); + expect(offlineSource.copyWith(available: true).trackCount, 1200); }); });