import 'dart:io'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/utils/path_match_keys.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, 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 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; final Map filePathById; const LocalLibraryLookupIndex({ this.isrcs = const {}, this.matchKeys = const {}, this.filePathById = const {}, }); } 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; bool _historyAttached = false; LibraryDatabase._init(); Future get database async { if (_database != null) return _database!; _database = await _initDB('local_library.db'); return _database!; } Future _ensureHistoryAttached(Database db) async { if (_historyAttached) return; await HistoryDatabase.instance.database; final dbPath = await getApplicationDocumentsDirectory(); final historyPath = join(dbPath.path, 'history.db'); try { await db.execute('ATTACH DATABASE ? AS history_db', [historyPath]); } catch (e) { final message = e.toString().toLowerCase(); if (!message.contains('already in use') && !message.contains('already exists')) { rethrow; } } _historyAttached = true; } Future _initDB(String fileName) async { final dbPath = await getApplicationDocumentsDirectory(); final path = join(dbPath.path, fileName); _log.i('Initializing library database at: $path'); return await openDatabase( path, version: 8, onConfigure: (db) async { await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); }, onCreate: _createDB, onUpgrade: _upgradeDB, ); } Future _createDB(Database db, int version) async { _log.i('Creating library database schema v$version'); await db.execute(''' CREATE TABLE library ( id TEXT PRIMARY KEY, track_name TEXT NOT NULL, artist_name TEXT NOT NULL, album_name TEXT NOT NULL, album_artist TEXT, file_path TEXT NOT NULL UNIQUE, cover_path TEXT, scanned_at TEXT NOT NULL, file_mod_time INTEGER, isrc TEXT, track_number INTEGER, total_tracks INTEGER, disc_number INTEGER, total_discs INTEGER, duration INTEGER, release_date TEXT, bit_depth INTEGER, sample_rate INTEGER, bitrate INTEGER, genre TEXT, composer TEXT, label TEXT, copyright TEXT, format TEXT, track_name_norm TEXT, artist_name_norm TEXT, album_name_norm TEXT, album_artist_norm TEXT, match_key TEXT, album_key TEXT ) '''); await db.execute('CREATE INDEX idx_library_isrc ON library(isrc)'); await db.execute( 'CREATE INDEX idx_library_track_artist ON library(track_name, artist_name)', ); await db.execute( 'CREATE INDEX idx_library_album ON library(album_name, album_artist)', ); await db.execute( 'CREATE INDEX idx_library_file_path ON library(file_path)', ); await _createNormalizedIndexes(db); await _createPathKeyTable(db); _log.i('Library database schema created with indexes'); } Future _upgradeDB(Database db, int oldVersion, int newVersion) async { _log.i('Upgrading library database from v$oldVersion to v$newVersion'); if (oldVersion < 2) { await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT'); _log.i('Added cover_path column'); } if (oldVersion < 3) { await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER'); _log.i('Added file_mod_time column for incremental scanning'); } if (oldVersion < 4) { await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER'); _log.i('Added bitrate column for lossy format quality'); } if (oldVersion < 5) { await db.execute('ALTER TABLE library ADD COLUMN label TEXT'); await db.execute('ALTER TABLE library ADD COLUMN copyright TEXT'); _log.i('Added label/copyright columns'); } if (oldVersion < 6) { await db.execute('ALTER TABLE library ADD COLUMN total_tracks INTEGER'); await db.execute('ALTER TABLE library ADD COLUMN total_discs INTEGER'); await db.execute('ALTER TABLE library ADD COLUMN composer TEXT'); _log.i('Added total_tracks/total_discs/composer columns'); } if (oldVersion < 7) { await _addColumnIfMissing(db, 'library', 'track_name_norm', 'TEXT'); await _addColumnIfMissing(db, 'library', 'artist_name_norm', 'TEXT'); await _addColumnIfMissing(db, 'library', 'album_name_norm', 'TEXT'); await _addColumnIfMissing(db, 'library', 'album_artist_norm', 'TEXT'); await _addColumnIfMissing(db, 'library', 'match_key', 'TEXT'); await _addColumnIfMissing(db, 'library', 'album_key', 'TEXT'); await _backfillNormalizedColumns(db); await _createNormalizedIndexes(db); _log.i('Added normalized local library lookup columns'); } if (oldVersion < 8) { await _createPathKeyTable(db); await _backfillPathKeys(db); _log.i('Added local library path-key lookup table'); } } Future _createPathKeyTable(DatabaseExecutor db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS library_path_keys ( item_id TEXT NOT NULL, path_key TEXT NOT NULL, PRIMARY KEY (item_id, path_key) ) '''); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_library_path_keys_key ON library_path_keys(path_key)', ); } Future _backfillPathKeys(Database db) async { final rows = await db.query('library', columns: ['id', 'file_path']); final batch = db.batch(); for (final row in rows) { _putPathKeysInBatch( batch, row['id'] as String, row['file_path'] as String?, ); } await batch.commit(noResult: true); } void _putPathKeysInBatch(Batch batch, String id, String? filePath) { batch.delete('library_path_keys', where: 'item_id = ?', whereArgs: [id]); for (final key in buildPathMatchKeys(filePath)) { batch.insert('library_path_keys', { 'item_id': id, 'path_key': key, }, conflictAlgorithm: ConflictAlgorithm.ignore); } } static String normalizeLookupText(String? value) { return (value ?? '').trim().toLowerCase(); } static String matchKeyFor(String trackName, String artistName) { return '${normalizeLookupText(trackName)}|${normalizeLookupText(artistName)}'; } static String albumKeyFor( String albumName, String? albumArtist, String artistName, ) { return '${normalizeLookupText(albumName)}|${normalizeLookupText(albumArtist ?? artistName)}'; } Future _addColumnIfMissing( Database db, String table, String column, String type, ) async { final info = await db.rawQuery('PRAGMA table_info($table)'); final exists = info.any((row) => row['name'] == column); if (!exists) { await db.execute('ALTER TABLE $table ADD COLUMN $column $type'); } } Future _createNormalizedIndexes(DatabaseExecutor db) async { await db.execute( 'CREATE INDEX IF NOT EXISTS idx_library_match_key ON library(match_key)', ); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_library_album_key ON library(album_key)', ); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_library_track_norm ON library(track_name_norm)', ); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_library_artist_norm ON library(artist_name_norm)', ); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_library_album_norm ON library(album_name_norm)', ); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_library_scanned_at ON library(scanned_at)', ); } Future _backfillNormalizedColumns(Database db) async { final rows = await db.query( 'library', columns: [ 'id', 'track_name', 'artist_name', 'album_name', 'album_artist', ], ); final batch = db.batch(); for (final row in rows) { final trackName = row['track_name'] as String? ?? ''; final artistName = row['artist_name'] as String? ?? ''; final albumName = row['album_name'] as String? ?? ''; final albumArtist = row['album_artist'] as String?; batch.update( 'library', _normalizedColumns( trackName: trackName, artistName: artistName, albumName: albumName, albumArtist: albumArtist, ), where: 'id = ?', whereArgs: [row['id']], ); } await batch.commit(noResult: true); } Map _normalizedColumns({ required String trackName, required String artistName, required String albumName, required String? albumArtist, }) { final trackNorm = normalizeLookupText(trackName); final artistNorm = normalizeLookupText(artistName); final albumNorm = normalizeLookupText(albumName); final albumArtistNorm = normalizeLookupText(albumArtist ?? artistName); return { 'track_name_norm': trackNorm, 'artist_name_norm': artistNorm, 'album_name_norm': albumNorm, 'album_artist_norm': albumArtistNorm, 'match_key': '$trackNorm|$artistNorm', 'album_key': '$albumNorm|$albumArtistNorm', }; } Map _jsonToDbRow(Map json) { final row = { 'id': json['id'], 'track_name': json['trackName'], 'artist_name': json['artistName'], 'album_name': json['albumName'], 'album_artist': json['albumArtist'], 'file_path': json['filePath'], 'cover_path': json['coverPath'], 'scanned_at': json['scannedAt'], 'file_mod_time': json['fileModTime'], 'isrc': json['isrc'], 'track_number': json['trackNumber'], 'total_tracks': json['totalTracks'], 'disc_number': json['discNumber'], 'total_discs': json['totalDiscs'], 'duration': json['duration'], 'release_date': json['releaseDate'], 'bit_depth': json['bitDepth'], 'sample_rate': json['sampleRate'], 'bitrate': json['bitrate'], 'genre': json['genre'], 'composer': json['composer'], 'label': json['label'], 'copyright': json['copyright'], 'format': json['format'], }; row.addAll( _normalizedColumns( trackName: json['trackName'] as String? ?? '', artistName: json['artistName'] as String? ?? '', albumName: json['albumName'] as String? ?? '', albumArtist: json['albumArtist'] as String?, ), ); return row; } Map _dbRowToJson(Map row) { return { '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'], }; } Future upsert(Map json) async { final db = await database; await db.transaction((txn) async { await txn.insert( 'library', _jsonToDbRow(json), conflictAlgorithm: ConflictAlgorithm.replace, ); final batch = txn.batch(); _putPathKeysInBatch( batch, json['id'] as String, json['filePath'] as String?, ); await batch.commit(noResult: true); }); } Future upsertBatch(List> items) async { if (items.isEmpty) return; final db = await database; await db.transaction((txn) async { final batch = txn.batch(); for (final json in items) { batch.insert( 'library', _jsonToDbRow(json), conflictAlgorithm: ConflictAlgorithm.replace, ); _putPathKeysInBatch( batch, json['id'] as String, json['filePath'] as String?, ); } await batch.commit(noResult: true); }); _log.i('Batch inserted ${items.length} items'); } Future replaceAll(List> items) async { final db = await database; await db.transaction((txn) async { await txn.delete('library_path_keys'); await txn.delete('library'); if (items.isEmpty) { return; } final batch = txn.batch(); for (final json in items) { batch.insert( 'library', _jsonToDbRow(json), conflictAlgorithm: ConflictAlgorithm.replace, ); _putPathKeysInBatch( batch, json['id'] as String, json['filePath'] as String?, ); } await batch.commit(noResult: true); }); _log.i('Replaced library with ${items.length} items'); } Future>> getAll({int? limit, int? offset}) async { final db = await database; final rows = await db.query( 'library', orderBy: 'album_artist, album_name, disc_number, track_number', limit: limit, offset: offset, ); 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'\\') .replaceAll('%', r'\%') .replaceAll('_', r'\_'); } String _orderByForSort(LocalLibrarySortMode sortMode) { return switch (sortMode) { LocalLibrarySortMode.title => 'track_name_norm, artist_name_norm, album_name_norm, disc_number, track_number', LocalLibrarySortMode.artist => 'artist_name_norm, album_name_norm, disc_number, track_number, track_name_norm', LocalLibrarySortMode.latest => 'scanned_at DESC, album_artist_norm, album_name_norm, disc_number, track_number', LocalLibrarySortMode.quality => 'COALESCE(bit_depth, 0) DESC, COALESCE(sample_rate, 0) DESC, COALESCE(bitrate, 0) DESC, album_artist_norm, album_name_norm, disc_number, track_number', LocalLibrarySortMode.album => 'album_artist_norm, album_name_norm, COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name_norm', }; } 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 { final db = await database; await _ensureHistoryAttached(db); final args = []; final unionSql = _queueTrackUnionSql(request, args); final rows = await db.rawQuery( ''' SELECT * FROM ($unionSql) ORDER BY ${_queueTrackOrderBy(request.sortMode)} LIMIT ? OFFSET ? ''', [...args, request.limit, request.offset], ); return rows.map(_queueTrackRowToJson).toList(growable: false); } Future getQueueCounts(QueueLibraryDbQuery request) async { final db = await database; await _ensureHistoryAttached(db); final allArgs = []; final allSql = _queueTrackUnionSql( QueueLibraryDbQuery( limit: request.limit, offset: request.offset, filterMode: 'all', searchQuery: request.searchQuery, source: request.source, quality: request.quality, format: request.format, metadata: request.metadata, sortMode: request.sortMode, includeLocal: request.includeLocal, ), allArgs, ); final allRows = await db.rawQuery( 'SELECT COUNT(*) AS count FROM ($allSql)', allArgs, ); final singleArgs = []; final singleSql = _queueTrackUnionSql( QueueLibraryDbQuery( limit: request.limit, offset: request.offset, filterMode: 'singles', searchQuery: request.searchQuery, source: request.source, quality: request.quality, format: request.format, metadata: request.metadata, sortMode: request.sortMode, includeLocal: request.includeLocal, ), singleArgs, ); final singleRows = await db.rawQuery( 'SELECT COUNT(*) AS count FROM ($singleSql)', singleArgs, ); final albumArgs = []; final albumSql = _queueAlbumUnionSql(request, albumArgs); final albumRows = await db.rawQuery( 'SELECT COUNT(*) AS count FROM ($albumSql)', albumArgs, ); return QueueLibraryCounts( allTrackCount: Sqflite.firstIntValue(allRows) ?? 0, albumCount: Sqflite.firstIntValue(albumRows) ?? 0, singleTrackCount: Sqflite.firstIntValue(singleRows) ?? 0, ); } Future>> getQueueAlbumPage( QueueLibraryDbQuery request, ) async { final db = await database; await _ensureHistoryAttached(db); final args = []; final unionSql = _queueAlbumUnionSql(request, args); final rows = await db.rawQuery( ''' SELECT * FROM ($unionSql) ORDER BY ${_queueAlbumOrderBy(request.sortMode)} LIMIT ? OFFSET ? ''', [...args, request.limit, request.offset], ); return rows.toList(growable: false); } Future>> getQueueLocalAlbumTracks( String albumName, String artistName, ) async { final db = await database; final rows = await db.query( 'library', where: 'LOWER(album_name) = ? AND LOWER(COALESCE(album_artist, artist_name)) = ?', whereArgs: [albumName.toLowerCase(), artistName.toLowerCase()], orderBy: 'COALESCE(disc_number, 0), COALESCE(track_number, 0), track_name', ); 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(''' LOWER(h.album_name) || '|' || LOWER(COALESCE(h.album_artist, h.artist_name)) IN ( SELECT LOWER(album_name) || '|' || LOWER(COALESCE(album_artist, artist_name)) FROM history_db.history GROUP BY LOWER(album_name), LOWER(COALESCE(album_artist, artist_name)) 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, NULL AS bitrate, NULL AS 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 LOWER(album_name) || '|' || LOWER(COALESCE(album_artist, artist_name)) AS album_key, COUNT(*) AS track_count, MAX(CAST(strftime('%s', downloaded_at) AS INTEGER) * 1000) AS latest_added FROM history_db.history GROUP BY LOWER(album_name), LOWER(COALESCE(album_artist, artist_name)) HAVING COUNT(*) > 1 ) c ON c.album_key = LOWER(h.album_name) || '|' || LOWER(COALESCE(h.album_artist, h.artist_name)) ${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(''' ( LOWER(h.track_name) LIKE ? ESCAPE '\\' OR LOWER(h.artist_name) LIKE ? ESCAPE '\\' OR LOWER(h.album_name) LIKE ? ESCAPE '\\' OR LOWER(COALESCE(h.album_artist, '')) LIKE ? ESCAPE '\\' ) '''); args.addAll([like, like, like, like]); } _appendQueueCommonFilters( where, args, request, filePathExpr: 'h.file_path', 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', 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 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) { where.add('LOWER($filePathExpr) LIKE ?'); args.add('%.$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'], '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( 'library', where: 'id = ?', whereArgs: [id], limit: 1, ); if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } Future?> getByIsrc(String isrc) async { final db = await database; final rows = await db.query( 'library', where: 'isrc = ?', whereArgs: [isrc], limit: 1, ); if (rows.isEmpty) return null; 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, ) async { final db = await database; final rows = await db.query( 'library', where: 'match_key = ?', whereArgs: [matchKeyFor(trackName, artistName)], ); return rows.map(_dbRowToJson).toList(); } Future?> findFirstByTrackAndArtist( String trackName, String artistName, ) async { final db = await database; final rows = await db.query( 'library', where: 'match_key = ?', whereArgs: [matchKeyFor(trackName, artistName)], orderBy: _orderByForSort(LocalLibrarySortMode.album), limit: 1, ); if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } Future?> findExisting({ String? isrc, String? trackName, String? artistName, }) async { if (isrc != null && isrc.isNotEmpty) { final byIsrc = await getByIsrc(isrc); if (byIsrc != null) return byIsrc; } if (trackName != null && artistName != null) { final matches = await findByTrackAndArtist(trackName, artistName); if (matches.isNotEmpty) return matches.first; } return null; } 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 id, file_path, isrc, match_key FROM library', ); final isrcs = {}; final matchKeys = {}; final filePathById = {}; for (final row in rows) { final id = row['id'] as String?; final filePath = row['file_path'] as String?; if (id != null && id.isNotEmpty && filePath != null) { filePathById[id] = filePath; } final isrc = row['isrc'] as String?; if (isrc != null && isrc.isNotEmpty) { isrcs.add(isrc); } final matchKey = row['match_key'] as String?; if (matchKey != null && matchKey.isNotEmpty) { matchKeys.add(matchKey); } } return LocalLibraryLookupIndex( isrcs: Set.unmodifiable(isrcs), matchKeys: Set.unmodifiable(matchKeys), filePathById: Map.unmodifiable(filePathById), ); } Future> getCoverPaths({int? limit, int? offset}) async { final db = await database; final rows = await db.query( 'library', columns: ['cover_path'], where: 'cover_path IS NOT NULL AND cover_path != ""', limit: limit, offset: offset, ); return rows .map((row) => row['cover_path'] as String?) .whereType() .toList(growable: false); } Future deleteByPath(String filePath) async { final db = await database; final rows = await db.query( 'library', columns: ['id'], where: 'file_path = ?', whereArgs: [filePath], ); final ids = rows.map((row) => row['id'] as String).toList(growable: false); await db.transaction((txn) async { for (final id in ids) { await txn.delete( 'library_path_keys', where: 'item_id = ?', whereArgs: [id], ); } await txn.delete( 'library', where: 'file_path = ?', whereArgs: [filePath], ); }); } Future replaceWithConvertedItem({ required LocalLibraryItem item, required String newFilePath, required String targetFormat, required String bitrate, }) async { final db = await database; final stat = await fileStat(newFilePath); final now = DateTime.now(); final normalizedFormat = _normalizeConvertedFormat(targetFormat); final updated = item.toJson() ..['id'] = _generateLibraryId(newFilePath) ..['filePath'] = newFilePath ..['scannedAt'] = now.toIso8601String() ..['fileModTime'] = stat?.modified?.millisecondsSinceEpoch ..['format'] = normalizedFormat ..['bitrate'] = _convertedBitrate( targetFormat: targetFormat, bitrate: bitrate, ); if (normalizedFormat == 'mp3' || normalizedFormat == 'opus') { updated['bitDepth'] = null; } await db.transaction((txn) async { await txn.delete( 'library_path_keys', where: 'item_id = ?', whereArgs: [item.id], ); await txn.delete( 'library', where: 'id = ? OR file_path = ?', whereArgs: [item.id, item.filePath], ); await txn.insert( 'library', _jsonToDbRow(updated), conflictAlgorithm: ConflictAlgorithm.replace, ); final batch = txn.batch(); _putPathKeysInBatch( batch, updated['id'] as String, updated['filePath'] as String?, ); await batch.commit(noResult: true); }); } Future updateAudioMetadata( String id, { int? duration, int? bitDepth, int? sampleRate, int? bitrate, }) async { final values = {}; if (duration != null && duration > 0) { values['duration'] = duration; } if (bitDepth != null && bitDepth > 0) { values['bit_depth'] = bitDepth; } if (sampleRate != null && sampleRate > 0) { values['sample_rate'] = sampleRate; } if (bitrate != null && bitrate > 0) { values['bitrate'] = bitrate; } if (values.isEmpty) return; final db = await database; await db.update('library', values, where: 'id = ?', whereArgs: [id]); } Future delete(String id) async { final db = await database; await db.transaction((txn) async { await txn.delete( 'library_path_keys', where: 'item_id = ?', whereArgs: [id], ); await txn.delete('library', where: 'id = ?', whereArgs: [id]); }); } Future cleanupMissingFiles() async { final db = await database; final rows = await db.query('library', columns: ['id', 'file_path']); final missingIds = []; const checkChunkSize = 16; for (var i = 0; i < rows.length; i += checkChunkSize) { final end = (i + checkChunkSize < rows.length) ? i + checkChunkSize : rows.length; final chunk = rows.sublist(i, end); final checks = await Future.wait>( chunk.map((row) async { final id = row['id'] as String; final filePath = row['file_path'] as String; return MapEntry(id, await fileExists(filePath)); }), ); for (final check in checks) { if (!check.value) { missingIds.add(check.key); } } } if (missingIds.isEmpty) { return 0; } var removed = 0; const deleteChunkSize = 500; for (var i = 0; i < missingIds.length; i += deleteChunkSize) { final end = (i + deleteChunkSize < missingIds.length) ? i + deleteChunkSize : missingIds.length; final idChunk = missingIds.sublist(i, end); final placeholders = List.filled(idChunk.length, '?').join(','); await db.rawDelete( 'DELETE FROM library_path_keys WHERE item_id IN ($placeholders)', idChunk, ); removed += await db.rawDelete( 'DELETE FROM library WHERE id IN ($placeholders)', idChunk, ); } if (removed > 0) { _log.i('Cleaned up $removed missing files from library'); } return removed; } Future clearAll() async { final db = await database; await db.transaction((txn) async { await txn.delete('library_path_keys'); await txn.delete('library'); }); _log.i('Cleared all library data'); } Future getCount() async { final db = await database; final result = await db.rawQuery('SELECT COUNT(*) as count FROM library'); return Sqflite.firstIntValue(result) ?? 0; } Future>> search( String query, { int limit = 50, }) async { final db = await database; final searchQuery = '%${_escapeLikePattern(query.toLowerCase())}%'; final rows = await db.query( 'library', where: "LOWER(track_name) LIKE ? ESCAPE '\\' OR LOWER(artist_name) LIKE ? ESCAPE '\\' OR LOWER(album_name) LIKE ? ESCAPE '\\'", whereArgs: [searchQuery, searchQuery, searchQuery], orderBy: 'track_name', limit: limit, ); return rows.map(_dbRowToJson).toList(); } Future close() async { final db = await database; await db.close(); _database = null; _historyAttached = false; } Future> getFileModTimes() async { final db = await database; final rows = await db.rawQuery( 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library', ); final result = {}; for (final row in rows) { final path = row['file_path'] as String; final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0; result[path] = modTime; } return result; } Future writeFileModTimesSnapshot() async { final db = await database; final rows = await db.rawQuery( 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library', ); final tempDir = await getTemporaryDirectory(); final file = File( join( tempDir.path, 'library_file_mod_times_${DateTime.now().microsecondsSinceEpoch}.tsv', ), ); final buffer = StringBuffer(); for (final row in rows) { final path = row['file_path'] as String?; if (path == null || path.isEmpty) continue; final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0; buffer ..write(modTime) ..write('\t') ..writeln(path); } await file.writeAsString(buffer.toString(), flush: true); return file.path; } Future updateFileModTimes(Map fileModTimes) async { if (fileModTimes.isEmpty) return; final db = await database; final batch = db.batch(); for (final entry in fileModTimes.entries) { batch.update( 'library', {'file_mod_time': entry.value}, where: 'file_path = ?', whereArgs: [entry.key], ); } await batch.commit(noResult: true); } Future> getAllFilePaths() async { final db = await database; final rows = await db.rawQuery('SELECT file_path FROM library'); return rows.map((r) => r['file_path'] as String).toSet(); } Future deleteByPaths(List filePaths) async { if (filePaths.isEmpty) return 0; final db = await database; var totalDeleted = 0; const chunkSize = 500; for (var i = 0; i < filePaths.length; i += chunkSize) { final end = (i + chunkSize < filePaths.length) ? i + chunkSize : filePaths.length; final chunk = filePaths.sublist(i, end); final placeholders = List.filled(chunk.length, '?').join(','); final rows = await db.rawQuery( 'SELECT id FROM library WHERE file_path IN ($placeholders)', chunk, ); final ids = rows .map((row) => row['id'] as String) .toList(growable: false); if (ids.isNotEmpty) { final idPlaceholders = List.filled(ids.length, '?').join(','); await db.rawDelete( 'DELETE FROM library_path_keys WHERE item_id IN ($idPlaceholders)', ids, ); } totalDeleted += await db.rawDelete( 'DELETE FROM library WHERE file_path IN ($placeholders)', chunk, ); } if (totalDeleted > 0) { _log.i('Deleted $totalDeleted items from library'); } return totalDeleted; } String _normalizeConvertedFormat(String targetFormat) { switch (targetFormat.trim().toLowerCase()) { case 'alac': return 'm4a'; case 'flac': return 'flac'; case 'opus': return 'opus'; default: return 'mp3'; } } int? _convertedBitrate({ required String targetFormat, required String bitrate, }) { switch (targetFormat.trim().toLowerCase()) { case 'mp3': case 'opus': final match = RegExp(r'(\d+)').firstMatch(bitrate); return match != null ? int.tryParse(match.group(1)!) : null; default: return null; } } String _generateLibraryId(String filePath) { return 'lib_${_hashString(filePath).toRadixString(16)}'; } int _hashString(String input) { var hash = 5381; for (final codeUnit in input.codeUnits) { hash = (((hash << 5) + hash) + codeUnit) & 0xffffffff; } return hash; } }