mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-17 00:20:32 +02:00
v3.6.5: audio format conversion, PC v7.0.8 backend merge, Amazon re-enabled
This commit is contained in:
@@ -56,6 +56,48 @@ class FFmpegService {
|
||||
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
|
||||
}
|
||||
|
||||
static List<String> _buildDecryptionKeyCandidates(String rawKey) {
|
||||
final candidates = <String>[];
|
||||
|
||||
void addCandidate(String key) {
|
||||
final normalized = key.trim();
|
||||
if (normalized.isEmpty) return;
|
||||
if (!candidates.contains(normalized)) {
|
||||
candidates.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
final trimmed = rawKey.trim();
|
||||
if (trimmed.isEmpty) return candidates;
|
||||
|
||||
addCandidate(trimmed);
|
||||
|
||||
final noPrefix = trimmed.startsWith(RegExp(r'0x', caseSensitive: false))
|
||||
? trimmed.substring(2)
|
||||
: trimmed;
|
||||
addCandidate(noPrefix);
|
||||
|
||||
final compactHex = noPrefix.replaceAll(RegExp(r'[^0-9a-fA-F]'), '');
|
||||
if (compactHex.isNotEmpty && compactHex.length.isEven) {
|
||||
addCandidate(compactHex);
|
||||
}
|
||||
|
||||
try {
|
||||
final b64 = noPrefix.replaceAll(RegExp(r'\s+'), '');
|
||||
final decoded = base64Decode(b64);
|
||||
if (decoded.isNotEmpty) {
|
||||
final hex = decoded
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
if (hex.isNotEmpty) {
|
||||
addCandidate(hex);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
static Future<FFmpegResult> _execute(String command) async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute(command);
|
||||
@@ -77,7 +119,7 @@ class FFmpegService {
|
||||
final outputPath = _buildOutputPath(inputPath, '.flac');
|
||||
|
||||
final command =
|
||||
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||
'-v error -xerror -i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
||||
|
||||
final result = await _execute(command);
|
||||
|
||||
@@ -133,6 +175,111 @@ class FFmpegService {
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<String?> decryptAudioFile({
|
||||
required String inputPath,
|
||||
required String decryptionKey,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final trimmedKey = decryptionKey.trim();
|
||||
if (trimmedKey.isEmpty) return inputPath;
|
||||
|
||||
// Amazon encrypted streams are commonly MP4 container with FLAC audio.
|
||||
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
|
||||
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
|
||||
? '.flac'
|
||||
: inputPath.toLowerCase().endsWith('.flac')
|
||||
? '.flac'
|
||||
: inputPath.toLowerCase().endsWith('.mp3')
|
||||
? '.mp3'
|
||||
: inputPath.toLowerCase().endsWith('.opus')
|
||||
? '.opus'
|
||||
: '.flac';
|
||||
var tempOutput = _buildOutputPath(inputPath, preferredExt);
|
||||
|
||||
String buildDecryptCommand(
|
||||
String outputPath, {
|
||||
required bool mapAudioOnly,
|
||||
required String key,
|
||||
}) {
|
||||
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
|
||||
return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y';
|
||||
}
|
||||
|
||||
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
|
||||
if (keyCandidates.isEmpty) {
|
||||
_log.e('No usable decryption key candidates');
|
||||
return null;
|
||||
}
|
||||
|
||||
FFmpegResult? lastResult;
|
||||
var decryptSucceeded = false;
|
||||
|
||||
for (final keyCandidate in keyCandidates) {
|
||||
_log.d(
|
||||
'Executing FFmpeg decrypt command (key length: ${keyCandidate.length})',
|
||||
);
|
||||
var result = await _execute(
|
||||
buildDecryptCommand(
|
||||
tempOutput,
|
||||
mapAudioOnly: preferredExt == '.flac',
|
||||
key: keyCandidate,
|
||||
),
|
||||
);
|
||||
|
||||
// Fallback for uncommon streams that cannot be remuxed into FLAC.
|
||||
if (!result.success && preferredExt == '.flac') {
|
||||
final fallbackOutput = _buildOutputPath(inputPath, '.m4a');
|
||||
final fallbackResult = await _execute(
|
||||
buildDecryptCommand(
|
||||
fallbackOutput,
|
||||
mapAudioOnly: false,
|
||||
key: keyCandidate,
|
||||
),
|
||||
);
|
||||
if (fallbackResult.success) {
|
||||
tempOutput = fallbackOutput;
|
||||
result = fallbackResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
decryptSucceeded = true;
|
||||
lastResult = result;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
await tempFile.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
lastResult = result;
|
||||
}
|
||||
|
||||
if (!decryptSucceeded) {
|
||||
_log.e('FFmpeg decrypt failed: ${lastResult?.output ?? 'unknown error'}');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
final inputFile = File(inputPath);
|
||||
if (!await tempFile.exists()) {
|
||||
_log.e('Decrypted output file not found: $tempOutput');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (deleteOriginal && await inputFile.exists()) {
|
||||
await inputFile.delete();
|
||||
}
|
||||
return tempOutput;
|
||||
} catch (e) {
|
||||
_log.e('Failed to finalize decrypted file: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> convertFlacToMp3(
|
||||
String inputPath, {
|
||||
String bitrate = '320k',
|
||||
@@ -616,6 +763,97 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified audio format conversion with full metadata + cover preservation.
|
||||
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
|
||||
/// Returns the new file path on success, null on failure.
|
||||
static Future<String?> convertAudioFormat({
|
||||
required String inputPath,
|
||||
required String targetFormat,
|
||||
required String bitrate,
|
||||
required Map<String, String> metadata,
|
||||
String? coverPath,
|
||||
bool deleteOriginal = true,
|
||||
}) async {
|
||||
final format = targetFormat.toLowerCase();
|
||||
if (format != 'mp3' && format != 'opus') {
|
||||
_log.e('Unsupported target format: $targetFormat');
|
||||
return null;
|
||||
}
|
||||
|
||||
final extension = format == 'opus' ? '.opus' : '.mp3';
|
||||
final outputPath = _buildOutputPath(inputPath, extension);
|
||||
|
||||
// Step 1: Convert audio
|
||||
String command;
|
||||
if (format == 'opus') {
|
||||
command =
|
||||
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
|
||||
} else {
|
||||
command =
|
||||
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
|
||||
}
|
||||
|
||||
_log.i(
|
||||
'Converting ${inputPath.split(Platform.pathSeparator).last} to $format @ $bitrate',
|
||||
);
|
||||
final result = await _execute(command);
|
||||
|
||||
if (!result.success) {
|
||||
_log.e('Audio conversion failed: ${result.output}');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Embed metadata + cover into the converted file.
|
||||
// Treat embed failure as conversion failure when metadata/cover was requested.
|
||||
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
|
||||
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
|
||||
if (hasMetadata || hasCover) {
|
||||
String? embedResult;
|
||||
if (format == 'mp3') {
|
||||
embedResult = await embedMetadataToMp3(
|
||||
mp3Path: outputPath,
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
} else {
|
||||
embedResult = await embedMetadataToOpus(
|
||||
opusPath: outputPath,
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
if (embedResult == null) {
|
||||
_log.e(
|
||||
'Metadata/Cover preservation failed, rolling back converted file',
|
||||
);
|
||||
try {
|
||||
final out = File(outputPath);
|
||||
if (await out.exists()) {
|
||||
await out.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to cleanup failed converted file: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Delete original if requested
|
||||
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 Map<String, String> _convertToId3Tags(
|
||||
Map<String, String> vorbisMetadata,
|
||||
) {
|
||||
|
||||
@@ -17,21 +17,21 @@ String? _currentContainerPath;
|
||||
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: 3,
|
||||
@@ -39,10 +39,10 @@ class HistoryDatabase {
|
||||
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,
|
||||
@@ -73,16 +73,20 @@ class HistoryDatabase {
|
||||
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)');
|
||||
|
||||
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');
|
||||
if (oldVersion < 2) {
|
||||
@@ -95,20 +99,20 @@ class HistoryDatabase {
|
||||
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==================== iOS Path Normalization ====================
|
||||
|
||||
|
||||
/// Pattern to match iOS container paths
|
||||
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
|
||||
static final _iosContainerPattern = RegExp(
|
||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
|
||||
/// Initialize and cache the current iOS container path
|
||||
Future<void> _initContainerPath() async {
|
||||
if (!Platform.isIOS || _currentContainerPath != null) return;
|
||||
|
||||
|
||||
try {
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
// Extract container path up to and including the UUID folder
|
||||
@@ -122,55 +126,58 @@ class HistoryDatabase {
|
||||
_log.w('Failed to get iOS container path: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Normalize iOS file path by replacing old container UUID with current one
|
||||
/// This fixes the issue where iOS changes container UUID after app updates
|
||||
String _normalizeIosPath(String? filePath) {
|
||||
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
||||
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
||||
|
||||
|
||||
// Check if path contains an iOS container path
|
||||
if (_iosContainerPattern.hasMatch(filePath)) {
|
||||
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
|
||||
final normalized = filePath.replaceFirst(
|
||||
_iosContainerPattern,
|
||||
_currentContainerPath!,
|
||||
);
|
||||
if (normalized != filePath) {
|
||||
_log.d('Normalized iOS path: $filePath -> $normalized');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
/// Migrate iOS paths in database to use current container UUID
|
||||
/// This is called once after app update if container changed
|
||||
Future<bool> migrateIosContainerPaths() async {
|
||||
if (!Platform.isIOS) return false;
|
||||
|
||||
|
||||
await _initContainerPath();
|
||||
if (_currentContainerPath == null) return false;
|
||||
|
||||
|
||||
final prefs = await _prefs;
|
||||
final lastContainer = prefs.getString('ios_last_container_path');
|
||||
|
||||
|
||||
if (lastContainer == _currentContainerPath) {
|
||||
_log.d('iOS container path unchanged, skipping migration');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
|
||||
|
||||
|
||||
try {
|
||||
final db = await database;
|
||||
|
||||
|
||||
// Get all items with iOS paths
|
||||
final rows = await db.query('history', columns: ['id', 'file_path']);
|
||||
int updatedCount = 0;
|
||||
final batch = db.batch();
|
||||
|
||||
|
||||
for (final row in rows) {
|
||||
final id = row['id'] as String;
|
||||
final oldPath = row['file_path'] as String?;
|
||||
|
||||
|
||||
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
|
||||
final newPath = _normalizeIosPath(oldPath);
|
||||
if (newPath != oldPath) {
|
||||
@@ -184,14 +191,14 @@ class HistoryDatabase {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (updatedCount > 0) {
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
|
||||
// Save current container path
|
||||
await prefs.setString('ios_last_container_path', _currentContainerPath!);
|
||||
|
||||
|
||||
_log.i('iOS path migration complete: $updatedCount paths updated');
|
||||
return updatedCount > 0;
|
||||
} catch (e, stack) {
|
||||
@@ -199,32 +206,34 @@ class HistoryDatabase {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Migrate data from SharedPreferences to SQLite
|
||||
/// Returns true if migration was performed, false if already migrated
|
||||
Future<bool> migrateFromSharedPreferences() async {
|
||||
final prefs = await _prefs;
|
||||
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');
|
||||
|
||||
_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(
|
||||
@@ -233,20 +242,20 @@ class HistoryDatabase {
|
||||
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 {
|
||||
@@ -278,7 +287,7 @@ class HistoryDatabase {
|
||||
'copyright': json['copyright'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// Convert DB row (snake_case) to JSON format (camelCase)
|
||||
/// Also normalizes iOS paths if container UUID changed
|
||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||
@@ -311,9 +320,9 @@ class HistoryDatabase {
|
||||
'copyright': row['copyright'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ==================== CRUD Operations ====================
|
||||
|
||||
|
||||
/// Insert or update a history item
|
||||
Future<void> upsert(Map<String, dynamic> json) async {
|
||||
final db = await database;
|
||||
@@ -323,7 +332,7 @@ class HistoryDatabase {
|
||||
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;
|
||||
@@ -335,7 +344,7 @@ class HistoryDatabase {
|
||||
);
|
||||
return rows.map(_dbRowToJson).toList();
|
||||
}
|
||||
|
||||
|
||||
/// Get item by ID
|
||||
Future<Map<String, dynamic>?> getById(String id) async {
|
||||
final db = await database;
|
||||
@@ -348,7 +357,7 @@ class HistoryDatabase {
|
||||
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;
|
||||
@@ -361,7 +370,7 @@ class HistoryDatabase {
|
||||
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;
|
||||
@@ -374,7 +383,7 @@ class HistoryDatabase {
|
||||
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;
|
||||
@@ -384,42 +393,42 @@ class HistoryDatabase {
|
||||
);
|
||||
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 != ""'
|
||||
'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,
|
||||
@@ -428,7 +437,7 @@ class HistoryDatabase {
|
||||
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);
|
||||
@@ -442,31 +451,63 @@ class HistoryDatabase {
|
||||
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (isrc != null && isrc.isNotEmpty) {
|
||||
return await getByIsrc(isrc);
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Close database
|
||||
|
||||
/// Close database
|
||||
Future<void> close() async {
|
||||
final db = await database;
|
||||
await db.close();
|
||||
_database = null;
|
||||
}
|
||||
|
||||
|
||||
/// Update file path for a history entry (e.g. after format conversion)
|
||||
Future<void> updateFilePath(
|
||||
String id,
|
||||
String newFilePath, {
|
||||
String? newSafFileName,
|
||||
String? newQuality,
|
||||
int? newBitDepth,
|
||||
int? newSampleRate,
|
||||
bool clearAudioSpecs = false,
|
||||
}) async {
|
||||
final db = await database;
|
||||
final values = <String, dynamic>{'file_path': newFilePath};
|
||||
if (newSafFileName != null) {
|
||||
values['saf_file_name'] = newSafFileName;
|
||||
}
|
||||
if (newQuality != null) {
|
||||
values['quality'] = newQuality;
|
||||
}
|
||||
if (clearAudioSpecs) {
|
||||
values['bit_depth'] = null;
|
||||
values['sample_rate'] = null;
|
||||
} else {
|
||||
if (newBitDepth != null) {
|
||||
values['bit_depth'] = newBitDepth;
|
||||
}
|
||||
if (newSampleRate != null) {
|
||||
values['sample_rate'] = newSampleRate;
|
||||
}
|
||||
}
|
||||
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// Get all file paths from download history
|
||||
/// Used to exclude downloaded files from local library scan
|
||||
Future<Set<String>> getAllFilePaths() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""'
|
||||
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""',
|
||||
);
|
||||
return rows.map((r) => r['file_path'] as String).toSet();
|
||||
}
|
||||
|
||||
|
||||
/// Get all entries with file paths for orphan detection
|
||||
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
|
||||
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
|
||||
@@ -478,11 +519,11 @@ class HistoryDatabase {
|
||||
''');
|
||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
|
||||
/// Delete multiple entries by IDs
|
||||
Future<int> deleteByIds(List<String> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
|
||||
|
||||
final db = await database;
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
final count = await db.rawDelete(
|
||||
|
||||
Reference in New Issue
Block a user