mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
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:
1 parent
c2b7a0b782
commit
fbed496014
8 files changed
+541
-497
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -139,7 +139,7 @@ print('inventory passed')
|
||||
''',
|
||||
jsonEncode({
|
||||
'history': _tableDefinition(
|
||||
'lib/services/history_database.dart',
|
||||
'lib/services/history_schema.dart',
|
||||
'history',
|
||||
),
|
||||
'library': _tableDefinition(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -56,7 +56,7 @@ print('both databases passed')
|
||||
''',
|
||||
jsonEncode({
|
||||
'history': _tableDefinition(
|
||||
'lib/services/history_database.dart',
|
||||
'lib/services/history_schema.dart',
|
||||
'history',
|
||||
),
|
||||
'library': _tableDefinition(
|
||||
|
||||
@@ -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/'
|
||||
|
||||
Reference in new issue
Block a user