Files
SpotiFLAC-Mobile/lib/services/album_completeness.dart
T
zarzet 9ea2647c39 feat(library): filter albums with missing tracks
Assess album completeness from the full local inventory, deduplicate track positions, and retain unknown status for ambiguous tags or disconnected sources. Add filters and count badges to Material and Mornye, including single-track albums and filtered search.
2026-10-02 17:03:07 +07:00

129 lines
5.2 KiB
Dart

const incompleteAlbumFilter = 'incomplete-album';
const unknownAlbumCompletenessFilter = 'unknown-album-completeness';
bool isAlbumCompletenessFilter(String? filter) =>
filter == incompleteAlbumFilter || filter == unknownAlbumCompletenessFilter;
/// Combine downloaded and scanned music without counting the same stored file
/// twice. Retained rows on disconnected sources make the assessment unknown.
String albumInventorySql({required bool includeLocal}) {
const sameStoredFile = '''
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 historyUnavailable = includeLocal
? '''EXISTS (
SELECT 1 FROM library l JOIN library_sources source ON source.id = l.source_id
WHERE source.enabled = 1 AND source.available = 0
AND EXISTS ($sameStoredFile AND hpk.item_id = h.id)
)'''
: '0';
return '''
SELECT h.album_key, LOWER(TRIM(h.track_name)) AS title,
h.track_number, h.total_tracks, h.disc_number, h.total_discs,
$historyUnavailable AS unavailable
FROM history_db.history h
${includeLocal ? '''
UNION ALL
SELECT l.album_key, LOWER(TRIM(l.track_name)) AS title,
l.track_number, l.total_tracks, l.disc_number, l.total_discs,
CASE WHEN source.available = 0 THEN 1 ELSE 0 END AS unavailable
FROM library l LEFT JOIN library_sources source ON source.id = l.source_id
WHERE COALESCE(source.enabled, 1) = 1 AND NOT EXISTS ($sameStoredFile)
''' : ''}
''';
}
class AlbumCompleteness {
final String status;
final int present;
final int? expected;
const AlbumCompleteness({
required this.status,
required this.present,
this.expected,
});
static AlbumCompleteness? fromRow(Map<String, dynamic> row) {
final status = row['album_completeness'] as String?;
if (status == null) return null;
return AlbumCompleteness(
status: status,
present: (row['album_present_tracks'] as num?)?.toInt() ?? 0,
expected: (row['album_expected_tracks'] as num?)?.toInt(),
);
}
String get badge => status == 'unknown'
? '?'
: expected == null
? '$present/?'
: '$present/$expected';
}
/// Assess the full inventory, never the subset matching a search or quality
/// filter. Input columns: album_key, title, track_number, total_tracks,
/// disc_number, total_discs, unavailable. Equal multi-disc totals can mean
/// album-wide or per-disc totals; do not guess which convention the tags use.
String albumCompletenessSql(String tracksSql) =>
'''
WITH tracks AS ($tracksSql),
slots AS (
SELECT album_key, COALESCE(NULLIF(disc_number, 0), 1) AS disc,
track_number, COUNT(DISTINCT title) AS titles
FROM tracks
GROUP BY album_key, disc, track_number
),
discs AS (
SELECT t.album_key, COALESCE(NULLIF(t.disc_number, 0), 1) AS disc,
COUNT(DISTINCT CASE WHEN t.track_number > 0 THEN t.track_number END) AS present,
MAX(t.total_tracks) AS expected,
MIN(CASE WHEN t.total_discs > 0 THEN t.total_discs END) AS min_discs,
MAX(t.total_discs) AS max_discs,
MAX(CASE WHEN COALESCE(t.track_number, 0) <= 0
OR COALESCE(t.disc_number, 0) < 0
OR COALESCE(t.total_tracks, 0) < 0 OR COALESCE(t.total_discs, 0) < 0
OR (COALESCE(t.disc_number, 0) = 0 AND t.total_discs > 1)
OR COALESCE(t.unavailable, 0) != 0 THEN 1 ELSE 0 END) AS invalid,
MAX(t.track_number) AS last_track,
MIN(CASE WHEN t.total_tracks > 0 THEN t.total_tracks END) AS min_expected,
MAX(s.titles) AS slot_titles
FROM tracks t JOIN slots s ON s.album_key = t.album_key
AND s.disc = COALESCE(NULLIF(t.disc_number, 0), 1)
AND s.track_number IS t.track_number
GROUP BY t.album_key, disc
),
albums AS (
SELECT album_key, SUM(present) AS present, SUM(expected) AS expected_sum,
MIN(expected) AS min_expected, MAX(expected) AS max_expected,
MIN(min_discs) AS min_discs, MAX(max_discs) AS max_discs,
COUNT(*) AS seen_discs, MIN(disc) AS first_disc, MAX(disc) AS last_disc,
MAX(CASE WHEN invalid = 1 OR slot_titles > 1
OR COALESCE(expected, 0) <= 0 OR min_expected != expected
OR last_track > expected THEN 1 ELSE 0 END) AS invalid
FROM discs GROUP BY album_key
),
assessed AS (
SELECT *, CASE
WHEN invalid = 1 OR first_disc < 1
OR (max_discs > 0 AND (min_discs != max_discs OR last_disc > max_discs))
THEN 1 ELSE 0 END AS uncertain,
CASE WHEN last_disc = 1 AND COALESCE(max_discs, 1) <= 1 THEN expected_sum
WHEN max_discs = seen_discs AND min_expected != max_expected
THEN expected_sum ELSE NULL END AS expected,
CASE WHEN max_discs > seen_discs THEN 1 ELSE 0 END AS missing_disc
FROM albums
)
SELECT album_key, present AS album_present_tracks,
CASE WHEN uncertain = 0 THEN expected END AS album_expected_tracks,
CASE WHEN uncertain = 1 THEN 'unknown'
WHEN missing_disc = 1 THEN 'incomplete'
WHEN expected IS NOT NULL AND present < expected THEN 'incomplete'
WHEN expected IS NOT NULL AND present = expected THEN 'complete'
WHEN expected IS NULL AND present < max_expected THEN 'incomplete'
ELSE 'unknown' END AS album_completeness
FROM assessed
''';