diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 2fc7c38b..161e330f 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -9,221 +9,11 @@ import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; +part 'library_database_models.dart'; +part 'library_database_queue_sql.dart'; + final _log = AppLogger('LibraryDatabase'); -class LocalLibraryItem { - final String id; - final String trackName; - final String artistName; - final String albumName; - final String? albumArtist; - final String filePath; - final String? coverPath; - final DateTime scannedAt; - final int? fileModTime; - final String? isrc; - final int? trackNumber; - final int? totalTracks; - final int? discNumber; - final int? totalDiscs; - final int? duration; - final String? releaseDate; - final int? bitDepth; - final int? sampleRate; - final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg) - final String? genre; - final String? composer; - final String? label; - final String? copyright; - final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a - - const LocalLibraryItem({ - required this.id, - required this.trackName, - required this.artistName, - required this.albumName, - this.albumArtist, - required this.filePath, - this.coverPath, - required this.scannedAt, - this.fileModTime, - this.isrc, - this.trackNumber, - this.totalTracks, - this.discNumber, - this.totalDiscs, - this.duration, - this.releaseDate, - this.bitDepth, - this.sampleRate, - this.bitrate, - this.genre, - this.composer, - this.label, - this.copyright, - this.format, - }); - - Map toJson() => { - 'id': id, - 'trackName': trackName, - 'artistName': artistName, - 'albumName': albumName, - 'albumArtist': albumArtist, - 'filePath': filePath, - 'coverPath': coverPath, - 'scannedAt': scannedAt.toIso8601String(), - 'fileModTime': fileModTime, - 'isrc': isrc, - 'trackNumber': trackNumber, - 'totalTracks': totalTracks, - 'discNumber': discNumber, - 'totalDiscs': totalDiscs, - 'duration': duration, - 'releaseDate': releaseDate, - 'bitDepth': bitDepth, - 'sampleRate': sampleRate, - 'bitrate': bitrate, - 'genre': genre, - 'composer': composer, - 'label': label, - 'copyright': copyright, - 'format': format, - }; - - factory LocalLibraryItem.fromJson(Map json) => - LocalLibraryItem( - id: json['id'] as String, - trackName: json['trackName'] as String, - artistName: json['artistName'] as String, - albumName: json['albumName'] as String, - albumArtist: json['albumArtist'] as String?, - filePath: json['filePath'] as String, - coverPath: json['coverPath'] as String?, - scannedAt: DateTime.parse(json['scannedAt'] as String), - fileModTime: (json['fileModTime'] as num?)?.toInt(), - isrc: json['isrc'] as String?, - trackNumber: (json['trackNumber'] as num?)?.toInt(), - totalTracks: (json['totalTracks'] as num?)?.toInt(), - discNumber: (json['discNumber'] as num?)?.toInt(), - totalDiscs: (json['totalDiscs'] as num?)?.toInt(), - duration: (json['duration'] as num?)?.toInt(), - releaseDate: json['releaseDate'] as String?, - bitDepth: (json['bitDepth'] as num?)?.toInt(), - sampleRate: (json['sampleRate'] as num?)?.toInt(), - bitrate: (json['bitrate'] as num?)?.toInt(), - genre: json['genre'] as String?, - composer: json['composer'] as String?, - label: json['label'] as String?, - copyright: json['copyright'] as String?, - format: json['format'] as String?, - ); - - String get matchKey => - '${LibraryDatabase.normalizeLookupText(trackName)}|${LibraryDatabase.normalizeLookupText(artistName)}'; - String get albumKey => - '${LibraryDatabase.normalizeLookupText(albumName)}|${LibraryDatabase.normalizeLookupText(albumArtist ?? artistName)}'; -} - -enum LocalLibrarySortMode { album, title, artist, latest, quality } - -enum LocalLibraryFilterMode { all, albums, singles } - -class LocalLibraryLookupIndex { - final Set isrcs; - final Set matchKeys; - - const LocalLibraryLookupIndex({ - this.isrcs = const {}, - this.matchKeys = const {}, - }); -} - -class LocalLibraryBatchLookupRequest { - final String? id; - final String? isrc; - final String trackName; - final String artistName; - - const LocalLibraryBatchLookupRequest({ - this.id, - this.isrc, - required this.trackName, - required this.artistName, - }); -} - -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; - final String filterMode; - final String? searchQuery; - final String? source; - final String? quality; - final String? format; - final String? metadata; - final String sortMode; - final bool includeLocal; - - const QueueLibraryDbQuery({ - this.limit = 100, - this.offset = 0, - this.filterMode = 'all', - this.searchQuery, - this.source, - this.quality, - this.format, - this.metadata, - this.sortMode = 'latest', - this.includeLocal = true, - }); -} - -class QueueLibraryCounts { - final int allTrackCount; - final int albumCount; - final int singleTrackCount; - - const QueueLibraryCounts({ - required this.allTrackCount, - required this.albumCount, - required this.singleTrackCount, - }); -} - class LibraryDatabase { static final LibraryDatabase instance = LibraryDatabase._init(); static Database? _database; @@ -782,575 +572,6 @@ class LibraryDatabase { return rows.map(_dbRowToJson).toList(growable: false); } - String _queueTrackUnionSql(QueueLibraryDbQuery request, List args) { - final parts = []; - if (request.source != 'local') { - final where = []; - _appendQueueHistoryFilters(where, args, request); - if (request.filterMode == 'singles') { - where.add(''' - h.album_key IN ( - SELECT album_key - FROM history_db.history - GROUP BY album_key - HAVING COUNT(*) = 1 - ) - '''); - } - parts.add(''' - SELECT - 'downloaded' AS queue_source, - 'dl_' || h.id AS unified_id, - h.id, - h.track_name, - h.artist_name, - h.album_name, - h.album_artist, - h.cover_url, - h.file_path, - h.storage_mode, - h.download_tree_uri, - h.saf_relative_dir, - h.saf_file_name, - h.saf_repaired, - h.service, - h.downloaded_at, - h.isrc, - h.spotify_id, - h.track_number, - h.total_tracks, - h.disc_number, - h.total_discs, - h.duration, - h.release_date, - h.quality, - h.bit_depth, - h.sample_rate, - h.genre, - h.composer, - h.label, - h.copyright, - NULL AS cover_path, - NULL AS scanned_at, - NULL AS file_mod_time, - h.bitrate, - h.format, - LOWER(h.track_name) AS sort_track, - LOWER(h.artist_name) AS sort_artist, - LOWER(h.album_name) AS sort_album, - LOWER(COALESCE(h.genre, '')) AS sort_genre, - h.release_date AS sort_release, - CAST(strftime('%s', h.downloaded_at) AS INTEGER) * 1000 AS sort_added - FROM history_db.history h - ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} - '''); - } - - if (request.includeLocal && request.source != 'downloaded') { - final 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 = l.id - ) - ''', - ]; - _appendQueueLocalFilters(where, args, request); - if (request.filterMode == 'singles') { - where.add(''' - l.album_key IN ( - SELECT album_key - FROM library - 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 - ) - GROUP BY album_key - HAVING COUNT(*) = 1 - ) - '''); - } - parts.add(''' - SELECT - 'local' AS queue_source, - 'local_' || l.id AS unified_id, - l.id, - l.track_name, - l.artist_name, - l.album_name, - l.album_artist, - NULL AS cover_url, - l.file_path, - NULL AS storage_mode, - NULL AS download_tree_uri, - NULL AS saf_relative_dir, - NULL AS saf_file_name, - 0 AS saf_repaired, - 'local' AS service, - NULL AS downloaded_at, - l.isrc, - NULL AS spotify_id, - l.track_number, - l.total_tracks, - l.disc_number, - l.total_discs, - l.duration, - l.release_date, - NULL AS quality, - l.bit_depth, - l.sample_rate, - l.genre, - l.composer, - l.label, - l.copyright, - l.cover_path, - l.scanned_at, - l.file_mod_time, - l.bitrate, - l.format, - l.track_name_norm AS sort_track, - l.artist_name_norm AS sort_artist, - l.album_name_norm AS sort_album, - LOWER(COALESCE(l.genre, '')) AS sort_genre, - l.release_date AS sort_release, - COALESCE(l.file_mod_time, CAST(strftime('%s', l.scanned_at) AS INTEGER) * 1000) AS sort_added - FROM library l - ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} - '''); - } - - if (parts.isEmpty) { - return ''' - SELECT - NULL AS queue_source, - NULL AS unified_id, - NULL AS id, - NULL AS track_name, - NULL AS artist_name, - NULL AS album_name, - NULL AS album_artist, - NULL AS cover_url, - NULL AS file_path, - NULL AS storage_mode, - NULL AS download_tree_uri, - NULL AS saf_relative_dir, - NULL AS saf_file_name, - NULL AS saf_repaired, - NULL AS service, - NULL AS downloaded_at, - NULL AS isrc, - NULL AS spotify_id, - NULL AS track_number, - NULL AS total_tracks, - NULL AS disc_number, - NULL AS total_discs, - NULL AS duration, - NULL AS release_date, - NULL AS quality, - NULL AS bit_depth, - NULL AS sample_rate, - NULL AS genre, - NULL AS composer, - NULL AS label, - NULL AS copyright, - NULL AS cover_path, - NULL AS scanned_at, - NULL AS file_mod_time, - NULL AS bitrate, - NULL AS format, - NULL AS sort_track, - NULL AS sort_artist, - NULL AS sort_album, - NULL AS sort_genre, - NULL AS sort_release, - NULL AS sort_added - WHERE 0 - '''; - } - return parts.join(' UNION ALL '); - } - - String _queueAlbumUnionSql(QueueLibraryDbQuery request, List args) { - final parts = []; - if (request.source != 'local') { - final where = []; - _appendQueueHistoryFilters(where, args, request); - parts.add(''' - SELECT - 'downloaded' AS queue_source, - c.album_key, - MIN(h.album_name) AS album_name, - COALESCE(NULLIF(MIN(h.album_artist), ''), MIN(h.artist_name)) AS artist_name, - MAX(CASE WHEN h.cover_url IS NOT NULL AND h.cover_url != '' THEN h.cover_url END) AS cover_url, - NULL AS cover_path, - MAX(h.file_path) AS sample_file_path, - COUNT(*) AS track_count, - c.latest_added AS sort_added, - MIN(LOWER(COALESCE(h.album_name, ''))) AS sort_album, - MIN(LOWER(COALESCE(h.album_artist, h.artist_name, ''))) AS sort_artist, - MAX(h.release_date) AS sort_release, - MAX(LOWER(COALESCE(h.genre, ''))) AS sort_genre - FROM history_db.history h - JOIN ( - SELECT - album_key, - COUNT(*) AS track_count, - MAX(CAST(strftime('%s', downloaded_at) AS INTEGER) * 1000) AS latest_added - FROM history_db.history - GROUP BY album_key - HAVING COUNT(*) > 1 - ) c - ON c.album_key = h.album_key - ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} - GROUP BY c.album_key - '''); - } - - if (request.includeLocal && request.source != 'downloaded') { - final 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 = l.id - ) - ''', - ]; - _appendQueueLocalFilters(where, args, request); - parts.add(''' - SELECT - 'local' AS queue_source, - c.album_key, - MIN(l.album_name) AS album_name, - COALESCE(NULLIF(MIN(l.album_artist), ''), MIN(l.artist_name)) AS artist_name, - NULL AS cover_url, - MAX(CASE WHEN l.cover_path IS NOT NULL AND l.cover_path != '' THEN l.cover_path END) AS cover_path, - MAX(l.file_path) AS sample_file_path, - COUNT(*) AS track_count, - c.latest_added AS sort_added, - MIN(l.album_name_norm) AS sort_album, - MIN(l.album_artist_norm) AS sort_artist, - MAX(l.release_date) AS sort_release, - MAX(LOWER(COALESCE(l.genre, ''))) AS sort_genre - FROM library l - JOIN ( - SELECT - album_key, - COUNT(*) AS track_count, - MAX(COALESCE(file_mod_time, CAST(strftime('%s', scanned_at) AS INTEGER) * 1000)) AS latest_added - FROM library - 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 - ) - GROUP BY album_key - HAVING COUNT(*) > 1 - ) c ON c.album_key = l.album_key - ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} - GROUP BY c.album_key - '''); - } - - if (parts.isEmpty) { - return ''' - SELECT - NULL AS queue_source, - NULL AS album_key, - NULL AS album_name, - NULL AS artist_name, - NULL AS cover_url, - NULL AS cover_path, - NULL AS sample_file_path, - NULL AS track_count, - NULL AS sort_added, - NULL AS sort_album, - NULL AS sort_artist, - NULL AS sort_release, - NULL AS sort_genre - WHERE 0 - '''; - } - return parts.join(' UNION ALL '); - } - - void _appendQueueHistoryFilters( - List where, - List args, - QueueLibraryDbQuery request, - ) { - final query = normalizeLookupText(request.searchQuery); - if (query.isNotEmpty) { - final like = '%${_escapeLikePattern(query)}%'; - where.add("h.search_text LIKE ? ESCAPE '\\'"); - args.add(like); - } - _appendQueueCommonFilters( - where, - args, - request, - filePathExpr: 'h.file_path', - formatExpr: 'h.format', - qualityExpr: 'h.quality', - bitDepthExpr: 'h.bit_depth', - artistExpr: 'h.artist_name', - albumArtistExpr: 'h.album_artist', - releaseDateExpr: 'h.release_date', - genreExpr: 'h.genre', - trackNumberExpr: 'h.track_number', - discNumberExpr: 'h.disc_number', - isrcExpr: 'h.isrc', - labelExpr: 'h.label', - ); - } - - void _appendQueueLocalFilters( - List where, - List args, - QueueLibraryDbQuery request, - ) { - final query = normalizeLookupText(request.searchQuery); - if (query.isNotEmpty) { - final like = '%${_escapeLikePattern(query)}%'; - where.add(''' - ( - l.track_name_norm LIKE ? ESCAPE '\\' OR - l.artist_name_norm LIKE ? ESCAPE '\\' OR - l.album_name_norm LIKE ? ESCAPE '\\' OR - l.album_artist_norm LIKE ? ESCAPE '\\' - ) - '''); - args.addAll([like, like, like, like]); - } - _appendQueueCommonFilters( - where, - args, - request, - filePathExpr: 'l.file_path', - formatExpr: 'l.format', - qualityExpr: 'NULL', - bitDepthExpr: 'l.bit_depth', - artistExpr: 'l.artist_name', - albumArtistExpr: 'l.album_artist', - releaseDateExpr: 'l.release_date', - genreExpr: 'l.genre', - trackNumberExpr: 'l.track_number', - discNumberExpr: 'l.disc_number', - isrcExpr: 'l.isrc', - labelExpr: 'l.label', - ); - } - - void _appendQueueCommonFilters( - List where, - List args, - QueueLibraryDbQuery request, { - required String filePathExpr, - required String? formatExpr, - required String qualityExpr, - required String bitDepthExpr, - required String artistExpr, - required String albumArtistExpr, - required String releaseDateExpr, - required String genreExpr, - required String trackNumberExpr, - required String discNumberExpr, - required String isrcExpr, - required String labelExpr, - }) { - final quality = request.quality?.trim().toLowerCase(); - if (quality != null && quality.isNotEmpty) { - final isHiRes = - '(COALESCE($bitDepthExpr, 0) >= 24 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'24%\')'; - final isCd = - '(COALESCE($bitDepthExpr, 0) = 16 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'16%\')'; - switch (quality) { - case 'hires': - where.add(isHiRes); - break; - case 'cd': - where.add(isCd); - break; - case 'lossy': - where.add('NOT ($isHiRes OR $isCd)'); - break; - } - } - - final format = request.format?.trim().toLowerCase(); - if (format != null && format.isNotEmpty) { - if (formatExpr == null) { - where.add('LOWER($filePathExpr) LIKE ?'); - args.add('%.$format'); - } else { - where.add( - '(LOWER(COALESCE($formatExpr, \'\')) = ? OR LOWER($filePathExpr) LIKE ?)', - ); - args.addAll([format, '%.$format']); - } - } - - final metadata = request.metadata?.trim(); - if (metadata == null || metadata.isEmpty) return; - final hasArtist = 'TRIM(COALESCE($artistExpr, \'\')) != \'\''; - final hasAlbumArtist = 'TRIM(COALESCE($albumArtistExpr, \'\')) != \'\''; - final hasReleaseDate = - 'TRIM(COALESCE($releaseDateExpr, \'\')) GLOB \'*[0-9][0-9][0-9][0-9]*\''; - final hasGenre = 'TRIM(COALESCE($genreExpr, \'\')) != \'\''; - final hasTrackNumber = 'COALESCE($trackNumberExpr, 0) > 0'; - final hasDiscNumber = 'COALESCE($discNumberExpr, 0) > 0'; - final hasLabel = 'TRIM(COALESCE($labelExpr, \'\')) != \'\''; - final normalizedIsrc = - 'REPLACE(REPLACE(UPPER(TRIM(COALESCE($isrcExpr, \'\'))), \'-\', \'\'), \' \', \'\')'; - final hasIncorrectIsrc = - 'TRIM(COALESCE($isrcExpr, \'\')) != \'\' AND LENGTH($normalizedIsrc) != 12'; - final isComplete = - '($hasArtist AND $hasAlbumArtist AND $hasReleaseDate AND $hasGenre AND $hasTrackNumber AND $hasDiscNumber AND $hasLabel AND NOT ($hasIncorrectIsrc))'; - - switch (metadata) { - case 'complete': - where.add(isComplete); - break; - case 'missing-any': - where.add('NOT $isComplete'); - break; - case 'missing-year': - where.add('NOT ($hasReleaseDate)'); - break; - case 'missing-genre': - where.add('NOT ($hasGenre)'); - break; - case 'missing-album-artist': - where.add('NOT ($hasAlbumArtist)'); - break; - case 'missing-track-number': - where.add('NOT ($hasTrackNumber)'); - break; - case 'missing-disc-number': - where.add('NOT ($hasDiscNumber)'); - break; - case 'missing-artist': - where.add('NOT ($hasArtist)'); - break; - case 'incorrect-isrc-format': - where.add('($hasIncorrectIsrc)'); - break; - case 'missing-label': - where.add('NOT ($hasLabel)'); - break; - } - } - - String _queueTrackOrderBy(String sortMode) { - return switch (sortMode) { - 'oldest' => 'sort_added ASC, sort_track ASC', - 'a-z' => 'sort_track ASC, sort_artist ASC', - 'z-a' => 'sort_track DESC, sort_artist DESC', - 'artist-asc' => 'sort_artist ASC, sort_track ASC', - 'artist-desc' => 'sort_artist DESC, sort_track ASC', - 'album-asc' => 'sort_album ASC, sort_track ASC', - 'album-desc' => 'sort_album DESC, sort_track ASC', - 'release-oldest' => 'sort_release ASC, sort_track ASC', - 'release-newest' => 'sort_release DESC, sort_track ASC', - 'genre-asc' => 'sort_genre ASC, sort_track ASC', - 'genre-desc' => 'sort_genre DESC, sort_track ASC', - _ => 'sort_added DESC, sort_track ASC', - }; - } - - String _queueAlbumOrderBy(String sortMode) { - return switch (sortMode) { - 'oldest' => 'sort_added ASC, sort_album ASC', - 'a-z' || 'album-asc' => 'sort_album ASC, sort_artist ASC', - 'z-a' || 'album-desc' => 'sort_album DESC, sort_artist DESC', - 'artist-asc' => 'sort_artist ASC, sort_album ASC', - 'artist-desc' => 'sort_artist DESC, sort_album ASC', - 'release-oldest' => 'sort_release ASC, sort_album ASC', - 'release-newest' => 'sort_release DESC, sort_album ASC', - 'genre-asc' => 'sort_genre ASC, sort_album ASC', - 'genre-desc' => 'sort_genre DESC, sort_album ASC', - _ => 'sort_added DESC, sort_album ASC', - }; - } - - Map _queueTrackRowToJson(Map row) { - final source = row['queue_source'] as String? ?? ''; - if (source == 'local') { - return { - 'source': source, - 'item': { - 'id': row['id'], - 'trackName': row['track_name'], - 'artistName': row['artist_name'], - 'albumName': row['album_name'], - 'albumArtist': row['album_artist'], - 'filePath': row['file_path'], - 'coverPath': row['cover_path'], - 'scannedAt': row['scanned_at'], - 'fileModTime': row['file_mod_time'], - 'isrc': row['isrc'], - 'trackNumber': row['track_number'], - 'totalTracks': row['total_tracks'], - 'discNumber': row['disc_number'], - 'totalDiscs': row['total_discs'], - 'duration': row['duration'], - 'releaseDate': row['release_date'], - 'bitDepth': row['bit_depth'], - 'sampleRate': row['sample_rate'], - 'bitrate': row['bitrate'], - 'genre': row['genre'], - 'composer': row['composer'], - 'label': row['label'], - 'copyright': row['copyright'], - 'format': row['format'], - }, - }; - } - - return { - 'source': source, - 'item': { - 'id': row['id'], - 'trackName': row['track_name'], - 'artistName': row['artist_name'], - 'albumName': row['album_name'], - 'albumArtist': row['album_artist'], - 'coverUrl': row['cover_url'], - 'filePath': row['file_path'], - 'storageMode': row['storage_mode'], - 'downloadTreeUri': row['download_tree_uri'], - 'safRelativeDir': row['saf_relative_dir'], - 'safFileName': row['saf_file_name'], - 'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true, - 'service': row['service'], - 'downloadedAt': row['downloaded_at'], - 'isrc': row['isrc'], - 'spotifyId': row['spotify_id'], - 'trackNumber': row['track_number'], - 'totalTracks': row['total_tracks'], - 'discNumber': row['disc_number'], - 'totalDiscs': row['total_discs'], - 'duration': row['duration'], - 'releaseDate': row['release_date'], - 'quality': row['quality'], - 'bitDepth': row['bit_depth'], - 'sampleRate': row['sample_rate'], - 'bitrate': row['bitrate'], - 'format': row['format'], - 'genre': row['genre'], - 'composer': row['composer'], - 'label': row['label'], - 'copyright': row['copyright'], - }, - }; - } - Future?> getById(String id) async { final db = await database; final rows = await db.query( diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart new file mode 100644 index 00000000..b64f12c8 --- /dev/null +++ b/lib/services/library_database_models.dart @@ -0,0 +1,216 @@ +part of 'library_database.dart'; + +// Row models and query descriptors for the local library database. + +class LocalLibraryItem { + final String id; + final String trackName; + final String artistName; + final String albumName; + final String? albumArtist; + final String filePath; + final String? coverPath; + final DateTime scannedAt; + final int? fileModTime; + final String? isrc; + final int? trackNumber; + final int? totalTracks; + final int? discNumber; + final int? totalDiscs; + final int? duration; + final String? releaseDate; + final int? bitDepth; + final int? sampleRate; + final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg) + final String? genre; + final String? composer; + final String? label; + final String? copyright; + final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a + + const LocalLibraryItem({ + required this.id, + required this.trackName, + required this.artistName, + required this.albumName, + this.albumArtist, + required this.filePath, + this.coverPath, + required this.scannedAt, + this.fileModTime, + this.isrc, + this.trackNumber, + this.totalTracks, + this.discNumber, + this.totalDiscs, + this.duration, + this.releaseDate, + this.bitDepth, + this.sampleRate, + this.bitrate, + this.genre, + this.composer, + this.label, + this.copyright, + this.format, + }); + + Map toJson() => { + 'id': id, + 'trackName': trackName, + 'artistName': artistName, + 'albumName': albumName, + 'albumArtist': albumArtist, + 'filePath': filePath, + 'coverPath': coverPath, + 'scannedAt': scannedAt.toIso8601String(), + 'fileModTime': fileModTime, + 'isrc': isrc, + 'trackNumber': trackNumber, + 'totalTracks': totalTracks, + 'discNumber': discNumber, + 'totalDiscs': totalDiscs, + 'duration': duration, + 'releaseDate': releaseDate, + 'bitDepth': bitDepth, + 'sampleRate': sampleRate, + 'bitrate': bitrate, + 'genre': genre, + 'composer': composer, + 'label': label, + 'copyright': copyright, + 'format': format, + }; + + factory LocalLibraryItem.fromJson(Map json) => + LocalLibraryItem( + id: json['id'] as String, + trackName: json['trackName'] as String, + artistName: json['artistName'] as String, + albumName: json['albumName'] as String, + albumArtist: json['albumArtist'] as String?, + filePath: json['filePath'] as String, + coverPath: json['coverPath'] as String?, + scannedAt: DateTime.parse(json['scannedAt'] as String), + fileModTime: (json['fileModTime'] as num?)?.toInt(), + isrc: json['isrc'] as String?, + trackNumber: (json['trackNumber'] as num?)?.toInt(), + totalTracks: (json['totalTracks'] as num?)?.toInt(), + discNumber: (json['discNumber'] as num?)?.toInt(), + totalDiscs: (json['totalDiscs'] as num?)?.toInt(), + duration: (json['duration'] as num?)?.toInt(), + releaseDate: json['releaseDate'] as String?, + bitDepth: (json['bitDepth'] as num?)?.toInt(), + sampleRate: (json['sampleRate'] as num?)?.toInt(), + bitrate: (json['bitrate'] as num?)?.toInt(), + genre: json['genre'] as String?, + composer: json['composer'] as String?, + label: json['label'] as String?, + copyright: json['copyright'] as String?, + format: json['format'] as String?, + ); + + String get matchKey => + '${LibraryDatabase.normalizeLookupText(trackName)}|${LibraryDatabase.normalizeLookupText(artistName)}'; + String get albumKey => + '${LibraryDatabase.normalizeLookupText(albumName)}|${LibraryDatabase.normalizeLookupText(albumArtist ?? artistName)}'; +} + +enum LocalLibrarySortMode { album, title, artist, latest, quality } + +enum LocalLibraryFilterMode { all, albums, singles } + +class LocalLibraryLookupIndex { + final Set isrcs; + final Set matchKeys; + + const LocalLibraryLookupIndex({ + this.isrcs = const {}, + this.matchKeys = const {}, + }); +} + +class LocalLibraryBatchLookupRequest { + final String? id; + final String? isrc; + final String trackName; + final String artistName; + + const LocalLibraryBatchLookupRequest({ + this.id, + this.isrc, + required this.trackName, + required this.artistName, + }); +} + +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; + final String filterMode; + final String? searchQuery; + final String? source; + final String? quality; + final String? format; + final String? metadata; + final String sortMode; + final bool includeLocal; + + const QueueLibraryDbQuery({ + this.limit = 100, + this.offset = 0, + this.filterMode = 'all', + this.searchQuery, + this.source, + this.quality, + this.format, + this.metadata, + this.sortMode = 'latest', + this.includeLocal = true, + }); +} + +class QueueLibraryCounts { + final int allTrackCount; + final int albumCount; + final int singleTrackCount; + + const QueueLibraryCounts({ + required this.allTrackCount, + required this.albumCount, + required this.singleTrackCount, + }); +} diff --git a/lib/services/library_database_queue_sql.dart b/lib/services/library_database_queue_sql.dart new file mode 100644 index 00000000..961c1e39 --- /dev/null +++ b/lib/services/library_database_queue_sql.dart @@ -0,0 +1,574 @@ +part of 'library_database.dart'; + +// SQL builders for the queue tab's history+local union queries. + +extension _LibraryDbQueueSql on LibraryDatabase { + String _queueTrackUnionSql(QueueLibraryDbQuery request, List args) { + final parts = []; + if (request.source != 'local') { + final where = []; + _appendQueueHistoryFilters(where, args, request); + if (request.filterMode == 'singles') { + where.add(''' + h.album_key IN ( + SELECT album_key + FROM history_db.history + GROUP BY album_key + HAVING COUNT(*) = 1 + ) + '''); + } + parts.add(''' + SELECT + 'downloaded' AS queue_source, + 'dl_' || h.id AS unified_id, + h.id, + h.track_name, + h.artist_name, + h.album_name, + h.album_artist, + h.cover_url, + h.file_path, + h.storage_mode, + h.download_tree_uri, + h.saf_relative_dir, + h.saf_file_name, + h.saf_repaired, + h.service, + h.downloaded_at, + h.isrc, + h.spotify_id, + h.track_number, + h.total_tracks, + h.disc_number, + h.total_discs, + h.duration, + h.release_date, + h.quality, + h.bit_depth, + h.sample_rate, + h.genre, + h.composer, + h.label, + h.copyright, + NULL AS cover_path, + NULL AS scanned_at, + NULL AS file_mod_time, + h.bitrate, + h.format, + LOWER(h.track_name) AS sort_track, + LOWER(h.artist_name) AS sort_artist, + LOWER(h.album_name) AS sort_album, + LOWER(COALESCE(h.genre, '')) AS sort_genre, + h.release_date AS sort_release, + CAST(strftime('%s', h.downloaded_at) AS INTEGER) * 1000 AS sort_added + FROM history_db.history h + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + '''); + } + + if (request.includeLocal && request.source != 'downloaded') { + final 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 = l.id + ) + ''', + ]; + _appendQueueLocalFilters(where, args, request); + if (request.filterMode == 'singles') { + where.add(''' + l.album_key IN ( + SELECT album_key + FROM library + 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 + ) + GROUP BY album_key + HAVING COUNT(*) = 1 + ) + '''); + } + parts.add(''' + SELECT + 'local' AS queue_source, + 'local_' || l.id AS unified_id, + l.id, + l.track_name, + l.artist_name, + l.album_name, + l.album_artist, + NULL AS cover_url, + l.file_path, + NULL AS storage_mode, + NULL AS download_tree_uri, + NULL AS saf_relative_dir, + NULL AS saf_file_name, + 0 AS saf_repaired, + 'local' AS service, + NULL AS downloaded_at, + l.isrc, + NULL AS spotify_id, + l.track_number, + l.total_tracks, + l.disc_number, + l.total_discs, + l.duration, + l.release_date, + NULL AS quality, + l.bit_depth, + l.sample_rate, + l.genre, + l.composer, + l.label, + l.copyright, + l.cover_path, + l.scanned_at, + l.file_mod_time, + l.bitrate, + l.format, + l.track_name_norm AS sort_track, + l.artist_name_norm AS sort_artist, + l.album_name_norm AS sort_album, + LOWER(COALESCE(l.genre, '')) AS sort_genre, + l.release_date AS sort_release, + COALESCE(l.file_mod_time, CAST(strftime('%s', l.scanned_at) AS INTEGER) * 1000) AS sort_added + FROM library l + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + '''); + } + + if (parts.isEmpty) { + return ''' + SELECT + NULL AS queue_source, + NULL AS unified_id, + NULL AS id, + NULL AS track_name, + NULL AS artist_name, + NULL AS album_name, + NULL AS album_artist, + NULL AS cover_url, + NULL AS file_path, + NULL AS storage_mode, + NULL AS download_tree_uri, + NULL AS saf_relative_dir, + NULL AS saf_file_name, + NULL AS saf_repaired, + NULL AS service, + NULL AS downloaded_at, + NULL AS isrc, + NULL AS spotify_id, + NULL AS track_number, + NULL AS total_tracks, + NULL AS disc_number, + NULL AS total_discs, + NULL AS duration, + NULL AS release_date, + NULL AS quality, + NULL AS bit_depth, + NULL AS sample_rate, + NULL AS genre, + NULL AS composer, + NULL AS label, + NULL AS copyright, + NULL AS cover_path, + NULL AS scanned_at, + NULL AS file_mod_time, + NULL AS bitrate, + NULL AS format, + NULL AS sort_track, + NULL AS sort_artist, + NULL AS sort_album, + NULL AS sort_genre, + NULL AS sort_release, + NULL AS sort_added + WHERE 0 + '''; + } + return parts.join(' UNION ALL '); + } + + String _queueAlbumUnionSql(QueueLibraryDbQuery request, List args) { + final parts = []; + if (request.source != 'local') { + final where = []; + _appendQueueHistoryFilters(where, args, request); + parts.add(''' + SELECT + 'downloaded' AS queue_source, + c.album_key, + MIN(h.album_name) AS album_name, + COALESCE(NULLIF(MIN(h.album_artist), ''), MIN(h.artist_name)) AS artist_name, + MAX(CASE WHEN h.cover_url IS NOT NULL AND h.cover_url != '' THEN h.cover_url END) AS cover_url, + NULL AS cover_path, + MAX(h.file_path) AS sample_file_path, + COUNT(*) AS track_count, + c.latest_added AS sort_added, + MIN(LOWER(COALESCE(h.album_name, ''))) AS sort_album, + MIN(LOWER(COALESCE(h.album_artist, h.artist_name, ''))) AS sort_artist, + MAX(h.release_date) AS sort_release, + MAX(LOWER(COALESCE(h.genre, ''))) AS sort_genre + FROM history_db.history h + JOIN ( + SELECT + album_key, + COUNT(*) AS track_count, + MAX(CAST(strftime('%s', downloaded_at) AS INTEGER) * 1000) AS latest_added + FROM history_db.history + GROUP BY album_key + HAVING COUNT(*) > 1 + ) c + ON c.album_key = h.album_key + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + GROUP BY c.album_key + '''); + } + + if (request.includeLocal && request.source != 'downloaded') { + final 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 = l.id + ) + ''', + ]; + _appendQueueLocalFilters(where, args, request); + parts.add(''' + SELECT + 'local' AS queue_source, + c.album_key, + MIN(l.album_name) AS album_name, + COALESCE(NULLIF(MIN(l.album_artist), ''), MIN(l.artist_name)) AS artist_name, + NULL AS cover_url, + MAX(CASE WHEN l.cover_path IS NOT NULL AND l.cover_path != '' THEN l.cover_path END) AS cover_path, + MAX(l.file_path) AS sample_file_path, + COUNT(*) AS track_count, + c.latest_added AS sort_added, + MIN(l.album_name_norm) AS sort_album, + MIN(l.album_artist_norm) AS sort_artist, + MAX(l.release_date) AS sort_release, + MAX(LOWER(COALESCE(l.genre, ''))) AS sort_genre + FROM library l + JOIN ( + SELECT + album_key, + COUNT(*) AS track_count, + MAX(COALESCE(file_mod_time, CAST(strftime('%s', scanned_at) AS INTEGER) * 1000)) AS latest_added + FROM library + 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 + ) + GROUP BY album_key + HAVING COUNT(*) > 1 + ) c ON c.album_key = l.album_key + ${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'} + GROUP BY c.album_key + '''); + } + + if (parts.isEmpty) { + return ''' + SELECT + NULL AS queue_source, + NULL AS album_key, + NULL AS album_name, + NULL AS artist_name, + NULL AS cover_url, + NULL AS cover_path, + NULL AS sample_file_path, + NULL AS track_count, + NULL AS sort_added, + NULL AS sort_album, + NULL AS sort_artist, + NULL AS sort_release, + NULL AS sort_genre + WHERE 0 + '''; + } + return parts.join(' UNION ALL '); + } + + void _appendQueueHistoryFilters( + List where, + List args, + QueueLibraryDbQuery request, + ) { + final query = LibraryDatabase.normalizeLookupText(request.searchQuery); + if (query.isNotEmpty) { + final like = '%${_escapeLikePattern(query)}%'; + where.add("h.search_text LIKE ? ESCAPE '\\'"); + args.add(like); + } + _appendQueueCommonFilters( + where, + args, + request, + filePathExpr: 'h.file_path', + formatExpr: 'h.format', + qualityExpr: 'h.quality', + bitDepthExpr: 'h.bit_depth', + artistExpr: 'h.artist_name', + albumArtistExpr: 'h.album_artist', + releaseDateExpr: 'h.release_date', + genreExpr: 'h.genre', + trackNumberExpr: 'h.track_number', + discNumberExpr: 'h.disc_number', + isrcExpr: 'h.isrc', + labelExpr: 'h.label', + ); + } + + void _appendQueueLocalFilters( + List where, + List args, + QueueLibraryDbQuery request, + ) { + final query = LibraryDatabase.normalizeLookupText(request.searchQuery); + if (query.isNotEmpty) { + final like = '%${_escapeLikePattern(query)}%'; + where.add(''' + ( + l.track_name_norm LIKE ? ESCAPE '\\' OR + l.artist_name_norm LIKE ? ESCAPE '\\' OR + l.album_name_norm LIKE ? ESCAPE '\\' OR + l.album_artist_norm LIKE ? ESCAPE '\\' + ) + '''); + args.addAll([like, like, like, like]); + } + _appendQueueCommonFilters( + where, + args, + request, + filePathExpr: 'l.file_path', + formatExpr: 'l.format', + qualityExpr: 'NULL', + bitDepthExpr: 'l.bit_depth', + artistExpr: 'l.artist_name', + albumArtistExpr: 'l.album_artist', + releaseDateExpr: 'l.release_date', + genreExpr: 'l.genre', + trackNumberExpr: 'l.track_number', + discNumberExpr: 'l.disc_number', + isrcExpr: 'l.isrc', + labelExpr: 'l.label', + ); + } + + void _appendQueueCommonFilters( + List where, + List args, + QueueLibraryDbQuery request, { + required String filePathExpr, + required String? formatExpr, + required String qualityExpr, + required String bitDepthExpr, + required String artistExpr, + required String albumArtistExpr, + required String releaseDateExpr, + required String genreExpr, + required String trackNumberExpr, + required String discNumberExpr, + required String isrcExpr, + required String labelExpr, + }) { + final quality = request.quality?.trim().toLowerCase(); + if (quality != null && quality.isNotEmpty) { + final isHiRes = + '(COALESCE($bitDepthExpr, 0) >= 24 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'24%\')'; + final isCd = + '(COALESCE($bitDepthExpr, 0) = 16 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'16%\')'; + switch (quality) { + case 'hires': + where.add(isHiRes); + break; + case 'cd': + where.add(isCd); + break; + case 'lossy': + where.add('NOT ($isHiRes OR $isCd)'); + break; + } + } + + final format = request.format?.trim().toLowerCase(); + if (format != null && format.isNotEmpty) { + if (formatExpr == null) { + where.add('LOWER($filePathExpr) LIKE ?'); + args.add('%.$format'); + } else { + where.add( + '(LOWER(COALESCE($formatExpr, \'\')) = ? OR LOWER($filePathExpr) LIKE ?)', + ); + args.addAll([format, '%.$format']); + } + } + + final metadata = request.metadata?.trim(); + if (metadata == null || metadata.isEmpty) return; + final hasArtist = 'TRIM(COALESCE($artistExpr, \'\')) != \'\''; + final hasAlbumArtist = 'TRIM(COALESCE($albumArtistExpr, \'\')) != \'\''; + final hasReleaseDate = + 'TRIM(COALESCE($releaseDateExpr, \'\')) GLOB \'*[0-9][0-9][0-9][0-9]*\''; + final hasGenre = 'TRIM(COALESCE($genreExpr, \'\')) != \'\''; + final hasTrackNumber = 'COALESCE($trackNumberExpr, 0) > 0'; + final hasDiscNumber = 'COALESCE($discNumberExpr, 0) > 0'; + final hasLabel = 'TRIM(COALESCE($labelExpr, \'\')) != \'\''; + final normalizedIsrc = + 'REPLACE(REPLACE(UPPER(TRIM(COALESCE($isrcExpr, \'\'))), \'-\', \'\'), \' \', \'\')'; + final hasIncorrectIsrc = + 'TRIM(COALESCE($isrcExpr, \'\')) != \'\' AND LENGTH($normalizedIsrc) != 12'; + final isComplete = + '($hasArtist AND $hasAlbumArtist AND $hasReleaseDate AND $hasGenre AND $hasTrackNumber AND $hasDiscNumber AND $hasLabel AND NOT ($hasIncorrectIsrc))'; + + switch (metadata) { + case 'complete': + where.add(isComplete); + break; + case 'missing-any': + where.add('NOT $isComplete'); + break; + case 'missing-year': + where.add('NOT ($hasReleaseDate)'); + break; + case 'missing-genre': + where.add('NOT ($hasGenre)'); + break; + case 'missing-album-artist': + where.add('NOT ($hasAlbumArtist)'); + break; + case 'missing-track-number': + where.add('NOT ($hasTrackNumber)'); + break; + case 'missing-disc-number': + where.add('NOT ($hasDiscNumber)'); + break; + case 'missing-artist': + where.add('NOT ($hasArtist)'); + break; + case 'incorrect-isrc-format': + where.add('($hasIncorrectIsrc)'); + break; + case 'missing-label': + where.add('NOT ($hasLabel)'); + break; + } + } + + String _queueTrackOrderBy(String sortMode) { + return switch (sortMode) { + 'oldest' => 'sort_added ASC, sort_track ASC', + 'a-z' => 'sort_track ASC, sort_artist ASC', + 'z-a' => 'sort_track DESC, sort_artist DESC', + 'artist-asc' => 'sort_artist ASC, sort_track ASC', + 'artist-desc' => 'sort_artist DESC, sort_track ASC', + 'album-asc' => 'sort_album ASC, sort_track ASC', + 'album-desc' => 'sort_album DESC, sort_track ASC', + 'release-oldest' => 'sort_release ASC, sort_track ASC', + 'release-newest' => 'sort_release DESC, sort_track ASC', + 'genre-asc' => 'sort_genre ASC, sort_track ASC', + 'genre-desc' => 'sort_genre DESC, sort_track ASC', + _ => 'sort_added DESC, sort_track ASC', + }; + } + + String _queueAlbumOrderBy(String sortMode) { + return switch (sortMode) { + 'oldest' => 'sort_added ASC, sort_album ASC', + 'a-z' || 'album-asc' => 'sort_album ASC, sort_artist ASC', + 'z-a' || 'album-desc' => 'sort_album DESC, sort_artist DESC', + 'artist-asc' => 'sort_artist ASC, sort_album ASC', + 'artist-desc' => 'sort_artist DESC, sort_album ASC', + 'release-oldest' => 'sort_release ASC, sort_album ASC', + 'release-newest' => 'sort_release DESC, sort_album ASC', + 'genre-asc' => 'sort_genre ASC, sort_album ASC', + 'genre-desc' => 'sort_genre DESC, sort_album ASC', + _ => 'sort_added DESC, sort_album ASC', + }; + } + + Map _queueTrackRowToJson(Map row) { + final source = row['queue_source'] as String? ?? ''; + if (source == 'local') { + return { + 'source': source, + 'item': { + 'id': row['id'], + 'trackName': row['track_name'], + 'artistName': row['artist_name'], + 'albumName': row['album_name'], + 'albumArtist': row['album_artist'], + 'filePath': row['file_path'], + 'coverPath': row['cover_path'], + 'scannedAt': row['scanned_at'], + 'fileModTime': row['file_mod_time'], + 'isrc': row['isrc'], + 'trackNumber': row['track_number'], + 'totalTracks': row['total_tracks'], + 'discNumber': row['disc_number'], + 'totalDiscs': row['total_discs'], + 'duration': row['duration'], + 'releaseDate': row['release_date'], + 'bitDepth': row['bit_depth'], + 'sampleRate': row['sample_rate'], + 'bitrate': row['bitrate'], + 'genre': row['genre'], + 'composer': row['composer'], + 'label': row['label'], + 'copyright': row['copyright'], + 'format': row['format'], + }, + }; + } + + return { + 'source': source, + 'item': { + 'id': row['id'], + 'trackName': row['track_name'], + 'artistName': row['artist_name'], + 'albumName': row['album_name'], + 'albumArtist': row['album_artist'], + 'coverUrl': row['cover_url'], + 'filePath': row['file_path'], + 'storageMode': row['storage_mode'], + 'downloadTreeUri': row['download_tree_uri'], + 'safRelativeDir': row['saf_relative_dir'], + 'safFileName': row['saf_file_name'], + 'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true, + 'service': row['service'], + 'downloadedAt': row['downloaded_at'], + 'isrc': row['isrc'], + 'spotifyId': row['spotify_id'], + 'trackNumber': row['track_number'], + 'totalTracks': row['total_tracks'], + 'discNumber': row['disc_number'], + 'totalDiscs': row['total_discs'], + 'duration': row['duration'], + 'releaseDate': row['release_date'], + 'quality': row['quality'], + 'bitDepth': row['bit_depth'], + 'sampleRate': row['sample_rate'], + 'bitrate': row['bitrate'], + 'format': row['format'], + 'genre': row['genre'], + 'composer': row['composer'], + 'label': row['label'], + 'copyright': row['copyright'], + }, + }; + } +}