import 'dart:convert'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('LibraryCollectionsDb'); const _dbFileName = 'library_collections.db'; const _dbVersion = 1; const _tableWishlist = 'wishlist_tracks'; const _tableLoved = 'loved_tracks'; const _tablePlaylists = 'playlists'; const _tablePlaylistTracks = 'playlist_tracks'; const _legacyCollectionsStorageKey = 'library_collections_v1'; const _migrationDoneKey = 'library_collections_migrated_to_sqlite_v1'; class LibraryCollectionsSnapshot { final List> wishlistRows; final List> lovedRows; final List> playlistRows; final List> playlistTrackRows; const LibraryCollectionsSnapshot({ required this.wishlistRows, required this.lovedRows, required this.playlistRows, required this.playlistTrackRows, }); } class PlaylistPickerSummaryRow { final String id; final String name; final String? coverImagePath; final String? previewCover; final DateTime createdAt; final DateTime updatedAt; final int trackCount; final bool containsAllRequestedTracks; const PlaylistPickerSummaryRow({ required this.id, required this.name, this.coverImagePath, this.previewCover, required this.createdAt, required this.updatedAt, required this.trackCount, required this.containsAllRequestedTracks, }); } class LibraryCollectionsDatabase { static final LibraryCollectionsDatabase instance = LibraryCollectionsDatabase._init(); static Database? _database; final Future _prefs = SharedPreferences.getInstance(); LibraryCollectionsDatabase._init(); Future get database async { if (_database != null) return _database!; _database = await _initDb(); return _database!; } Future _initDb() async { final dbPath = await getApplicationDocumentsDirectory(); final path = join(dbPath.path, _dbFileName); _log.i('Initializing collections database at: $path'); return openDatabase( path, version: _dbVersion, onConfigure: (db) async { await db.execute('PRAGMA foreign_keys = ON'); await db.rawQuery('PRAGMA journal_mode = WAL'); await db.execute('PRAGMA synchronous = NORMAL'); }, onCreate: _createDb, onUpgrade: _upgradeDb, ); } Future _createDb(Database db, int version) async { _log.i('Creating collections database schema v$version'); await db.execute(''' CREATE TABLE $_tableWishlist ( track_key TEXT PRIMARY KEY, track_json TEXT NOT NULL, added_at TEXT NOT NULL ) '''); await db.execute(''' CREATE TABLE $_tableLoved ( track_key TEXT PRIMARY KEY, track_json TEXT NOT NULL, added_at TEXT NOT NULL ) '''); await db.execute(''' CREATE TABLE $_tablePlaylists ( id TEXT PRIMARY KEY, name TEXT NOT NULL, cover_image_path TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) '''); await db.execute(''' CREATE TABLE $_tablePlaylistTracks ( playlist_id TEXT NOT NULL, track_key TEXT NOT NULL, track_json TEXT NOT NULL, added_at TEXT NOT NULL, PRIMARY KEY (playlist_id, track_key), FOREIGN KEY (playlist_id) REFERENCES $_tablePlaylists(id) ON DELETE CASCADE ) '''); await db.execute( 'CREATE INDEX idx_${_tableWishlist}_added_at ON $_tableWishlist(added_at DESC)', ); await db.execute( 'CREATE INDEX idx_${_tableLoved}_added_at ON $_tableLoved(added_at DESC)', ); await db.execute( 'CREATE INDEX idx_${_tablePlaylists}_created_at ON $_tablePlaylists(created_at DESC)', ); await db.execute( 'CREATE INDEX idx_${_tablePlaylistTracks}_playlist_id ON $_tablePlaylistTracks(playlist_id)', ); await db.execute( 'CREATE INDEX idx_${_tablePlaylistTracks}_added_at ON $_tablePlaylistTracks(added_at DESC)', ); } Future _upgradeDb(Database db, int oldVersion, int newVersion) async { _log.i('Upgrading collections database from v$oldVersion to v$newVersion'); } Future migrateFromSharedPreferences() async { final prefs = await _prefs; if (prefs.getBool(_migrationDoneKey) == true) { return false; } final raw = prefs.getString(_legacyCollectionsStorageKey); if (raw == null || raw.isEmpty) { await prefs.setBool(_migrationDoneKey, true); return false; } try { final decoded = jsonDecode(raw); if (decoded is! Map) { await prefs.setBool(_migrationDoneKey, true); return false; } final root = Map.from(decoded); final wishlistRaw = (root['wishlist'] as List?) ?? const []; final lovedRaw = (root['loved'] as List?) ?? const []; final playlistsRaw = (root['playlists'] as List?) ?? const []; final nowIso = DateTime.now().toIso8601String(); final db = await database; await db.transaction((txn) async { for (final entry in wishlistRaw.whereType>()) { final map = Map.from(entry); final trackKey = map['key'] as String?; final track = map['track']; if (trackKey == null || track is! Map) continue; final addedAt = (map['addedAt'] as String?) ?? nowIso; await txn.insert(_tableWishlist, { 'track_key': trackKey, 'track_json': jsonEncode(track), 'added_at': addedAt, }, conflictAlgorithm: ConflictAlgorithm.replace); } for (final entry in lovedRaw.whereType>()) { final map = Map.from(entry); final trackKey = map['key'] as String?; final track = map['track']; if (trackKey == null || track is! Map) continue; final addedAt = (map['addedAt'] as String?) ?? nowIso; await txn.insert(_tableLoved, { 'track_key': trackKey, 'track_json': jsonEncode(track), 'added_at': addedAt, }, conflictAlgorithm: ConflictAlgorithm.replace); } for (final playlistEntry in playlistsRaw.whereType>()) { final playlist = Map.from(playlistEntry); final playlistId = playlist['id'] as String?; if (playlistId == null || playlistId.isEmpty) continue; final createdAt = (playlist['createdAt'] as String?) ?? nowIso; final updatedAt = (playlist['updatedAt'] as String?) ?? createdAt; await txn.insert(_tablePlaylists, { 'id': playlistId, 'name': (playlist['name'] as String?) ?? '', 'cover_image_path': playlist['coverImagePath'] as String?, 'created_at': createdAt, 'updated_at': updatedAt, }, conflictAlgorithm: ConflictAlgorithm.replace); final tracksRaw = (playlist['tracks'] as List?) ?? const []; for (final trackEntry in tracksRaw.whereType>()) { final trackMap = Map.from(trackEntry); final trackKey = trackMap['key'] as String?; final track = trackMap['track']; if (trackKey == null || track is! Map) continue; final addedAt = (trackMap['addedAt'] as String?) ?? nowIso; await txn.insert(_tablePlaylistTracks, { 'playlist_id': playlistId, 'track_key': trackKey, 'track_json': jsonEncode(track), 'added_at': addedAt, }, conflictAlgorithm: ConflictAlgorithm.replace); } } }); await prefs.setBool(_migrationDoneKey, true); _log.i('Migrated legacy collections data to SQLite'); return true; } catch (e, stack) { _log.e('Failed migrating collections to SQLite: $e', e, stack); return false; } } Future loadSnapshot() async { final db = await database; final wishlistRows = await db.query( _tableWishlist, orderBy: 'added_at DESC, rowid DESC', ); final lovedRows = await db.query( _tableLoved, orderBy: 'added_at DESC, rowid DESC', ); final playlistRows = await db.query( _tablePlaylists, orderBy: 'created_at DESC, rowid DESC', ); final playlistTrackRows = await db.query( _tablePlaylistTracks, orderBy: 'playlist_id ASC, added_at DESC, rowid DESC', ); return LibraryCollectionsSnapshot( wishlistRows: wishlistRows, lovedRows: lovedRows, playlistRows: playlistRows, playlistTrackRows: playlistTrackRows, ); } Future> loadPlaylistPickerSummaries( List requestedTrackKeys, ) async { final db = await database; final uniqueTrackKeys = requestedTrackKeys .where((key) => key.trim().isNotEmpty) .toSet() .toList(growable: false); final playlistRows = await db.rawQuery(''' SELECT p.id, p.name, p.cover_image_path, p.created_at, p.updated_at, COUNT(pt.track_key) AS track_count FROM $_tablePlaylists p LEFT JOIN $_tablePlaylistTracks pt ON pt.playlist_id = p.id GROUP BY p.id ORDER BY p.created_at DESC, p.rowid DESC '''); final matchedCountsByPlaylistId = {}; if (uniqueTrackKeys.isNotEmpty) { final placeholders = List.filled(uniqueTrackKeys.length, '?').join(', '); final matchedRows = await db.rawQuery(''' SELECT playlist_id, COUNT(*) AS matched_count FROM $_tablePlaylistTracks WHERE track_key IN ($placeholders) GROUP BY playlist_id ''', uniqueTrackKeys); for (final row in matchedRows) { final playlistId = row['playlist_id']?.toString(); if (playlistId == null || playlistId.isEmpty) continue; matchedCountsByPlaylistId[playlistId] = (row['matched_count'] as num?)?.toInt() ?? 0; } } final playlistIdsNeedingPreview = playlistRows .where((row) { final coverPath = row['cover_image_path']?.toString(); return coverPath == null || coverPath.isEmpty; }) .map((row) => row['id']?.toString() ?? '') .where((id) => id.isNotEmpty) .toList(growable: false); final previewCoverByPlaylistId = {}; if (playlistIdsNeedingPreview.isNotEmpty) { final placeholders = List.filled( playlistIdsNeedingPreview.length, '?', ).join(', '); final previewRows = await db.rawQuery(''' SELECT outer_tracks.playlist_id, outer_tracks.track_json FROM $_tablePlaylistTracks outer_tracks WHERE outer_tracks.playlist_id IN ($placeholders) AND outer_tracks.rowid = ( SELECT inner_tracks.rowid FROM $_tablePlaylistTracks inner_tracks WHERE inner_tracks.playlist_id = outer_tracks.playlist_id ORDER BY inner_tracks.added_at DESC, inner_tracks.rowid DESC LIMIT 1 ) ''', playlistIdsNeedingPreview); for (final row in previewRows) { final playlistId = row['playlist_id']?.toString(); final trackJson = row['track_json'] as String?; if (playlistId == null || playlistId.isEmpty || trackJson == null || trackJson.isEmpty) { continue; } try { final decoded = jsonDecode(trackJson); if (decoded is! Map) continue; final coverUrl = decoded['coverUrl']?.toString(); if (coverUrl != null && coverUrl.isNotEmpty) { previewCoverByPlaylistId[playlistId] = coverUrl; } } catch (_) {} } } return playlistRows .map((row) { final id = row['id']?.toString() ?? ''; final createdAt = DateTime.tryParse(row['created_at']?.toString() ?? '') ?? DateTime.now(); final updatedAt = DateTime.tryParse(row['updated_at']?.toString() ?? '') ?? createdAt; return PlaylistPickerSummaryRow( id: id, name: row['name']?.toString() ?? '', coverImagePath: row['cover_image_path'] as String?, previewCover: previewCoverByPlaylistId[id], createdAt: createdAt, updatedAt: updatedAt, trackCount: (row['track_count'] as num?)?.toInt() ?? 0, containsAllRequestedTracks: uniqueTrackKeys.isNotEmpty && matchedCountsByPlaylistId[id] == uniqueTrackKeys.length, ); }) .toList(growable: false); } Future upsertWishlistEntry({ required String trackKey, required String trackJson, required String addedAt, }) async { final db = await database; await db.insert(_tableWishlist, { 'track_key': trackKey, 'track_json': trackJson, 'added_at': addedAt, }, conflictAlgorithm: ConflictAlgorithm.replace); } Future deleteWishlistEntry(String trackKey) async { final db = await database; await db.delete( _tableWishlist, where: 'track_key = ?', whereArgs: [trackKey], ); } Future upsertLovedEntry({ required String trackKey, required String trackJson, required String addedAt, }) async { final db = await database; await db.insert(_tableLoved, { 'track_key': trackKey, 'track_json': trackJson, 'added_at': addedAt, }, conflictAlgorithm: ConflictAlgorithm.replace); } Future deleteLovedEntry(String trackKey) async { final db = await database; await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]); } Future upsertPlaylist({ required String id, required String name, required String createdAt, required String updatedAt, String? coverImagePath, }) async { final db = await database; await db.insert(_tablePlaylists, { 'id': id, 'name': name, 'cover_image_path': coverImagePath, 'created_at': createdAt, 'updated_at': updatedAt, }, conflictAlgorithm: ConflictAlgorithm.replace); } Future renamePlaylist({ required String playlistId, required String name, required String updatedAt, }) async { final db = await database; await db.update( _tablePlaylists, {'name': name, 'updated_at': updatedAt}, where: 'id = ?', whereArgs: [playlistId], ); } Future updatePlaylistCover({ required String playlistId, required String updatedAt, String? coverImagePath, }) async { final db = await database; await db.update( _tablePlaylists, {'cover_image_path': coverImagePath, 'updated_at': updatedAt}, where: 'id = ?', whereArgs: [playlistId], ); } Future deletePlaylist(String playlistId) async { final db = await database; await db.delete(_tablePlaylists, where: 'id = ?', whereArgs: [playlistId]); } Future upsertPlaylistTrack({ required String playlistId, required String trackKey, required String trackJson, required String addedAt, required String playlistUpdatedAt, }) async { final db = await database; await db.transaction((txn) async { await txn.insert(_tablePlaylistTracks, { 'playlist_id': playlistId, 'track_key': trackKey, 'track_json': trackJson, 'added_at': addedAt, }, conflictAlgorithm: ConflictAlgorithm.replace); await txn.update( _tablePlaylists, {'updated_at': playlistUpdatedAt}, where: 'id = ?', whereArgs: [playlistId], ); }); } Future upsertPlaylistTracksBatch({ required String playlistId, required String playlistUpdatedAt, required List> tracks, }) async { if (tracks.isEmpty) return; final db = await database; await db.transaction((txn) async { final batch = txn.batch(); for (final track in tracks) { batch.insert(_tablePlaylistTracks, { 'playlist_id': playlistId, 'track_key': track['track_key'], 'track_json': track['track_json'], 'added_at': track['added_at'], }, conflictAlgorithm: ConflictAlgorithm.replace); } batch.update( _tablePlaylists, {'updated_at': playlistUpdatedAt}, where: 'id = ?', whereArgs: [playlistId], ); await batch.commit(noResult: true); }); } Future deletePlaylistTrack({ required String playlistId, required String trackKey, required String playlistUpdatedAt, }) async { final db = await database; await db.transaction((txn) async { await txn.delete( _tablePlaylistTracks, where: 'playlist_id = ? AND track_key = ?', whereArgs: [playlistId, trackKey], ); await txn.update( _tablePlaylists, {'updated_at': playlistUpdatedAt}, where: 'id = ?', whereArgs: [playlistId], ); }); } }