refactor(library): split row models and queue union SQL into part files

This commit is contained in:
zarzet
2026-07-27 00:32:42 +07:00
parent 5ee7f733cf
commit d0dca1a101
3 changed files with 793 additions and 782 deletions
+3 -782
View File
@@ -9,221 +9,11 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
part 'library_database_models.dart';
part 'library_database_queue_sql.dart';
final _log = AppLogger('LibraryDatabase');
class LocalLibraryItem {
final String id;
final String trackName;
final String artistName;
final String albumName;
final String? albumArtist;
final String filePath;
final String? coverPath;
final DateTime scannedAt;
final int? fileModTime;
final String? isrc;
final int? trackNumber;
final int? totalTracks;
final int? discNumber;
final int? totalDiscs;
final int? duration;
final String? releaseDate;
final int? bitDepth;
final int? sampleRate;
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
final String? genre;
final String? composer;
final String? label;
final String? copyright;
final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a
const LocalLibraryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.albumArtist,
required this.filePath,
this.coverPath,
required this.scannedAt,
this.fileModTime,
this.isrc,
this.trackNumber,
this.totalTracks,
this.discNumber,
this.totalDiscs,
this.duration,
this.releaseDate,
this.bitDepth,
this.sampleRate,
this.bitrate,
this.genre,
this.composer,
this.label,
this.copyright,
this.format,
});
Map<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 LocalLibraryLookupIndex {
final Set<String> isrcs;
final Set<String> matchKeys;
const LocalLibraryLookupIndex({
this.isrcs = const <String>{},
this.matchKeys = const <String>{},
});
}
class LocalLibraryBatchLookupRequest {
final String? id;
final String? isrc;
final String trackName;
final String artistName;
const LocalLibraryBatchLookupRequest({
this.id,
this.isrc,
required this.trackName,
required this.artistName,
});
}
class IsrcDuplicateEntry {
final String id;
final String source; // 'downloaded' (history row) or 'local' (library row)
final String trackName;
final String artistName;
final String albumName;
final String filePath;
final int? bitDepth;
final int? sampleRate;
final int? bitrate;
final String? format;
const IsrcDuplicateEntry({
required this.id,
required this.source,
required this.trackName,
required this.artistName,
required this.albumName,
required this.filePath,
this.bitDepth,
this.sampleRate,
this.bitrate,
this.format,
});
}
class IsrcDuplicateGroup {
final String isrc;
final List<IsrcDuplicateEntry> entries;
const IsrcDuplicateGroup({required this.isrc, required this.entries});
}
class QueueLibraryDbQuery {
final int limit;
final int offset;
final String filterMode;
final String? searchQuery;
final String? source;
final String? quality;
final String? format;
final String? metadata;
final String sortMode;
final bool includeLocal;
const QueueLibraryDbQuery({
this.limit = 100,
this.offset = 0,
this.filterMode = 'all',
this.searchQuery,
this.source,
this.quality,
this.format,
this.metadata,
this.sortMode = 'latest',
this.includeLocal = true,
});
}
class QueueLibraryCounts {
final int allTrackCount;
final int albumCount;
final int singleTrackCount;
const QueueLibraryCounts({
required this.allTrackCount,
required this.albumCount,
required this.singleTrackCount,
});
}
class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
static Database? _database;
@@ -782,575 +572,6 @@ class LibraryDatabase {
return rows.map(_dbRowToJson).toList(growable: false);
}
String _queueTrackUnionSql(QueueLibraryDbQuery request, List<Object?> args) {
final parts = <String>[];
if (request.source != 'local') {
final where = <String>[];
_appendQueueHistoryFilters(where, args, request);
if (request.filterMode == 'singles') {
where.add('''
h.album_key IN (
SELECT album_key
FROM history_db.history
GROUP BY album_key
HAVING COUNT(*) = 1
)
''');
}
parts.add('''
SELECT
'downloaded' AS queue_source,
'dl_' || h.id AS unified_id,
h.id,
h.track_name,
h.artist_name,
h.album_name,
h.album_artist,
h.cover_url,
h.file_path,
h.storage_mode,
h.download_tree_uri,
h.saf_relative_dir,
h.saf_file_name,
h.saf_repaired,
h.service,
h.downloaded_at,
h.isrc,
h.spotify_id,
h.track_number,
h.total_tracks,
h.disc_number,
h.total_discs,
h.duration,
h.release_date,
h.quality,
h.bit_depth,
h.sample_rate,
h.genre,
h.composer,
h.label,
h.copyright,
NULL AS cover_path,
NULL AS scanned_at,
NULL AS file_mod_time,
h.bitrate,
h.format,
LOWER(h.track_name) AS sort_track,
LOWER(h.artist_name) AS sort_artist,
LOWER(h.album_name) AS sort_album,
LOWER(COALESCE(h.genre, '')) AS sort_genre,
h.release_date AS sort_release,
CAST(strftime('%s', h.downloaded_at) AS INTEGER) * 1000 AS sort_added
FROM history_db.history h
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
''');
}
if (request.includeLocal && request.source != 'downloaded') {
final where = <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
album_key,
COUNT(*) AS track_count,
MAX(CAST(strftime('%s', downloaded_at) AS INTEGER) * 1000) AS latest_added
FROM history_db.history
GROUP BY album_key
HAVING COUNT(*) > 1
) c
ON c.album_key = h.album_key
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
GROUP BY c.album_key
''');
}
if (request.includeLocal && request.source != 'downloaded') {
final where = <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("h.search_text LIKE ? ESCAPE '\\'");
args.add(like);
}
_appendQueueCommonFilters(
where,
args,
request,
filePathExpr: 'h.file_path',
formatExpr: 'h.format',
qualityExpr: 'h.quality',
bitDepthExpr: 'h.bit_depth',
artistExpr: 'h.artist_name',
albumArtistExpr: 'h.album_artist',
releaseDateExpr: 'h.release_date',
genreExpr: 'h.genre',
trackNumberExpr: 'h.track_number',
discNumberExpr: 'h.disc_number',
isrcExpr: 'h.isrc',
labelExpr: 'h.label',
);
}
void _appendQueueLocalFilters(
List<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',
formatExpr: 'l.format',
qualityExpr: 'NULL',
bitDepthExpr: 'l.bit_depth',
artistExpr: 'l.artist_name',
albumArtistExpr: 'l.album_artist',
releaseDateExpr: 'l.release_date',
genreExpr: 'l.genre',
trackNumberExpr: 'l.track_number',
discNumberExpr: 'l.disc_number',
isrcExpr: 'l.isrc',
labelExpr: 'l.label',
);
}
void _appendQueueCommonFilters(
List<String> where,
List<Object?> args,
QueueLibraryDbQuery request, {
required String filePathExpr,
required String? formatExpr,
required String qualityExpr,
required String bitDepthExpr,
required String artistExpr,
required String albumArtistExpr,
required String releaseDateExpr,
required String genreExpr,
required String trackNumberExpr,
required String discNumberExpr,
required String isrcExpr,
required String labelExpr,
}) {
final quality = request.quality?.trim().toLowerCase();
if (quality != null && quality.isNotEmpty) {
final isHiRes =
'(COALESCE($bitDepthExpr, 0) >= 24 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'24%\')';
final isCd =
'(COALESCE($bitDepthExpr, 0) = 16 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'16%\')';
switch (quality) {
case 'hires':
where.add(isHiRes);
break;
case 'cd':
where.add(isCd);
break;
case 'lossy':
where.add('NOT ($isHiRes OR $isCd)');
break;
}
}
final format = request.format?.trim().toLowerCase();
if (format != null && format.isNotEmpty) {
if (formatExpr == null) {
where.add('LOWER($filePathExpr) LIKE ?');
args.add('%.$format');
} else {
where.add(
'(LOWER(COALESCE($formatExpr, \'\')) = ? OR LOWER($filePathExpr) LIKE ?)',
);
args.addAll([format, '%.$format']);
}
}
final metadata = request.metadata?.trim();
if (metadata == null || metadata.isEmpty) return;
final hasArtist = 'TRIM(COALESCE($artistExpr, \'\')) != \'\'';
final hasAlbumArtist = 'TRIM(COALESCE($albumArtistExpr, \'\')) != \'\'';
final hasReleaseDate =
'TRIM(COALESCE($releaseDateExpr, \'\')) GLOB \'*[0-9][0-9][0-9][0-9]*\'';
final hasGenre = 'TRIM(COALESCE($genreExpr, \'\')) != \'\'';
final hasTrackNumber = 'COALESCE($trackNumberExpr, 0) > 0';
final hasDiscNumber = 'COALESCE($discNumberExpr, 0) > 0';
final hasLabel = 'TRIM(COALESCE($labelExpr, \'\')) != \'\'';
final normalizedIsrc =
'REPLACE(REPLACE(UPPER(TRIM(COALESCE($isrcExpr, \'\'))), \'-\', \'\'), \' \', \'\')';
final hasIncorrectIsrc =
'TRIM(COALESCE($isrcExpr, \'\')) != \'\' AND LENGTH($normalizedIsrc) != 12';
final isComplete =
'($hasArtist AND $hasAlbumArtist AND $hasReleaseDate AND $hasGenre AND $hasTrackNumber AND $hasDiscNumber AND $hasLabel AND NOT ($hasIncorrectIsrc))';
switch (metadata) {
case 'complete':
where.add(isComplete);
break;
case 'missing-any':
where.add('NOT $isComplete');
break;
case 'missing-year':
where.add('NOT ($hasReleaseDate)');
break;
case 'missing-genre':
where.add('NOT ($hasGenre)');
break;
case 'missing-album-artist':
where.add('NOT ($hasAlbumArtist)');
break;
case 'missing-track-number':
where.add('NOT ($hasTrackNumber)');
break;
case 'missing-disc-number':
where.add('NOT ($hasDiscNumber)');
break;
case 'missing-artist':
where.add('NOT ($hasArtist)');
break;
case 'incorrect-isrc-format':
where.add('($hasIncorrectIsrc)');
break;
case 'missing-label':
where.add('NOT ($hasLabel)');
break;
}
}
String _queueTrackOrderBy(String sortMode) {
return switch (sortMode) {
'oldest' => 'sort_added ASC, sort_track ASC',
'a-z' => 'sort_track ASC, sort_artist ASC',
'z-a' => 'sort_track DESC, sort_artist DESC',
'artist-asc' => 'sort_artist ASC, sort_track ASC',
'artist-desc' => 'sort_artist DESC, sort_track ASC',
'album-asc' => 'sort_album ASC, sort_track ASC',
'album-desc' => 'sort_album DESC, sort_track ASC',
'release-oldest' => 'sort_release ASC, sort_track ASC',
'release-newest' => 'sort_release DESC, sort_track ASC',
'genre-asc' => 'sort_genre ASC, sort_track ASC',
'genre-desc' => 'sort_genre DESC, sort_track ASC',
_ => 'sort_added DESC, sort_track ASC',
};
}
String _queueAlbumOrderBy(String sortMode) {
return switch (sortMode) {
'oldest' => 'sort_added ASC, sort_album ASC',
'a-z' || 'album-asc' => 'sort_album ASC, sort_artist ASC',
'z-a' || 'album-desc' => 'sort_album DESC, sort_artist DESC',
'artist-asc' => 'sort_artist ASC, sort_album ASC',
'artist-desc' => 'sort_artist DESC, sort_album ASC',
'release-oldest' => 'sort_release ASC, sort_album ASC',
'release-newest' => 'sort_release DESC, sort_album ASC',
'genre-asc' => 'sort_genre ASC, sort_album ASC',
'genre-desc' => 'sort_genre DESC, sort_album ASC',
_ => 'sort_added DESC, sort_album ASC',
};
}
Map<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'],
'bitrate': row['bitrate'],
'format': row['format'],
'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(
+216
View File
@@ -0,0 +1,216 @@
part of 'library_database.dart';
// Row models and query descriptors for the local library database.
class LocalLibraryItem {
final String id;
final String trackName;
final String artistName;
final String albumName;
final String? albumArtist;
final String filePath;
final String? coverPath;
final DateTime scannedAt;
final int? fileModTime;
final String? isrc;
final int? trackNumber;
final int? totalTracks;
final int? discNumber;
final int? totalDiscs;
final int? duration;
final String? releaseDate;
final int? bitDepth;
final int? sampleRate;
final int? bitrate; // kbps, for lossy formats (mp3, opus, ogg)
final String? genre;
final String? composer;
final String? label;
final String? copyright;
final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a
const LocalLibraryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.albumArtist,
required this.filePath,
this.coverPath,
required this.scannedAt,
this.fileModTime,
this.isrc,
this.trackNumber,
this.totalTracks,
this.discNumber,
this.totalDiscs,
this.duration,
this.releaseDate,
this.bitDepth,
this.sampleRate,
this.bitrate,
this.genre,
this.composer,
this.label,
this.copyright,
this.format,
});
Map<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 LocalLibraryLookupIndex {
final Set<String> isrcs;
final Set<String> matchKeys;
const LocalLibraryLookupIndex({
this.isrcs = const <String>{},
this.matchKeys = const <String>{},
});
}
class LocalLibraryBatchLookupRequest {
final String? id;
final String? isrc;
final String trackName;
final String artistName;
const LocalLibraryBatchLookupRequest({
this.id,
this.isrc,
required this.trackName,
required this.artistName,
});
}
class IsrcDuplicateEntry {
final String id;
final String source; // 'downloaded' (history row) or 'local' (library row)
final String trackName;
final String artistName;
final String albumName;
final String filePath;
final int? bitDepth;
final int? sampleRate;
final int? bitrate;
final String? format;
const IsrcDuplicateEntry({
required this.id,
required this.source,
required this.trackName,
required this.artistName,
required this.albumName,
required this.filePath,
this.bitDepth,
this.sampleRate,
this.bitrate,
this.format,
});
}
class IsrcDuplicateGroup {
final String isrc;
final List<IsrcDuplicateEntry> entries;
const IsrcDuplicateGroup({required this.isrc, required this.entries});
}
class QueueLibraryDbQuery {
final int limit;
final int offset;
final String filterMode;
final String? searchQuery;
final String? source;
final String? quality;
final String? format;
final String? metadata;
final String sortMode;
final bool includeLocal;
const QueueLibraryDbQuery({
this.limit = 100,
this.offset = 0,
this.filterMode = 'all',
this.searchQuery,
this.source,
this.quality,
this.format,
this.metadata,
this.sortMode = 'latest',
this.includeLocal = true,
});
}
class QueueLibraryCounts {
final int allTrackCount;
final int albumCount;
final int singleTrackCount;
const QueueLibraryCounts({
required this.allTrackCount,
required this.albumCount,
required this.singleTrackCount,
});
}
@@ -0,0 +1,574 @@
part of 'library_database.dart';
// SQL builders for the queue tab's history+local union queries.
extension _LibraryDbQueueSql on LibraryDatabase {
String _queueTrackUnionSql(QueueLibraryDbQuery request, List<Object?> args) {
final parts = <String>[];
if (request.source != 'local') {
final where = <String>[];
_appendQueueHistoryFilters(where, args, request);
if (request.filterMode == 'singles') {
where.add('''
h.album_key IN (
SELECT album_key
FROM history_db.history
GROUP BY album_key
HAVING COUNT(*) = 1
)
''');
}
parts.add('''
SELECT
'downloaded' AS queue_source,
'dl_' || h.id AS unified_id,
h.id,
h.track_name,
h.artist_name,
h.album_name,
h.album_artist,
h.cover_url,
h.file_path,
h.storage_mode,
h.download_tree_uri,
h.saf_relative_dir,
h.saf_file_name,
h.saf_repaired,
h.service,
h.downloaded_at,
h.isrc,
h.spotify_id,
h.track_number,
h.total_tracks,
h.disc_number,
h.total_discs,
h.duration,
h.release_date,
h.quality,
h.bit_depth,
h.sample_rate,
h.genre,
h.composer,
h.label,
h.copyright,
NULL AS cover_path,
NULL AS scanned_at,
NULL AS file_mod_time,
h.bitrate,
h.format,
LOWER(h.track_name) AS sort_track,
LOWER(h.artist_name) AS sort_artist,
LOWER(h.album_name) AS sort_album,
LOWER(COALESCE(h.genre, '')) AS sort_genre,
h.release_date AS sort_release,
CAST(strftime('%s', h.downloaded_at) AS INTEGER) * 1000 AS sort_added
FROM history_db.history h
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
''');
}
if (request.includeLocal && request.source != 'downloaded') {
final where = <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
album_key,
COUNT(*) AS track_count,
MAX(CAST(strftime('%s', downloaded_at) AS INTEGER) * 1000) AS latest_added
FROM history_db.history
GROUP BY album_key
HAVING COUNT(*) > 1
) c
ON c.album_key = h.album_key
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
GROUP BY c.album_key
''');
}
if (request.includeLocal && request.source != 'downloaded') {
final where = <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 = LibraryDatabase.normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final like = '%${_escapeLikePattern(query)}%';
where.add("h.search_text LIKE ? ESCAPE '\\'");
args.add(like);
}
_appendQueueCommonFilters(
where,
args,
request,
filePathExpr: 'h.file_path',
formatExpr: 'h.format',
qualityExpr: 'h.quality',
bitDepthExpr: 'h.bit_depth',
artistExpr: 'h.artist_name',
albumArtistExpr: 'h.album_artist',
releaseDateExpr: 'h.release_date',
genreExpr: 'h.genre',
trackNumberExpr: 'h.track_number',
discNumberExpr: 'h.disc_number',
isrcExpr: 'h.isrc',
labelExpr: 'h.label',
);
}
void _appendQueueLocalFilters(
List<String> where,
List<Object?> args,
QueueLibraryDbQuery request,
) {
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final like = '%${_escapeLikePattern(query)}%';
where.add('''
(
l.track_name_norm LIKE ? ESCAPE '\\' OR
l.artist_name_norm LIKE ? ESCAPE '\\' OR
l.album_name_norm LIKE ? ESCAPE '\\' OR
l.album_artist_norm LIKE ? ESCAPE '\\'
)
''');
args.addAll([like, like, like, like]);
}
_appendQueueCommonFilters(
where,
args,
request,
filePathExpr: 'l.file_path',
formatExpr: 'l.format',
qualityExpr: 'NULL',
bitDepthExpr: 'l.bit_depth',
artistExpr: 'l.artist_name',
albumArtistExpr: 'l.album_artist',
releaseDateExpr: 'l.release_date',
genreExpr: 'l.genre',
trackNumberExpr: 'l.track_number',
discNumberExpr: 'l.disc_number',
isrcExpr: 'l.isrc',
labelExpr: 'l.label',
);
}
void _appendQueueCommonFilters(
List<String> where,
List<Object?> args,
QueueLibraryDbQuery request, {
required String filePathExpr,
required String? formatExpr,
required String qualityExpr,
required String bitDepthExpr,
required String artistExpr,
required String albumArtistExpr,
required String releaseDateExpr,
required String genreExpr,
required String trackNumberExpr,
required String discNumberExpr,
required String isrcExpr,
required String labelExpr,
}) {
final quality = request.quality?.trim().toLowerCase();
if (quality != null && quality.isNotEmpty) {
final isHiRes =
'(COALESCE($bitDepthExpr, 0) >= 24 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'24%\')';
final isCd =
'(COALESCE($bitDepthExpr, 0) = 16 OR LOWER(COALESCE($qualityExpr, \'\')) LIKE \'16%\')';
switch (quality) {
case 'hires':
where.add(isHiRes);
break;
case 'cd':
where.add(isCd);
break;
case 'lossy':
where.add('NOT ($isHiRes OR $isCd)');
break;
}
}
final format = request.format?.trim().toLowerCase();
if (format != null && format.isNotEmpty) {
if (formatExpr == null) {
where.add('LOWER($filePathExpr) LIKE ?');
args.add('%.$format');
} else {
where.add(
'(LOWER(COALESCE($formatExpr, \'\')) = ? OR LOWER($filePathExpr) LIKE ?)',
);
args.addAll([format, '%.$format']);
}
}
final metadata = request.metadata?.trim();
if (metadata == null || metadata.isEmpty) return;
final hasArtist = 'TRIM(COALESCE($artistExpr, \'\')) != \'\'';
final hasAlbumArtist = 'TRIM(COALESCE($albumArtistExpr, \'\')) != \'\'';
final hasReleaseDate =
'TRIM(COALESCE($releaseDateExpr, \'\')) GLOB \'*[0-9][0-9][0-9][0-9]*\'';
final hasGenre = 'TRIM(COALESCE($genreExpr, \'\')) != \'\'';
final hasTrackNumber = 'COALESCE($trackNumberExpr, 0) > 0';
final hasDiscNumber = 'COALESCE($discNumberExpr, 0) > 0';
final hasLabel = 'TRIM(COALESCE($labelExpr, \'\')) != \'\'';
final normalizedIsrc =
'REPLACE(REPLACE(UPPER(TRIM(COALESCE($isrcExpr, \'\'))), \'-\', \'\'), \' \', \'\')';
final hasIncorrectIsrc =
'TRIM(COALESCE($isrcExpr, \'\')) != \'\' AND LENGTH($normalizedIsrc) != 12';
final isComplete =
'($hasArtist AND $hasAlbumArtist AND $hasReleaseDate AND $hasGenre AND $hasTrackNumber AND $hasDiscNumber AND $hasLabel AND NOT ($hasIncorrectIsrc))';
switch (metadata) {
case 'complete':
where.add(isComplete);
break;
case 'missing-any':
where.add('NOT $isComplete');
break;
case 'missing-year':
where.add('NOT ($hasReleaseDate)');
break;
case 'missing-genre':
where.add('NOT ($hasGenre)');
break;
case 'missing-album-artist':
where.add('NOT ($hasAlbumArtist)');
break;
case 'missing-track-number':
where.add('NOT ($hasTrackNumber)');
break;
case 'missing-disc-number':
where.add('NOT ($hasDiscNumber)');
break;
case 'missing-artist':
where.add('NOT ($hasArtist)');
break;
case 'incorrect-isrc-format':
where.add('($hasIncorrectIsrc)');
break;
case 'missing-label':
where.add('NOT ($hasLabel)');
break;
}
}
String _queueTrackOrderBy(String sortMode) {
return switch (sortMode) {
'oldest' => 'sort_added ASC, sort_track ASC',
'a-z' => 'sort_track ASC, sort_artist ASC',
'z-a' => 'sort_track DESC, sort_artist DESC',
'artist-asc' => 'sort_artist ASC, sort_track ASC',
'artist-desc' => 'sort_artist DESC, sort_track ASC',
'album-asc' => 'sort_album ASC, sort_track ASC',
'album-desc' => 'sort_album DESC, sort_track ASC',
'release-oldest' => 'sort_release ASC, sort_track ASC',
'release-newest' => 'sort_release DESC, sort_track ASC',
'genre-asc' => 'sort_genre ASC, sort_track ASC',
'genre-desc' => 'sort_genre DESC, sort_track ASC',
_ => 'sort_added DESC, sort_track ASC',
};
}
String _queueAlbumOrderBy(String sortMode) {
return switch (sortMode) {
'oldest' => 'sort_added ASC, sort_album ASC',
'a-z' || 'album-asc' => 'sort_album ASC, sort_artist ASC',
'z-a' || 'album-desc' => 'sort_album DESC, sort_artist DESC',
'artist-asc' => 'sort_artist ASC, sort_album ASC',
'artist-desc' => 'sort_artist DESC, sort_album ASC',
'release-oldest' => 'sort_release ASC, sort_album ASC',
'release-newest' => 'sort_release DESC, sort_album ASC',
'genre-asc' => 'sort_genre ASC, sort_album ASC',
'genre-desc' => 'sort_genre DESC, sort_album ASC',
_ => 'sort_added DESC, sort_album ASC',
};
}
Map<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'],
'bitrate': row['bitrate'],
'format': row['format'],
'genre': row['genre'],
'composer': row['composer'],
'label': row['label'],
'copyright': row['copyright'],
},
};
}
}