From 97758908ca66d2a7380aa41d319d15ebc654cec8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 26 Jul 2026 18:44:06 +0700 Subject: [PATCH] refactor(library): drop unwired DB pagination providers --- lib/providers/download_history_provider.dart | 31 -- lib/providers/local_library_provider.dart | 77 ---- lib/services/library_database.dart | 361 +++++-------------- 3 files changed, 95 insertions(+), 374 deletions(-) diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index 3996a088..bbe16229 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -1860,37 +1860,6 @@ final downloadHistoryProvider = DownloadHistoryNotifier.new, ); -class DownloadHistoryPageRequest { - final int limit; - final int offset; - - const DownloadHistoryPageRequest({this.limit = 100, this.offset = 0}); - - @override - bool operator ==(Object other) => - other is DownloadHistoryPageRequest && - other.limit == limit && - other.offset == offset; - - @override - int get hashCode => Object.hash(limit, offset); -} - -final downloadHistoryPageProvider = FutureProvider.autoDispose - .family, DownloadHistoryPageRequest>(( - ref, - request, - ) async { - ref.watch( - downloadHistoryProvider.select((state) => state.loadedIndexVersion), - ); - final rows = await HistoryDatabase.instance.getAll( - limit: request.limit, - offset: request.offset, - ); - return rows.map(DownloadHistoryItem.fromJson).toList(growable: false); - }); - class DownloadHistoryGroupedCounts { final int albumCount; final int singleTrackCount; diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 62e9b56a..ab66ba4f 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -1044,80 +1044,3 @@ final localLibraryFirstCoverProvider = FutureProvider.autoDispose return null; }); -final localLibraryPageProvider = FutureProvider.autoDispose - .family, LocalLibraryPageRequest>(( - ref, - request, - ) async { - ref.watch( - localLibraryProvider.select((state) => state.loadedIndexVersion), - ); - final rows = await LibraryDatabase.instance.getPage(request); - return rows.map(LocalLibraryItem.fromJson).toList(growable: false); - }); - -final localLibraryPageCountProvider = FutureProvider.autoDispose - .family((ref, request) async { - ref.watch( - localLibraryProvider.select((state) => state.loadedIndexVersion), - ); - return LibraryDatabase.instance.getPageCount(request); - }); - -class LocalLibraryAlbumPageRequest { - final int limit; - final int offset; - final LocalLibraryFilterMode filterMode; - final LocalLibrarySortMode sortMode; - final String? searchQuery; - - const LocalLibraryAlbumPageRequest({ - this.limit = 100, - this.offset = 0, - this.filterMode = LocalLibraryFilterMode.albums, - this.sortMode = LocalLibrarySortMode.album, - this.searchQuery, - }); - - @override - bool operator ==(Object other) { - return other is LocalLibraryAlbumPageRequest && - other.limit == limit && - other.offset == offset && - other.filterMode == filterMode && - other.sortMode == sortMode && - other.searchQuery == searchQuery; - } - - @override - int get hashCode => - Object.hash(limit, offset, filterMode, sortMode, searchQuery); -} - -final localLibraryAlbumPageProvider = FutureProvider.autoDispose - .family, LocalLibraryAlbumPageRequest>(( - ref, - request, - ) async { - ref.watch( - localLibraryProvider.select((state) => state.loadedIndexVersion), - ); - return LibraryDatabase.instance.getAlbumPage( - limit: request.limit, - offset: request.offset, - filterMode: request.filterMode, - sortMode: request.sortMode, - searchQuery: request.searchQuery, - ); - }); - -final localLibraryAlbumCountProvider = FutureProvider.autoDispose - .family((ref, request) async { - ref.watch( - localLibraryProvider.select((state) => state.loadedIndexVersion), - ); - return LibraryDatabase.instance.getAlbumCount( - filterMode: request.filterMode, - searchQuery: request.searchQuery, - ); - }); diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index a0c02357..b03fd6d2 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -129,83 +129,6 @@ enum LocalLibrarySortMode { album, title, artist, latest, quality } enum LocalLibraryFilterMode { all, albums, singles } -class LocalLibraryPageRequest { - final int limit; - final int offset; - final LocalLibrarySortMode sortMode; - final LocalLibraryFilterMode filterMode; - final String? searchQuery; - final String? format; - - const LocalLibraryPageRequest({ - this.limit = 100, - this.offset = 0, - this.sortMode = LocalLibrarySortMode.album, - this.filterMode = LocalLibraryFilterMode.all, - this.searchQuery, - this.format, - }); - - @override - bool operator ==(Object other) { - return other is LocalLibraryPageRequest && - other.limit == limit && - other.offset == offset && - other.sortMode == sortMode && - other.filterMode == filterMode && - other.searchQuery == searchQuery && - other.format == format; - } - - @override - int get hashCode => - Object.hash(limit, offset, sortMode, filterMode, searchQuery, format); -} - -class LocalLibraryAlbumGroup { - final String albumKey; - final String albumName; - final String artistName; - final String? coverPath; - final int trackCount; - final int? maxBitDepth; - final int? maxSampleRate; - final int? maxBitrate; - final String? format; - final String? releaseDate; - final String? genre; - - const LocalLibraryAlbumGroup({ - required this.albumKey, - required this.albumName, - required this.artistName, - this.coverPath, - required this.trackCount, - this.maxBitDepth, - this.maxSampleRate, - this.maxBitrate, - this.format, - this.releaseDate, - this.genre, - }); - - factory LocalLibraryAlbumGroup.fromDbRow(Map row) { - return LocalLibraryAlbumGroup( - albumKey: row['album_key'] as String, - albumName: row['album_name'] as String? ?? '', - artistName: row['artist_name'] as String? ?? '', - coverPath: row['cover_path'] as String?, - trackCount: (row['track_count'] as num?)?.toInt() ?? 0, - maxBitDepth: (row['max_bit_depth'] as num?)?.toInt(), - maxSampleRate: (row['max_sample_rate'] as num?)?.toInt(), - maxBitrate: (row['max_bitrate'] as num?)?.toInt(), - format: row['format'] as String?, - releaseDate: row['release_date'] as String?, - genre: row['genre'] as String?, - ); - } -} - class LocalLibraryLookupIndex { final Set isrcs; final Set matchKeys; @@ -230,6 +153,39 @@ class LocalLibraryBatchLookupRequest { }); } +class IsrcDuplicateEntry { + final String id; + final String source; // 'downloaded' (history row) or 'local' (library row) + final String trackName; + final String artistName; + final String albumName; + final String filePath; + final int? bitDepth; + final int? sampleRate; + final int? bitrate; + final String? format; + + const IsrcDuplicateEntry({ + required this.id, + required this.source, + required this.trackName, + required this.artistName, + required this.albumName, + required this.filePath, + this.bitDepth, + this.sampleRate, + this.bitrate, + this.format, + }); +} + +class IsrcDuplicateGroup { + final String isrc; + final List entries; + + const IsrcDuplicateGroup({required this.isrc, required this.entries}); +} + class QueueLibraryDbQuery { final int limit; final int offset; @@ -655,146 +611,6 @@ class LibraryDatabase { return rows.map(_dbRowToJson).toList(); } - Future>> getPage( - LocalLibraryPageRequest request, - ) async { - final db = await database; - final where = []; - final whereArgs = []; - _appendPageFilters(where, whereArgs, request); - - final rows = await db.query( - 'library', - where: where.isEmpty ? null : where.join(' AND '), - whereArgs: whereArgs, - orderBy: _orderByForSort(request.sortMode), - limit: request.limit, - offset: request.offset, - ); - return rows.map(_dbRowToJson).toList(growable: false); - } - - Future getPageCount(LocalLibraryPageRequest request) async { - final db = await database; - final where = []; - final whereArgs = []; - _appendPageFilters(where, whereArgs, request); - final rows = await db.rawQuery( - 'SELECT COUNT(*) AS count FROM library' - '${where.isEmpty ? '' : ' WHERE ${where.join(' AND ')}'}', - whereArgs, - ); - return Sqflite.firstIntValue(rows) ?? 0; - } - - Future> getAlbumPage({ - int limit = 100, - int offset = 0, - LocalLibraryFilterMode filterMode = LocalLibraryFilterMode.albums, - LocalLibrarySortMode sortMode = LocalLibrarySortMode.album, - String? searchQuery, - }) async { - final db = await database; - final where = []; - final whereArgs = []; - _appendSearchFilter(where, whereArgs, searchQuery); - final having = switch (filterMode) { - LocalLibraryFilterMode.singles => 'COUNT(*) = 1', - LocalLibraryFilterMode.albums => 'COUNT(*) > 1', - LocalLibraryFilterMode.all => null, - }; - final rows = await db.rawQuery( - ''' - SELECT - album_key, - MIN(album_name) AS album_name, - COALESCE(NULLIF(MIN(album_artist), ''), MIN(artist_name)) AS artist_name, - MAX(CASE WHEN cover_path IS NOT NULL AND cover_path != '' THEN cover_path END) AS cover_path, - COUNT(*) AS track_count, - MAX(bit_depth) AS max_bit_depth, - MAX(sample_rate) AS max_sample_rate, - MAX(bitrate) AS max_bitrate, - MAX(format) AS format, - MAX(release_date) AS release_date, - MAX(genre) AS genre - FROM library - ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} - GROUP BY album_key - ${having == null ? '' : 'HAVING $having'} - ORDER BY ${_albumOrderByForSort(sortMode)} - LIMIT ? OFFSET ? - ''', - [...whereArgs, limit, offset], - ); - return rows.map(LocalLibraryAlbumGroup.fromDbRow).toList(growable: false); - } - - Future getAlbumCount({ - LocalLibraryFilterMode filterMode = LocalLibraryFilterMode.albums, - String? searchQuery, - }) async { - final db = await database; - final where = []; - final whereArgs = []; - _appendSearchFilter(where, whereArgs, searchQuery); - final having = switch (filterMode) { - LocalLibraryFilterMode.singles => 'COUNT(*) = 1', - LocalLibraryFilterMode.albums => 'COUNT(*) > 1', - LocalLibraryFilterMode.all => null, - }; - final rows = await db.rawQuery(''' - SELECT COUNT(*) AS count FROM ( - SELECT album_key - FROM library - ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} - GROUP BY album_key - ${having == null ? '' : 'HAVING $having'} - ) - ''', whereArgs); - return Sqflite.firstIntValue(rows) ?? 0; - } - - void _appendPageFilters( - List where, - List whereArgs, - LocalLibraryPageRequest request, - ) { - _appendSearchFilter(where, whereArgs, request.searchQuery); - final normalizedFormat = request.format?.trim().toLowerCase(); - if (normalizedFormat != null && normalizedFormat.isNotEmpty) { - where.add('LOWER(format) = ?'); - whereArgs.add(normalizedFormat); - } - switch (request.filterMode) { - case LocalLibraryFilterMode.all: - break; - case LocalLibraryFilterMode.albums: - where.add( - 'album_key IN (SELECT album_key FROM library GROUP BY album_key HAVING COUNT(*) > 1)', - ); - break; - case LocalLibraryFilterMode.singles: - where.add( - 'album_key IN (SELECT album_key FROM library GROUP BY album_key HAVING COUNT(*) = 1)', - ); - break; - } - } - - void _appendSearchFilter( - List where, - List whereArgs, - String? searchQuery, - ) { - final query = normalizeLookupText(searchQuery); - if (query.isEmpty) return; - final like = '%${_escapeLikePattern(query)}%'; - where.add( - "(track_name_norm LIKE ? ESCAPE '\\' OR artist_name_norm LIKE ? ESCAPE '\\' OR album_name_norm LIKE ? ESCAPE '\\' OR album_artist_norm LIKE ? ESCAPE '\\')", - ); - whereArgs.addAll([like, like, like, like]); - } - String _escapeLikePattern(String value) { return value .replaceAll('\\', r'\\') @@ -817,18 +633,6 @@ class LibraryDatabase { }; } - String _albumOrderByForSort(LocalLibrarySortMode sortMode) { - return switch (sortMode) { - LocalLibrarySortMode.latest => - 'MAX(scanned_at) DESC, artist_name, album_name', - LocalLibrarySortMode.quality => - 'MAX(COALESCE(bit_depth, 0)) DESC, MAX(COALESCE(sample_rate, 0)) DESC, MAX(COALESCE(bitrate, 0)) DESC, artist_name, album_name', - LocalLibrarySortMode.title => 'album_name, artist_name', - LocalLibrarySortMode.artist || - LocalLibrarySortMode.album => 'artist_name, album_name', - }; - } - Future>> getQueueTrackPage( QueueLibraryDbQuery request, ) async { @@ -1571,27 +1375,6 @@ class LibraryDatabase { return _dbRowToJson(rows.first); } - Future?> getByFilePath(String filePath) async { - final db = await database; - final rows = await db.query( - 'library', - where: 'file_path = ?', - whereArgs: [filePath], - limit: 1, - ); - if (rows.isEmpty) return null; - return _dbRowToJson(rows.first); - } - - Future existsByIsrc(String isrc) async { - final db = await database; - final result = await db.rawQuery( - 'SELECT 1 FROM library WHERE isrc = ? LIMIT 1', - [isrc], - ); - return result.isNotEmpty; - } - Future>> findByTrackAndArtist( String trackName, String artistName, @@ -1700,22 +1483,6 @@ class LibraryDatabase { .toList(growable: false); } - Future> getAllIsrcs() async { - final db = await database; - final rows = await db.rawQuery( - 'SELECT isrc FROM library WHERE isrc IS NOT NULL AND isrc != ""', - ); - return rows.map((r) => r['isrc'] as String).toSet(); - } - - Future> getAllTrackKeys() async { - final db = await database; - final rows = await db.rawQuery( - 'SELECT match_key FROM library WHERE match_key IS NOT NULL AND match_key != ""', - ); - return rows.map((r) => r['match_key'] as String).toSet(); - } - Future getLookupIndex() async { final db = await database; final rows = await db.rawQuery('SELECT isrc, match_key FROM library'); @@ -1752,6 +1519,68 @@ class LibraryDatabase { .toList(growable: false); } + /// Groups of tracks sharing one ISRC across download history and the + /// local library. Library rows whose path key already appears in history + /// are excluded so a scanned copy of a download doesn't pair with itself. + /// Entries come back best-quality first. + Future> findIsrcDuplicateGroups() async { + final db = await database; + final rows = await db.rawQuery(''' + WITH merged AS ( + SELECT h.id AS id, 'downloaded' AS source, h.track_name, h.artist_name, + h.album_name, h.file_path, UPPER(TRIM(h.isrc)) AS isrc_key, + h.bit_depth, h.sample_rate, h.bitrate, h.format + FROM history_db.history h + WHERE h.isrc IS NOT NULL AND TRIM(h.isrc) != '' + UNION ALL + 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 + WHERE l.isrc IS NOT NULL AND TRIM(l.isrc) != '' + AND 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 = l.id + ) + ) + SELECT * FROM merged + WHERE isrc_key IN ( + SELECT isrc_key FROM merged GROUP BY isrc_key HAVING COUNT(*) > 1 + ) + ORDER BY isrc_key, bit_depth DESC, sample_rate DESC, bitrate DESC + '''); + + final groups = >{}; + for (final row in rows) { + final isrc = row['isrc_key'] as String? ?? ''; + final filePath = row['file_path'] as String? ?? ''; + if (isrc.isEmpty || filePath.isEmpty) continue; + groups + .putIfAbsent(isrc, () => []) + .add( + IsrcDuplicateEntry( + id: row['id'] as String, + source: row['source'] as String, + trackName: row['track_name'] as String? ?? '', + artistName: row['artist_name'] as String? ?? '', + albumName: row['album_name'] as String? ?? '', + filePath: filePath, + bitDepth: (row['bit_depth'] as num?)?.toInt(), + sampleRate: (row['sample_rate'] as num?)?.toInt(), + bitrate: (row['bitrate'] as num?)?.toInt(), + format: row['format'] as String?, + ), + ); + } + return [ + for (final entry in groups.entries) + if (entry.value.length > 1) + IsrcDuplicateGroup(isrc: entry.key, entries: entry.value), + ]; + } + Future deleteByPath(String filePath) async { final db = await database; final rows = await db.query(