mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 08:30:33 +02:00
perf: migrate history to SQLite and optimize palette extraction
- Add SQLite database for download history with O(1) indexed lookups - Add in-memory Map indexes for O(1) getBySpotifyId/getByIsrc - Automatic migration from SharedPreferences on first launch - Fix PaletteService to use PaletteGenerator (isolate approach didn't work) - Use small image size (64x64) and limited colors (8) for speed - Add caching to avoid re-extraction - All screens now use consistent PaletteService - Update CHANGELOG with all v3.2.0 changes
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
import 'dart:convert';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('HistoryDatabase');
|
||||
|
||||
/// SQLite database service for download history
|
||||
/// Provides O(1) lookups by spotify_id and isrc with proper indexing
|
||||
class HistoryDatabase {
|
||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||
static Database? _database;
|
||||
|
||||
HistoryDatabase._init();
|
||||
|
||||
Future<Database> get database async {
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDB('history.db');
|
||||
return _database!;
|
||||
}
|
||||
|
||||
Future<Database> _initDB(String fileName) async {
|
||||
final dbPath = await getApplicationDocumentsDirectory();
|
||||
final path = join(dbPath.path, fileName);
|
||||
|
||||
_log.i('Initializing database at: $path');
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: _createDB,
|
||||
onUpgrade: _upgradeDB,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
service TEXT NOT NULL,
|
||||
downloaded_at TEXT NOT NULL,
|
||||
isrc TEXT,
|
||||
spotify_id TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
duration INTEGER,
|
||||
release_date TEXT,
|
||||
quality TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
genre TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT
|
||||
)
|
||||
''');
|
||||
|
||||
// Indexes for fast lookups
|
||||
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)');
|
||||
|
||||
_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');
|
||||
// Future migrations go here
|
||||
}
|
||||
|
||||
/// Migrate data from SharedPreferences to SQLite
|
||||
/// Returns true if migration was performed, false if already migrated
|
||||
Future<bool> migrateFromSharedPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final migrationKey = 'history_migrated_to_sqlite';
|
||||
|
||||
if (prefs.getBool(migrationKey) == true) {
|
||||
_log.d('Already migrated to SQLite');
|
||||
return false;
|
||||
}
|
||||
|
||||
final jsonStr = prefs.getString('download_history');
|
||||
if (jsonStr == null || jsonStr.isEmpty) {
|
||||
_log.d('No SharedPreferences history to migrate');
|
||||
await prefs.setBool(migrationKey, true);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite');
|
||||
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final json in jsonList) {
|
||||
final map = json as Map<String, dynamic>;
|
||||
batch.insert(
|
||||
'history',
|
||||
_jsonToDbRow(map),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
|
||||
// Mark as migrated but keep old data for safety
|
||||
await prefs.setBool(migrationKey, true);
|
||||
_log.i('Migration complete: ${jsonList.length} items');
|
||||
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
_log.e('Migration failed: $e', e, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert JSON format (camelCase) to DB row (snake_case)
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
return {
|
||||
'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'],
|
||||
'service': json['service'],
|
||||
'downloaded_at': json['downloadedAt'],
|
||||
'isrc': json['isrc'],
|
||||
'spotify_id': json['spotifyId'],
|
||||
'track_number': json['trackNumber'],
|
||||
'disc_number': json['discNumber'],
|
||||
'duration': json['duration'],
|
||||
'release_date': json['releaseDate'],
|
||||
'quality': json['quality'],
|
||||
'bit_depth': json['bitDepth'],
|
||||
'sample_rate': json['sampleRate'],
|
||||
'genre': json['genre'],
|
||||
'label': json['label'],
|
||||
'copyright': json['copyright'],
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert DB row (snake_case) to JSON format (camelCase)
|
||||
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': row['file_path'],
|
||||
'service': row['service'],
|
||||
'downloadedAt': row['downloaded_at'],
|
||||
'isrc': row['isrc'],
|
||||
'spotifyId': row['spotify_id'],
|
||||
'trackNumber': row['track_number'],
|
||||
'discNumber': row['disc_number'],
|
||||
'duration': row['duration'],
|
||||
'releaseDate': row['release_date'],
|
||||
'quality': row['quality'],
|
||||
'bitDepth': row['bit_depth'],
|
||||
'sampleRate': row['sample_rate'],
|
||||
'genre': row['genre'],
|
||||
'label': row['label'],
|
||||
'copyright': row['copyright'],
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== CRUD Operations ====================
|
||||
|
||||
/// Insert or update a history item
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
'history',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all history items ordered by download date (newest first)
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
orderBy: 'downloaded_at DESC',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
/// Get item by ID
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Get item by Spotify ID - O(1) with index
|
||||
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
where: 'spotify_id = ?',
|
||||
whereArgs: [spotifyId],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Get item by ISRC - O(1) with index
|
||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
where: 'isrc = ?',
|
||||
whereArgs: [isrc],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Check if spotify_id exists - O(1) with index
|
||||
Future<bool> existsBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery(
|
||||
'SELECT 1 FROM history WHERE spotify_id = ? LIMIT 1',
|
||||
[spotifyId],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get all spotify_ids as Set for fast in-memory lookup
|
||||
Future<Set<String>> getAllSpotifyIds() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""'
|
||||
);
|
||||
return rows.map((r) => r['spotify_id'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Delete by ID
|
||||
Future<void> deleteById(String id) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// Delete by Spotify ID
|
||||
Future<void> deleteBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
|
||||
}
|
||||
|
||||
/// Clear all history
|
||||
Future<void> clearAll() async {
|
||||
final db = await database;
|
||||
await db.delete('history');
|
||||
_log.i('Cleared all history');
|
||||
}
|
||||
|
||||
/// Get total count
|
||||
Future<int> getCount() async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
/// Find existing item by spotify_id or isrc (for deduplication)
|
||||
Future<Map<String, dynamic>?> findExisting({
|
||||
String? spotifyId,
|
||||
String? isrc,
|
||||
}) async {
|
||||
if (spotifyId != null && spotifyId.isNotEmpty) {
|
||||
final bySpotify = await getBySpotifyId(spotifyId);
|
||||
if (bySpotify != null) return bySpotify;
|
||||
|
||||
// Check for deezer: prefix matching
|
||||
if (spotifyId.startsWith('deezer:')) {
|
||||
final deezerId = spotifyId.substring(7);
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
where: 'spotify_id LIKE ?',
|
||||
whereArgs: ['deezer:$deezerId'],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
|
||||
}
|
||||
}
|
||||
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
return await getByIsrc(isrc);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close database
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
|
||||
/// Service for extracting dominant colors from images
|
||||
/// Uses caching to avoid re-extraction and small image size for speed
|
||||
class PaletteService {
|
||||
static final PaletteService instance = PaletteService._();
|
||||
PaletteService._();
|
||||
|
||||
/// Cache for already computed colors
|
||||
final Map<String, Color> _colorCache = {};
|
||||
|
||||
/// Extract dominant color from a network image URL
|
||||
/// Uses small image size and limited colors for speed
|
||||
Future<Color?> extractDominantColor(String? imageUrl) async {
|
||||
if (imageUrl == null || imageUrl.isEmpty) return null;
|
||||
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (_colorCache.containsKey(imageUrl)) {
|
||||
return _colorCache[imageUrl];
|
||||
}
|
||||
|
||||
try {
|
||||
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||
CachedNetworkImageProvider(imageUrl),
|
||||
size: const Size(64, 64), // Small size for speed
|
||||
maximumColorCount: 8, // Fewer colors for speed
|
||||
);
|
||||
|
||||
final color = paletteGenerator.dominantColor?.color ??
|
||||
paletteGenerator.vibrantColor?.color ??
|
||||
paletteGenerator.mutedColor?.color;
|
||||
|
||||
if (color != null) {
|
||||
_colorCache[imageUrl] = color;
|
||||
}
|
||||
|
||||
return color;
|
||||
} catch (e) {
|
||||
debugPrint('PaletteService error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the color cache
|
||||
void clearCache() {
|
||||
_colorCache.clear();
|
||||
}
|
||||
|
||||
/// Get cached color without computing
|
||||
Color? getCached(String? imageUrl) {
|
||||
if (imageUrl == null) return null;
|
||||
return _colorCache[imageUrl];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user