read embedded and sidecar lyrics

This commit is contained in:
Boof2015
2026-07-08 23:54:24 -04:00
parent f2fed5c9fe
commit c46830698f
4 changed files with 160 additions and 0 deletions
@@ -125,6 +125,21 @@ class AstraLibraryScannerModule : Module() {
withContext(Dispatchers.IO) { readReplayGain(uri) } withContext(Dispatchers.IO) { readReplayGain(uri) }
} }
// Sidecar lyrics: a `<name>.xlrc` (preferred) or `<name>.lrc` next to the track.
// Returns { text, format } or null. Read fresh on demand so files authored after
// a scan are picked up.
AsyncFunction("readSidecarLyrics") Coroutine { uri: String ->
withContext(Dispatchers.IO) { readSidecarLyrics(uri) }
}
// Embedded lyrics from container tags (Vorbis LYRICS/UNSYNCEDLYRICS for
// FLAC/Ogg/Opus, plus TXXX/MP4 lyric atoms), metadata-only (no PCM decode).
// Returns { text } or null. ID3 USLT/SYLT are not decoded by ExoPlayer and are
// intentionally out of scope here.
AsyncFunction("readEmbeddedLyrics") Coroutine { uri: String ->
withContext(Dispatchers.IO) { readEmbeddedLyrics(uri) }
}
Function("getArtworkDirPath") { Function("getArtworkDirPath") {
artworkDir().absolutePath artworkDir().absolutePath
} }
@@ -284,6 +299,90 @@ class AstraLibraryScannerModule : Module() {
return v / 256.0 + 5.0 return v / 256.0 + 5.0
} }
// ---------------------------------------------------------------------------
// Lyrics (v2 local sources)
// ---------------------------------------------------------------------------
/**
* Look for a sibling lyrics file next to the track: `<name>.xlrc` first, then
* `<name>.lrc`. Builds the sibling document URI by swapping the audio file's
* extension (the same tree grant covers it), so no directory listing is needed for
* the common case. Returns { text, format } or null.
*/
private fun readSidecarLyrics(uriStr: String): Map<String, Any?>? {
return try {
val uri = Uri.parse(uriStr)
val docId = DocumentsContract.getDocumentId(uri) ?: return null
val stem = docId.substringBeforeLast('.', "")
if (stem.isEmpty()) return null
for ((ext, format) in listOf("xlrc" to "xlrc", "lrc" to "lrc")) {
val siblingUri = DocumentsContract.buildDocumentUriUsingTree(uri, "$stem.$ext")
val text = readTextOrNull(siblingUri) ?: continue
if (text.isBlank()) continue
return mapOf("text" to text, "format" to format)
}
null
} catch (_: Throwable) {
null
}
}
private fun readTextOrNull(uri: Uri): String? {
return try {
requireContext().contentResolver.openInputStream(uri)?.use { input ->
input.bufferedReader(Charsets.UTF_8).readText()
}
} catch (_: Throwable) {
null
}
}
private val embeddedLyricKeys = setOf("lyrics", "unsyncedlyrics", "unsynced_lyrics", "syncedlyrics")
/**
* Read embedded lyrics from container metadata via ExoPlayer's MetadataRetriever.
* Covers Vorbis LYRICS/UNSYNCEDLYRICS (FLAC/Ogg/Opus), TXXX:LYRICS (ID3), and MP4
* freeform lyric atoms — metadata only, no PCM decode. ID3 USLT/SYLT arrive as
* undecoded BinaryFrames and are out of scope. Returns { text } or null.
*/
private fun readEmbeddedLyrics(uriStr: String): Map<String, Any?>? {
return try {
val mediaItem = MediaItem.fromUri(Uri.parse(uriStr))
val trackGroups = MetadataRetriever.retrieveMetadata(requireContext(), mediaItem)
.get(metadataTimeoutMs, TimeUnit.MILLISECONDS)
// Keep the longest lyric candidate (a full body beats a stray short tag).
var best: String? = null
fun consider(rawKey: String?, rawValue: String?) {
if (rawKey == null || rawValue == null) return
val key = rawKey.trim().lowercase()
val isLyricKey = key in embeddedLyricKeys || key.contains("lyric") || key == "©lyr"
if (!isLyricKey) return
val value = rawValue.trim()
if (value.isEmpty()) return
if (best == null || value.length > best!!.length) best = value
}
for (g in 0 until trackGroups.length) {
val group = trackGroups.get(g)
for (f in 0 until group.length) {
val metadata = group.getFormat(f).metadata ?: continue
for (i in 0 until metadata.length()) {
when (val entry = metadata.get(i)) {
is TextInformationFrame -> if (entry.id == "TXXX") consider(entry.description, entry.value)
is VorbisComment -> consider(entry.key, entry.value)
is InternalFrame -> consider(entry.description, entry.text)
}
}
}
}
best?.let { mapOf("text" to it) }
} catch (_: Throwable) {
null
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Directory walk // Directory walk
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+23
View File
@@ -51,6 +51,17 @@ export interface ReplayGainTags {
albumPeak: number | null; albumPeak: number | null;
} }
/** A `.xlrc`/`.lrc` file found next to the track. */
export interface SidecarLyrics {
text: string;
format: 'xlrc' | 'lrc';
}
/** Embedded lyrics text read from container tags. */
export interface EmbeddedLyrics {
text: string;
}
export interface ScanProgressEvent { export interface ScanProgressEvent {
phase: 'discovering'; phase: 'discovering';
found: number; found: number;
@@ -85,6 +96,18 @@ declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibrarySca
* are null when the tag is absent; the whole call is cheap (metadata only). * are null when the tag is absent; the whole call is cheap (metadata only).
*/ */
readReplayGain(uri: string): Promise<ReplayGainTags>; readReplayGain(uri: string): Promise<ReplayGainTags>;
/**
* Look for a sibling lyrics file next to the track (`<name>.xlrc` preferred, then
* `<name>.lrc`) and return its text + format, or null. Read fresh on demand so
* files authored after a scan are picked up.
*/
readSidecarLyrics(uri: string): Promise<SidecarLyrics | null>;
/**
* Read embedded lyrics from container tags (Vorbis LYRICS/UNSYNCEDLYRICS for
* FLAC/Ogg/Opus, TXXX:LYRICS, MP4 lyric atoms) without decoding audio. Null when
* absent. ID3 USLT/SYLT are not covered (ExoPlayer leaves them undecoded).
*/
readEmbeddedLyrics(uri: string): Promise<EmbeddedLyrics | null>;
getArtworkDirPath(): string; getArtworkDirPath(): string;
getArtworkThumbDirPath(): string; getArtworkThumbDirPath(): string;
ensureArtworkThumbnails(hashes: string[]): Promise<number>; ensureArtworkThumbnails(hashes: string[]): Promise<number>;
+38
View File
@@ -0,0 +1,38 @@
// Local lyrics sources (v2) — sidecar files + embedded tags, read through the
// native scanner module and parsed by the shared JS parser. Both are no-ops for
// non-local (remote / streaming) tracks. Sidecar wins over embedded, and both win
// over online lookup (see the orchestrator ordering in lyrics.ts).
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
import { parseLyricsText } from './parsing';
import type { LyricsPayload } from './types';
function isLocalPath(path: string): boolean {
return path.startsWith('content://') || path.startsWith('file://');
}
/** Resolve a sibling `<name>.xlrc`/`.lrc` next to a local track. */
export async function resolveSidecarLyrics(trackPath: string): Promise<LyricsPayload | null> {
if (!isLocalPath(trackPath)) return null;
try {
const sidecar = await AstraLibraryScanner.readSidecarLyrics(trackPath);
if (!sidecar?.text) return null;
const source = sidecar.format === 'xlrc' ? 'xlrc' : 'lrc';
return parseLyricsText(sidecar.text, source, sidecar.format);
} catch {
return null;
}
}
/** Resolve embedded lyrics (Vorbis LYRICS/etc.) from a local track's container. */
export async function resolveEmbeddedLyrics(trackPath: string): Promise<LyricsPayload | null> {
if (!isLocalPath(trackPath)) return null;
try {
const embedded = await AstraLibraryScanner.readEmbeddedLyrics(trackPath);
if (!embedded?.text) return null;
// Parse as LRC so timestamped tags become synced; plain text stays plain.
return parseLyricsText(embedded.text, 'embedded', 'lrc');
} catch {
return null;
}
}
Binary file not shown.