refactor: extract shared helpers for repeated low-level patterns

Dart:
- notification_service: single _details() builder replaces 13 copies
  of the NotificationDetails block
- platform_bridge: _invokeMap() for 34 invoke+decode call sites,
  _cachedInvoke() unifies the three TTL/in-flight cache scaffolds
- ffmpeg_service: _promoteTempOutput(), _appendCoverInputArgs(),
  single _writeReplayGainTags() and _convertToLossless() for the
  ALAC/FLAC twins
- sqlite_helpers.dart: shared openAppDatabase/path-key/migration
  helpers for the three database classes
- library_collections: parametrized wishlist/loved/favorite CRUD
- extension_provider: one predicate-based replacedBuiltIn* lookup

Go:
- extension runtime: parseGojaHeaders/coerceGojaBody/doExtensionHTTP
  shared by httpGet/httpPost/httpRequest/shortcuts/fetch
- exports_metadata: applyAudioMetadataToResult + successMethodJSON,
  APE edit path reuses audioMetadataFromEditFields
- lyrics: lrclibGet() for both LRCLib fetchers
- extension_store: drop hand-rolled strings helpers
This commit is contained in:
zarzet
2026-07-12 14:10:23 +07:00
parent aa2cdef1d2
commit 4e0cae9c20
16 changed files with 851 additions and 1779 deletions
+176 -322
View File
@@ -1445,75 +1445,14 @@ class FFmpegService {
String albumPeak, {
bool returnTempPath = false,
void Function(String tempPath)? onTempReady,
}) async {
final ext = filePath.contains('.')
? '.${filePath.split('.').last}'
: '.tmp';
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
final arguments = <String>[
'-v',
'error',
'-hide_banner',
'-i',
filePath,
'-map',
'0',
'-c',
'copy',
'-map_metadata',
'0',
'-metadata',
'REPLAYGAIN_ALBUM_GAIN=$albumGain',
'-metadata',
'REPLAYGAIN_ALBUM_PEAK=$albumPeak',
];
if (ext.toLowerCase() == '.opus') {
final r128 = replayGainDbToR128(albumGain);
if (r128 != null) {
arguments
..add('-metadata')
..add('R128_ALBUM_GAIN=$r128');
}
}
arguments
..add(tempOutput)
..add('-y');
_log.d('Writing album ReplayGain tags via FFmpeg');
final result = await _executeWithArguments(arguments);
if (result.success) {
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
if (returnTempPath) {
onTempReady?.call(tempOutput);
return true;
}
final originalFile = File(filePath);
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(filePath);
await tempFile.delete();
_log.d('Album ReplayGain tags written successfully');
return true;
}
} catch (e) {
_log.w('Failed to replace file with album ReplayGain: $e');
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) await tempFile.delete();
} catch (_) {}
return false;
}
}) => _writeReplayGainTags(
filePath,
'Album',
albumGain,
albumPeak,
returnTempPath: returnTempPath,
onTempReady: onTempReady,
);
/// Write track ReplayGain tags to a file via FFmpeg, replacing it in place.
///
@@ -1525,12 +1464,24 @@ class FFmpegService {
String filePath,
String trackGain,
String trackPeak,
) async {
) => _writeReplayGainTags(filePath, 'Track', trackGain, trackPeak);
/// Shared implementation for album/track ReplayGain tagging.
/// [scope] is 'Album' or 'Track'; it selects the REPLAYGAIN_*/R128_* tags.
static Future<bool> _writeReplayGainTags(
String filePath,
String scope,
String gain,
String peak, {
bool returnTempPath = false,
void Function(String tempPath)? onTempReady,
}) async {
final ext = filePath.contains('.')
? '.${filePath.split('.').last}'
: '.tmp';
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
final tag = scope.toUpperCase();
final arguments = <String>[
'-v',
'error',
@@ -1544,17 +1495,17 @@ class FFmpegService {
'-map_metadata',
'0',
'-metadata',
'REPLAYGAIN_TRACK_GAIN=$trackGain',
'REPLAYGAIN_${tag}_GAIN=$gain',
'-metadata',
'REPLAYGAIN_TRACK_PEAK=$trackPeak',
'REPLAYGAIN_${tag}_PEAK=$peak',
];
if (ext.toLowerCase() == '.opus') {
final r128 = replayGainDbToR128(trackGain);
final r128 = replayGainDbToR128(gain);
if (r128 != null) {
arguments
..add('-metadata')
..add('R128_TRACK_GAIN=$r128');
..add('R128_${tag}_GAIN=$r128');
}
}
@@ -1562,24 +1513,30 @@ class FFmpegService {
..add(tempOutput)
..add('-y');
_log.d('Writing track ReplayGain tags via FFmpeg');
_log.d('Writing ${scope.toLowerCase()} ReplayGain tags via FFmpeg');
final result = await _executeWithArguments(arguments);
if (result.success) {
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
final originalFile = File(filePath);
if (await originalFile.exists()) {
await originalFile.delete();
if (returnTempPath) {
try {
if (await File(tempOutput).exists()) {
onTempReady?.call(tempOutput);
return true;
}
await tempFile.copy(filePath);
await tempFile.delete();
_log.d('Track ReplayGain tags written successfully');
return true;
} catch (e) {
_log.w(
'Failed to replace file with ${scope.toLowerCase()} ReplayGain: $e',
);
}
} catch (e) {
_log.w('Failed to replace file with track ReplayGain: $e');
} else if (await _promoteTempOutput(
tempOutput,
filePath,
onError: (e) => _log.w(
'Failed to replace file with ${scope.toLowerCase()} ReplayGain: $e',
),
)) {
_log.d('$scope ReplayGain tags written successfully');
return true;
}
}
@@ -1591,6 +1548,58 @@ class FFmpegService {
return false;
}
/// Replace [targetPath] with the successful FFmpeg temp output at
/// [tempOutput]. Returns `true` when the target was replaced. The temp
/// file is always cleaned up, including when the replacement fails.
static Future<bool> _promoteTempOutput(
String tempOutput,
String targetPath, {
void Function()? onMissing,
required void Function(Object e) onError,
}) async {
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
final originalFile = File(targetPath);
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(targetPath);
await tempFile.delete();
return true;
}
onMissing?.call();
return false;
} catch (e) {
onError(e);
return false;
} finally {
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) await tempFile.delete();
} catch (_) {}
}
}
/// Map input #1 (the cover image) as attached picture art.
static void _appendCoverInputArgs(
List<String> arguments, {
String map = '1:v',
String disposition = '-disposition:v:0',
}) {
arguments
..add('-map')
..add(map)
..add('-c:v')
..add('copy')
..add(disposition)
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
}
static Future<String?> embedMetadata({
required String flacPath,
String? coverPath,
@@ -1612,17 +1621,11 @@ class FFmpegService {
..add('0:a');
if (coverPath != null) {
arguments
..add('-map')
..add('1:0')
..add('-c:v')
..add('copy')
..add('-disposition:v')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
_appendCoverInputArgs(
arguments,
map: '1:0',
disposition: '-disposition:v',
);
}
arguments
@@ -1645,26 +1648,14 @@ class FFmpegService {
final result = await _executeWithArguments(arguments);
if (result.success) {
try {
final tempFile = File(tempOutput);
final originalFile = File(flacPath);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(flacPath);
await tempFile.delete();
return flacPath;
} else {
_log.e('Temp output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace file after metadata embed: $e');
return null;
}
final promoted = await _promoteTempOutput(
tempOutput,
flacPath,
onMissing: () => _log.e('Temp output file not found: $tempOutput'),
onError: (e) =>
_log.e('Failed to replace file after metadata embed: $e'),
);
return promoted ? flacPath : null;
}
try {
@@ -1824,27 +1815,16 @@ class FFmpegService {
String mp3Path,
String tempOutput,
) async {
try {
final tempFile = File(tempOutput);
final originalFile = File(mp3Path);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(mp3Path);
await tempFile.delete();
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} else {
_log.e('Temp MP3 output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
}
final promoted = await _promoteTempOutput(
tempOutput,
mp3Path,
onMissing: () => _log.e('Temp MP3 output file not found: $tempOutput'),
onError: (e) =>
_log.e('Failed to replace MP3 file after metadata embed: $e'),
);
if (!promoted) return null;
_log.d('MP3 metadata embedded successfully');
return mp3Path;
}
static String? _extractLyricsForId3(Map<String, String>? metadata) {
@@ -2105,27 +2085,17 @@ class FFmpegService {
final result = await _executeWithArguments(arguments);
if (result.success) {
try {
final tempFile = File(tempOutput);
final originalFile = File(opusPath);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(opusPath);
await tempFile.delete();
_log.d('Opus metadata embedded successfully');
return opusPath;
} else {
_log.e('Temp Opus output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace Opus file after metadata embed: $e');
return null;
}
final promoted = await _promoteTempOutput(
tempOutput,
opusPath,
onMissing: () =>
_log.e('Temp Opus output file not found: $tempOutput'),
onError: (e) =>
_log.e('Failed to replace Opus file after metadata embed: $e'),
);
if (!promoted) return null;
_log.d('Opus metadata embedded successfully');
return opusPath;
}
try {
@@ -2186,17 +2156,7 @@ class FFmpegService {
if (hasCover) {
// Mark the image as an attached picture so the container writes a proper
// covr atom instead of a generic MJPEG video track.
arguments
..add('-map')
..add('1:v')
..add('-c:v')
..add('copy')
..add('-disposition:v:0')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
_appendCoverInputArgs(arguments);
}
if (metadata != null) {
@@ -2236,34 +2196,24 @@ class FFmpegService {
}
if (result.success) {
try {
final tempFile = File(tempOutput);
final originalFile = File(m4aPath);
final promoted = await _promoteTempOutput(
tempOutput,
m4aPath,
onMissing: () => _log.e('Temp M4A output file not found: $tempOutput'),
onError: (e) =>
_log.e('Failed to replace M4A file after metadata embed: $e'),
);
if (!promoted) return null;
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(m4aPath);
await tempFile.delete();
// FFmpeg's MP4 muxer ignores ISRC and label, so write them natively
// as iTunes freeform atoms. Only fields the caller supplied are
// touched (an empty value clears the tag).
if (metadata != null) {
await _writeM4AFreeformTags(m4aPath, metadata);
}
_log.d('M4A metadata embedded successfully');
return m4aPath;
} else {
_log.e('Temp M4A output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace M4A file after metadata embed: $e');
return null;
// FFmpeg's MP4 muxer ignores ISRC and label, so write them natively
// as iTunes freeform atoms. Only fields the caller supplied are
// touched (an empty value clears the tag).
if (metadata != null) {
await _writeM4AFreeformTags(m4aPath, metadata);
}
_log.d('M4A metadata embedded successfully');
return m4aPath;
}
try {
@@ -2424,21 +2374,11 @@ class FFmpegService {
)
: const _ResolvedLosslessConversionQuality();
if (format == 'alac') {
return _convertToAlac(
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
targetSampleRate: resolvedLosslessQuality.targetSampleRate,
processing: losslessProcessing,
deleteOriginal: deleteOriginal,
);
}
if (format == 'flac') {
return _convertToFlac(
if (format == 'alac' || format == 'flac') {
return _convertToLossless(
inputPath: inputPath,
metadata: metadata,
codec: format,
coverPath: coverPath,
artistTagMode: artistTagMode,
targetBitDepth: resolvedLosslessQuality.targetBitDepth,
@@ -2547,97 +2487,12 @@ class FFmpegService {
return outputPath;
}
/// Convert to ALAC (.m4a) or FLAC per [codec].
/// Metadata and cover art are embedded in a single FFmpeg pass.
static Future<String?> _convertToAlac({
required String inputPath,
required Map<String, String> metadata,
String? coverPath,
int? targetBitDepth,
int? targetSampleRate,
LosslessConversionProcessing processing =
const LosslessConversionProcessing(),
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.m4a');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', inputPath];
final hasCover =
coverPath != null &&
coverPath.trim().isNotEmpty &&
await File(coverPath).exists();
if (hasCover) {
arguments
..add('-i')
..add(coverPath);
}
arguments
..add('-map')
..add('0:a');
if (hasCover) {
arguments
..add('-map')
..add('1:v')
..add('-c:v')
..add('copy')
..add('-disposition:v:0')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
}
arguments
..add('-c:a')
..add('alac');
_appendLosslessCodecQualityArguments(
arguments,
codec: 'alac',
targetBitDepth: targetBitDepth,
targetSampleRate: targetSampleRate,
processing: processing,
);
arguments
..add('-map_metadata')
..add('-1');
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
arguments
..add(outputPath)
..add('-y');
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to ALAC'
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}'
'${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}'
'${processing.normalizedResampler != 'swr' ? ' resampler=${processing.normalizedResampler}' : ''}',
);
final result = await _executeWithArguments(arguments);
if (!result.success) {
_log.e('ALAC conversion failed: ${result.output}');
return null;
}
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
static Future<String?> _convertToFlac({
static Future<String?> _convertToLossless({
required String inputPath,
required Map<String, String> metadata,
required String codec, // 'alac' or 'flac'
String? coverPath,
String artistTagMode = artistTagModeJoined,
int? targetBitDepth,
@@ -2646,7 +2501,8 @@ class FFmpegService {
const LosslessConversionProcessing(),
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.flac');
final isAlac = codec == 'alac';
final outputPath = _buildOutputPath(inputPath, isAlac ? '.m4a' : '.flac');
final arguments = <String>['-v', 'error', '-hide_banner', '-i', inputPath];
final hasCover =
@@ -2663,46 +2519,44 @@ class FFmpegService {
..add('-map')
..add('0:a');
if (hasCover) {
arguments
..add('-map')
..add('1:v')
..add('-c:v')
..add('copy')
..add('-disposition:v:0')
..add('attached_pic')
..add('-metadata:s:v')
..add('title=Album cover')
..add('-metadata:s:v')
..add('comment=Cover (front)');
_appendCoverInputArgs(arguments);
}
arguments
..add('-c:a')
..add('flac')
..add('-compression_level')
..add('8');
..add(codec);
if (!isAlac) {
arguments
..add('-compression_level')
..add('8');
}
_appendLosslessCodecQualityArguments(
arguments,
codec: 'flac',
codec: codec,
targetBitDepth: targetBitDepth,
targetSampleRate: targetSampleRate,
processing: processing,
);
arguments
..add('-map_metadata')
..add('0');
..add(isAlac ? '-1' : '0');
_appendVorbisMetadataToArguments(
arguments,
metadata,
artistTagMode: artistTagMode,
);
if (isAlac) {
_appendMappedMetadataToArguments(arguments, _convertToM4aTags(metadata));
} else {
_appendVorbisMetadataToArguments(
arguments,
metadata,
artistTagMode: artistTagMode,
);
}
arguments
..add(outputPath)
..add('-y');
final label = isAlac ? 'ALAC' : 'FLAC';
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to FLAC'
'Converting ${inputPath.split(Platform.pathSeparator).last} to $label'
'${targetBitDepth != null ? ' $targetBitDepth-bit' : ''}'
'${targetSampleRate != null ? ' @ ${targetSampleRate}Hz' : ''}'
'${processing.hasDither ? ' dither=${processing.normalizedDither}' : ''}'
@@ -2711,7 +2565,7 @@ class FFmpegService {
final result = await _executeWithArguments(arguments);
if (!result.success) {
_log.e('FLAC conversion failed: ${result.output}');
_log.e('$label conversion failed: ${result.output}');
return null;
}
+16 -76
View File
@@ -1,11 +1,10 @@
import 'dart:convert';
import 'dart:io';
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/services/sqlite_helpers.dart' as sqlite;
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
final _log = AppLogger('HistoryDatabase');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@@ -72,26 +71,13 @@ class HistoryDatabase {
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,
_database = await sqlite.openAppDatabase(
'history.db',
version: 9,
onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL');
},
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
return _database!;
}
Future<void> _createDB(Database db, int version) async {
@@ -196,24 +182,23 @@ class HistoryDatabase {
}
if (oldVersion < 7) {
await _createPathKeyTable(db);
await _backfillPathKeys(db);
await sqlite.backfillPathKeys(db, 'history', 'history_path_keys');
}
if (oldVersion < 8) {
await _addColumnIfMissing(db, 'history', 'spotify_id_norm', 'TEXT');
await _addColumnIfMissing(db, 'history', 'isrc_norm', 'TEXT');
await _addColumnIfMissing(db, 'history', 'match_key', 'TEXT');
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');
await _backfillNormalizedColumns(db);
await _createNormalizedIndexes(db);
}
if (oldVersion < 9) {
await _addColumnIfMissing(db, 'history', 'bitrate', 'INTEGER');
await _addColumnIfMissing(db, 'history', 'format', 'TEXT');
await sqlite.addColumnIfMissing(db, 'history', 'bitrate', 'INTEGER');
await sqlite.addColumnIfMissing(db, 'history', 'format', 'TEXT');
}
}
static String normalizeLookupText(String? value) {
return (value ?? '').trim().toLowerCase();
}
static String normalizeLookupText(String? value) =>
sqlite.normalizeLookupText(value);
static String normalizeIsrc(String? value) {
return (value ?? '').trim().toUpperCase().replaceAll(RegExp(r'[-\s]'), '');
@@ -243,21 +228,6 @@ class HistoryDatabase {
return candidates.toList(growable: false);
}
Future<void> _addColumnIfMissing(
Database db,
String table,
String column,
String type,
) async {
final columns = await db.rawQuery('PRAGMA table_info($table)');
final exists = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == column,
);
if (!exists) {
await db.execute('ALTER TABLE $table ADD COLUMN $column $type');
}
}
Future<void> _createNormalizedIndexes(DatabaseExecutor db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)',
@@ -305,41 +275,11 @@ class HistoryDatabase {
};
}
Future<void> _createPathKeyTable(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS history_path_keys (
item_id TEXT NOT NULL,
path_key TEXT NOT NULL,
PRIMARY KEY (item_id, path_key)
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_history_path_keys_key ON history_path_keys(path_key)',
);
}
Future<void> _createPathKeyTable(DatabaseExecutor db) =>
sqlite.createPathKeyTable(db, 'history_path_keys');
Future<void> _backfillPathKeys(Database db) async {
final rows = await db.query('history', columns: ['id', 'file_path']);
final batch = db.batch();
for (final row in rows) {
_putPathKeysInBatch(
batch,
row['id'] as String,
row['file_path'] as String?,
);
}
await batch.commit(noResult: true);
}
void _putPathKeysInBatch(Batch batch, String id, String? filePath) {
batch.delete('history_path_keys', where: 'item_id = ?', whereArgs: [id]);
for (final key in buildPathMatchKeys(filePath)) {
batch.insert('history_path_keys', {
'item_id': id,
'path_key': key,
}, conflictAlgorithm: ConflictAlgorithm.ignore);
}
}
void _putPathKeysInBatch(Batch batch, String id, String? filePath) =>
sqlite.putPathKeysInBatch(batch, 'history_path_keys', id, filePath);
static final _iosContainerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
+51 -60
View File
@@ -1,9 +1,8 @@
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/services/sqlite_helpers.dart' as sqlite;
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LibraryCollectionsDb');
@@ -69,27 +68,14 @@ class LibraryCollectionsDatabase {
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDb();
return _database!;
}
Future<Database> _initDb() async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, _dbFileName);
_log.i('Initializing collections database at: $path');
return openDatabase(
path,
_database = await sqlite.openAppDatabase(
_dbFileName,
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');
},
foreignKeys: true,
onCreate: _createDb,
onUpgrade: _upgradeDb,
);
return _database!;
}
Future<void> _createDb(Database db, int version) async {
@@ -412,67 +398,70 @@ class LibraryCollectionsDatabase {
.toList(growable: false);
}
Future<void> upsertWishlistEntry({
required String trackKey,
required String trackJson,
Future<void> _upsertEntry(
String table,
String prefix, {
required String key,
required String json,
required String addedAt,
}) async {
final db = await database;
await db.insert(_tableWishlist, {
'track_key': trackKey,
'track_json': trackJson,
await db.insert(table, {
'${prefix}_key': key,
'${prefix}_json': json,
'added_at': addedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<void> deleteWishlistEntry(String trackKey) async {
Future<void> _deleteEntry(String table, String prefix, String key) async {
final db = await database;
await db.delete(
_tableWishlist,
where: 'track_key = ?',
whereArgs: [trackKey],
);
await db.delete(table, where: '${prefix}_key = ?', whereArgs: [key]);
}
Future<void> upsertWishlistEntry({
required String trackKey,
required String trackJson,
required String addedAt,
}) => _upsertEntry(
_tableWishlist,
'track',
key: trackKey,
json: trackJson,
addedAt: addedAt,
);
Future<void> deleteWishlistEntry(String trackKey) =>
_deleteEntry(_tableWishlist, 'track', trackKey);
Future<void> 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);
}
}) => _upsertEntry(
_tableLoved,
'track',
key: trackKey,
json: trackJson,
addedAt: addedAt,
);
Future<void> deleteLovedEntry(String trackKey) async {
final db = await database;
await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]);
}
Future<void> deleteLovedEntry(String trackKey) =>
_deleteEntry(_tableLoved, 'track', trackKey);
Future<void> upsertFavoriteArtistEntry({
required String artistKey,
required String artistJson,
required String addedAt,
}) async {
final db = await database;
await db.insert(_tableFavoriteArtists, {
'artist_key': artistKey,
'artist_json': artistJson,
'added_at': addedAt,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}) => _upsertEntry(
_tableFavoriteArtists,
'artist',
key: artistKey,
json: artistJson,
addedAt: addedAt,
);
Future<void> deleteFavoriteArtistEntry(String artistKey) async {
final db = await database;
await db.delete(
_tableFavoriteArtists,
where: 'artist_key = ?',
whereArgs: [artistKey],
);
}
Future<void> deleteFavoriteArtistEntry(String artistKey) =>
_deleteEntry(_tableFavoriteArtists, 'artist', artistKey);
Future<void> upsertPlaylist({
required String id,
@@ -602,7 +591,9 @@ class LibraryCollectionsDatabase {
/// `LibraryCollectionsState.toJson()` (wishlist/loved/playlists/favoriteArtists).
/// Track entries carry a nested `track` map (stored as `track_json`); favorite
/// artist entries are stored whole as `artist_json`.
Future<void> replaceAllFromBackup(Map<String, dynamic> collectionsJson) async {
Future<void> replaceAllFromBackup(
Map<String, dynamic> collectionsJson,
) async {
final nowIso = DateTime.now().toIso8601String();
List<Map<String, dynamic>> listOf(String key) {
+30 -77
View File
@@ -6,7 +6,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
final _log = AppLogger('LibraryDatabase');
@@ -264,7 +264,12 @@ class LibraryDatabase {
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('local_library.db');
_database = await sqlite.openAppDatabase(
'local_library.db',
version: 8,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
return _database!;
}
@@ -285,24 +290,6 @@ class LibraryDatabase {
_historyAttached = true;
}
Future<Database> _initDB(String fileName) async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName);
_log.i('Initializing library database at: $path');
return await openDatabase(
path,
version: 8,
onConfigure: (db) async {
await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL');
},
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
}
Future<void> _createDB(Database db, int version) async {
_log.i('Creating library database schema v$version');
@@ -389,62 +376,41 @@ class LibraryDatabase {
}
if (oldVersion < 7) {
await _addColumnIfMissing(db, 'library', 'track_name_norm', 'TEXT');
await _addColumnIfMissing(db, 'library', 'artist_name_norm', 'TEXT');
await _addColumnIfMissing(db, 'library', 'album_name_norm', 'TEXT');
await _addColumnIfMissing(db, 'library', 'album_artist_norm', 'TEXT');
await _addColumnIfMissing(db, 'library', 'match_key', 'TEXT');
await _addColumnIfMissing(db, 'library', 'album_key', 'TEXT');
await sqlite.addColumnIfMissing(db, 'library', 'track_name_norm', 'TEXT');
await sqlite.addColumnIfMissing(
db,
'library',
'artist_name_norm',
'TEXT',
);
await sqlite.addColumnIfMissing(db, 'library', 'album_name_norm', 'TEXT');
await sqlite.addColumnIfMissing(
db,
'library',
'album_artist_norm',
'TEXT',
);
await sqlite.addColumnIfMissing(db, 'library', 'match_key', 'TEXT');
await sqlite.addColumnIfMissing(db, 'library', 'album_key', 'TEXT');
await _backfillNormalizedColumns(db);
await _createNormalizedIndexes(db);
_log.i('Added normalized local library lookup columns');
}
if (oldVersion < 8) {
await _createPathKeyTable(db);
await _backfillPathKeys(db);
await sqlite.backfillPathKeys(db, 'library', 'library_path_keys');
_log.i('Added local library path-key lookup table');
}
}
Future<void> _createPathKeyTable(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS library_path_keys (
item_id TEXT NOT NULL,
path_key TEXT NOT NULL,
PRIMARY KEY (item_id, path_key)
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_library_path_keys_key ON library_path_keys(path_key)',
);
}
Future<void> _createPathKeyTable(DatabaseExecutor db) =>
sqlite.createPathKeyTable(db, 'library_path_keys');
Future<void> _backfillPathKeys(Database db) async {
final rows = await db.query('library', columns: ['id', 'file_path']);
final batch = db.batch();
for (final row in rows) {
_putPathKeysInBatch(
batch,
row['id'] as String,
row['file_path'] as String?,
);
}
await batch.commit(noResult: true);
}
void _putPathKeysInBatch(Batch batch, String id, String? filePath) =>
sqlite.putPathKeysInBatch(batch, 'library_path_keys', id, filePath);
void _putPathKeysInBatch(Batch batch, String id, String? filePath) {
batch.delete('library_path_keys', where: 'item_id = ?', whereArgs: [id]);
for (final key in buildPathMatchKeys(filePath)) {
batch.insert('library_path_keys', {
'item_id': id,
'path_key': key,
}, conflictAlgorithm: ConflictAlgorithm.ignore);
}
}
static String normalizeLookupText(String? value) {
return (value ?? '').trim().toLowerCase();
}
static String normalizeLookupText(String? value) =>
sqlite.normalizeLookupText(value);
static String matchKeyFor(String trackName, String artistName) {
return '${normalizeLookupText(trackName)}|${normalizeLookupText(artistName)}';
@@ -458,19 +424,6 @@ class LibraryDatabase {
return '${normalizeLookupText(albumName)}|${normalizeLookupText(albumArtist ?? artistName)}';
}
Future<void> _addColumnIfMissing(
Database db,
String table,
String column,
String type,
) async {
final info = await db.rawQuery('PRAGMA table_info($table)');
final exists = info.any((row) => row['name'] == column);
if (!exists) {
await db.execute('ALTER TABLE $table ADD COLUMN $column $type');
}
}
Future<void> _createNormalizedIndexes(DatabaseExecutor db) async {
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_library_match_key ON library(match_key)',
+63 -323
View File
@@ -138,6 +138,43 @@ class NotificationService {
}
}
/// Builds [NotificationDetails]. A non-null [progress] switches to the
/// low-importance ongoing progress style; otherwise a dismissible alert.
NotificationDetails _details({
bool library = false,
int? progress,
bool playSound = false,
bool presentBadge = false,
bool presentSound = false,
}) {
final inProgress = progress != null;
return NotificationDetails(
android: AndroidNotificationDetails(
library ? libraryChannelId : channelId,
library ? libraryChannelName : channelName,
channelDescription: library
? libraryChannelDescription
: channelDescription,
importance: inProgress ? Importance.low : Importance.defaultImportance,
priority: inProgress ? Priority.low : Priority.defaultPriority,
showProgress: inProgress,
maxProgress: inProgress ? 100 : 0,
progress: progress ?? 0,
ongoing: inProgress,
autoCancel: !inProgress,
playSound: playSound,
enableVibration: !inProgress,
onlyAlertOnce: inProgress,
icon: '@mipmap/ic_launcher',
),
iOS: DarwinNotificationDetails(
presentAlert: !inProgress,
presentBadge: presentBadge,
presentSound: presentSound,
),
);
}
Future<void> showDownloadProgress({
required String trackName,
required String artistName,
@@ -148,40 +185,12 @@ class NotificationService {
final percentage = total > 0 ? (progress * 100 ~/ total) : 0;
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: percentage,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: downloadProgressId,
title:
_l10n?.notifDownloadingTrack(trackName) ?? 'Downloading $trackName',
body: '$artistName$percentage%',
details: details,
details: _details(progress: percentage),
);
}
@@ -191,41 +200,12 @@ class NotificationService {
}) async {
if (!_isInitialized) await initialize();
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: 100,
indeterminate: false,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: downloadProgressId,
title: _l10n?.notifFinalizingTrack(trackName) ?? 'Finalizing $trackName',
body:
'$artistName${_l10n?.notifEmbeddingMetadata ?? 'Embedding metadata...'}',
details: details,
details: _details(progress: 100),
);
}
@@ -252,33 +232,11 @@ class NotificationService {
: (_l10n?.notifDownloadComplete ?? 'Download Complete');
}
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: downloadProgressId,
title: title,
body: '$trackName - $artistName',
details: details,
details: _details(presentBadge: true),
);
}
@@ -304,33 +262,15 @@ class NotificationService {
: (_l10n?.notifTracksDownloadedSuccess(completedCount) ??
'$completedCount tracks downloaded successfully');
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: downloadProgressId,
title: title,
body: body,
details: details,
details: _details(
playSound: true,
presentBadge: true,
presentSound: true,
),
);
}
@@ -344,33 +284,15 @@ class NotificationService {
_l10n?.notifVerificationRequiredBody ??
'Open the app to complete verification and resume downloads';
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: downloadProgressId,
title: title,
body: body,
details: details,
details: _details(
playSound: true,
presentBadge: true,
presentSound: true,
),
);
}
@@ -384,33 +306,11 @@ class NotificationService {
_l10n?.notifDownloadsCanceledBody(canceledCount) ??
'$canceledCount downloads canceled by user';
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: downloadProgressId,
title: title,
body: body,
details: details,
details: _details(presentBadge: true),
);
}
@@ -441,39 +341,11 @@ class NotificationService {
? '$progressBody\n$currentFile'
: progressBody;
final androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: percentage,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: _l10n?.notifScanningLibrary ?? 'Scanning local library',
body: body,
details: details,
details: _details(library: true, progress: percentage),
);
}
@@ -498,100 +370,34 @@ class NotificationService {
}
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: _l10n?.notifLibraryScanComplete ?? 'Library scan complete',
body:
'${_l10n?.notifLibraryScanCompleteBody(totalTracks) ?? '$totalTracks tracks indexed'}$suffix',
details: details,
details: _details(library: true),
);
}
Future<void> showLibraryScanFailed(String message) async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: _l10n?.notifLibraryScanFailed ?? 'Library scan failed',
body: message,
details: details,
details: _details(library: true),
);
}
Future<void> showLibraryScanCancelled() async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
libraryChannelId,
libraryChannelName,
channelDescription: libraryChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: false,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: libraryScanId,
title: _l10n?.notifLibraryScanCancelled ?? 'Library scan cancelled',
body: _l10n?.notifLibraryScanStopped ?? 'Scan stopped before completion.',
details: details,
details: _details(library: true),
);
}
@@ -610,34 +416,6 @@ class NotificationService {
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
final androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.low,
priority: Priority.low,
showProgress: true,
maxProgress: 100,
progress: percentage,
ongoing: true,
autoCancel: false,
playSound: false,
enableVibration: false,
onlyAlertOnce: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
final details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: updateDownloadId,
title:
@@ -646,76 +424,38 @@ class NotificationService {
body:
_l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ??
'$receivedMB / $totalMB MB • $percentage%',
details: details,
details: _details(progress: percentage),
);
}
Future<void> showUpdateDownloadComplete({required String version}) async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: updateDownloadId,
title: _l10n?.notifUpdateReady ?? 'Update Ready',
body:
_l10n?.notifUpdateReadyBody(version) ??
'${AppInfo.appName} v$version downloaded. Tap to install.',
details: details,
details: _details(
playSound: true,
presentBadge: true,
presentSound: true,
),
);
}
Future<void> showUpdateDownloadFailed() async {
if (!_isInitialized) await initialize();
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: false,
presentSound: false,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: updateDownloadId,
title: _l10n?.notifUpdateFailed ?? 'Update Failed',
body:
_l10n?.notifUpdateFailedBody ??
'Could not download update. Try again later.',
details: details,
// Android playSound defaults to true here (was omitted originally).
details: _details(playSound: true),
);
}
+118 -211
View File
@@ -138,62 +138,66 @@ class PlatformBridge {
static Future<Map<String, dynamic>> checkAvailability(
String spotifyId,
String isrc,
) async {
final cacheKey = _availabilityCacheKey(spotifyId, isrc);
if (cacheKey.isEmpty) {
) {
Future<Map<String, dynamic>> load() {
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
final result = await _channel.invokeMethod('checkAvailability', {
return _invokeMap('checkAvailability', {
'spotify_id': spotifyId,
'isrc': isrc,
});
return _decodeRequiredMapResult(result, 'checkAvailability');
}
await _ensurePersistentLookupCachesLoaded();
final cached = _getCachedMap(_availabilityCache, cacheKey);
if (cached != null) return cached;
final inFlight = _availabilityInFlight[cacheKey];
if (inFlight != null) return _copyStringMap(await inFlight);
final generation = _lookupCacheGeneration;
final future = _invokeCachedMap(
final cacheKey = _availabilityCacheKey(spotifyId, isrc);
if (cacheKey.isEmpty) return load();
return _cachedInvoke(
cacheKey,
_availabilityCache,
() async {
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
final result = await _channel.invokeMethod('checkAvailability', {
'spotify_id': spotifyId,
'isrc': isrc,
});
return _decodeRequiredMapResult(result, 'checkAvailability');
},
_availabilityInFlight,
_availabilityCacheTtl,
generation,
_availabilityPersistentCacheKey,
load,
);
_availabilityInFlight[cacheKey] = future;
}
static Future<Map<String, dynamic>> _invokeMap(
String method, [
dynamic args,
]) async {
final result = await _channel.invokeMethod(method, args);
return _decodeRequiredMapResult(result, method);
}
static Future<Map<String, dynamic>> _cachedInvoke(
String cacheKey,
Map<String, _BridgeCacheEntry> cache,
Map<String, Future<Map<String, dynamic>>> inFlight,
Duration ttl,
String persistentCacheKey,
Future<Map<String, dynamic>> Function() loader,
) async {
await _ensurePersistentLookupCachesLoaded();
final cached = _getCachedMap(cache, cacheKey);
if (cached != null) return cached;
final pending = inFlight[cacheKey];
if (pending != null) return _copyStringMap(await pending);
final generation = _lookupCacheGeneration;
final future = () async {
final value = await loader();
if (generation == _lookupCacheGeneration) {
_putCachedMap(cache, cacheKey, value, ttl, persistentCacheKey);
}
return _copyStringMap(value);
}();
inFlight[cacheKey] = future;
try {
return _copyStringMap(await future);
} finally {
_availabilityInFlight.remove(cacheKey);
inFlight.remove(cacheKey);
}
}
static Future<Map<String, dynamic>> _invokeCachedMap(
String key,
Map<String, _BridgeCacheEntry> cache,
Future<Map<String, dynamic>> Function() loader,
Duration ttl,
int generation,
String persistentCacheKey,
) async {
final value = await loader();
if (generation == _lookupCacheGeneration) {
_putCachedMap(cache, key, value, ttl, persistentCacheKey);
}
return _copyStringMap(value);
}
static String _availabilityCacheKey(String spotifyId, String isrc) {
final normalizedIsrc = isrc.trim().toUpperCase();
if (normalizedIsrc.isNotEmpty) {
@@ -465,10 +469,8 @@ class PlatformBridge {
static Future<Map<String, dynamic>> _invokeDownloadMethod(
String method,
DownloadRequestPayload payload,
) async {
final request = jsonEncode(payload.toJson());
final result = await _channel.invokeMethod(method, request);
return _decodeRequiredMapResult(result, method);
) {
return _invokeMap(method, jsonEncode(payload.toJson()));
}
static Future<Map<String, dynamic>> downloadByStrategy({
@@ -585,12 +587,8 @@ class PlatformBridge {
static Future<Map<String, dynamic>> checkDuplicate(
String outputDir,
String isrc,
) async {
final result = await _channel.invokeMethod('checkDuplicate', {
'output_dir': outputDir,
'isrc': isrc,
});
return _decodeRequiredMapResult(result, 'checkDuplicate');
) {
return _invokeMap('checkDuplicate', {'output_dir': outputDir, 'isrc': isrc});
}
static Future<String> buildFilename(
@@ -642,22 +640,20 @@ class PlatformBridge {
return result as bool;
}
static Future<Map<String, dynamic>> safStat(String uri) async {
final result = await _channel.invokeMethod('safStat', {'uri': uri});
return _decodeRequiredMapResult(result, 'safStat');
static Future<Map<String, dynamic>> safStat(String uri) {
return _invokeMap('safStat', {'uri': uri});
}
static Future<Map<String, dynamic>> resolveSafFile({
required String treeUri,
required String fileName,
String relativeDir = '',
}) async {
final result = await _channel.invokeMethod('resolveSafFile', {
}) {
return _invokeMap('resolveSafFile', {
'tree_uri': treeUri,
'relative_dir': relativeDir,
'file_name': fileName,
});
return _decodeRequiredMapResult(result, 'resolveSafFile');
}
static Future<String?> copyContentUriToTemp(String uri) async {
@@ -724,14 +720,13 @@ class PlatformBridge {
String trackName,
String artistName, {
int durationMs = 0,
}) async {
final result = await _channel.invokeMethod('fetchLyrics', {
}) {
return _invokeMap('fetchLyrics', {
'spotify_id': spotifyId,
'track_name': trackName,
'artist_name': artistName,
'duration_ms': durationMs,
});
return _decodeRequiredMapResult(result, 'fetchLyrics');
}
static Future<String> getLyricsLRC(
@@ -757,26 +752,24 @@ class PlatformBridge {
String artistName, {
String? filePath,
int durationMs = 0,
}) async {
final result = await _channel.invokeMethod('getLyricsLRCWithSource', {
}) {
return _invokeMap('getLyricsLRCWithSource', {
'spotify_id': spotifyId,
'track_name': trackName,
'artist_name': artistName,
'file_path': filePath ?? '',
'duration_ms': durationMs,
});
return _decodeRequiredMapResult(result, 'getLyricsLRCWithSource');
}
static Future<Map<String, dynamic>> embedLyricsToFile(
String filePath,
String lyrics,
) async {
final result = await _channel.invokeMethod('embedLyricsToFile', {
) {
return _invokeMap('embedLyricsToFile', {
'file_path': filePath,
'lyrics': lyrics,
});
return _decodeRequiredMapResult(result, 'embedLyricsToFile');
}
static Future<void> cleanupConnections() async {
@@ -787,24 +780,22 @@ class PlatformBridge {
String coverUrl,
String outputPath, {
bool maxQuality = true,
}) async {
final result = await _channel.invokeMethod('downloadCoverToFile', {
}) {
return _invokeMap('downloadCoverToFile', {
'cover_url': coverUrl,
'output_path': outputPath,
'max_quality': maxQuality,
});
return _decodeRequiredMapResult(result, 'downloadCoverToFile');
}
static Future<Map<String, dynamic>> extractCoverToFile(
String audioPath,
String outputPath,
) async {
final result = await _channel.invokeMethod('extractCoverToFile', {
) {
return _invokeMap('extractCoverToFile', {
'audio_path': audioPath,
'output_path': outputPath,
});
return _decodeRequiredMapResult(result, 'extractCoverToFile');
}
static Future<Map<String, dynamic>> fetchAndSaveLyrics({
@@ -814,8 +805,8 @@ class PlatformBridge {
required int durationMs,
required String outputPath,
String audioFilePath = '',
}) async {
final result = await _channel.invokeMethod('fetchAndSaveLyrics', {
}) {
return _invokeMap('fetchAndSaveLyrics', {
'track_name': trackName,
'artist_name': artistName,
'spotify_id': spotifyId,
@@ -823,7 +814,6 @@ class PlatformBridge {
'output_path': outputPath,
'audio_file_path': audioFilePath,
});
return _decodeRequiredMapResult(result, 'fetchAndSaveLyrics');
}
/// Providers not in the list are disabled.
@@ -855,38 +845,28 @@ class PlatformBridge {
});
}
static Future<Map<String, dynamic>> getLyricsFetchOptions() async {
final result = await _channel.invokeMethod('getLyricsFetchOptions');
return _decodeRequiredMapResult(result, 'getLyricsFetchOptions');
static Future<Map<String, dynamic>> getLyricsFetchOptions() {
return _invokeMap('getLyricsFetchOptions');
}
static Future<Map<String, dynamic>> reEnrichFile(
Map<String, dynamic> request,
) async {
final requestJSON = jsonEncode(request);
final result = await _channel.invokeMethod('reEnrichFile', {
'request_json': requestJSON,
});
return _decodeRequiredMapResult(result, 'reEnrichFile');
) {
return _invokeMap('reEnrichFile', {'request_json': jsonEncode(request)});
}
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
final result = await _channel.invokeMethod('readFileMetadata', {
'file_path': filePath,
});
return _decodeRequiredMapResult(result, 'readFileMetadata');
static Future<Map<String, dynamic>> readFileMetadata(String filePath) {
return _invokeMap('readFileMetadata', {'file_path': filePath});
}
static Future<Map<String, dynamic>> editFileMetadata(
String filePath,
Map<String, String> metadata,
) async {
final metadataJSON = jsonEncode(metadata);
final result = await _channel.invokeMethod('editFileMetadata', {
) {
return _invokeMap('editFileMetadata', {
'file_path': filePath,
'metadata_json': metadataJSON,
'metadata_json': jsonEncode(metadata),
});
return _decodeRequiredMapResult(result, 'editFileMetadata');
}
/// Writes ISRC and label into an M4A/MP4 file as iTunes freeform atoms.
@@ -896,13 +876,11 @@ class PlatformBridge {
static Future<Map<String, dynamic>> writeM4AFreeformTags(
String filePath,
Map<String, String> fields,
) async {
final metadataJSON = jsonEncode(fields);
final result = await _channel.invokeMethod('writeM4AFreeformTags', {
) {
return _invokeMap('writeM4AFreeformTags', {
'file_path': filePath,
'metadata_json': metadataJSON,
'metadata_json': jsonEncode(fields),
});
return _decodeRequiredMapResult(result, 'writeM4AFreeformTags');
}
/// Normalizes a decrypted AC-4 file to a standards-compliant ISO MP4 and
@@ -912,12 +890,11 @@ class PlatformBridge {
static Future<Map<String, dynamic>> ensureAC4Config(
String filePath,
String sourcePath,
) async {
final result = await _channel.invokeMethod('ensureAC4Config', {
) {
return _invokeMap('ensureAC4Config', {
'file_path': filePath,
'source_path': sourcePath,
});
return _decodeRequiredMapResult(result, 'ensureAC4Config');
}
/// Writes iTunes-style metadata (and cover art) into an AC-4 MP4. Returns a
@@ -928,14 +905,12 @@ class PlatformBridge {
String filePath,
Map<String, String> metadata,
String coverPath,
) async {
final metadataJSON = jsonEncode(metadata);
final result = await _channel.invokeMethod('writeAC4Metadata', {
) {
return _invokeMap('writeAC4Metadata', {
'file_path': filePath,
'metadata_json': metadataJSON,
'metadata_json': jsonEncode(metadata),
'cover_path': coverPath,
});
return _decodeRequiredMapResult(result, 'writeAC4Metadata');
}
/// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries
@@ -944,30 +919,27 @@ class PlatformBridge {
String filePath,
String artist,
String albumArtist,
) async {
final result = await _channel.invokeMethod('rewriteSplitArtistTags', {
) {
return _invokeMap('rewriteSplitArtistTags', {
'file_path': filePath,
'artist': artist,
'album_artist': albumArtist,
});
return _decodeRequiredMapResult(result, 'rewriteSplitArtistTags');
}
static Future<bool> writeTempToSaf(String tempPath, String safUri) async {
final result = await _channel.invokeMethod('writeTempToSaf', {
final map = await _invokeMap('writeTempToSaf', {
'temp_path': tempPath,
'saf_uri': safUri,
});
final map = _decodeRequiredMapResult(result, 'writeTempToSaf');
return map['success'] == true;
}
static Future<bool> writeSafSidecarLrc(String safUri, String lyrics) async {
final result = await _channel.invokeMethod('writeSafSidecarLrc', {
final map = await _invokeMap('writeSafSidecarLrc', {
'saf_uri': safUri,
'lyrics': lyrics,
});
final map = _decodeRequiredMapResult(result, 'writeSafSidecarLrc');
return map['success'] == true;
}
@@ -1130,35 +1102,24 @@ class PlatformBridge {
static Future<Map<String, dynamic>> getDeezerRelatedArtists(
String artistId, {
int limit = 12,
}) async {
final result = await _channel.invokeMethod('getDeezerRelatedArtists', {
}) {
return _invokeMap('getDeezerRelatedArtists', {
'artist_id': artistId,
'limit': limit,
});
return _decodeRequiredMapResult(result, 'getDeezerRelatedArtists');
}
static Future<Map<String, dynamic>> getProviderMetadata(
String providerId,
String resourceType,
String resourceId,
) async {
final cacheKey = _providerMetadataCacheKey(
providerId,
resourceType,
resourceId,
);
await _ensurePersistentLookupCachesLoaded();
final cached = _getCachedMap(_metadataCache, cacheKey);
if (cached != null) return cached;
final inFlight = _metadataInFlight[cacheKey];
if (inFlight != null) return _copyStringMap(await inFlight);
final generation = _lookupCacheGeneration;
final future = _invokeCachedMap(
cacheKey,
) {
return _cachedInvoke(
_providerMetadataCacheKey(providerId, resourceType, resourceId),
_metadataCache,
_metadataInFlight,
_metadataCacheTtl,
_metadataPersistentCacheKey,
() async {
final result = await _channel.invokeMethod('getProviderMetadata', {
'provider_id': providerId,
@@ -1172,27 +1133,17 @@ class PlatformBridge {
}
return _decodeRequiredMapResult(result, 'getProviderMetadata');
},
_metadataCacheTtl,
generation,
_metadataPersistentCacheKey,
);
_metadataInFlight[cacheKey] = future;
try {
return _copyStringMap(await future);
} finally {
_metadataInFlight.remove(cacheKey);
}
}
static Future<Map<String, dynamic>> searchDeezerByISRC(
String isrc, {
String? itemId,
}) async {
final result = await _channel.invokeMethod('searchDeezerByISRC', {
}) {
return _invokeMap('searchDeezerByISRC', {
'isrc': isrc,
'item_id': itemId ?? '',
});
return _decodeRequiredMapResult(result, 'searchDeezerByISRC');
}
static Future<Map<String, String>?> getDeezerExtendedMetadata(
@@ -1221,40 +1172,18 @@ class PlatformBridge {
static Future<Map<String, dynamic>> convertSpotifyToDeezer(
String resourceType,
String spotifyId,
) async {
final cacheKey = _providerMetadataCacheKey(
'spotify-to-deezer',
resourceType,
spotifyId,
);
await _ensurePersistentLookupCachesLoaded();
final cached = _getCachedMap(_metadataCache, cacheKey);
if (cached != null) return cached;
final inFlight = _metadataInFlight[cacheKey];
if (inFlight != null) return _copyStringMap(await inFlight);
final generation = _lookupCacheGeneration;
final future = _invokeCachedMap(
cacheKey,
) {
return _cachedInvoke(
_providerMetadataCacheKey('spotify-to-deezer', resourceType, spotifyId),
_metadataCache,
() async {
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
'resource_type': resourceType,
'spotify_id': spotifyId,
});
return _decodeRequiredMapResult(result, 'convertSpotifyToDeezer');
},
_metadataInFlight,
_metadataCacheTtl,
generation,
_metadataPersistentCacheKey,
() => _invokeMap('convertSpotifyToDeezer', {
'resource_type': resourceType,
'spotify_id': spotifyId,
}),
);
_metadataInFlight[cacheKey] = future;
try {
return _copyStringMap(await future);
} finally {
_metadataInFlight.remove(cacheKey);
}
}
static Future<List<Map<String, dynamic>>> getGoLogs() async {
@@ -1295,12 +1224,9 @@ class PlatformBridge {
static Future<Map<String, dynamic>> loadExtensionsFromDir(
String dirPath,
) async {
) {
_log.d('loadExtensionsFromDir: $dirPath');
final result = await _channel.invokeMethod('loadExtensionsFromDir', {
'dir_path': dirPath,
});
return _decodeRequiredMapResult(result, 'loadExtensionsFromDir');
return _invokeMap('loadExtensionsFromDir', {'dir_path': dirPath});
}
static Future<Map<String, dynamic>> loadExtensionFromPath(
@@ -1308,10 +1234,7 @@ class PlatformBridge {
) async {
_log.d('loadExtensionFromPath: $filePath');
await _clearLookupCaches();
final result = await _channel.invokeMethod('loadExtensionFromPath', {
'file_path': filePath,
});
return _decodeRequiredMapResult(result, 'loadExtensionFromPath');
return _invokeMap('loadExtensionFromPath', {'file_path': filePath});
}
static Future<void> unloadExtension(String extensionId) async {
@@ -1333,20 +1256,14 @@ class PlatformBridge {
static Future<Map<String, dynamic>> upgradeExtension(String filePath) async {
_log.d('upgradeExtension: $filePath');
await _clearLookupCaches();
final result = await _channel.invokeMethod('upgradeExtension', {
'file_path': filePath,
});
return _decodeRequiredMapResult(result, 'upgradeExtension');
return _invokeMap('upgradeExtension', {'file_path': filePath});
}
static Future<Map<String, dynamic>> checkExtensionUpgrade(
String filePath,
) async {
) {
_log.d('checkExtensionUpgrade: $filePath');
final result = await _channel.invokeMethod('checkExtensionUpgrade', {
'file_path': filePath,
});
return _decodeRequiredMapResult(result, 'checkExtensionUpgrade');
return _invokeMap('checkExtensionUpgrade', {'file_path': filePath});
}
static Future<List<Map<String, dynamic>>> getInstalledExtensions() async {
@@ -1406,20 +1323,14 @@ class PlatformBridge {
static Future<Map<String, dynamic>> getExtensionSettings(
String extensionId,
) async {
final result = await _channel.invokeMethod('getExtensionSettings', {
'extension_id': extensionId,
});
return _decodeRequiredMapResult(result, 'getExtensionSettings');
) {
return _invokeMap('getExtensionSettings', {'extension_id': extensionId});
}
static Future<Map<String, dynamic>> checkExtensionHealth(
String extensionId,
) async {
final result = await _channel.invokeMethod('checkExtensionHealth', {
'extension_id': extensionId,
});
return _decodeRequiredMapResult(result, 'checkExtensionHealth');
) {
return _invokeMap('checkExtensionHealth', {'extension_id': extensionId});
}
static Future<void> setExtensionSettings(
@@ -1928,10 +1839,9 @@ class PlatformBridge {
}
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
final result = await _channel.invokeMethod('getSafFileModTimes', {
final map = await _invokeMap('getSafFileModTimes', {
'uris': jsonEncode(uris),
});
final map = _decodeRequiredMapResult(result, 'getSafFileModTimes');
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
}
@@ -2165,12 +2075,11 @@ class PlatformBridge {
static Future<Map<String, dynamic>> runPostProcessing(
String filePath, {
Map<String, dynamic>? metadata,
}) async {
final result = await _channel.invokeMethod('runPostProcessing', {
}) {
return _invokeMap('runPostProcessing', {
'file_path': filePath,
'metadata': metadata != null ? jsonEncode(metadata) : '',
});
return _decodeRequiredMapResult(result, 'runPostProcessing');
}
static Future<Map<String, dynamic>> runPostProcessingV2(
@@ -2183,11 +2092,10 @@ class PlatformBridge {
} else {
input['path'] = filePath;
}
final result = await _channel.invokeMethod('runPostProcessingV2', {
return _invokeMap('runPostProcessingV2', {
'input': jsonEncode(input),
'metadata': metadata != null ? jsonEncode(metadata) : '',
});
return _decodeRequiredMapResult(result, 'runPostProcessingV2');
}
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
@@ -2265,12 +2173,11 @@ class PlatformBridge {
static Future<Map<String, dynamic>> parseCueSheet(
String cuePath, {
String audioDir = '',
}) async {
}) {
_log.i('parseCueSheet: $cuePath (audioDir: $audioDir)');
final result = await _channel.invokeMethod('parseCueSheet', {
return _invokeMap('parseCueSheet', {
'cue_path': cuePath,
'audio_dir': audioDir,
});
return _decodeRequiredMapResult(result, 'parseCueSheet');
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:sqflite/sqflite.dart';
final _log = AppLogger('AppSqlite');
/// Opens a database file in the app documents directory with the shared
/// WAL + synchronous=NORMAL configuration.
Future<Database> openAppDatabase(
String fileName, {
required int version,
required Future<void> Function(Database db, int version) onCreate,
required Future<void> Function(Database db, int oldVersion, int newVersion)
onUpgrade,
bool foreignKeys = false,
}) async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName);
_log.i('Initializing database at: $path');
return openDatabase(
path,
version: version,
onConfigure: (db) async {
if (foreignKeys) {
await db.execute('PRAGMA foreign_keys = ON');
}
await db.rawQuery('PRAGMA journal_mode = WAL');
await db.execute('PRAGMA synchronous = NORMAL');
},
onCreate: onCreate,
onUpgrade: onUpgrade,
);
}
String normalizeLookupText(String? value) {
return (value ?? '').trim().toLowerCase();
}
Future<void> addColumnIfMissing(
Database db,
String table,
String column,
String type,
) async {
final columns = await db.rawQuery('PRAGMA table_info($table)');
final exists = columns.any(
(row) => (row['name']?.toString().toLowerCase() ?? '') == column,
);
if (!exists) {
await db.execute('ALTER TABLE $table ADD COLUMN $column $type');
}
}
Future<void> createPathKeyTable(DatabaseExecutor db, String table) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $table (
item_id TEXT NOT NULL,
path_key TEXT NOT NULL,
PRIMARY KEY (item_id, path_key)
)
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_${table}_key ON $table(path_key)',
);
}
Future<void> backfillPathKeys(
Database db,
String sourceTable,
String keyTable,
) async {
final rows = await db.query(sourceTable, columns: ['id', 'file_path']);
final batch = db.batch();
for (final row in rows) {
putPathKeysInBatch(
batch,
keyTable,
row['id'] as String,
row['file_path'] as String?,
);
}
await batch.commit(noResult: true);
}
void putPathKeysInBatch(
Batch batch,
String table,
String id,
String? filePath,
) {
batch.delete(table, where: 'item_id = ?', whereArgs: [id]);
for (final key in buildPathMatchKeys(filePath)) {
batch.insert(table, {
'item_id': id,
'path_key': key,
}, conflictAlgorithm: ConflictAlgorithm.ignore);
}
}