refactor(history): move the history schema and row mapper into their own files

Mirror the library_schema and library_row_mapper split: HistorySchema owns
create, upgrade, index creation and backfills, HistoryRowMapper owns the
JSON/row mapping and the derived normalized and queue-sort columns. SQL text
is unchanged. Tests that read the history CREATE TABLE and index text now
read history_schema.dart.
This commit is contained in:
zarzet committed 2026-10-18 23:28:33 +07:00
1 parent c2b7a0b782
commit fbed496014
8 files changed
+541 -497

No files matched your search

+27 -492
View File
@@ -5,11 +5,12 @@ import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
import 'package:spotiflac_android/services/history_maintenance.dart';
import 'package:spotiflac_android/services/history_row_mapper.dart';
import 'package:spotiflac_android/services/history_schema.dart';
import 'package:spotiflac_android/utils/isrc_utils.dart' as isrc;
import 'package:spotiflac_android/utils/ios_container_paths.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/utils/audio_format_utils.dart';
final _log = AppLogger('HistoryDatabase');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@@ -113,233 +114,26 @@ class HistoryDatabase {
final db = await sqlite.openAppDatabase(
'history.db',
version: schemaVersion,
onCreate: _createDB,
onUpgrade: _upgradeDB,
onCreate: (db, version) async {
_searchFtsAvailable = await HistorySchema.create(db, version);
},
onUpgrade: HistorySchema.upgrade,
);
// onCreate normally initializes this derived index. Retry once after
// opening an existing database in case an earlier setup was
// interrupted; unsupported SQLite builds remain on the LIKE fallback.
_searchFtsAvailable ??= await _createSearchFts(db);
_searchFtsAvailable ??= await HistorySchema.createSearchFts(db);
return db;
});
}
bool get searchFtsAvailable => _searchFtsAvailable ?? false;
Future<void> _createDB(Database db, int version) async {
_log.i('Creating database schema v$version');
await db.execute('''
CREATE TABLE history (
id TEXT PRIMARY KEY,
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
album_artist TEXT,
cover_url TEXT,
file_path TEXT NOT NULL,
storage_mode TEXT,
download_tree_uri TEXT,
saf_relative_dir TEXT,
saf_file_name TEXT,
saf_repaired INTEGER,
service TEXT NOT NULL,
downloaded_at TEXT NOT NULL,
isrc TEXT,
spotify_id TEXT,
track_number INTEGER,
total_tracks INTEGER,
disc_number INTEGER,
total_discs INTEGER,
duration INTEGER,
release_date TEXT,
quality TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
bitrate INTEGER,
format TEXT,
genre TEXT,
composer TEXT,
label TEXT,
copyright TEXT,
explicit INTEGER NOT NULL DEFAULT 0,
has_lyrics INTEGER NOT NULL DEFAULT 0,
lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0,
has_replaygain INTEGER NOT NULL DEFAULT 0,
replaygain_metadata_scan_version INTEGER NOT NULL DEFAULT 0,
spotify_id_norm TEXT,
isrc_norm TEXT,
match_key TEXT,
album_key TEXT,
search_text TEXT,
sort_track TEXT,
sort_artist TEXT,
sort_album TEXT,
sort_album_artist TEXT,
sort_genre TEXT,
sort_release TEXT,
sort_added INTEGER
)
''');
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
await db.execute(
'CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)',
);
await db.execute(
'CREATE INDEX idx_album ON history(album_name, album_artist)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)',
);
await _createNormalizedIndexes(db);
await _createQueueIndexes(db);
await _createPathKeyTable(db);
_searchFtsAvailable = await _createSearchFts(db);
_log.i('Database schema created with indexes');
}
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
await db.execute('ALTER TABLE history ADD COLUMN storage_mode TEXT');
await db.execute('ALTER TABLE history ADD COLUMN download_tree_uri TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_relative_dir TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_file_name TEXT');
}
if (oldVersion < 3) {
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
}
if (oldVersion < 4) {
final columns = await db.rawQuery('PRAGMA table_info(history)');
final hasComposer = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'composer',
);
if (!hasComposer) {
await db.execute('ALTER TABLE history ADD COLUMN composer TEXT');
}
}
if (oldVersion < 5) {
final columns = await db.rawQuery('PRAGMA table_info(history)');
final hasTotalTracks = columns.any(
(row) =>
(row['name']?.toString().toLowerCase() ?? '') == 'total_tracks',
);
final hasTotalDiscs = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'total_discs',
);
if (!hasTotalTracks) {
await db.execute('ALTER TABLE history ADD COLUMN total_tracks INTEGER');
}
if (!hasTotalDiscs) {
await db.execute('ALTER TABLE history ADD COLUMN total_discs INTEGER');
}
}
if (oldVersion < 6) {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)',
);
}
if (oldVersion < 7) {
await _createPathKeyTable(db);
}
if (oldVersion < 8) {
await sqlite.addColumnIfMissing(db, 'history', 'spotify_id_norm', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'isrc_norm', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'match_key', 'TEXT');
}
if (oldVersion < 9) {
await sqlite.addColumnIfMissing(db, 'history', 'bitrate', 'INTEGER');
await sqlite.addColumnIfMissing(db, 'history', 'format', 'TEXT');
}
if (oldVersion < 10) {
await sqlite.addColumnIfMissing(db, 'history', 'album_key', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'search_text', 'TEXT');
await _backfillNormalizedColumns(db);
await _createNormalizedIndexes(db);
}
if (oldVersion < 11) {
await sqlite.addColumnIfMissing(db, 'history', 'sort_track', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_artist', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_album', 'TEXT');
await sqlite.addColumnIfMissing(
db,
'history',
'sort_album_artist',
'TEXT',
);
await sqlite.addColumnIfMissing(db, 'history', 'sort_genre', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_release', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_added', 'INTEGER');
await _backfillQueueSortColumns(db);
await _createQueueIndexes(db);
_log.i('Added persisted queue sort columns');
}
if (oldVersion < 12) {
await sqlite.addColumnIfMissing(
db,
'history',
'explicit',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added explicit-content metadata');
}
if (oldVersion < 13) {
await sqlite.addColumnIfMissing(
db,
'history',
'has_lyrics',
'INTEGER NOT NULL DEFAULT 0',
);
await sqlite.addColumnIfMissing(
db,
'history',
'lyrics_metadata_scan_version',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added indexed lyrics availability metadata');
}
if (oldVersion < 14) {
await sqlite.addColumnIfMissing(
db,
'history',
'has_replaygain',
'INTEGER NOT NULL DEFAULT 0',
);
await sqlite.addColumnIfMissing(
db,
'history',
'replaygain_metadata_scan_version',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added indexed ReplayGain availability metadata');
}
if (oldVersion < 15) {
await sqlite.backfillPathKeys(db, 'history', 'history_path_keys');
_log.i('Updated history path keys with provider document identities');
}
}
Future<bool> _createSearchFts(DatabaseExecutor db) {
return sqlite.createTrigramFtsIndex(
db,
ftsTable: searchFtsTable,
contentTable: 'history',
triggerPrefix: 'history_search_fts',
);
}
static String normalizeLookupText(String? value) =>
sqlite.normalizeLookupText(value);
static String normalizeIsrc(String? value) => isrc.normalizeIsrc(value);
static String _normalizeSpotifyId(String? value) {
return (value ?? '').trim().toLowerCase();
}
/// Returns '' for an empty track name, unlike [LibraryDatabase.matchKeyFor],
/// which returns '|artist' in that case.
static String matchKeyFor(String? trackName, String? artistName) {
@@ -372,164 +166,6 @@ class HistoryDatabase {
return candidates.toList(growable: false);
}
Future<void> _createNormalizedIndexes(DatabaseExecutor db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_album_key ON history(album_key)',
);
}
Future<void> _createQueueIndexes(DatabaseExecutor db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_added ON history(sort_added DESC, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_track ON history(sort_track, sort_artist, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_artist ON history(sort_artist, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_album ON history(sort_album, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_genre ON history(sort_genre, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_release ON history(sort_release, sort_track, id)',
);
}
Future<void> _backfillNormalizedColumns(Database db) async {
final rows = await db.query(
'history',
columns: [
'id',
'spotify_id',
'isrc',
'track_name',
'artist_name',
'album_name',
'album_artist',
],
);
final batch = db.batch();
for (final row in rows) {
batch.update(
'history',
_normalizedColumns(
spotifyId: row['spotify_id'] as String?,
isrc: row['isrc'] as String?,
trackName: row['track_name'] as String?,
artistName: row['artist_name'] as String?,
albumName: row['album_name'] as String?,
albumArtist: row['album_artist'] as String?,
),
where: 'id = ?',
whereArgs: [row['id']],
);
}
await batch.commit(noResult: true);
}
Map<String, dynamic> _normalizedColumns({
required String? spotifyId,
required String? isrc,
required String? trackName,
required String? artistName,
required String? albumName,
required String? albumArtist,
}) {
final normalizedTrack = normalizeLookupText(trackName);
final normalizedArtist = normalizeLookupText(artistName);
final normalizedAlbum = normalizeLookupText(albumName);
final normalizedAlbumArtist = normalizeLookupText(
(albumArtist ?? '').trim().isEmpty ? artistName : albumArtist,
);
return {
'spotify_id_norm': _normalizeSpotifyId(spotifyId),
'isrc_norm': normalizeIsrc(isrc),
'match_key': matchKeyFor(trackName, artistName),
'album_key': '$normalizedAlbum|$normalizedAlbumArtist',
'search_text': [
normalizedTrack,
normalizedArtist,
normalizedAlbum,
normalizedAlbumArtist,
].where((value) => value.isNotEmpty).join(' '),
};
}
Map<String, dynamic> _queueSortColumns({
required String? trackName,
required String? artistName,
required String? albumName,
required String? albumArtist,
required String? genre,
required String? releaseDate,
required Object? downloadedAt,
}) {
final parsedDownloadedAt = downloadedAt is DateTime
? downloadedAt
: DateTime.tryParse(downloadedAt?.toString() ?? '');
return {
'sort_track': normalizeLookupText(trackName),
'sort_artist': normalizeLookupText(artistName),
'sort_album': normalizeLookupText(albumName),
'sort_album_artist': normalizeLookupText(
(albumArtist ?? '').trim().isEmpty ? artistName : albumArtist,
),
'sort_genre': normalizeLookupText(genre),
'sort_release': releaseDate?.trim() ?? '',
'sort_added': parsedDownloadedAt?.millisecondsSinceEpoch ?? 0,
};
}
Future<void> _backfillQueueSortColumns(Database db) async {
final rows = await db.query(
'history',
columns: [
'id',
'track_name',
'artist_name',
'album_name',
'album_artist',
'genre',
'release_date',
'downloaded_at',
],
);
final batch = db.batch();
for (final row in rows) {
batch.update(
'history',
_queueSortColumns(
trackName: row['track_name'] as String?,
artistName: row['artist_name'] as String?,
albumName: row['album_name'] as String?,
albumArtist: row['album_artist'] as String?,
genre: row['genre'] as String?,
releaseDate: row['release_date'] as String?,
downloadedAt: row['downloaded_at'],
),
where: 'id = ?',
whereArgs: [row['id']],
);
}
await batch.commit(noResult: true);
}
Future<void> _createPathKeyTable(DatabaseExecutor db) =>
sqlite.createPathKeyTable(db, 'history_path_keys');
void _putPathKeysInBatch(Batch batch, String id, String? filePath) =>
sqlite.putPathKeysInBatch(batch, 'history_path_keys', id, filePath);
@@ -637,7 +273,7 @@ class HistoryDatabase {
final map = Map<String, dynamic>.from(json as Map);
batch.insert(
'history',
_jsonToDbRow(map),
HistoryRowMapper.jsonToDbRow(map),
conflictAlgorithm: ConflictAlgorithm.replace,
);
_putPathKeysInBatch(
@@ -659,127 +295,18 @@ class HistoryDatabase {
}
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
final downloadedAt = json['downloadedAt'];
final parsedDownloadedAt = downloadedAt is DateTime
? downloadedAt
: DateTime.tryParse(downloadedAt?.toString() ?? '');
final row = {
'id': json['id'],
'track_name': json['trackName'],
'artist_name': json['artistName'],
'album_name': json['albumName'],
'album_artist': json['albumArtist'],
'cover_url': json['coverUrl'],
'file_path': json['filePath'],
'storage_mode': json['storageMode'],
'download_tree_uri': json['downloadTreeUri'],
'saf_relative_dir': json['safRelativeDir'],
'saf_file_name': json['safFileName'],
'saf_repaired': json['safRepaired'] == true ? 1 : 0,
'service': json['service'],
'downloaded_at':
parsedDownloadedAt?.toUtc().toIso8601String() ??
downloadedAt?.toString(),
'isrc': json['isrc'],
'spotify_id': json['spotifyId'],
'track_number': json['trackNumber'],
'total_tracks': json['totalTracks'],
'disc_number': json['discNumber'],
'total_discs': json['totalDiscs'],
'duration': json['duration'],
'release_date': json['releaseDate'],
'quality': json['quality'],
'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'],
'bitrate': json['bitrate'],
'format': json['format'],
'genre': json['genre'],
'composer': json['composer'],
'label': json['label'],
'copyright': json['copyright'],
'explicit': json['explicit'] == true ? 1 : 0,
'has_lyrics': json['hasLyrics'] == true ? 1 : 0,
'lyrics_metadata_scan_version':
(json['lyricsMetadataScanVersion'] as num?)?.toInt() ??
(json.containsKey('hasLyrics') ? 1 : 0),
'has_replaygain': metadataHasReplayGain(json) ? 1 : 0,
'replaygain_metadata_scan_version':
(json['replayGainMetadataScanVersion'] as num?)?.toInt() ?? 0,
};
row.addAll(
_queueSortColumns(
trackName: json['trackName'] as String?,
artistName: json['artistName'] as String?,
albumName: json['albumName'] as String?,
albumArtist: json['albumArtist'] as String?,
genre: json['genre'] as String?,
releaseDate: json['releaseDate'] as String?,
downloadedAt: parsedDownloadedAt,
),
);
row.addAll(
_normalizedColumns(
spotifyId: json['spotifyId'] as String?,
isrc: json['isrc'] as String?,
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'],
'coverUrl': row['cover_url'],
'filePath': _normalizeIosPath(row['file_path'] as String?),
'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'],
'explicit': row['explicit'] == 1 || row['explicit'] == true,
'hasLyrics': row['has_lyrics'] == 1 || row['has_lyrics'] == true,
'lyricsMetadataScanVersion': row['lyrics_metadata_scan_version'] ?? 0,
'hasReplayGain':
row['has_replaygain'] == 1 || row['has_replaygain'] == true,
'replayGainMetadataScanVersion':
row['replaygain_metadata_scan_version'] ?? 0,
};
}
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) =>
HistoryRowMapper.dbRowToJson(
row,
_normalizeIosPath(row['file_path'] as String?),
);
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
await db.transaction((txn) async {
await txn.insert(
'history',
_jsonToDbRow(json),
HistoryRowMapper.jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
final batch = txn.batch();
@@ -800,7 +327,7 @@ class HistoryDatabase {
for (final json in items) {
batch.insert(
'history',
_jsonToDbRow(json),
HistoryRowMapper.jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
_putPathKeysInBatch(
@@ -814,7 +341,10 @@ class HistoryDatabase {
}
Future<void> replaceAll(Stream<Map<String, dynamic>> items) async =>
replaceHistoryRows(await database, items.map(_jsonToDbRow));
replaceHistoryRows(
await database,
items.map(HistoryRowMapper.jsonToDbRow),
);
Future<Set<String>> updateExistingBatch(
List<Map<String, dynamic>> items,
@@ -822,7 +352,10 @@ class HistoryDatabase {
if (items.isEmpty) return const {};
final db = await database;
return db.transaction(
(txn) => updateExistingHistoryRows(txn, items.map(_jsonToDbRow)),
(txn) => updateExistingHistoryRows(
txn,
items.map(HistoryRowMapper.jsonToDbRow),
),
);
}
@@ -939,7 +472,9 @@ class HistoryDatabase {
final spotifyCandidates = spotifyLookupCandidates(request.spotifyId);
if (spotifyCandidates.isNotEmpty) {
final placeholders = List.filled(spotifyCandidates.length, '?').join(',');
final normalized = spotifyCandidates.map(_normalizeSpotifyId).toList();
final normalized = spotifyCandidates
.map(HistoryRowMapper.normalizeSpotifyId)
.toList();
final rows = await db.query(
'history',
columns: columns,
@@ -1024,7 +559,7 @@ class HistoryDatabase {
if (candidate.isNotEmpty && seen.add(exactKey)) {
addCandidate(requestIndex, priority++, 'spotify_id', candidate);
}
final normalized = _normalizeSpotifyId(candidate);
final normalized = HistoryRowMapper.normalizeSpotifyId(candidate);
final normalizedKey = 'spotify_id_norm\u0000$normalized';
if (normalized.isNotEmpty && seen.add(normalizedKey)) {
addCandidate(
+182
View File
@@ -0,0 +1,182 @@
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/utils/audio_format_utils.dart';
/// Maps history JSON to storage rows and back without opening a database.
abstract final class HistoryRowMapper {
static String normalizeSpotifyId(String? value) {
return (value ?? '').trim().toLowerCase();
}
static Map<String, dynamic> normalizedColumns({
required String? spotifyId,
required String? isrc,
required String? trackName,
required String? artistName,
required String? albumName,
required String? albumArtist,
}) {
final normalizedTrack = HistoryDatabase.normalizeLookupText(trackName);
final normalizedArtist = HistoryDatabase.normalizeLookupText(artistName);
final normalizedAlbum = HistoryDatabase.normalizeLookupText(albumName);
final normalizedAlbumArtist = HistoryDatabase.normalizeLookupText(
(albumArtist ?? '').trim().isEmpty ? artistName : albumArtist,
);
return {
'spotify_id_norm': normalizeSpotifyId(spotifyId),
'isrc_norm': HistoryDatabase.normalizeIsrc(isrc),
'match_key': HistoryDatabase.matchKeyFor(trackName, artistName),
'album_key': '$normalizedAlbum|$normalizedAlbumArtist',
'search_text': [
normalizedTrack,
normalizedArtist,
normalizedAlbum,
normalizedAlbumArtist,
].where((value) => value.isNotEmpty).join(' '),
};
}
static Map<String, dynamic> queueSortColumns({
required String? trackName,
required String? artistName,
required String? albumName,
required String? albumArtist,
required String? genre,
required String? releaseDate,
required Object? downloadedAt,
}) {
final parsedDownloadedAt = downloadedAt is DateTime
? downloadedAt
: DateTime.tryParse(downloadedAt?.toString() ?? '');
return {
'sort_track': HistoryDatabase.normalizeLookupText(trackName),
'sort_artist': HistoryDatabase.normalizeLookupText(artistName),
'sort_album': HistoryDatabase.normalizeLookupText(albumName),
'sort_album_artist': HistoryDatabase.normalizeLookupText(
(albumArtist ?? '').trim().isEmpty ? artistName : albumArtist,
),
'sort_genre': HistoryDatabase.normalizeLookupText(genre),
'sort_release': releaseDate?.trim() ?? '',
'sort_added': parsedDownloadedAt?.millisecondsSinceEpoch ?? 0,
};
}
static Map<String, dynamic> jsonToDbRow(Map<String, dynamic> json) {
final downloadedAt = json['downloadedAt'];
final parsedDownloadedAt = downloadedAt is DateTime
? downloadedAt
: DateTime.tryParse(downloadedAt?.toString() ?? '');
final row = {
'id': json['id'],
'track_name': json['trackName'],
'artist_name': json['artistName'],
'album_name': json['albumName'],
'album_artist': json['albumArtist'],
'cover_url': json['coverUrl'],
'file_path': json['filePath'],
'storage_mode': json['storageMode'],
'download_tree_uri': json['downloadTreeUri'],
'saf_relative_dir': json['safRelativeDir'],
'saf_file_name': json['safFileName'],
'saf_repaired': json['safRepaired'] == true ? 1 : 0,
'service': json['service'],
'downloaded_at':
parsedDownloadedAt?.toUtc().toIso8601String() ??
downloadedAt?.toString(),
'isrc': json['isrc'],
'spotify_id': json['spotifyId'],
'track_number': json['trackNumber'],
'total_tracks': json['totalTracks'],
'disc_number': json['discNumber'],
'total_discs': json['totalDiscs'],
'duration': json['duration'],
'release_date': json['releaseDate'],
'quality': json['quality'],
'bit_depth': json['bitDepth'],
'sample_rate': json['sampleRate'],
'bitrate': json['bitrate'],
'format': json['format'],
'genre': json['genre'],
'composer': json['composer'],
'label': json['label'],
'copyright': json['copyright'],
'explicit': json['explicit'] == true ? 1 : 0,
'has_lyrics': json['hasLyrics'] == true ? 1 : 0,
'lyrics_metadata_scan_version':
(json['lyricsMetadataScanVersion'] as num?)?.toInt() ??
(json.containsKey('hasLyrics') ? 1 : 0),
'has_replaygain': metadataHasReplayGain(json) ? 1 : 0,
'replaygain_metadata_scan_version':
(json['replayGainMetadataScanVersion'] as num?)?.toInt() ?? 0,
};
row.addAll(
queueSortColumns(
trackName: json['trackName'] as String?,
artistName: json['artistName'] as String?,
albumName: json['albumName'] as String?,
albumArtist: json['albumArtist'] as String?,
genre: json['genre'] as String?,
releaseDate: json['releaseDate'] as String?,
downloadedAt: parsedDownloadedAt,
),
);
row.addAll(
normalizedColumns(
spotifyId: json['spotifyId'] as String?,
isrc: json['isrc'] as String?,
trackName: json['trackName'] as String?,
artistName: json['artistName'] as String?,
albumName: json['albumName'] as String?,
albumArtist: json['albumArtist'] as String?,
),
);
return row;
}
/// [filePath] is the already-normalized value for `row['file_path']`, since
/// iOS container rebasing depends on HistoryDatabase state.
static Map<String, dynamic> dbRowToJson(
Map<String, dynamic> row,
String filePath,
) {
return {
'id': row['id'],
'trackName': row['track_name'],
'artistName': row['artist_name'],
'albumName': row['album_name'],
'albumArtist': row['album_artist'],
'coverUrl': row['cover_url'],
'filePath': filePath,
'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'],
'explicit': row['explicit'] == 1 || row['explicit'] == true,
'hasLyrics': row['has_lyrics'] == 1 || row['has_lyrics'] == true,
'lyricsMetadataScanVersion': row['lyrics_metadata_scan_version'] ?? 0,
'hasReplayGain':
row['has_replaygain'] == 1 || row['has_replaygain'] == true,
'replayGainMetadataScanVersion':
row['replaygain_metadata_scan_version'] ?? 0,
};
}
}
+327
View File
@@ -0,0 +1,327 @@
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/history_row_mapper.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('HistoryDatabase');
/// Schema lifecycle for history.db, independent of row reads and writes.
abstract final class HistorySchema {
/// Creates the schema and returns whether the optional FTS index is
/// available.
static Future<bool> create(Database db, int version) async {
_log.i('Creating database schema v$version');
await db.execute('''
CREATE TABLE history (
id TEXT PRIMARY KEY,
track_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
album_artist TEXT,
cover_url TEXT,
file_path TEXT NOT NULL,
storage_mode TEXT,
download_tree_uri TEXT,
saf_relative_dir TEXT,
saf_file_name TEXT,
saf_repaired INTEGER,
service TEXT NOT NULL,
downloaded_at TEXT NOT NULL,
isrc TEXT,
spotify_id TEXT,
track_number INTEGER,
total_tracks INTEGER,
disc_number INTEGER,
total_discs INTEGER,
duration INTEGER,
release_date TEXT,
quality TEXT,
bit_depth INTEGER,
sample_rate INTEGER,
bitrate INTEGER,
format TEXT,
genre TEXT,
composer TEXT,
label TEXT,
copyright TEXT,
explicit INTEGER NOT NULL DEFAULT 0,
has_lyrics INTEGER NOT NULL DEFAULT 0,
lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0,
has_replaygain INTEGER NOT NULL DEFAULT 0,
replaygain_metadata_scan_version INTEGER NOT NULL DEFAULT 0,
spotify_id_norm TEXT,
isrc_norm TEXT,
match_key TEXT,
album_key TEXT,
search_text TEXT,
sort_track TEXT,
sort_artist TEXT,
sort_album TEXT,
sort_album_artist TEXT,
sort_genre TEXT,
sort_release TEXT,
sort_added INTEGER
)
''');
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
await db.execute(
'CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)',
);
await db.execute(
'CREATE INDEX idx_album ON history(album_name, album_artist)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)',
);
await _createNormalizedIndexes(db);
await _createQueueIndexes(db);
await _createPathKeyTable(db);
final searchFtsAvailable = await createSearchFts(db);
_log.i('Database schema created with indexes');
return searchFtsAvailable;
}
static Future<void> upgrade(
Database db,
int oldVersion,
int newVersion,
) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
await db.execute('ALTER TABLE history ADD COLUMN storage_mode TEXT');
await db.execute('ALTER TABLE history ADD COLUMN download_tree_uri TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_relative_dir TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_file_name TEXT');
}
if (oldVersion < 3) {
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
}
if (oldVersion < 4) {
final columns = await db.rawQuery('PRAGMA table_info(history)');
final hasComposer = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'composer',
);
if (!hasComposer) {
await db.execute('ALTER TABLE history ADD COLUMN composer TEXT');
}
}
if (oldVersion < 5) {
final columns = await db.rawQuery('PRAGMA table_info(history)');
final hasTotalTracks = columns.any(
(row) =>
(row['name']?.toString().toLowerCase() ?? '') == 'total_tracks',
);
final hasTotalDiscs = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == 'total_discs',
);
if (!hasTotalTracks) {
await db.execute('ALTER TABLE history ADD COLUMN total_tracks INTEGER');
}
if (!hasTotalDiscs) {
await db.execute('ALTER TABLE history ADD COLUMN total_discs INTEGER');
}
}
if (oldVersion < 6) {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)',
);
}
if (oldVersion < 7) {
await _createPathKeyTable(db);
}
if (oldVersion < 8) {
await sqlite.addColumnIfMissing(db, 'history', 'spotify_id_norm', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'isrc_norm', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'match_key', 'TEXT');
}
if (oldVersion < 9) {
await sqlite.addColumnIfMissing(db, 'history', 'bitrate', 'INTEGER');
await sqlite.addColumnIfMissing(db, 'history', 'format', 'TEXT');
}
if (oldVersion < 10) {
await sqlite.addColumnIfMissing(db, 'history', 'album_key', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'search_text', 'TEXT');
await _backfillNormalizedColumns(db);
await _createNormalizedIndexes(db);
}
if (oldVersion < 11) {
await sqlite.addColumnIfMissing(db, 'history', 'sort_track', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_artist', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_album', 'TEXT');
await sqlite.addColumnIfMissing(
db,
'history',
'sort_album_artist',
'TEXT',
);
await sqlite.addColumnIfMissing(db, 'history', 'sort_genre', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_release', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'sort_added', 'INTEGER');
await _backfillQueueSortColumns(db);
await _createQueueIndexes(db);
_log.i('Added persisted queue sort columns');
}
if (oldVersion < 12) {
await sqlite.addColumnIfMissing(
db,
'history',
'explicit',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added explicit-content metadata');
}
if (oldVersion < 13) {
await sqlite.addColumnIfMissing(
db,
'history',
'has_lyrics',
'INTEGER NOT NULL DEFAULT 0',
);
await sqlite.addColumnIfMissing(
db,
'history',
'lyrics_metadata_scan_version',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added indexed lyrics availability metadata');
}
if (oldVersion < 14) {
await sqlite.addColumnIfMissing(
db,
'history',
'has_replaygain',
'INTEGER NOT NULL DEFAULT 0',
);
await sqlite.addColumnIfMissing(
db,
'history',
'replaygain_metadata_scan_version',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added indexed ReplayGain availability metadata');
}
if (oldVersion < 15) {
await sqlite.backfillPathKeys(db, 'history', 'history_path_keys');
_log.i('Updated history path keys with provider document identities');
}
}
static Future<bool> createSearchFts(DatabaseExecutor db) {
return sqlite.createTrigramFtsIndex(
db,
ftsTable: HistoryDatabase.searchFtsTable,
contentTable: 'history',
triggerPrefix: 'history_search_fts',
);
}
static Future<void> _createNormalizedIndexes(DatabaseExecutor db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_album_key ON history(album_key)',
);
}
static Future<void> _createQueueIndexes(DatabaseExecutor db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_added ON history(sort_added DESC, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_track ON history(sort_track, sort_artist, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_artist ON history(sort_artist, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_album ON history(sort_album, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_genre ON history(sort_genre, sort_track, id)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_queue_release ON history(sort_release, sort_track, id)',
);
}
static Future<void> _backfillNormalizedColumns(Database db) async {
final rows = await db.query(
'history',
columns: [
'id',
'spotify_id',
'isrc',
'track_name',
'artist_name',
'album_name',
'album_artist',
],
);
final batch = db.batch();
for (final row in rows) {
batch.update(
'history',
HistoryRowMapper.normalizedColumns(
spotifyId: row['spotify_id'] as String?,
isrc: row['isrc'] as String?,
trackName: row['track_name'] as String?,
artistName: row['artist_name'] as String?,
albumName: row['album_name'] as String?,
albumArtist: row['album_artist'] as String?,
),
where: 'id = ?',
whereArgs: [row['id']],
);
}
await batch.commit(noResult: true);
}
static Future<void> _backfillQueueSortColumns(Database db) async {
final rows = await db.query(
'history',
columns: [
'id',
'track_name',
'artist_name',
'album_name',
'album_artist',
'genre',
'release_date',
'downloaded_at',
],
);
final batch = db.batch();
for (final row in rows) {
batch.update(
'history',
HistoryRowMapper.queueSortColumns(
trackName: row['track_name'] as String?,
artistName: row['artist_name'] as String?,
albumName: row['album_name'] as String?,
albumArtist: row['album_artist'] as String?,
genre: row['genre'] as String?,
releaseDate: row['release_date'] as String?,
downloadedAt: row['downloaded_at'],
),
where: 'id = ?',
whereArgs: [row['id']],
);
}
await batch.commit(noResult: true);
}
static Future<void> _createPathKeyTable(DatabaseExecutor db) =>
sqlite.createPathKeyTable(db, 'history_path_keys');
}
+1 -1
View File
@@ -78,7 +78,7 @@ add(True, 'local3', 'local-only', 3, 3)
print(json.dumps([dict(row) for row in db.execute(data['sql'], data['args'])]))
''',
jsonEncode({
'history': _definition('lib/services/history_database.dart', 'history'),
'history': _definition('lib/services/history_schema.dart', 'history'),
'library': _definition('lib/services/library_schema.dart', 'library'),
'sql': sql,
'args': arguments ?? [],
+1 -1
View File
@@ -139,7 +139,7 @@ print('inventory passed')
''',
jsonEncode({
'history': _tableDefinition(
'lib/services/history_database.dart',
'lib/services/history_schema.dart',
'history',
),
'library': _tableDefinition(
+1 -1
View File
@@ -118,7 +118,7 @@ void main() {
await db.execute("ATTACH DATABASE ':memory:' AS history_db");
final definition = RegExp(r'CREATE TABLE history\s*\([\s\S]*?\n\s*\)')
.firstMatch(
File('lib/services/history_database.dart').readAsStringSync(),
File('lib/services/history_schema.dart').readAsStringSync(),
)!
.group(0)!;
await db.execute(
+1 -1
View File
@@ -56,7 +56,7 @@ print('both databases passed')
''',
jsonEncode({
'history': _tableDefinition(
'lib/services/history_database.dart',
'lib/services/history_schema.dart',
'history',
),
'library': _tableDefinition(
+1 -1
View File
@@ -94,7 +94,7 @@ void main() {
'NativeHistoryStore.kt',
).readAsStringSync();
final historyDatabaseSource = File(
'lib/services/history_database.dart',
'lib/services/history_schema.dart',
).readAsStringSync();
final workerSnapshotSource = File(
'android/app/src/main/kotlin/com/zarz/spotiflac/'