Files
SpotiFLAC-Mobile/lib/services/library_queue_store.dart
T

1214 lines
39 KiB
Dart

import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/services/album_completeness.dart';
import 'package:spotiflac_android/services/library_database_models.dart';
import 'package:spotiflac_android/services/library_schema.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
// SQL builders for the queue tab's history+local union queries.
String confirmedMissingLyricsSqlPredicate({
required String hasLyricsExpr,
required String lyricsKnownExpr,
}) => '($lyricsKnownExpr) AND COALESCE($hasLyricsExpr, 0) = 0';
String missingReplayGainSqlPredicate({required String hasReplayGainExpr}) =>
'COALESCE($hasReplayGainExpr, 0) = 0';
class _QueueOrderTerm {
final String column;
final bool descending;
const _QueueOrderTerm(this.column, {this.descending = false});
}
/// Queue reads against an already opened library with history attached.
class LibraryQueueStore {
final Database _db;
final bool historyFts;
final bool localFts;
const LibraryQueueStore(
this._db, {
required this.historyFts,
required this.localFts,
});
Future<QueueLibraryDbPage> trackPage(QueueLibraryDbQuery request) async {
final args = <Object?>[];
final orderTerms = _queueTrackOrderTerms(request.sortMode);
final usesCursor =
request.cursor != null &&
request.cursor!.values.length == orderTerms.length;
final unionSql = _queueTrackUnionSql(
request,
args,
orderTerms: orderTerms,
usesCursor: usesCursor,
);
final rows = await _db.rawQuery(
'''
SELECT *
FROM ($unionSql)
ORDER BY ${_queueOrderBy(orderTerms)}
LIMIT ? ${usesCursor ? '' : 'OFFSET ?'}
''',
[...args, request.limit, if (!usesCursor) request.offset],
);
return QueueLibraryDbPage(
rows: rows.map(_queueTrackRowToJson).toList(growable: false),
nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms),
);
}
Future<QueueLibraryCounts> counts(QueueLibraryDbQuery request) async {
final fastCounts = await _getUnfilteredQueueCounts(request);
if (fastCounts != null) return fastCounts;
final parts = <String>[];
final args = <Object?>[];
if (request.source != 'local') {
final where = <String>[];
_appendQueueHistoryFilters(where, args, request);
parts.add('''
SELECT
COUNT(*) AS all_count,
COUNT(DISTINCT CASE WHEN grouped.track_count > ${isAlbumCompletenessFilter(request.metadata) ? 0 : 1} THEN h.album_key END) AS album_count,
COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
FROM history_db.history h
JOIN (
SELECT album_key, COUNT(*) AS track_count
FROM history_db.history
GROUP BY album_key
) grouped ON grouped.album_key = h.album_key
${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);
parts.add('''
SELECT
COUNT(*) AS all_count,
COUNT(DISTINCT CASE WHEN grouped.track_count > ${isAlbumCompletenessFilter(request.metadata) ? 0 : 1} THEN l.album_key END) AS album_count,
COALESCE(SUM(CASE WHEN grouped.track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
FROM ${LibrarySchema.visibleView} l
JOIN (
SELECT album_key, COUNT(*) AS track_count
FROM ${LibrarySchema.visibleView} candidate
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 = candidate.id
)
GROUP BY album_key
) grouped ON grouped.album_key = l.album_key
WHERE ${where.join(' AND ')}
''');
}
if (parts.isEmpty) {
return const QueueLibraryCounts(
allTrackCount: 0,
albumCount: 0,
singleTrackCount: 0,
);
}
final rows = await _db.rawQuery('''
SELECT
COALESCE(SUM(all_count), 0) AS all_count,
COALESCE(SUM(single_count), 0) AS single_count,
COALESCE(SUM(album_count), 0) AS album_count
FROM (${parts.join(' UNION ALL ')})
''', args);
final row = rows.isNotEmpty ? rows.first : const <String, Object?>{};
return QueueLibraryCounts(
allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0,
albumCount: (row['album_count'] as num?)?.toInt() ?? 0,
singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0,
);
}
/// The default Library badges do not need a row-by-row join against album
/// counts. Aggregate the covering album-key indexes directly and reserve the
/// more expensive filtered query for active search/quality/metadata filters.
Future<QueueLibraryCounts?> _getUnfilteredQueueCounts(
QueueLibraryDbQuery request,
) async {
if (sqlite.normalizeLookupText(request.searchQuery).isNotEmpty ||
request.albumArtist != null ||
request.quality != null ||
request.format != null ||
request.metadata != null) {
return null;
}
final source = request.source;
if (source != null && source != 'downloaded' && source != 'local') {
return null;
}
final parts = <String>[];
if (source != 'local') {
parts.add('''
SELECT
COALESCE(SUM(track_count), 0) AS all_count,
COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count,
COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
FROM (
SELECT album_key, COUNT(*) AS track_count
FROM history_db.history
GROUP BY album_key
)
''');
}
if (request.includeLocal && source != 'downloaded') {
parts.add('''
SELECT
COALESCE(SUM(track_count), 0) AS all_count,
COALESCE(SUM(CASE WHEN track_count > 1 THEN 1 ELSE 0 END), 0) AS album_count,
COALESCE(SUM(CASE WHEN track_count = 1 THEN 1 ELSE 0 END), 0) AS single_count
FROM (
SELECT l.album_key, COUNT(*) AS track_count
FROM ${LibrarySchema.visibleView} l
WHERE NOT EXISTS (
SELECT 1
FROM library_path_keys lpk
JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key
WHERE lpk.item_id = l.id
)
GROUP BY l.album_key
)
''');
}
if (parts.isEmpty) {
return const QueueLibraryCounts(
allTrackCount: 0,
albumCount: 0,
singleTrackCount: 0,
);
}
final rows = await _db.rawQuery('''
SELECT
COALESCE(SUM(all_count), 0) AS all_count,
COALESCE(SUM(album_count), 0) AS album_count,
COALESCE(SUM(single_count), 0) AS single_count
FROM (${parts.join(' UNION ALL ')})
''');
final row = rows.isEmpty ? const <String, Object?>{} : rows.first;
return QueueLibraryCounts(
allTrackCount: (row['all_count'] as num?)?.toInt() ?? 0,
albumCount: (row['album_count'] as num?)?.toInt() ?? 0,
singleTrackCount: (row['single_count'] as num?)?.toInt() ?? 0,
);
}
Future<QueueLibraryDbPage> albumPage(QueueLibraryDbQuery request) async {
final args = <Object?>[];
final orderTerms = _queueAlbumOrderTerms(request.sortMode);
final usesCursor =
request.cursor != null &&
request.cursor!.values.length == orderTerms.length;
final unionSql = _queueAlbumUnionSql(
request,
args,
orderTerms: orderTerms,
usesCursor: usesCursor,
);
final rows = await _db.rawQuery(
'''
SELECT *
FROM ($unionSql)
ORDER BY ${_queueOrderBy(orderTerms)}
LIMIT ? ${usesCursor ? '' : 'OFFSET ?'}
''',
[...args, request.limit, if (!usesCursor) request.offset],
);
return QueueLibraryDbPage(
rows: rows.toList(growable: false),
nextCursor: _queueCursorFromRow(rows.lastOrNull, orderTerms),
);
}
/// Album artists across downloaded and scanned music, without loading tracks
/// into Dart. Scanned paths already represented by downloads are excluded.
Future<List<Map<String, dynamic>>> artistPage(
QueueLibraryDbQuery request,
) async {
final parts = <String>[
'''
SELECT sort_album_artist AS artist_key,
COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name,
cover_url, NULL AS cover_path, file_path AS sample_file_path
FROM history_db.history
''',
if (request.includeLocal)
'''
SELECT album_artist_norm AS artist_key,
COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name,
NULL AS cover_url, cover_path, file_path AS sample_file_path
FROM ${LibrarySchema.visibleView} l
WHERE NOT EXISTS (
SELECT 1 FROM library_path_keys lpk
JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key
WHERE lpk.item_id = l.id
)
''',
];
final search = sqlite.normalizeLookupText(request.searchQuery);
return _db.rawQuery(
'''
SELECT artist_key, MIN(artist_name) AS artist_name,
MAX(NULLIF(cover_url, '')) AS cover_url,
MAX(NULLIF(cover_path, '')) AS cover_path,
MAX(sample_file_path) AS sample_file_path,
COUNT(*) AS track_count
FROM (${parts.join(' UNION ALL ')})
WHERE artist_key != '' ${search.isEmpty ? '' : "AND artist_key LIKE ? ESCAPE '\\'"}
GROUP BY artist_key
ORDER BY artist_key
LIMIT ? OFFSET ?
''',
[
if (search.isNotEmpty) '%${_escapeLikePattern(search)}%',
request.limit,
request.offset,
],
);
}
String _escapeLikePattern(String value) {
return value
.replaceAll('\\', r'\\')
.replaceAll('%', r'\%')
.replaceAll('_', r'\_');
}
String _queueTrackUnionSql(
QueueLibraryDbQuery request,
List<Object?> args, {
required List<_QueueOrderTerm> orderTerms,
required bool usesCursor,
}) {
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
)
''');
}
final selectSql =
'''
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,
h.has_replaygain,
h.replaygain_metadata_scan_version,
h.sort_track,
h.sort_artist,
h.sort_album,
h.sort_genre,
h.sort_release,
h.sort_added
FROM history_db.history h
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
''';
parts.add(
_boundedQueuePart(
selectSql,
request,
args,
orderTerms,
usesCursor: usesCursor,
),
);
}
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 ${LibrarySchema.visibleView} candidate
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 = candidate.id
)
GROUP BY album_key
HAVING COUNT(*) = 1
)
''');
}
final selectSql =
'''
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.has_replaygain,
CASE WHEN l.audio_metadata_scan_version >= $libraryAudioMetadataScanVersion
THEN 1 ELSE 0 END AS replaygain_metadata_scan_version,
l.track_name_norm AS sort_track,
l.artist_name_norm AS sort_artist,
l.album_name_norm AS sort_album,
l.sort_genre,
l.sort_release,
l.sort_added
FROM ${LibrarySchema.visibleView} l
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
''';
parts.add(
_boundedQueuePart(
selectSql,
request,
args,
orderTerms,
usesCursor: usesCursor,
),
);
}
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 has_replaygain,
NULL AS replaygain_metadata_scan_version,
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, {
required List<_QueueOrderTerm> orderTerms,
required bool usesCursor,
}) {
final unfiltered =
sqlite.normalizeLookupText(request.searchQuery).isEmpty &&
request.albumArtist == null &&
request.quality == null &&
request.format == null &&
request.metadata == null;
final assessesCompleteness = isAlbumCompletenessFilter(request.metadata);
final minimumTracks =
request.includeSingleTrackAlbums || assessesCompleteness ? 0 : 1;
final completenessSql = assessesCompleteness
? albumCompletenessSql(
albumInventorySql(includeLocal: request.includeLocal),
)
: null;
final completenessColumns = assessesCompleteness
? 'ac.album_completeness, ac.album_present_tracks, ac.album_expected_tracks,'
: 'NULL AS album_completeness, NULL AS album_present_tracks, NULL AS album_expected_tracks,';
final parts = <String>[];
if (request.source != 'local') {
final albumKey = unfiltered ? 'h.album_key' : 'c.album_key';
final where = <String>[];
_appendQueueHistoryFilters(
where,
args,
request,
hasCompletenessJoin: assessesCompleteness,
);
final selectSql =
'''
SELECT
'downloaded' AS queue_source,
$albumKey AS 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,
$completenessColumns
${unfiltered ? 'MAX(COALESCE(h.sort_added, 0))' : 'c.latest_added'} AS sort_added,
MIN(COALESCE(h.sort_album, '')) AS sort_album,
MIN(COALESCE(h.sort_album_artist, '')) AS sort_artist,
COALESCE(MAX(h.release_date), '') AS sort_release,
COALESCE(MAX(h.sort_genre), '') AS sort_genre
FROM history_db.history h
${unfiltered ? '' : '''JOIN (
SELECT
album_key,
COUNT(*) AS track_count,
MAX(COALESCE(sort_added, 0)) AS latest_added
FROM history_db.history
GROUP BY album_key
HAVING COUNT(*) > $minimumTracks
) c
ON c.album_key = h.album_key'''}
${assessesCompleteness ? 'JOIN ($completenessSql) ac ON ac.album_key = h.album_key' : ''}
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
GROUP BY $albumKey
${unfiltered ? 'HAVING $albumKey IS NOT NULL AND COUNT(*) > $minimumTracks' : ''}
''';
parts.add(
_boundedQueuePart(
selectSql,
request,
args,
orderTerms,
usesCursor: usesCursor,
),
);
}
if (request.includeLocal && request.source != 'downloaded') {
final albumKey = unfiltered ? 'l.album_key' : 'c.album_key';
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,
hasCompletenessJoin: assessesCompleteness,
);
final selectSql =
'''
SELECT
'local' AS queue_source,
$albumKey AS 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,
$completenessColumns
${unfiltered ? 'MAX(COALESCE(l.sort_added, 0))' : 'c.latest_added'} AS sort_added,
MIN(l.album_name_norm) AS sort_album,
MIN(l.album_artist_norm) AS sort_artist,
COALESCE(MAX(l.release_date), '') AS sort_release,
COALESCE(MAX(l.sort_genre), '') AS sort_genre
FROM ${LibrarySchema.visibleView} l
${unfiltered ? '' : '''JOIN (
SELECT
album_key,
COUNT(*) AS track_count,
MAX(COALESCE(sort_added, 0)) AS latest_added
FROM ${LibrarySchema.visibleView} candidate
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 = candidate.id
)
GROUP BY album_key
HAVING COUNT(*) > $minimumTracks
) c ON c.album_key = l.album_key'''}
${assessesCompleteness ? 'JOIN ($completenessSql) ac ON ac.album_key = l.album_key' : ''}
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
GROUP BY $albumKey
${unfiltered ? 'HAVING $albumKey IS NOT NULL AND COUNT(*) > $minimumTracks' : ''}
''';
parts.add(
_boundedQueuePart(
selectSql,
request,
args,
orderTerms,
usesCursor: usesCursor,
),
);
}
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 album_completeness,
NULL AS album_present_tracks,
NULL AS album_expected_tracks,
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, {
bool hasCompletenessJoin = false,
}) {
_appendAlbumCompletenessFilter(
where,
request,
albumKey: 'h.album_key',
hasJoin: hasCompletenessJoin,
);
if (request.albumArtist != null) {
where.add('h.sort_album_artist = ?');
args.add(sqlite.normalizeLookupText(request.albumArtist));
}
final query = sqlite.normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
if (historyFts && ftsQuery != null) {
where.add('''
h.rowid IN (
SELECT rowid
FROM history_db.history_search_fts
WHERE history_search_fts MATCH ?
)
''');
args.add(ftsQuery);
} else {
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',
hasLyricsExpr: 'h.has_lyrics',
lyricsKnownExpr: 'COALESCE(h.lyrics_metadata_scan_version, 0) >= 1',
hasReplayGainExpr: 'h.has_replaygain',
);
}
void _appendQueueLocalFilters(
List<String> where,
List<Object?> args,
QueueLibraryDbQuery request, {
bool hasCompletenessJoin = false,
}) {
_appendAlbumCompletenessFilter(
where,
request,
albumKey: 'l.album_key',
hasJoin: hasCompletenessJoin,
);
if (request.albumArtist != null) {
where.add('l.album_artist_norm = ?');
args.add(sqlite.normalizeLookupText(request.albumArtist));
}
final query = sqlite.normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
if (localFts && ftsQuery != null) {
where.add('''
l.id IN (
SELECT id FROM library WHERE rowid IN (
SELECT rowid
FROM library_search_fts
WHERE library_search_fts MATCH ?
)
)
''');
args.add(ftsQuery);
} else {
final like = '%${_escapeLikePattern(query)}%';
where.add("l.search_text LIKE ? ESCAPE '\\'");
args.add(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',
hasLyricsExpr: 'l.has_lyrics',
lyricsKnownExpr:
'COALESCE(l.audio_metadata_scan_version, 0) >= $libraryLyricsMetadataScanVersion',
hasReplayGainExpr: 'l.has_replaygain',
);
}
void _appendAlbumCompletenessFilter(
List<String> where,
QueueLibraryDbQuery request, {
required String albumKey,
required bool hasJoin,
}) {
if (!isAlbumCompletenessFilter(request.metadata)) return;
final status = request.metadata == incompleteAlbumFilter
? 'incomplete'
: 'unknown';
if (hasJoin) {
where.add("ac.album_completeness = '$status'");
return;
}
final assessment = albumCompletenessSql(
albumInventorySql(includeLocal: request.includeLocal),
);
where.add(
"$albumKey IN (SELECT album_key FROM ($assessment) WHERE album_completeness = '$status')",
);
}
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,
required String hasLyricsExpr,
required String lyricsKnownExpr,
required String hasReplayGainExpr,
}) {
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-isrc':
where.add('TRIM(COALESCE($isrcExpr, \'\')) = \'\'');
break;
case 'missing-label':
where.add('NOT ($hasLabel)');
break;
case 'missing-lyrics':
// A default false value on legacy rows means "not scanned yet", not
// "confirmed missing". Only show files whose lyrics probe completed.
where.add(
confirmedMissingLyricsSqlPredicate(
hasLyricsExpr: hasLyricsExpr,
lyricsKnownExpr: lyricsKnownExpr,
),
);
break;
case 'missing-replaygain':
// Include legacy rows without indexed ReplayGain as candidates until
// a metadata refresh confirms their tags. Requiring a new scan version
// hides the entire older collection before it has been rescanned.
where.add(
missingReplayGainSqlPredicate(hasReplayGainExpr: hasReplayGainExpr),
);
break;
}
}
String _boundedQueuePart(
String selectSql,
QueueLibraryDbQuery request,
List<Object?> args,
List<_QueueOrderTerm> orderTerms, {
required bool usesCursor,
}) {
final cursorPredicate = usesCursor
? _queueCursorPredicate(request.cursor, orderTerms, args)
: '';
final branchLimit = usesCursor
? request.limit
: request.limit + request.offset;
args.add(branchLimit);
final branchOrder = orderTerms
.where((term) => term.column != 'queue_source')
.toList(growable: false);
return '''
SELECT * FROM (
SELECT *
FROM ($selectSql)
${cursorPredicate.isEmpty ? '' : 'WHERE $cursorPredicate'}
ORDER BY ${_queueOrderBy(branchOrder)}
LIMIT ?
)
''';
}
List<_QueueOrderTerm> _queueTrackOrderTerms(String sortMode) {
return switch (sortMode) {
'oldest' => const [
_QueueOrderTerm('sort_added'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'a-z' => const [
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('sort_artist'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'z-a' => const [
_QueueOrderTerm('sort_track', descending: true),
_QueueOrderTerm('sort_artist', descending: true),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'artist-asc' => const [
_QueueOrderTerm('sort_artist'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'artist-desc' => const [
_QueueOrderTerm('sort_artist', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'album-asc' => const [
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'album-desc' => const [
_QueueOrderTerm('sort_album', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'release-oldest' => const [
_QueueOrderTerm('sort_release'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'release-newest' => const [
_QueueOrderTerm('sort_release', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'genre-asc' => const [
_QueueOrderTerm('sort_genre'),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
'genre-desc' => const [
_QueueOrderTerm('sort_genre', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
_ => const [
_QueueOrderTerm('sort_added', descending: true),
_QueueOrderTerm('sort_track'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('id'),
],
};
}
List<_QueueOrderTerm> _queueAlbumOrderTerms(String sortMode) {
return switch (sortMode) {
'oldest' => const [
_QueueOrderTerm('sort_added'),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'a-z' || 'album-asc' => const [
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('sort_artist'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'z-a' || 'album-desc' => const [
_QueueOrderTerm('sort_album', descending: true),
_QueueOrderTerm('sort_artist', descending: true),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'artist-asc' => const [
_QueueOrderTerm('sort_artist'),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'artist-desc' => const [
_QueueOrderTerm('sort_artist', descending: true),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'release-oldest' => const [
_QueueOrderTerm('sort_release'),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'release-newest' => const [
_QueueOrderTerm('sort_release', descending: true),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'genre-asc' => const [
_QueueOrderTerm('sort_genre'),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
'genre-desc' => const [
_QueueOrderTerm('sort_genre', descending: true),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
_ => const [
_QueueOrderTerm('sort_added', descending: true),
_QueueOrderTerm('sort_album'),
_QueueOrderTerm('queue_source'),
_QueueOrderTerm('album_key'),
],
};
}
String _queueOrderBy(List<_QueueOrderTerm> terms) => terms
.map((term) => '${term.column} ${term.descending ? 'DESC' : 'ASC'}')
.join(', ');
String _queueCursorPredicate(
QueueLibraryDbCursor? cursor,
List<_QueueOrderTerm> terms,
List<Object?> args,
) {
if (cursor == null || cursor.values.length != terms.length) return '';
final clauses = <String>[];
final first = terms.first;
final coarseOperator = first.descending ? '<=' : '>=';
args.add(cursor.values.first);
for (var i = 0; i < terms.length; i++) {
final comparisons = <String>[];
for (var j = 0; j < i; j++) {
comparisons.add('${terms[j].column} = ?');
args.add(cursor.values[j]);
}
comparisons.add(
'${terms[i].column} ${terms[i].descending ? '<' : '>'} ?',
);
args.add(cursor.values[i]);
clauses.add('(${comparisons.join(' AND ')})');
}
return '(${first.column} $coarseOperator ?) AND (${clauses.join(' OR ')})';
}
QueueLibraryDbCursor? _queueCursorFromRow(
Map<String, dynamic>? row,
List<_QueueOrderTerm> terms,
) {
if (row == null) return null;
final values = <Object>[];
for (final term in terms) {
final value = row[term.column];
if (value is! Object) return null;
values.add(value);
}
return QueueLibraryDbCursor(List<Object>.unmodifiable(values));
}
Map<String, dynamic> _queueTrackRowToJson(Map<String, dynamic> row) {
final source = row['queue_source'] as String? ?? '';
final item = <String, dynamic>{
'id': row['id'],
'trackName': row['track_name'],
'artistName': row['artist_name'],
'albumName': row['album_name'],
'albumArtist': row['album_artist'],
'filePath': row['file_path'],
'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'],
'format': row['format'],
'hasReplayGain':
row['has_replaygain'] == 1 || row['has_replaygain'] == true,
'genre': row['genre'],
'composer': row['composer'],
'label': row['label'],
'copyright': row['copyright'],
if (source == 'local') ...{
'coverPath': row['cover_path'],
'scannedAt': row['scanned_at'],
'fileModTime': row['file_mod_time'],
} else ...{
'coverUrl': row['cover_url'],
'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'],
'spotifyId': row['spotify_id'],
'quality': row['quality'],
'replayGainMetadataScanVersion':
row['replaygain_metadata_scan_version'] ?? 0,
},
};
return {'source': source, 'item': item};
}
}