Files
SpotiFLAC-Mobile/lib/services/library_database.dart
T
zarzet 01a5b43613 perf: unify queue tab with DB-backed pagination and cross-database queries
Replace in-memory list merging in the queue tab with fully database-
backed pagination using ATTACH DATABASE to join library and history
tables in a single UNION ALL query.

Queue tab:
- Remove localLibraryAllItemsProvider and _queueHistoryStatsProvider
- Add _queueLibraryPageProvider and _queueLibraryCountsProvider backed
  by LibraryDatabase.getQueueTrackPage/getQueueCounts/getQueueAlbumPage
- Implement infinite scroll via _handleLibraryScrollNotification with
  _libraryPageLimit growing by 300 per batch
- Album/single/total counts computed via SQL GROUP BY aggregates

History database (v5 -> v8):
- v6: add idx_history_track_artist index
- v7: add history_path_keys table for cross-DB dedup, backfill from
  existing rows
- v8: add spotify_id_norm, isrc_norm, match_key normalized columns
  with indexes, backfill from existing data
- Add getAlbumTracks, findByTrackAndArtist, getGroupedCounts,
  existsTrack, findExistingTrack, existingTrackKeys batch lookup
- deleteBySpotifyId now returns deleted count for accurate totalCount
- All write paths maintain history_path_keys consistency

Library database (v7 -> v8):
- v8: add library_path_keys table for cross-DB dedup
- Add getQueueTrackPage, getQueueCounts, getQueueAlbumPage with
  ATTACH DATABASE for cross-DB UNION ALL queries
- Dedup local items against history via path_keys JOIN
- All write/delete paths maintain library_path_keys consistency

Download history provider:
- Load only 100 recent items into state.items at startup
- Store lookupItems as immutable List field instead of recomputing
  from maps on every access
- Add async fallback to DB in _putInMemoryHistory for items outside
  the 100-item window
- Add downloadHistoryPageProvider, downloadHistoryGroupedCountsProvider,
  downloadedAlbumTracksProvider, downloadHistoryBatchExistsProvider
- Add catchError to adoptNativeHistoryItem async block
- Fix removeBySpotifyId to query actual DB count instead of decrement

Screen migrations:
- album/artist/playlist/home screens use async DB lookups instead of
  sync in-memory state for track existence and playback resolution
- downloaded_album_screen uses downloadedAlbumTracksProvider
- library_tracks_folder_screen uses downloadHistoryBatchExistsProvider
  for skip-downloaded checks and cover resolution
2026-05-06 04:38:51 +07:00

2040 lines
62 KiB
Dart

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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String> isrcs;
final Set<String> matchKeys;
final Map<String, String> filePathById;
const LocalLibraryLookupIndex({
this.isrcs = const <String>{},
this.matchKeys = const <String>{},
this.filePathById = const <String, String>{},
});
}
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<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('local_library.db');
return _database!;
}
Future<void> _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<Database> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<String, dynamic> _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<String, dynamic> _jsonToDbRow(Map<String, dynamic> 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<String, dynamic> _dbRowToJson(Map<String, dynamic> 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<void> upsert(Map<String, dynamic> 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<void> upsertBatch(List<Map<String, dynamic>> 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<void> replaceAll(List<Map<String, dynamic>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> getPage(
LocalLibraryPageRequest request,
) async {
final db = await database;
final where = <String>[];
final whereArgs = <Object?>[];
_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<int> getPageCount(LocalLibraryPageRequest request) async {
final db = await database;
final where = <String>[];
final whereArgs = <Object?>[];
_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<List<LocalLibraryAlbumGroup>> getAlbumPage({
int limit = 100,
int offset = 0,
LocalLibraryFilterMode filterMode = LocalLibraryFilterMode.albums,
LocalLibrarySortMode sortMode = LocalLibrarySortMode.album,
String? searchQuery,
}) async {
final db = await database;
final where = <String>[];
final whereArgs = <Object?>[];
_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<int> getAlbumCount({
LocalLibraryFilterMode filterMode = LocalLibraryFilterMode.albums,
String? searchQuery,
}) async {
final db = await database;
final where = <String>[];
final whereArgs = <Object?>[];
_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<String> where,
List<Object?> 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<String> where,
List<Object?> 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<List<Map<String, dynamic>>> getQueueTrackPage(
QueueLibraryDbQuery request,
) async {
final db = await database;
await _ensureHistoryAttached(db);
final args = <Object?>[];
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<QueueLibraryCounts> getQueueCounts(QueueLibraryDbQuery request) async {
final db = await database;
await _ensureHistoryAttached(db);
final allArgs = <Object?>[];
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 = <Object?>[];
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 = <Object?>[];
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<List<Map<String, dynamic>>> getQueueAlbumPage(
QueueLibraryDbQuery request,
) async {
final db = await database;
await _ensureHistoryAttached(db);
final args = <Object?>[];
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<List<Map<String, dynamic>>> 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<Object?> args) {
final parts = <String>[];
if (request.source != 'local') {
final where = <String>[];
_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 = <String>[
'''
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<Object?> args) {
final parts = <String>[];
if (request.source != 'local') {
final where = <String>[];
_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 = <String>[
'''
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<String> where,
List<Object?> 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<String> where,
List<Object?> 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<String> where,
List<Object?> 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<String, dynamic> _queueTrackRowToJson(Map<String, dynamic> 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<Map<String, dynamic>?> 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<Map<String, dynamic>?> 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<Map<String, dynamic>?> 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<bool> 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<List<Map<String, dynamic>>> 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<Map<String, dynamic>?> 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<Map<String, dynamic>?> 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<Set<String>> 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<Set<String>> 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<LocalLibraryLookupIndex> getLookupIndex() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT id, file_path, isrc, match_key FROM library',
);
final isrcs = <String>{};
final matchKeys = <String>{};
final filePathById = <String, String>{};
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<String>.unmodifiable(isrcs),
matchKeys: Set<String>.unmodifiable(matchKeys),
filePathById: Map<String, String>.unmodifiable(filePathById),
);
}
Future<List<String>> 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<String>()
.toList(growable: false);
}
Future<void> 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<void> 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<void> updateAudioMetadata(
String id, {
int? duration,
int? bitDepth,
int? sampleRate,
int? bitrate,
}) async {
final values = <String, dynamic>{};
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<void> 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<int> cleanupMissingFiles() async {
final db = await database;
final rows = await db.query('library', columns: ['id', 'file_path']);
final missingIds = <String>[];
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<MapEntry<String, bool>>(
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<void> 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<int> getCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM library');
return Sqflite.firstIntValue(result) ?? 0;
}
Future<List<Map<String, dynamic>>> 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<void> close() async {
final db = await database;
await db.close();
_database = null;
_historyAttached = false;
}
Future<Map<String, int>> 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 = <String, int>{};
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<String> 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<void> updateFileModTimes(Map<String, int> 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<Set<String>> 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<int> deleteByPaths(List<String> 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;
}
}