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
@@ -33,6 +33,8 @@ class DownloadHistoryItem {
final String? label;
final String? copyright;
final bool explicit;
final bool hasLyrics;
final int lyricsMetadataScanVersion;
const DownloadHistoryItem({
required this.id,
@@ -67,6 +69,8 @@ class DownloadHistoryItem {
this.label,
this.copyright,
this.explicit = false,
this.hasLyrics = false,
this.lyricsMetadataScanVersion = 0,
});
Map<String, dynamic> toJson() => {
@@ -102,6 +106,8 @@ class DownloadHistoryItem {
'label': label,
'copyright': copyright,
'explicit': explicit,
'hasLyrics': hasLyrics,
'lyricsMetadataScanVersion': lyricsMetadataScanVersion,
};
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) =>
@@ -138,6 +144,9 @@ class DownloadHistoryItem {
label: json['label'] as String?,
copyright: json['copyright'] as String?,
explicit: parseExplicitFlag(json['explicit']) == true,
hasLyrics: json['hasLyrics'] == true || json['hasLyrics'] == 1,
lyricsMetadataScanVersion:
(json['lyricsMetadataScanVersion'] as num?)?.toInt() ?? 0,
);
DownloadHistoryItem copyWith({
@@ -170,6 +179,8 @@ class DownloadHistoryItem {
String? label,
String? copyright,
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) {
return DownloadHistoryItem(
id: id,
@@ -204,6 +215,9 @@ class DownloadHistoryItem {
label: label ?? this.label,
copyright: copyright ?? this.copyright,
explicit: explicit ?? this.explicit,
hasLyrics: hasLyrics ?? this.hasLyrics,
lyricsMetadataScanVersion:
lyricsMetadataScanVersion ?? this.lyricsMetadataScanVersion,
);
}
}
+22 -1
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/audio_format_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
part 'download_history_models.dart';
@@ -202,6 +203,15 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
normalizeOptionalString(item.copyright) ??
normalizeOptionalString(existing.copyright),
explicit: item.explicit || existing.explicit,
hasLyrics:
item.lyricsMetadataScanVersion >=
existing.lyricsMetadataScanVersion
? item.hasLyrics
: existing.hasLyrics,
lyricsMetadataScanVersion: max(
item.lyricsMetadataScanVersion,
existing.lyricsMetadataScanVersion,
),
);
return (item: mergedItem, existingId: existing?.id);
}
@@ -479,6 +489,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
int? duration,
String? composer,
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) async {
final target = await _historyItemForUpdate(id);
if (target == null) {
@@ -502,6 +514,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
duration: duration,
composer: composer,
explicit: explicit,
hasLyrics: hasLyrics,
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
);
if (updated.quality == current.quality &&
@@ -515,7 +529,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
updated.totalDiscs == current.totalDiscs &&
updated.duration == current.duration &&
updated.composer == current.composer &&
updated.explicit == current.explicit) {
updated.explicit == current.explicit &&
updated.hasLyrics == current.hasLyrics &&
updated.lyricsMetadataScanVersion ==
current.lyricsMetadataScanVersion) {
return;
}
@@ -550,6 +567,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
String? label,
String? copyright,
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) async {
final target = await _historyItemForUpdate(id);
if (target == null) {
@@ -574,6 +593,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
label: label,
copyright: copyright,
explicit: explicit,
hasLyrics: hasLyrics,
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
);
final updatedItems = target.index >= 0
@@ -260,11 +260,19 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
trimmed.endsWith('.aac') ||
trimmed.endsWith('.mp3') ||
trimmed.endsWith('.opus') ||
trimmed.endsWith('.ogg');
trimmed.endsWith('.ogg') ||
trimmed.endsWith('.ape') ||
trimmed.endsWith('.wv') ||
trimmed.endsWith('.mpc') ||
trimmed.endsWith('.wav') ||
trimmed.endsWith('.aiff') ||
trimmed.endsWith('.aif') ||
trimmed.endsWith('.aifc');
}
bool _shouldBackfillAudioMetadata(DownloadHistoryItem item) {
return _needsAverageBitrateBackfill(item) ||
return item.lyricsMetadataScanVersion < 1 ||
_needsAverageBitrateBackfill(item) ||
_shouldBackfillAudioMetadataIgnoringBitrate(item);
}
@@ -372,10 +380,54 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
);
}
Future<bool?> _probeSidecarLyrics(DownloadHistoryItem item) async {
final filePath = item.filePath.trim();
if (filePath.isEmpty) return null;
String? tempPath;
try {
if (filePath.startsWith('content://')) {
final treeUri = normalizeOptionalString(item.downloadTreeUri);
final fileName = normalizeOptionalString(item.safFileName);
if (treeUri == null || fileName == null) return null;
final replacedName = fileName.replaceFirst(RegExp(r'\.[^.]+$'), '.lrc');
final lrcName = replacedName == fileName
? '$fileName.lrc'
: replacedName;
final resolved = await PlatformBridge.resolveSafFile(
treeUri: treeUri,
relativeDir: item.safRelativeDir ?? '',
fileName: lrcName,
);
final uri = normalizeOptionalString(resolved['uri']?.toString());
if (uri == null) return false;
tempPath = await PlatformBridge.copyContentUriToTemp(uri);
if (tempPath == null) return null;
return hasUsableLyricsContent(await File(tempPath).readAsString());
}
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
final sidecar = File(safeLrcPath);
if (!await sidecar.exists()) return false;
return hasUsableLyricsContent(await sidecar.readAsString());
} catch (e) {
_historyLog.d('Sidecar lyrics probe failed for $filePath: $e');
return null;
} finally {
if (tempPath != null) {
try {
await File(tempPath).delete();
} catch (_) {}
}
}
}
Future<Map<String, dynamic>?> _probeAudioMetadata(
String filePath, {
DownloadHistoryItem item, {
String? fallbackQuality,
}) async {
final filePath = item.filePath;
if (!_supportsAudioMetadataProbe(filePath)) {
return null;
}
@@ -414,6 +466,13 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
final totalTracks = readPositiveInt(result['total_tracks']);
final discNumber = readPositiveInt(result['disc_number']);
final totalDiscs = readPositiveInt(result['total_discs']);
final embeddedHasLyrics =
result['hasLyrics'] == true ||
hasUsableLyricsContent(result['lyrics']?.toString() ?? '');
final sidecarHasLyrics = await _probeSidecarLyrics(item);
final hasLyrics = embeddedHasLyrics || sidecarHasLyrics == true;
final lyricsMetadataScanVersion =
embeddedHasLyrics || sidecarHasLyrics != null ? 1 : 0;
if (quality == null &&
bitDepth == null &&
@@ -425,7 +484,10 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
trackNumber == null &&
totalTracks == null &&
discNumber == null &&
totalDiscs == null) {
totalDiscs == null &&
result['hasLyrics'] == null &&
result['lyrics'] == null &&
sidecarHasLyrics == null) {
return null;
}
@@ -442,6 +504,8 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
'totalTracks': totalTracks,
'discNumber': discNumber,
'totalDiscs': totalDiscs,
'hasLyrics': hasLyrics,
'lyricsMetadataScanVersion': lyricsMetadataScanVersion,
};
} catch (e) {
_historyLog.d('Audio metadata probe failed for $filePath: $e');
@@ -497,9 +561,10 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
final item = items[index];
Map<String, dynamic>? probed;
if (_shouldBackfillAudioMetadataIgnoringBitrate(item)) {
if (item.lyricsMetadataScanVersion < 1 ||
_shouldBackfillAudioMetadataIgnoringBitrate(item)) {
probed = await _probeAudioMetadata(
item.filePath,
item,
fallbackQuality: item.quality,
);
} else if (_needsAverageBitrateBackfill(item)) {
@@ -541,6 +606,9 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
final resolvedTotalTracks = probed['totalTracks'] as int?;
final resolvedDiscNumber = probed['discNumber'] as int?;
final resolvedTotalDiscs = probed['totalDiscs'] as int?;
final resolvedHasLyrics = probed['hasLyrics'] as bool?;
final resolvedLyricsScanVersion =
probed['lyricsMetadataScanVersion'] as int?;
final qualityChanged =
resolvedQuality != null && resolvedQuality != item.quality;
@@ -566,6 +634,11 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
resolvedDiscNumber != null && resolvedDiscNumber != item.discNumber;
final totalDiscsChanged =
resolvedTotalDiscs != null && resolvedTotalDiscs != item.totalDiscs;
final hasLyricsChanged =
resolvedHasLyrics != null && resolvedHasLyrics != item.hasLyrics;
final lyricsScanVersionChanged =
resolvedLyricsScanVersion != null &&
resolvedLyricsScanVersion != item.lyricsMetadataScanVersion;
if (!qualityChanged &&
!bitDepthChanged &&
@@ -577,7 +650,9 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
!trackNumberChanged &&
!totalTracksChanged &&
!discNumberChanged &&
!totalDiscsChanged) {
!totalDiscsChanged &&
!hasLyricsChanged &&
!lyricsScanVersionChanged) {
continue;
}
@@ -593,6 +668,8 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
totalTracks: resolvedTotalTracks,
discNumber: resolvedDiscNumber,
totalDiscs: resolvedTotalDiscs,
hasLyrics: resolvedHasLyrics,
lyricsMetadataScanVersion: resolvedLyricsScanVersion,
);
updatedItems ??= [...items];
updatedItems[index] = updated;
@@ -31,6 +31,7 @@ import 'package:spotiflac_android/utils/audio_format_utils.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/progress_stream_poller.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
@@ -750,10 +750,11 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
filePath: '',
durationMs: track.duration * 1000,
);
if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') {
if (hasUsableLyricsContent(fetchedLrc) &&
!isInstrumentalLyricsMarker(fetchedLrc)) {
lrcContent = fetchedLrc;
_log.d('Lyrics fetched for $format (${fetchedLrc.length} chars)');
} else if (fetchedLrc == '[instrumental:true]') {
} else if (isInstrumentalLyricsMarker(fetchedLrc)) {
_log.d('Track is instrumental, skipping lyrics handling');
}
} catch (e) {
@@ -259,6 +259,8 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
String? genre,
String? label,
String? copyright,
required bool hasLyrics,
required int lyricsMetadataScanVersion,
}) {
final backendTitle = result['title'] as String?;
final backendArtist = result['artist'] as String?;
@@ -343,6 +345,53 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
explicit:
trackToDownload.isExplicit ||
parseExplicitFlag(result['explicit']) == true,
hasLyrics: hasLyrics,
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
);
}
Future<({bool hasLyrics, int scanVersion})> _resolveFinalLyricsAvailability({
required String filePath,
Map<String, dynamic>? probedMetadata,
bool externalLrcWritten = false,
}) async {
var metadataScanned = false;
var hasEmbeddedLyrics = false;
try {
final metadata =
probedMetadata ?? await PlatformBridge.readFileMetadata(filePath);
if (metadata['error'] == null &&
(metadata.containsKey('lyrics') ||
metadata.containsKey('hasLyrics'))) {
metadataScanned = true;
hasEmbeddedLyrics =
metadata['hasLyrics'] == true ||
hasUsableLyricsContent(metadata['lyrics']?.toString() ?? '');
}
} catch (e) {
_log.d('Final lyrics metadata probe failed for $filePath: $e');
}
var hasSidecarLyrics = externalLrcWritten;
if (!isContentUri(filePath)) {
try {
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
final sidecar = File(safeLrcPath);
if (await sidecar.exists()) {
hasSidecarLyrics = hasUsableLyricsContent(
await sidecar.readAsString(),
);
}
} catch (e) {
_log.d('Final sidecar lyrics probe failed for $filePath: $e');
}
}
final hasLyrics = hasEmbeddedLyrics || hasSidecarLyrics;
return (
hasLyrics: hasLyrics,
scanVersion: hasLyrics || metadataScanned ? 1 : 0,
);
}
@@ -462,14 +511,14 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
}
}
Future<void> _writeLrcToSaf({
Future<bool> _writeLrcToSaf({
required String treeUri,
required String relativeDir,
required String baseName,
required String lrcContent,
}) async {
try {
if (lrcContent.isEmpty) return;
if (!hasUsableLyricsContent(lrcContent)) return false;
final tempDir = await getTemporaryDirectory();
final tempPath = '${tempDir.path}/$baseName.lrc';
await File(tempPath).writeAsString(lrcContent);
@@ -489,8 +538,10 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
try {
await File(tempPath).delete();
} catch (_) {}
return uri != null;
} catch (e) {
_log.w('Failed to create external LRC in SAF: $e');
return false;
}
}
@@ -1230,7 +1281,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
/// modes). [resolveBaseName] and [onFetchError] are each caller's own
/// base-name fallback chain and fetch-failure log line, evaluated lazily
/// to match the original call sites exactly.
Future<void> _saveExternalLrc({
Future<bool> _saveExternalLrc({
required Map<String, dynamic> result,
required AppSettings settings,
required ExtensionState extensionState,
@@ -1250,11 +1301,11 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
!_shouldSkipLyrics(extensionState, track.source, service) &&
(lyricsMode == 'external' || lyricsMode == 'both');
if (!shouldSaveExternalLrc) {
return;
return false;
}
String? lrcContent = result['lyrics_lrc'] as String?;
if (lrcContent == null || lrcContent.isEmpty) {
if (!hasUsableLyricsContent(lrcContent ?? '')) {
try {
lrcContent = await PlatformBridge.getLyricsLRC(
track.id,
@@ -1266,31 +1317,37 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
onFetchError(e);
}
}
if (lrcContent == null || lrcContent.isEmpty) {
return;
if (!hasUsableLyricsContent(lrcContent ?? '') ||
isInstrumentalLyricsMarker(lrcContent!)) {
return false;
}
final resolvedLrc = lrcContent;
if (storageMode == 'saf' && isContentUri(filePath)) {
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
return;
return false;
}
final baseName = await resolveBaseName();
await _writeLrcToSaf(
final written = await _writeLrcToSaf(
treeUri: downloadTreeUri,
relativeDir: safRelativeDir,
baseName: baseName,
lrcContent: lrcContent,
lrcContent: resolvedLrc,
);
return;
if (written) result['lyrics_lrc'] = resolvedLrc;
return written;
}
try {
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
await File(safeLrcPath).writeAsString(lrcContent);
await File(safeLrcPath).writeAsString(resolvedLrc);
result['lyrics_lrc'] = resolvedLrc;
_log.d('Native-worker external LRC saved: $safeLrcPath');
return true;
} catch (e) {
_log.w('Failed to save native-worker external LRC: $e');
return false;
}
}
}
@@ -149,6 +149,9 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
storedQuality;
final useSaf = context.storageMode == 'saf';
final resultFileName = result['file_name']?.toString().trim();
final lyricsAvailability = await _resolveFinalLyricsAvailability(
filePath: filePath,
);
await ref
.read(downloadHistoryProvider.notifier)
@@ -172,6 +175,8 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
genre: normalizeOptionalString(result['genre']?.toString()),
label: normalizeOptionalString(result['label']?.toString()),
copyright: normalizeOptionalString(result['copyright']?.toString()),
hasLyrics: lyricsAvailability.hasLyrics,
lyricsMetadataScanVersion: lyricsAvailability.scanVersion,
),
preserveTrackVariant: context.item.preserveQualityVariant,
);
@@ -1441,7 +1446,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
if (finalQuality != null) actualQuality = finalQuality;
}
await _saveExternalLrc(
final externalLrcWritten = await _saveExternalLrc(
result: result,
settings: settings,
extensionState: ref.read(extensionProvider),
@@ -1483,6 +1488,10 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
lowerFilePath.endsWith('.ogg');
final completedFilePath = filePath;
final lyricsAvailability = await _resolveFinalLyricsAvailability(
filePath: completedFilePath,
externalLrcWritten: externalLrcWritten,
);
await persistBeforePublishingDownloadCompletion(
persist: () async {
if (!settings.saveDownloadHistory) return;
@@ -1511,6 +1520,8 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
copyright: normalizeOptionalString(
result['copyright'] as String?,
),
hasLyrics: lyricsAvailability.hasLyrics,
lyricsMetadataScanVersion: lyricsAvailability.scanVersion,
),
preserveTrackVariant: item.preserveQualityVariant,
);
@@ -128,6 +128,7 @@ class _DownloadRun {
/// Filled by the SAF embed op from the local temp so the final quality
/// probe doesn't have to copy the published file back out of SAF.
Map<String, dynamic>? probedFinalMetadata;
bool externalLrcWritten = false;
Future<void> _run() async {
final normalizedService = n._normalizeQueuedService(item.service);
@@ -802,7 +803,7 @@ class _DownloadRun {
final lrcTarget = filePath;
if (effectiveSafMode && lrcTarget != null && isContentUri(lrcTarget)) {
await n._saveExternalLrc(
externalLrcWritten = await n._saveExternalLrc(
result: result,
settings: settings,
extensionState: extensionState,
@@ -1609,7 +1610,7 @@ class _DownloadRun {
required String format,
bool writeExternalLrc = true,
bool rebuildTrack = true,
}) {
}) async {
final track = rebuildTrack
? n._buildTrackForMetadataEmbedding(
trackToDownload,
@@ -1617,7 +1618,7 @@ class _DownloadRun {
resolvedAlbumArtist,
)
: trackToDownload;
return n._embedMetadataToFile(
final lrcContent = await n._embedMetadataToFile(
path,
track,
format: format,
@@ -1628,6 +1629,10 @@ class _DownloadRun {
downloadService: item.service,
writeExternalLrc: writeExternalLrc,
);
if (lrcContent != null && lrcContent.isNotEmpty) {
result['lyrics_lrc'] = lrcContent;
}
return lrcContent;
}
Future<void> _recoverSafUriIfNeeded() async {
@@ -1735,6 +1740,7 @@ class _DownloadRun {
? probed
: await PlatformBridge.readFileMetadata(path);
if (metadata['error'] == null) {
probedFinalMetadata = metadata;
final probedBitDepth = metadata['bit_depth'] is num
? (metadata['bit_depth'] as num).toInt()
: int.tryParse(metadata['bit_depth']?.toString() ?? '');
@@ -1790,6 +1796,11 @@ class _DownloadRun {
final historyBitDepth = isLossyOutput ? null : finalBitDepth;
final historySampleRate = isLossyOutput ? null : finalSampleRate;
final historyBitrate = finalBitrateKbps;
final lyricsAvailability = await n._resolveFinalLyricsAvailability(
filePath: historyFilePath,
probedMetadata: probedFinalMetadata,
externalLrcWritten: externalLrcWritten,
);
await persistBeforePublishingDownloadCompletion(
persist: () async {
@@ -1814,6 +1825,8 @@ class _DownloadRun {
genre: effectiveGenre,
label: effectiveLabel,
copyright: effectiveCopyright,
hasLyrics: lyricsAvailability.hasLyrics,
lyricsMetadataScanVersion: lyricsAvailability.scanVersion,
),
preserveTrackVariant: item.preserveQualityVariant,
);