mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 13:22:49 +02:00
feat(library): support multiple storage folders
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => '중복 표시기 표시';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Показать индикатор дубликатов';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Показати індикатор дублікатів';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+8
-8
@@ -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<void> _initializeAppServices() async {
|
||||
|
||||
@@ -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<LocalLibrarySource> sources;
|
||||
final String? scanningSourceId;
|
||||
final int excludedDownloadedCount;
|
||||
final Set<String> _trackKeySet;
|
||||
final Set<String> _isrcSet;
|
||||
@@ -46,6 +49,8 @@ class LocalLibraryState {
|
||||
this.totalCount = 0,
|
||||
this.loadedIndexVersion = 0,
|
||||
this.lastScannedAt,
|
||||
this.sources = const <LocalLibrarySource>[],
|
||||
this.scanningSourceId,
|
||||
this.excludedDownloadedCount = 0,
|
||||
Set<String>? trackKeySet,
|
||||
Set<String>? isrcSet,
|
||||
@@ -81,6 +86,10 @@ class LocalLibraryState {
|
||||
int? totalCount,
|
||||
int? loadedIndexVersion,
|
||||
DateTime? lastScannedAt,
|
||||
bool clearLastScannedAt = false,
|
||||
List<LocalLibrarySource>? sources,
|
||||
String? scanningSourceId,
|
||||
bool clearScanningSourceId = false,
|
||||
int? excludedDownloadedCount,
|
||||
Set<String>? trackKeySet,
|
||||
Set<String>? 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<LocalLibraryState> {
|
||||
Future<void>? _loadFuture;
|
||||
bool _scanCancelRequested = false;
|
||||
bool _scanInProgress = false;
|
||||
StreamSubscription<void>? _storageEventsSubscription;
|
||||
Timer? _storageEventDebounce;
|
||||
static const _scanNotificationHeartbeat = Duration(seconds: 4);
|
||||
int _lastScanNotificationPercent = -1;
|
||||
int _lastScanNotificationTotalFiles = -1;
|
||||
@@ -139,7 +156,25 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
@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<LocalLibraryState> {
|
||||
_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<LocalLibraryState> {
|
||||
loadedIndexVersion: state.loadedIndexVersion + 1,
|
||||
lastScannedAt: lastScannedAt,
|
||||
excludedDownloadedCount: excludedDownloadedCount,
|
||||
sources: sources,
|
||||
trackKeySet: lookupIndex.matchKeys,
|
||||
isrcSet: lookupIndex.isrcs,
|
||||
);
|
||||
@@ -190,6 +230,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
|
||||
);
|
||||
_hasLoadedFromDatabase = true;
|
||||
if (reconnectedSources.isNotEmpty) {
|
||||
unawaited(
|
||||
Future<void>.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<LocalLibraryState> {
|
||||
await _ensureLoadedFromDatabase();
|
||||
}
|
||||
|
||||
Future<LocalLibrarySource> 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<void> 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<void> removeSource(String sourceId) async {
|
||||
await _db.removeSource(sourceId);
|
||||
await _refreshSummaryFromStorage();
|
||||
}
|
||||
|
||||
Future<void> refreshSourceAvailability({bool scanReconnected = false}) async {
|
||||
await _ensureLoadedFromDatabase();
|
||||
final before = await _db.getSources();
|
||||
final reconnected = <String>[];
|
||||
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<void> _scanSourcesSequentially(
|
||||
Iterable<String> sourceIds, {
|
||||
bool forceFullScan = false,
|
||||
}) async {
|
||||
for (final sourceId in sourceIds) {
|
||||
await startSourceScan(sourceId, forceFullScan: forceFullScan);
|
||||
if (_scanCancelRequested) break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> _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<DateTime>()
|
||||
.fold<DateTime?>(
|
||||
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<LocalLibraryState> {
|
||||
_isLoaded = true;
|
||||
}
|
||||
|
||||
Future<void> _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<bool> _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<List<String>> _refreshSourceAvailabilityInDatabase() async {
|
||||
final sources = await _db.getSources();
|
||||
final reconnected = <String>[];
|
||||
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<String> downloadedPathKeys) {
|
||||
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
|
||||
return false;
|
||||
@@ -239,6 +534,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
Future<({int inserted, int skipped})?> _replaceFromFullScanStream({
|
||||
required String sourceId,
|
||||
required String folderPath,
|
||||
required bool isSaf,
|
||||
required Set<String> downloadedPathKeys,
|
||||
@@ -283,7 +579,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
}
|
||||
|
||||
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<LocalLibraryState> {
|
||||
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<LocalLibraryState> {
|
||||
scannedFiles: 0,
|
||||
scanErrorCount: 0,
|
||||
scanWasCancelled: false,
|
||||
scanningSourceId: activeSourceId,
|
||||
);
|
||||
_resetScanNotificationTracking();
|
||||
if (_shouldShowScanProgressNotification(
|
||||
@@ -384,9 +703,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'(${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<LocalLibraryState> {
|
||||
}
|
||||
|
||||
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<LocalLibraryState> {
|
||||
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<LocalLibraryState> {
|
||||
final useSnapshotBridge =
|
||||
Platform.isAndroid && existingFiles.isNotEmpty;
|
||||
final snapshotPath = useSnapshotBridge
|
||||
? await _db.writeFileModTimesSnapshot()
|
||||
? await _db.writeFileModTimesSnapshot(sourceId: activeSourceId)
|
||||
: null;
|
||||
|
||||
Map<String, dynamic> result;
|
||||
@@ -566,7 +896,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
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<LocalLibraryState> {
|
||||
}
|
||||
|
||||
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<LocalLibraryState> {
|
||||
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<LocalLibraryState> {
|
||||
}
|
||||
_stopProgressPolling();
|
||||
_scanInProgress = false;
|
||||
state = state.copyWith(clearScanningSourceId: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,28 +1164,29 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
|
||||
Future<int> 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<void> clearLibrary() async {
|
||||
@@ -856,7 +1200,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.w('Failed to clear lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = LocalLibraryState(loadedIndexVersion: state.loadedIndexVersion + 1);
|
||||
await _refreshSummaryFromStorage();
|
||||
state = state.copyWith(clearLastScannedAt: true);
|
||||
_log.i('Library cleared');
|
||||
}
|
||||
|
||||
|
||||
@@ -747,17 +747,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<LibrarySettingsPage> {
|
||||
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<LibrarySettingsPage> {
|
||||
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<void> _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<void> _cancelScan() async {
|
||||
@@ -225,12 +232,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
}
|
||||
|
||||
Future<void> _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<LibrarySettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeSource(LocalLibrarySource source) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<LibrarySettingsPage> {
|
||||
@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<LibrarySettingsPage> {
|
||||
.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<LibrarySettingsPage> {
|
||||
)
|
||||
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<LibrarySettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _LibrarySourceSettingsItem extends StatelessWidget {
|
||||
final LocalLibrarySource source;
|
||||
final bool isScanning;
|
||||
final bool enabled;
|
||||
final ValueChanged<bool> 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<void> _showActions(BuildContext context) async {
|
||||
final canScan =
|
||||
enabled && source.enabled && source.available && !isScanning;
|
||||
final action = await showAppBottomSheet<String>(
|
||||
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;
|
||||
|
||||
@@ -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> _database =
|
||||
sqlite.SingleFlightInitializer<Database>();
|
||||
@@ -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<void> _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<void> _createPathKeyTable(DatabaseExecutor db) =>
|
||||
@@ -378,11 +428,15 @@ class LibraryDatabase {
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
Map<String, dynamic> _jsonToDbRow(
|
||||
Map<String, dynamic> 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<String, dynamic> _dbRowToJson(Map<String, dynamic> 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<void> upsert(Map<String, dynamic> json) async {
|
||||
Future<void> upsert(Map<String, dynamic> 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<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
Future<void> upsertBatch(
|
||||
List<Map<String, dynamic>> 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<int> replaceSourceStream(
|
||||
String sourceId,
|
||||
Stream<Map<String, dynamic>> 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<void> 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<List<LocalLibrarySource>> 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<LocalLibrarySource?> 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<String, Object?> 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<void> upsertSource(LocalLibrarySource source) async {
|
||||
final db = await database;
|
||||
final values = <String, Object?>{
|
||||
'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<void> updateSourceState(
|
||||
String sourceId, {
|
||||
bool? enabled,
|
||||
bool? available,
|
||||
DateTime? lastScannedAt,
|
||||
DateTime? lastSeenAt,
|
||||
String? lastScanError,
|
||||
bool clearLastScanError = false,
|
||||
}) async {
|
||||
final values = <String, Object?>{};
|
||||
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<void> 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<void> 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>?> 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<Map<String, dynamic>?> 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<LocalLibraryLookupIndex> 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 = <String>{};
|
||||
final matchKeys = <String>{};
|
||||
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<int> cleanupMissingFiles() async {
|
||||
Future<int> 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 = <String>[];
|
||||
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<int> 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<int> 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<Map<String, int>> getFileModTimes() async {
|
||||
Future<Map<String, int>> 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 = <String, int>{};
|
||||
for (final row in rows) {
|
||||
@@ -1321,11 +1630,13 @@ class LibraryDatabase {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<String> writeFileModTimesSnapshot() async {
|
||||
Future<String> 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(
|
||||
|
||||
@@ -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<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'sourceId': sourceId,
|
||||
'trackName': trackName,
|
||||
'artistName': artistName,
|
||||
'albumName': albumName,
|
||||
@@ -94,6 +99,7 @@ class LocalLibraryItem {
|
||||
factory LocalLibraryItem.fromJson(Map<String, dynamic> 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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -179,6 +179,8 @@ class PlatformBridge {
|
||||
static final StreamController<ExtensionSessionGrantEvent>
|
||||
_extensionSessionGrantEvents =
|
||||
StreamController<ExtensionSessionGrantEvent>.broadcast();
|
||||
static final StreamController<void> _libraryStorageEvents =
|
||||
StreamController<void>.broadcast();
|
||||
static bool _backendEventHandlerInstalled = false;
|
||||
|
||||
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
|
||||
@@ -191,6 +193,11 @@ class PlatformBridge {
|
||||
return _extensionSessionGrantEvents.stream;
|
||||
}
|
||||
|
||||
static Stream<void> 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user