feat(library): support multiple storage folders

This commit is contained in:
zarzet
2026-08-22 11:15:42 +07:00
parent 89d39c4074
commit 60624436a6
25 changed files with 1530 additions and 134 deletions
+48
View File
@@ -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:
+27
View File
@@ -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';
+27
View File
@@ -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';
+27
View File
@@ -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';
+27
View File
@@ -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';
+27
View File
@@ -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';
+27
View File
@@ -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';
+27
View File
@@ -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 => '중복 표시기 표시';
+27
View File
@@ -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';
+27
View File
@@ -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 => 'Показать индикатор дубликатов';
+27
View File
@@ -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';
+27
View File
@@ -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 => 'Показати індикатор дублікатів';
+32
View File
@@ -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"
+8
View File
@@ -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
View File
@@ -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 {
+374 -29
View File
@@ -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');
}
+2 -10
View File
@@ -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();
}
+2 -10
View File
@@ -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();
}
+243 -44
View File
@@ -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;
+337 -26
View File
@@ -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(
+66
View 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 }
+6 -6
View File
@@ -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
+10
View File
@@ -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;
}