mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 13:18:02 +02:00
01a5b43613
Replace in-memory list merging in the queue tab with fully database- backed pagination using ATTACH DATABASE to join library and history tables in a single UNION ALL query. Queue tab: - Remove localLibraryAllItemsProvider and _queueHistoryStatsProvider - Add _queueLibraryPageProvider and _queueLibraryCountsProvider backed by LibraryDatabase.getQueueTrackPage/getQueueCounts/getQueueAlbumPage - Implement infinite scroll via _handleLibraryScrollNotification with _libraryPageLimit growing by 300 per batch - Album/single/total counts computed via SQL GROUP BY aggregates History database (v5 -> v8): - v6: add idx_history_track_artist index - v7: add history_path_keys table for cross-DB dedup, backfill from existing rows - v8: add spotify_id_norm, isrc_norm, match_key normalized columns with indexes, backfill from existing data - Add getAlbumTracks, findByTrackAndArtist, getGroupedCounts, existsTrack, findExistingTrack, existingTrackKeys batch lookup - deleteBySpotifyId now returns deleted count for accurate totalCount - All write paths maintain history_path_keys consistency Library database (v7 -> v8): - v8: add library_path_keys table for cross-DB dedup - Add getQueueTrackPage, getQueueCounts, getQueueAlbumPage with ATTACH DATABASE for cross-DB UNION ALL queries - Dedup local items against history via path_keys JOIN - All write/delete paths maintain library_path_keys consistency Download history provider: - Load only 100 recent items into state.items at startup - Store lookupItems as immutable List field instead of recomputing from maps on every access - Add async fallback to DB in _putInMemoryHistory for items outside the 100-item window - Add downloadHistoryPageProvider, downloadHistoryGroupedCountsProvider, downloadedAlbumTracksProvider, downloadHistoryBatchExistsProvider - Add catchError to adoptNativeHistoryItem async block - Fix removeBySpotifyId to query actual DB count instead of decrement Screen migrations: - album/artist/playlist/home screens use async DB lookups instead of sync in-memory state for track existence and playback resolution - downloaded_album_screen uses downloadedAlbumTracksProvider - library_tracks_folder_screen uses downloadHistoryBatchExistsProvider for skip-downloaded checks and cover resolution
794 lines
22 KiB
Dart
794 lines
22 KiB
Dart
part of 'queue_tab.dart';
|
|
|
|
enum LibraryItemSource { downloaded, local }
|
|
|
|
class UnifiedLibraryItem {
|
|
final String id;
|
|
final String trackName;
|
|
final String artistName;
|
|
final String albumName;
|
|
final String? coverUrl;
|
|
final String? localCoverPath;
|
|
final String filePath;
|
|
final String? quality;
|
|
final DateTime addedAt;
|
|
final LibraryItemSource source;
|
|
|
|
final DownloadHistoryItem? historyItem;
|
|
final LocalLibraryItem? localItem;
|
|
|
|
UnifiedLibraryItem({
|
|
required this.id,
|
|
required this.trackName,
|
|
required this.artistName,
|
|
required this.albumName,
|
|
this.coverUrl,
|
|
this.localCoverPath,
|
|
required this.filePath,
|
|
this.quality,
|
|
required this.addedAt,
|
|
required this.source,
|
|
this.historyItem,
|
|
this.localItem,
|
|
});
|
|
|
|
factory UnifiedLibraryItem.fromDownloadHistory(DownloadHistoryItem item) {
|
|
return UnifiedLibraryItem(
|
|
id: 'dl_${item.id}',
|
|
trackName: item.trackName,
|
|
artistName: item.artistName,
|
|
albumName: item.albumName,
|
|
coverUrl: item.coverUrl,
|
|
filePath: item.filePath,
|
|
quality: buildDisplayAudioQuality(
|
|
bitDepth: item.bitDepth,
|
|
sampleRate: item.sampleRate,
|
|
storedQuality: item.quality,
|
|
),
|
|
addedAt: item.downloadedAt,
|
|
source: LibraryItemSource.downloaded,
|
|
historyItem: item,
|
|
);
|
|
}
|
|
|
|
factory UnifiedLibraryItem.fromLocalLibrary(LocalLibraryItem item) {
|
|
String? quality;
|
|
if (item.bitrate != null && item.bitrate! > 0) {
|
|
quality = buildDisplayAudioQuality(
|
|
bitrateKbps: item.bitrate,
|
|
format: item.format,
|
|
);
|
|
} else if (item.bitDepth != null &&
|
|
item.bitDepth! > 0 &&
|
|
item.sampleRate != null) {
|
|
quality = buildDisplayAudioQuality(
|
|
bitDepth: item.bitDepth,
|
|
sampleRate: item.sampleRate,
|
|
);
|
|
}
|
|
return UnifiedLibraryItem(
|
|
id: 'local_${item.id}',
|
|
trackName: item.trackName,
|
|
artistName: item.artistName,
|
|
albumName: item.albumName,
|
|
coverUrl: null,
|
|
localCoverPath: item.coverPath,
|
|
filePath: item.filePath,
|
|
quality: quality,
|
|
addedAt: item.fileModTime != null
|
|
? DateTime.fromMillisecondsSinceEpoch(item.fileModTime!)
|
|
: item.scannedAt,
|
|
source: LibraryItemSource.local,
|
|
localItem: item,
|
|
);
|
|
}
|
|
|
|
bool get hasCover =>
|
|
coverUrl != null ||
|
|
(localCoverPath != null && localCoverPath!.isNotEmpty);
|
|
|
|
String? get albumArtist => historyItem?.albumArtist ?? localItem?.albumArtist;
|
|
|
|
String? get releaseDate => historyItem?.releaseDate ?? localItem?.releaseDate;
|
|
|
|
String? get genre => historyItem?.genre ?? localItem?.genre;
|
|
|
|
int? get trackNumber => historyItem?.trackNumber ?? localItem?.trackNumber;
|
|
|
|
int? get discNumber => historyItem?.discNumber ?? localItem?.discNumber;
|
|
|
|
String? get isrc => historyItem?.isrc ?? localItem?.isrc;
|
|
|
|
String? get label => historyItem?.label ?? localItem?.label;
|
|
|
|
String get searchKey =>
|
|
'${trackName.toLowerCase()}|${artistName.toLowerCase()}|${albumName.toLowerCase()}';
|
|
String get albumKey =>
|
|
'${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
|
|
|
/// Returns the collection key used to match this item against playlist
|
|
/// entries. Uses the same logic as [trackCollectionKey] from the collections
|
|
/// provider: prefer ISRC, fall back to source:id.
|
|
String get collectionKey {
|
|
if (historyItem != null) {
|
|
final isrc = historyItem!.isrc?.trim();
|
|
if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}';
|
|
final source = historyItem!.service.trim().isNotEmpty
|
|
? historyItem!.service.trim()
|
|
: 'builtin';
|
|
return '$source:${historyItem!.id}';
|
|
}
|
|
if (localItem != null) {
|
|
final isrc = localItem!.isrc?.trim();
|
|
if (isrc != null && isrc.isNotEmpty) return 'isrc:${isrc.toUpperCase()}';
|
|
return 'local:${localItem!.id}';
|
|
}
|
|
return 'builtin:$id';
|
|
}
|
|
|
|
Track toTrack() {
|
|
if (historyItem != null) {
|
|
final h = historyItem!;
|
|
return Track(
|
|
id: h.id,
|
|
name: h.trackName,
|
|
artistName: h.artistName,
|
|
albumName: h.albumName,
|
|
albumArtist: h.albumArtist,
|
|
coverUrl: h.coverUrl,
|
|
isrc: h.isrc,
|
|
duration: h.duration ?? 0,
|
|
trackNumber: h.trackNumber,
|
|
discNumber: h.discNumber,
|
|
releaseDate: h.releaseDate,
|
|
source: h.service,
|
|
);
|
|
}
|
|
if (localItem != null) {
|
|
final l = localItem!;
|
|
return Track(
|
|
id: l.id,
|
|
name: l.trackName,
|
|
artistName: l.artistName,
|
|
albumName: l.albumName,
|
|
albumArtist: l.albumArtist,
|
|
coverUrl: l.coverPath,
|
|
isrc: l.isrc,
|
|
duration: l.duration ?? 0,
|
|
trackNumber: l.trackNumber,
|
|
discNumber: l.discNumber,
|
|
releaseDate: l.releaseDate,
|
|
source: 'local',
|
|
);
|
|
}
|
|
return Track(
|
|
id: id,
|
|
name: trackName,
|
|
artistName: artistName,
|
|
albumName: albumName,
|
|
coverUrl: coverUrl,
|
|
duration: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GroupedAlbum {
|
|
final String albumName;
|
|
final String artistName;
|
|
final String? coverUrl;
|
|
final String sampleFilePath;
|
|
final List<DownloadHistoryItem> tracks;
|
|
final int? trackCount;
|
|
final DateTime latestDownload;
|
|
final String searchKey;
|
|
|
|
_GroupedAlbum({
|
|
required this.albumName,
|
|
required this.artistName,
|
|
this.coverUrl,
|
|
required this.sampleFilePath,
|
|
required this.tracks,
|
|
this.trackCount,
|
|
required this.latestDownload,
|
|
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
|
|
|
String get key => '$albumName|$artistName';
|
|
|
|
int get displayTrackCount => trackCount ?? tracks.length;
|
|
}
|
|
|
|
class _GroupedLocalAlbum {
|
|
final String albumName;
|
|
final String artistName;
|
|
final String? coverPath;
|
|
final List<LocalLibraryItem> tracks;
|
|
final int? trackCount;
|
|
final DateTime latestScanned;
|
|
final String searchKey;
|
|
|
|
_GroupedLocalAlbum({
|
|
required this.albumName,
|
|
required this.artistName,
|
|
this.coverPath,
|
|
required this.tracks,
|
|
this.trackCount,
|
|
required this.latestScanned,
|
|
}) : searchKey = '${albumName.toLowerCase()}|${artistName.toLowerCase()}';
|
|
|
|
String get key => '$albumName|$artistName';
|
|
|
|
int get displayTrackCount => trackCount ?? tracks.length;
|
|
}
|
|
|
|
class _HistoryStats {
|
|
final Map<String, int> albumCounts;
|
|
final List<_GroupedAlbum> groupedAlbums;
|
|
final int albumCount;
|
|
final int singleTracks;
|
|
|
|
const _HistoryStats({
|
|
required this.albumCounts,
|
|
required this.groupedAlbums,
|
|
required this.albumCount,
|
|
required this.singleTracks,
|
|
});
|
|
}
|
|
|
|
class _FilterContentData {
|
|
final List<DownloadHistoryItem> historyItems;
|
|
final List<UnifiedLibraryItem> unifiedItems;
|
|
final List<UnifiedLibraryItem> filteredUnifiedItems;
|
|
final List<_GroupedAlbum> filteredGroupedAlbums;
|
|
final List<_GroupedLocalAlbum> filteredGroupedLocalAlbums;
|
|
final bool showFilteringIndicator;
|
|
final int? totalTrackCountOverride;
|
|
final int? totalAlbumCountOverride;
|
|
|
|
const _FilterContentData({
|
|
required this.historyItems,
|
|
required this.unifiedItems,
|
|
required this.filteredUnifiedItems,
|
|
required this.filteredGroupedAlbums,
|
|
required this.filteredGroupedLocalAlbums,
|
|
required this.showFilteringIndicator,
|
|
this.totalTrackCountOverride,
|
|
this.totalAlbumCountOverride,
|
|
});
|
|
|
|
int get totalTrackCount =>
|
|
totalTrackCountOverride ?? filteredUnifiedItems.length;
|
|
int get totalAlbumCount =>
|
|
totalAlbumCountOverride ??
|
|
filteredGroupedAlbums.length + filteredGroupedLocalAlbums.length;
|
|
}
|
|
|
|
class _QueueLibraryPageRequest {
|
|
final String filterMode;
|
|
final int limit;
|
|
final String searchQuery;
|
|
final String? filterSource;
|
|
final String? filterQuality;
|
|
final String? filterFormat;
|
|
final String? filterMetadata;
|
|
final String sortMode;
|
|
final bool localLibraryEnabled;
|
|
|
|
const _QueueLibraryPageRequest({
|
|
required this.filterMode,
|
|
required this.limit,
|
|
required this.searchQuery,
|
|
required this.filterSource,
|
|
required this.filterQuality,
|
|
required this.filterFormat,
|
|
required this.filterMetadata,
|
|
required this.sortMode,
|
|
required this.localLibraryEnabled,
|
|
});
|
|
|
|
QueueLibraryDbQuery toDbQuery() => QueueLibraryDbQuery(
|
|
limit: limit,
|
|
filterMode: filterMode,
|
|
searchQuery: searchQuery,
|
|
source: filterSource,
|
|
quality: filterQuality,
|
|
format: filterFormat,
|
|
metadata: filterMetadata,
|
|
sortMode: sortMode,
|
|
includeLocal: localLibraryEnabled,
|
|
);
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
identical(this, other) ||
|
|
other is _QueueLibraryPageRequest &&
|
|
filterMode == other.filterMode &&
|
|
limit == other.limit &&
|
|
searchQuery == other.searchQuery &&
|
|
filterSource == other.filterSource &&
|
|
filterQuality == other.filterQuality &&
|
|
filterFormat == other.filterFormat &&
|
|
filterMetadata == other.filterMetadata &&
|
|
sortMode == other.sortMode &&
|
|
localLibraryEnabled == other.localLibraryEnabled;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(
|
|
filterMode,
|
|
limit,
|
|
searchQuery,
|
|
filterSource,
|
|
filterQuality,
|
|
filterFormat,
|
|
filterMetadata,
|
|
sortMode,
|
|
localLibraryEnabled,
|
|
);
|
|
}
|
|
|
|
class _QueueLibraryCountsRequest {
|
|
final String searchQuery;
|
|
final String? filterSource;
|
|
final String? filterQuality;
|
|
final String? filterFormat;
|
|
final String? filterMetadata;
|
|
final bool localLibraryEnabled;
|
|
|
|
const _QueueLibraryCountsRequest({
|
|
required this.searchQuery,
|
|
required this.filterSource,
|
|
required this.filterQuality,
|
|
required this.filterFormat,
|
|
required this.filterMetadata,
|
|
required this.localLibraryEnabled,
|
|
});
|
|
|
|
QueueLibraryDbQuery toDbQuery() => QueueLibraryDbQuery(
|
|
searchQuery: searchQuery,
|
|
source: filterSource,
|
|
quality: filterQuality,
|
|
format: filterFormat,
|
|
metadata: filterMetadata,
|
|
includeLocal: localLibraryEnabled,
|
|
);
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
identical(this, other) ||
|
|
other is _QueueLibraryCountsRequest &&
|
|
searchQuery == other.searchQuery &&
|
|
filterSource == other.filterSource &&
|
|
filterQuality == other.filterQuality &&
|
|
filterFormat == other.filterFormat &&
|
|
filterMetadata == other.filterMetadata &&
|
|
localLibraryEnabled == other.localLibraryEnabled;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(
|
|
searchQuery,
|
|
filterSource,
|
|
filterQuality,
|
|
filterFormat,
|
|
filterMetadata,
|
|
localLibraryEnabled,
|
|
);
|
|
}
|
|
|
|
class _QueueLibraryPageData {
|
|
final List<UnifiedLibraryItem> items;
|
|
final List<DownloadHistoryItem> historyItems;
|
|
final List<LocalLibraryItem> localItems;
|
|
final List<_GroupedAlbum> groupedAlbums;
|
|
final List<_GroupedLocalAlbum> groupedLocalAlbums;
|
|
|
|
const _QueueLibraryPageData({
|
|
this.items = const [],
|
|
this.historyItems = const [],
|
|
this.localItems = const [],
|
|
this.groupedAlbums = const [],
|
|
this.groupedLocalAlbums = const [],
|
|
});
|
|
|
|
_FilterContentData toFilterContentData(
|
|
LibraryCollectionsState collectionState, {
|
|
int? totalTrackCount,
|
|
int? totalAlbumCount,
|
|
}) {
|
|
final filteredItems = !collectionState.hasPlaylistTracks
|
|
? items
|
|
: items
|
|
.where(
|
|
(item) =>
|
|
!collectionState.isTrackInAnyPlaylist(item.collectionKey),
|
|
)
|
|
.toList(growable: false);
|
|
return _FilterContentData(
|
|
historyItems: historyItems,
|
|
unifiedItems: items,
|
|
filteredUnifiedItems: filteredItems,
|
|
filteredGroupedAlbums: groupedAlbums,
|
|
filteredGroupedLocalAlbums: groupedLocalAlbums,
|
|
showFilteringIndicator: false,
|
|
totalTrackCountOverride: totalTrackCount,
|
|
totalAlbumCountOverride: totalAlbumCount,
|
|
);
|
|
}
|
|
}
|
|
|
|
final _queueLibraryPageProvider =
|
|
FutureProvider.family<_QueueLibraryPageData, _QueueLibraryPageRequest>((
|
|
ref,
|
|
request,
|
|
) async {
|
|
ref.watch(
|
|
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
|
|
);
|
|
ref.watch(
|
|
localLibraryProvider.select((state) => state.loadedIndexVersion),
|
|
);
|
|
final dbQuery = request.toDbQuery();
|
|
if (request.filterMode == 'albums') {
|
|
final rows = await LibraryDatabase.instance.getQueueAlbumPage(dbQuery);
|
|
final groupedAlbums = <_GroupedAlbum>[];
|
|
final groupedLocalAlbums = <_GroupedLocalAlbum>[];
|
|
for (final row in rows) {
|
|
final source = row['queue_source'] as String? ?? '';
|
|
final latestMillis = (row['sort_added'] as num?)?.toInt() ?? 0;
|
|
final latest = DateTime.fromMillisecondsSinceEpoch(latestMillis);
|
|
if (source == 'local') {
|
|
groupedLocalAlbums.add(
|
|
_GroupedLocalAlbum(
|
|
albumName: row['album_name'] as String? ?? '',
|
|
artistName: row['artist_name'] as String? ?? '',
|
|
coverPath: row['cover_path'] as String?,
|
|
tracks: const [],
|
|
trackCount: (row['track_count'] as num?)?.toInt() ?? 0,
|
|
latestScanned: latest,
|
|
),
|
|
);
|
|
} else if (source == 'downloaded') {
|
|
groupedAlbums.add(
|
|
_GroupedAlbum(
|
|
albumName: row['album_name'] as String? ?? '',
|
|
artistName: row['artist_name'] as String? ?? '',
|
|
coverUrl: row['cover_url'] as String?,
|
|
sampleFilePath: row['sample_file_path'] as String? ?? '',
|
|
tracks: const [],
|
|
trackCount: (row['track_count'] as num?)?.toInt() ?? 0,
|
|
latestDownload: latest,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return _QueueLibraryPageData(
|
|
groupedAlbums: groupedAlbums,
|
|
groupedLocalAlbums: groupedLocalAlbums,
|
|
);
|
|
}
|
|
|
|
final rows = await LibraryDatabase.instance.getQueueTrackPage(dbQuery);
|
|
final items = <UnifiedLibraryItem>[];
|
|
final historyItems = <DownloadHistoryItem>[];
|
|
final localItems = <LocalLibraryItem>[];
|
|
for (final row in rows) {
|
|
final source = row['source'] as String? ?? '';
|
|
final itemJson = Map<String, dynamic>.from(row['item'] as Map);
|
|
if (source == 'local') {
|
|
final item = LocalLibraryItem.fromJson(itemJson);
|
|
localItems.add(item);
|
|
items.add(UnifiedLibraryItem.fromLocalLibrary(item));
|
|
} else if (source == 'downloaded') {
|
|
final item = DownloadHistoryItem.fromJson(itemJson);
|
|
historyItems.add(item);
|
|
items.add(UnifiedLibraryItem.fromDownloadHistory(item));
|
|
}
|
|
}
|
|
return _QueueLibraryPageData(
|
|
items: items,
|
|
historyItems: historyItems,
|
|
localItems: localItems,
|
|
);
|
|
});
|
|
|
|
final _queueLibraryCountsProvider =
|
|
FutureProvider.family<QueueLibraryCounts, _QueueLibraryCountsRequest>((
|
|
ref,
|
|
request,
|
|
) async {
|
|
ref.watch(
|
|
downloadHistoryProvider.select((state) => state.loadedIndexVersion),
|
|
);
|
|
ref.watch(
|
|
localLibraryProvider.select((state) => state.loadedIndexVersion),
|
|
);
|
|
return LibraryDatabase.instance.getQueueCounts(request.toDbQuery());
|
|
});
|
|
|
|
class _UnifiedCacheEntry {
|
|
final List<DownloadHistoryItem> historyItems;
|
|
final List<LocalLibraryItem> localItems;
|
|
final Map<String, int> localAlbumCounts;
|
|
final String query;
|
|
final List<UnifiedLibraryItem> items;
|
|
|
|
const _UnifiedCacheEntry({
|
|
required this.historyItems,
|
|
required this.localItems,
|
|
required this.localAlbumCounts,
|
|
required this.query,
|
|
required this.items,
|
|
});
|
|
}
|
|
|
|
class _QueueItemIdsSnapshot {
|
|
final List<String> ids;
|
|
|
|
const _QueueItemIdsSnapshot(this.ids);
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
identical(this, other) ||
|
|
other is _QueueItemIdsSnapshot && listEquals(ids, other.ids);
|
|
|
|
@override
|
|
int get hashCode => Object.hashAll(ids);
|
|
}
|
|
|
|
class _FileExistsListenableCache {
|
|
static const int _maxCacheSize = 500;
|
|
|
|
final Map<String, bool> _cache = {};
|
|
final Map<String, ValueNotifier<bool>> _notifiers = {};
|
|
final ValueNotifier<bool> _alwaysMissingNotifier = ValueNotifier(false);
|
|
final Set<String> _pendingChecks = {};
|
|
|
|
ValueListenable<bool> listenable(String? filePath) {
|
|
final cleanPath = DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
|
|
if (cleanPath.isEmpty) return _alwaysMissingNotifier;
|
|
|
|
final existingNotifier = _notifiers[cleanPath];
|
|
if (existingNotifier != null) {
|
|
final cached = _cache[cleanPath];
|
|
if (cached != null && existingNotifier.value != cached) {
|
|
existingNotifier.value = cached;
|
|
} else if (cached == null) {
|
|
_startCheck(cleanPath);
|
|
}
|
|
return existingNotifier;
|
|
}
|
|
|
|
if (_notifiers.length >= _maxCacheSize) {
|
|
final oldestKey = _notifiers.keys.first;
|
|
_notifiers.remove(oldestKey)?.dispose();
|
|
_cache.remove(oldestKey);
|
|
}
|
|
|
|
final notifier = ValueNotifier<bool>(_cache[cleanPath] ?? true);
|
|
_notifiers[cleanPath] = notifier;
|
|
_startCheck(cleanPath);
|
|
return notifier;
|
|
}
|
|
|
|
void _startCheck(String cleanPath) {
|
|
if (_pendingChecks.contains(cleanPath)) {
|
|
return;
|
|
}
|
|
|
|
final cached = _cache[cleanPath];
|
|
if (cached != null) {
|
|
final notifier = _notifiers[cleanPath];
|
|
if (notifier != null && notifier.value != cached) {
|
|
notifier.value = cached;
|
|
}
|
|
return;
|
|
}
|
|
|
|
_pendingChecks.add(cleanPath);
|
|
Future.microtask(() async {
|
|
final exists = await fileExists(cleanPath);
|
|
_pendingChecks.remove(cleanPath);
|
|
_cache[cleanPath] = exists;
|
|
final notifier = _notifiers[cleanPath];
|
|
if (notifier != null && notifier.value != exists) {
|
|
notifier.value = exists;
|
|
}
|
|
});
|
|
}
|
|
|
|
void dispose() {
|
|
for (final notifier in _notifiers.values) {
|
|
notifier.dispose();
|
|
}
|
|
_notifiers.clear();
|
|
_alwaysMissingNotifier.dispose();
|
|
}
|
|
}
|
|
|
|
bool _queueHasMetadataValue(String? value) {
|
|
return value != null && value.trim().isNotEmpty;
|
|
}
|
|
|
|
String _queueNormalizedMetadataValue(String? value) {
|
|
return value?.trim().toLowerCase() ?? '';
|
|
}
|
|
|
|
DateTime? _queueParseReleaseDate(String? value) {
|
|
final trimmed = value?.trim() ?? '';
|
|
if (trimmed.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
final parsed = DateTime.tryParse(trimmed);
|
|
if (parsed != null) {
|
|
return parsed;
|
|
}
|
|
|
|
final yearMatch = RegExp(r'(\d{4})').firstMatch(trimmed);
|
|
if (yearMatch == null) {
|
|
return null;
|
|
}
|
|
|
|
final year = int.tryParse(yearMatch.group(1)!);
|
|
if (year == null || year <= 0) {
|
|
return null;
|
|
}
|
|
return DateTime(year);
|
|
}
|
|
|
|
bool _queueMatchesMetadataFilter({
|
|
required String? filterMetadata,
|
|
required String? artistName,
|
|
required String? albumArtist,
|
|
required String? releaseDate,
|
|
required String? genre,
|
|
required int? trackNumber,
|
|
required int? discNumber,
|
|
required String? isrc,
|
|
required String? label,
|
|
}) {
|
|
if (filterMetadata == null) {
|
|
return true;
|
|
}
|
|
|
|
final hasArtist = _queueHasMetadataValue(artistName);
|
|
final hasAlbumArtist = _queueHasMetadataValue(albumArtist);
|
|
final hasReleaseDate = _queueParseReleaseDate(releaseDate) != null;
|
|
final hasGenre = _queueHasMetadataValue(genre);
|
|
final hasTrackNumber = trackNumber != null && trackNumber > 0;
|
|
final hasDiscNumber = discNumber != null && discNumber > 0;
|
|
final hasLabel = _queueHasMetadataValue(label);
|
|
final hasIncorrectIsrc = _queueHasIncorrectIsrcFormat(isrc);
|
|
final isComplete =
|
|
hasArtist &&
|
|
hasAlbumArtist &&
|
|
hasReleaseDate &&
|
|
hasGenre &&
|
|
hasTrackNumber &&
|
|
hasDiscNumber &&
|
|
hasLabel &&
|
|
!hasIncorrectIsrc;
|
|
|
|
switch (filterMetadata) {
|
|
case 'complete':
|
|
return isComplete;
|
|
case 'missing-any':
|
|
return !isComplete;
|
|
case 'missing-year':
|
|
return !hasReleaseDate;
|
|
case 'missing-genre':
|
|
return !hasGenre;
|
|
case 'missing-album-artist':
|
|
return !hasAlbumArtist;
|
|
case 'missing-track-number':
|
|
return !hasTrackNumber;
|
|
case 'missing-disc-number':
|
|
return !hasDiscNumber;
|
|
case 'missing-artist':
|
|
return !hasArtist;
|
|
case 'incorrect-isrc-format':
|
|
return hasIncorrectIsrc;
|
|
case 'missing-label':
|
|
return !hasLabel;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
bool _queueHasIncorrectIsrcFormat(String? isrc) {
|
|
final raw = isrc?.trim() ?? '';
|
|
if (raw.isEmpty) return false;
|
|
final normalized = raw.toUpperCase().replaceAll(RegExp(r'[-\s]'), '');
|
|
return !RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$').hasMatch(normalized);
|
|
}
|
|
|
|
bool _queueUnifiedItemMatchesMetadataFilter(
|
|
UnifiedLibraryItem item,
|
|
String? filterMetadata,
|
|
) {
|
|
return _queueMatchesMetadataFilter(
|
|
filterMetadata: filterMetadata,
|
|
artistName: item.artistName,
|
|
albumArtist: item.albumArtist,
|
|
releaseDate: item.releaseDate,
|
|
genre: item.genre,
|
|
trackNumber: item.trackNumber,
|
|
discNumber: item.discNumber,
|
|
isrc: item.isrc,
|
|
label: item.label,
|
|
);
|
|
}
|
|
|
|
int _queueCompareOptionalText(
|
|
String? left,
|
|
String? right, {
|
|
bool descending = false,
|
|
}) {
|
|
final normalizedLeft = _queueNormalizedMetadataValue(left);
|
|
final normalizedRight = _queueNormalizedMetadataValue(right);
|
|
final leftEmpty = normalizedLeft.isEmpty;
|
|
final rightEmpty = normalizedRight.isEmpty;
|
|
|
|
if (leftEmpty && rightEmpty) {
|
|
return 0;
|
|
}
|
|
if (leftEmpty) {
|
|
return 1;
|
|
}
|
|
if (rightEmpty) {
|
|
return -1;
|
|
}
|
|
|
|
final comparison = normalizedLeft.compareTo(normalizedRight);
|
|
return descending ? -comparison : comparison;
|
|
}
|
|
|
|
int _queueCompareOptionalDate(
|
|
DateTime? left,
|
|
DateTime? right, {
|
|
bool descending = false,
|
|
}) {
|
|
if (left == null && right == null) {
|
|
return 0;
|
|
}
|
|
if (left == null) {
|
|
return 1;
|
|
}
|
|
if (right == null) {
|
|
return -1;
|
|
}
|
|
|
|
final comparison = left.compareTo(right);
|
|
return descending ? -comparison : comparison;
|
|
}
|
|
|
|
Map<String, List<String>> _filterHistoryInIsolate(Map<String, Object> payload) {
|
|
final entries = (payload['entries'] as List).cast<List<Object?>>();
|
|
final albumCounts = Map<String, int>.from(payload['albumCounts'] as Map);
|
|
final query = (payload['query'] as String?) ?? '';
|
|
final hasQuery = query.isNotEmpty;
|
|
|
|
final allIds = <String>[];
|
|
final albumIds = <String>[];
|
|
final singleIds = <String>[];
|
|
|
|
for (final entry in entries) {
|
|
final id = entry[0] as String;
|
|
final albumKey = entry[1] as String;
|
|
if (hasQuery) {
|
|
final searchKey = entry[2] as String;
|
|
if (!searchKey.contains(query)) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
allIds.add(id);
|
|
final count = albumCounts[albumKey] ?? 0;
|
|
if (count > 1) {
|
|
albumIds.add(id);
|
|
} else if (count == 1) {
|
|
singleIds.add(id);
|
|
}
|
|
}
|
|
|
|
return {'all': allIds, 'albums': albumIds, 'singles': singleIds};
|
|
}
|