From fbed4960141d05d2c89841db8551b4c15f3bc48d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:51:52 +0700 Subject: [PATCH] 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. --- lib/services/history_database.dart | 519 ++--------------------- lib/services/history_row_mapper.dart | 182 ++++++++ lib/services/history_schema.dart | 327 ++++++++++++++ test/album_completeness_query_test.dart | 2 +- test/album_completeness_test.dart | 2 +- test/library_queue_store_test.dart | 2 +- test/replaygain_library_filter_test.dart | 2 +- test/source_contracts_test.dart | 2 +- 8 files changed, 541 insertions(+), 497 deletions(-) create mode 100644 lib/services/history_row_mapper.dart create mode 100644 lib/services/history_schema.dart diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 99f65ed8..d30d3f02 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -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 _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 _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 _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 _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 _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 _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 _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 _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 _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 _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 _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.from(json as Map); batch.insert( 'history', - _jsonToDbRow(map), + HistoryRowMapper.jsonToDbRow(map), conflictAlgorithm: ConflictAlgorithm.replace, ); _putPathKeysInBatch( @@ -659,127 +295,18 @@ class HistoryDatabase { } } - Map _jsonToDbRow(Map 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 _dbRowToJson(Map 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 _dbRowToJson(Map row) => + HistoryRowMapper.dbRowToJson( + row, + _normalizeIosPath(row['file_path'] as String?), + ); Future upsert(Map 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 replaceAll(Stream> items) async => - replaceHistoryRows(await database, items.map(_jsonToDbRow)); + replaceHistoryRows( + await database, + items.map(HistoryRowMapper.jsonToDbRow), + ); Future> updateExistingBatch( List> 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( diff --git a/lib/services/history_row_mapper.dart b/lib/services/history_row_mapper.dart new file mode 100644 index 00000000..aee2bc60 --- /dev/null +++ b/lib/services/history_row_mapper.dart @@ -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 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 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 jsonToDbRow(Map 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 dbRowToJson( + Map 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, + }; + } +} diff --git a/lib/services/history_schema.dart b/lib/services/history_schema.dart new file mode 100644 index 00000000..ee7e7b3c --- /dev/null +++ b/lib/services/history_schema.dart @@ -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 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 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 createSearchFts(DatabaseExecutor db) { + return sqlite.createTrigramFtsIndex( + db, + ftsTable: HistoryDatabase.searchFtsTable, + contentTable: 'history', + triggerPrefix: 'history_search_fts', + ); + } + + static Future _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 _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 _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 _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 _createPathKeyTable(DatabaseExecutor db) => + sqlite.createPathKeyTable(db, 'history_path_keys'); +} diff --git a/test/album_completeness_query_test.dart b/test/album_completeness_query_test.dart index 41512b18..dfe72697 100644 --- a/test/album_completeness_query_test.dart +++ b/test/album_completeness_query_test.dart @@ -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 ?? [], diff --git a/test/album_completeness_test.dart b/test/album_completeness_test.dart index 89faa2ca..4dd4bf28 100644 --- a/test/album_completeness_test.dart +++ b/test/album_completeness_test.dart @@ -139,7 +139,7 @@ print('inventory passed') ''', jsonEncode({ 'history': _tableDefinition( - 'lib/services/history_database.dart', + 'lib/services/history_schema.dart', 'history', ), 'library': _tableDefinition( diff --git a/test/library_queue_store_test.dart b/test/library_queue_store_test.dart index edcf5c65..4e061b34 100644 --- a/test/library_queue_store_test.dart +++ b/test/library_queue_store_test.dart @@ -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( diff --git a/test/replaygain_library_filter_test.dart b/test/replaygain_library_filter_test.dart index bc7441c1..29c7c6a4 100644 --- a/test/replaygain_library_filter_test.dart +++ b/test/replaygain_library_filter_test.dart @@ -56,7 +56,7 @@ print('both databases passed') ''', jsonEncode({ 'history': _tableDefinition( - 'lib/services/history_database.dart', + 'lib/services/history_schema.dart', 'history', ), 'library': _tableDefinition( diff --git a/test/source_contracts_test.dart b/test/source_contracts_test.dart index aa56c990..465d110a 100644 --- a/test/source_contracts_test.dart +++ b/test/source_contracts_test.dart @@ -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/'