mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
feat(library): add lyrics filtering and metadata actions
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user