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
@@ -39,7 +39,7 @@ object NativeDownloadFinalizer {
const val NATIVE_WORKER_CONTRACT_VERSION = 1
// Native finalizer owns background-safe history writes while Flutter may be suspended.
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
const val HISTORY_SCHEMA_VERSION = 12
const val HISTORY_SCHEMA_VERSION = 13
internal val activeFFmpegSessionIds = mutableSetOf<Long>()
internal val nativeFFmpegSessionIds = BoundedRegistry<Long>(maxEntries = 256)
internal val activeFFmpegSessionLock = Any()
@@ -88,6 +88,8 @@ object NativeDownloadFinalizer {
"label",
"copyright",
"explicit",
"has_lyrics",
"lyrics_metadata_scan_version",
"spotify_id_norm",
"isrc_norm",
"match_key",
@@ -137,6 +139,9 @@ object NativeDownloadFinalizer {
var audioCodec: String? = null,
var pendingExternalLrc: String? = null,
var pendingExternalLrcFileName: String? = null,
var lyricsMetadataScanned: Boolean = false,
var hasEmbeddedLyrics: Boolean = false,
var externalLrcWritten: Boolean = false,
)
internal data class ReplayGainScan(
@@ -997,6 +1002,15 @@ object NativeDownloadFinalizer {
val metadata = parseObject(Gobackend.readFileMetadata(probePath))
if (metadata.has("error")) return
if (metadata.has("lyrics") || metadata.has("hasLyrics")) {
state.lyricsMetadataScanned = true
state.hasEmbeddedLyrics =
metadata.optBoolean("hasLyrics", false) ||
NativeFinalizationPolicy.hasUsableLyricsContent(
metadata.optString("lyrics", ""),
)
}
val bitDepth = optPositiveInt(metadata, "bit_depth")
val sampleRate = optPositiveInt(metadata, "sample_rate")
val probedCodec = normalizeAudioCodec(
@@ -1311,6 +1325,18 @@ object NativeDownloadFinalizer {
input.request.optBoolean("explicit", false)
) 1 else 0,
)
values.put(
"has_lyrics",
if (state.hasEmbeddedLyrics || state.externalLrcWritten) 1 else 0,
)
values.put(
"lyrics_metadata_scan_version",
if (
state.lyricsMetadataScanned ||
state.hasEmbeddedLyrics ||
state.externalLrcWritten
) 1 else 0,
)
putNormalizedHistoryColumns(values)
return values
}
@@ -1374,6 +1400,8 @@ object NativeDownloadFinalizer {
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,
@@ -1412,6 +1440,8 @@ object NativeDownloadFinalizer {
ensureHistoryColumn(db, "sort_release", "ALTER TABLE history ADD COLUMN sort_release TEXT")
ensureHistoryColumn(db, "sort_added", "ALTER TABLE history ADD COLUMN sort_added INTEGER")
ensureHistoryColumn(db, "explicit", "ALTER TABLE history ADD COLUMN explicit INTEGER NOT NULL DEFAULT 0")
ensureHistoryColumn(db, "has_lyrics", "ALTER TABLE history ADD COLUMN has_lyrics INTEGER NOT NULL DEFAULT 0")
ensureHistoryColumn(db, "lyrics_metadata_scan_version", "ALTER TABLE history ADD COLUMN lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0")
ensureHistoryPathKeyTable(db)
if (needsBackfill) {
backfillNormalizedHistoryColumns(db)
@@ -1937,6 +1967,9 @@ object NativeDownloadFinalizer {
putCamel("composer", "composer")
putCamel("label", "label")
putCamel("copyright", "copyright")
json.put("explicit", values.getAsInteger("explicit") == 1)
json.put("hasLyrics", values.getAsInteger("has_lyrics") == 1)
json.put("lyricsMetadataScanVersion", values.getAsInteger("lyrics_metadata_scan_version") ?: 0)
return json
}
@@ -11,6 +11,51 @@ import kotlin.math.roundToInt
* finalizer's I/O-heavy orchestration.
*/
internal object NativeFinalizationPolicy {
private val lyricsMetadataLinePattern = Regex(
"^\\[[a-z][a-z0-9_]*:.*]$",
RegexOption.IGNORE_CASE,
)
private val lyricsBackgroundPattern = Regex(
"^\\[bg:(.*)]$",
RegexOption.IGNORE_CASE,
)
private val lyricsTimestampPattern = Regex(
"^\\[\\d{1,3}:\\d{1,2}(?:[.:]\\d{1,3})?]",
)
private val lyricsInlineTimestampPattern = Regex(
"<\\d{1,3}:\\d{1,2}(?:[.:]\\d{1,3})?>",
)
fun hasUsableLyricsContent(raw: String?): Boolean {
val lyrics = raw.orEmpty().trim()
if (lyrics.equals("[instrumental:true]", ignoreCase = true)) return true
for (line in lyrics.lineSequence()) {
var cleaned = line.trim()
if (cleaned.isEmpty()) continue
val background = lyricsBackgroundPattern.matchEntire(cleaned)
if (background != null) {
cleaned = background.groupValues[1].trim()
} else if (lyricsMetadataLinePattern.matches(cleaned)) {
continue
}
while (lyricsTimestampPattern.containsMatchIn(cleaned)) {
cleaned = lyricsTimestampPattern.replaceFirst(cleaned, "").trim()
}
cleaned = lyricsInlineTimestampPattern.replace(cleaned, "").trim()
if (
cleaned.startsWith("v1:", ignoreCase = true) ||
cleaned.startsWith("v2:", ignoreCase = true)
) {
cleaned = cleaned.drop(3).trim()
}
if (cleaned.isNotEmpty()) return true
}
return false
}
data class AutoConversionTarget(
val codec: String,
val extension: String,
@@ -177,7 +177,10 @@ internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: N
val lyricsMode = input.request.optString("lyrics_mode", "")
if (lyricsMode != "external" && lyricsMode != "both") return
val lrc = resolveLyricsLrc(input)
if (lrc.isBlank() || lrc == "[instrumental:true]") return
if (
!NativeFinalizationPolicy.hasUsableLyricsContent(lrc) ||
lrc.trim().equals("[instrumental:true]", ignoreCase = true)
) return
val audioFileName = if (isDeferredSafRequest(input)) {
desiredFileName(input, state, File(state.filePath).extension)
} else {
@@ -195,7 +198,7 @@ internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: N
val temp = File(context.cacheDir, "native_lrc_${System.nanoTime()}.lrc")
temp.writeText(lrc)
try {
SafDownloadHandler.writeFileToSaf(
val uri = SafDownloadHandler.writeFileToSaf(
context = context,
treeUriStr = treeUri,
relativeDir = relativeDir,
@@ -203,12 +206,14 @@ internal fun NativeDownloadFinalizer.writeExternalLrc(context: Context, input: N
mimeType = "application/octet-stream",
srcPath = temp.absolutePath,
)
state.externalLrcWritten = uri != null
} finally {
temp.delete()
}
} else {
val target = File(File(state.filePath).parentFile, "$baseName.lrc")
target.writeText(lrc)
state.externalLrcWritten = true
}
}
@@ -300,8 +305,8 @@ internal fun NativeDownloadFinalizer.embedBasicMetadata(context: Context, path:
(lyricsMode == "embed" || lyricsMode == "both")
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
val shouldEmbedLyrics = shouldResolveLyrics &&
lyrics.isNotBlank() &&
lyrics != "[instrumental:true]"
NativeFinalizationPolicy.hasUsableLyricsContent(lyrics) &&
!lyrics.trim().equals("[instrumental:true]", ignoreCase = true)
// FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the
// tag block atomically without an ffmpeg remux (which drops foreign
// frames and rewrites the whole container). The Go side answers
@@ -181,6 +181,8 @@ internal fun NativeDownloadFinalizer.publishPendingDeferredExternalLrc(
)
if (newUri == null) {
Log.w(TAG, "Failed to publish deferred external LRC: $fileName")
} else {
state.externalLrcWritten = true
}
} catch (e: Exception) {
Log.w(TAG, "Failed to publish deferred external LRC: ${e.message}")
@@ -7,6 +7,31 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class NativeFinalizationPolicyTest {
@Test
fun usableLyricsRejectsHeadersButKeepsRealAndInstrumentalContent() {
assertFalse(
NativeFinalizationPolicy.hasUsableLyricsContent(
"[ar:Artist]\n[ti:Title]\n[offset:0]",
),
)
assertFalse(
NativeFinalizationPolicy.hasUsableLyricsContent(
"[00:01.00]\n<00:01.10>\nv1:",
),
)
assertTrue(
NativeFinalizationPolicy.hasUsableLyricsContent(
"[00:01.00]<00:01.10>v1: First line",
),
)
assertTrue(
NativeFinalizationPolicy.hasUsableLyricsContent("[bg:Backing vocal]"),
)
assertTrue(
NativeFinalizationPolicy.hasUsableLyricsContent("[instrumental:true]"),
)
}
@Test
fun automaticConversionSettingsAreNormalizedAndComparable() {
val target = checkNotNull(