feat(library): add lyrics filtering and metadata actions

This commit is contained in:
zarzet
2026-08-27 01:44:06 +07:00
parent 04c42bd511
commit 1a7c20111a
60 changed files with 1454 additions and 276 deletions
+4 -1
View File
@@ -79,6 +79,9 @@ class BackupService {
static const int formatVersion = 1;
static const String fileExtension = 'json';
static String encode(Map<String, dynamic> envelope) =>
const JsonEncoder.withIndent(' ').convert(envelope);
/// Builds the backup envelope written to disk.
static Map<String, dynamic> buildEnvelope({
required Map<String, dynamic>? settings,
@@ -119,7 +122,7 @@ class BackupService {
final fileName = 'spotiflac_backup_$stamp.$fileExtension';
final file = File(p.join(backupsDir.path, fileName));
await file.writeAsString(jsonEncode(envelope), flush: true);
await file.writeAsString(encode(envelope), flush: true);
_log.i('Backup written to ${file.path}');
return file;
}
+4 -3
View File
@@ -4,6 +4,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
class SavedCoverResult {
final String fileName;
@@ -20,9 +21,9 @@ class CoverDownloadService {
required String baseName,
required AppSettings settings,
}) async {
final normalizedUrl = coverUrl.trim();
if (normalizedUrl.isEmpty) {
throw const FormatException('No cover art source available');
final normalizedUrl = normalizeRemoteHttpUrl(coverUrl);
if (normalizedUrl == null) {
throw const FormatException('No remote cover art source available');
}
final safeBaseName = await PlatformBridge.sanitizeFilename(baseName.trim());
+32 -1
View File
@@ -65,7 +65,7 @@ class HistoryBatchLookupRequest {
}
class HistoryDatabase {
static const int schemaVersion = 12;
static const int schemaVersion = 13;
static final HistoryDatabase instance = HistoryDatabase._init();
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
@@ -120,6 +120,8 @@ class HistoryDatabase {
label TEXT,
copyright TEXT,
explicit INTEGER NOT NULL DEFAULT 0,
has_lyrics INTEGER NOT NULL DEFAULT 0,
lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0,
spotify_id_norm TEXT,
isrc_norm TEXT,
match_key TEXT,
@@ -239,6 +241,21 @@ class HistoryDatabase {
);
_log.i('Added explicit-content metadata');
}
if (oldVersion < 13) {
await sqlite.addColumnIfMissing(
db,
'history',
'has_lyrics',
'INTEGER NOT NULL DEFAULT 0',
);
await sqlite.addColumnIfMissing(
db,
'history',
'lyrics_metadata_scan_version',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added indexed lyrics availability metadata');
}
}
static String normalizeLookupText(String? value) =>
@@ -628,6 +645,10 @@ class HistoryDatabase {
'label': json['label'],
'copyright': json['copyright'],
'explicit': json['explicit'] == true ? 1 : 0,
'has_lyrics': json['hasLyrics'] == true ? 1 : 0,
'lyrics_metadata_scan_version':
(json['lyricsMetadataScanVersion'] as num?)?.toInt() ??
(json.containsKey('hasLyrics') ? 1 : 0),
};
row.addAll(
_queueSortColumns(
@@ -687,6 +708,8 @@ class HistoryDatabase {
'label': row['label'],
'copyright': row['copyright'],
'explicit': row['explicit'] == 1 || row['explicit'] == true,
'hasLyrics': row['has_lyrics'] == 1 || row['has_lyrics'] == true,
'lyricsMetadataScanVersion': row['lyrics_metadata_scan_version'] ?? 0,
};
}
@@ -1089,6 +1112,8 @@ class HistoryDatabase {
String? newQuality,
int? newBitDepth,
int? newSampleRate,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) async {
final db = await database;
final values = <String, dynamic>{};
@@ -1101,6 +1126,12 @@ class HistoryDatabase {
if (newSampleRate != null) {
values['sample_rate'] = newSampleRate;
}
if (hasLyrics != null) {
values['has_lyrics'] = hasLyrics ? 1 : 0;
}
if (lyricsMetadataScanVersion != null) {
values['lyrics_metadata_scan_version'] = lyricsMetadataScanVersion;
}
if (values.isEmpty) {
return;
}
+19 -3
View File
@@ -16,10 +16,10 @@ final _log = AppLogger('LibraryDatabase');
class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
static const int schemaVersion = 12;
static const int schemaVersion = 13;
static const String legacySourceId = LocalLibraryItem.legacySourceId;
static const String visibleLibraryView = 'library_visible';
static const int audioMetadataScanVersion = 2;
static const int audioMetadataScanVersion = 3;
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
bool _historyAttached = false;
@@ -84,8 +84,9 @@ class LibraryDatabase {
label TEXT,
copyright TEXT,
explicit INTEGER NOT NULL DEFAULT 0,
has_lyrics INTEGER NOT NULL DEFAULT 0,
format TEXT,
audio_metadata_scan_version INTEGER NOT NULL DEFAULT 2,
audio_metadata_scan_version INTEGER NOT NULL DEFAULT 3,
track_name_norm TEXT,
artist_name_norm TEXT,
album_name_norm TEXT,
@@ -211,6 +212,15 @@ class LibraryDatabase {
);
_log.i('Added explicit-content metadata');
}
if (oldVersion < 13) {
await sqlite.addColumnIfMissing(
db,
'library',
'has_lyrics',
'INTEGER NOT NULL DEFAULT 0',
);
_log.i('Added indexed lyrics availability metadata');
}
}
Future<void> _createLibrarySources(DatabaseExecutor db) async {
@@ -470,6 +480,7 @@ class LibraryDatabase {
'label': json['label'],
'copyright': json['copyright'],
'explicit': json['explicit'] == true || json['explicit'] == 1 ? 1 : 0,
'has_lyrics': json['hasLyrics'] == true || json['hasLyrics'] == 1 ? 1 : 0,
'format': json['format'],
'audio_metadata_scan_version':
(json['audioMetadataScanVersion'] as num?)?.toInt() ??
@@ -525,6 +536,7 @@ class LibraryDatabase {
'label': row['label'],
'copyright': row['copyright'],
'explicit': row['explicit'] == 1 || row['explicit'] == true,
'hasLyrics': row['has_lyrics'] == 1 || row['has_lyrics'] == true,
'format': row['format'],
};
}
@@ -1486,6 +1498,7 @@ class LibraryDatabase {
int? sampleRate,
int? bitrate,
bool? explicit,
bool? hasLyrics,
String? format,
}) async {
final values = <String, dynamic>{};
@@ -1504,6 +1517,9 @@ class LibraryDatabase {
if (explicit != null) {
values['explicit'] = explicit ? 1 : 0;
}
if (hasLyrics != null) {
values['has_lyrics'] = hasLyrics ? 1 : 0;
}
final normalizedFormat = normalizeAudioFormatValue(format);
if (normalizedFormat != null) {
values['format'] = normalizedFormat;
@@ -39,6 +39,7 @@ class LocalLibraryItem {
final String? label;
final String? copyright;
final bool explicit;
final bool hasLyrics;
final String? format; // flac, alac, eac3, ac3, ac4, mp3, opus, m4a
const LocalLibraryItem({
@@ -67,6 +68,7 @@ class LocalLibraryItem {
this.label,
this.copyright,
this.explicit = false,
this.hasLyrics = false,
this.format,
});
@@ -96,6 +98,7 @@ class LocalLibraryItem {
'label': label,
'copyright': copyright,
'explicit': explicit,
'hasLyrics': hasLyrics,
'format': format,
};
@@ -126,6 +129,7 @@ class LocalLibraryItem {
label: json['label'] as String?,
copyright: json['copyright'] as String?,
explicit: json['explicit'] == true || json['explicit'] == 1,
hasLyrics: json['hasLyrics'] == true || json['hasLyrics'] == 1,
format: json['format'] as String?,
);
@@ -135,6 +139,7 @@ class LocalLibraryItem {
int? sampleRate,
int? bitrate,
bool? explicit,
bool? hasLyrics,
String? format,
}) {
return LocalLibraryItem(
@@ -163,6 +168,7 @@ class LocalLibraryItem {
label: label,
copyright: copyright,
explicit: explicit ?? this.explicit,
hasLyrics: hasLyrics ?? this.hasLyrics,
format: format ?? this.format,
);
}
@@ -385,6 +385,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
discNumberExpr: 'h.disc_number',
isrcExpr: 'h.isrc',
labelExpr: 'h.label',
hasLyricsExpr: 'h.has_lyrics',
);
}
@@ -415,6 +416,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
discNumberExpr: 'l.disc_number',
isrcExpr: 'l.isrc',
labelExpr: 'l.label',
hasLyricsExpr: 'l.has_lyrics',
);
}
@@ -434,6 +436,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
required String discNumberExpr,
required String isrcExpr,
required String labelExpr,
required String hasLyricsExpr,
}) {
final quality = request.quality?.trim().toLowerCase();
if (quality != null && quality.isNotEmpty) {
@@ -518,6 +521,9 @@ extension _LibraryDbQueueSql on LibraryDatabase {
case 'missing-label':
where.add('NOT ($hasLabel)');
break;
case 'missing-lyrics':
where.add('COALESCE($hasLyricsExpr, 0) = 0');
break;
}
}