mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 19:54:26 +02:00
support sidecar and embedded lyrics
This commit is contained in:
@@ -111,6 +111,10 @@ export async function getLyricsCacheCount(db: LibraryDatabase): Promise<number>
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function deleteLyricsCache(db: LibraryDatabase, trackPath: string): Promise<void> {
|
||||
await db.run('DELETE FROM lyrics_cache WHERE track_path = ?', [trackPath]);
|
||||
}
|
||||
|
||||
export async function clearLyricsCache(db: LibraryDatabase): Promise<void> {
|
||||
await db.run('DELETE FROM lyrics_cache');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createEmbeddedLyricsPayload, isLocalLyricsPath } from './embedded.ts';
|
||||
import { getLyricsPayloadSourceLabel } from './presentation.ts';
|
||||
|
||||
test('plain embedded text becomes an Embedded plain payload', () => {
|
||||
const payload = createEmbeddedLyricsPayload({
|
||||
status: 'hit',
|
||||
text: 'First line\nSecond line',
|
||||
syncText: [],
|
||||
});
|
||||
|
||||
assert.equal(payload?.source, 'embedded');
|
||||
assert.equal(payload?.format, 'plain');
|
||||
assert.equal(payload?.plainLyrics, 'First line\nSecond line');
|
||||
assert.equal(getLyricsPayloadSourceLabel(payload!), 'Embedded');
|
||||
});
|
||||
|
||||
test('timestamped text embedded in a plain tag is parsed as LRC', () => {
|
||||
const payload = createEmbeddedLyricsPayload({
|
||||
status: 'hit',
|
||||
text: '[00:01.00]First\n[00:02.50]Second',
|
||||
syncText: [],
|
||||
});
|
||||
|
||||
assert.equal(payload?.format, 'lrc');
|
||||
assert.deepEqual(payload?.syncedLines.map((line) => [line.timestampMs, line.text]), [
|
||||
[1_000, 'First'],
|
||||
[2_500, 'Second'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('structured SYLT entries take precedence over timestamps in raw text', () => {
|
||||
const payload = createEmbeddedLyricsPayload({
|
||||
status: 'hit',
|
||||
text: '[00:09.00]Raw fallback',
|
||||
syncText: [
|
||||
{ timestampMs: 2_000, text: 'Second' },
|
||||
{ timestampMs: 1_000, text: 'First' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(payload?.format, 'lrc');
|
||||
assert.deepEqual(payload?.syncedLines.map((line) => [line.timestampMs, line.text]), [
|
||||
[1_000, 'First'],
|
||||
[2_000, 'Second'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('only local file schemes are eligible for native embedded inspection', () => {
|
||||
assert.equal(isLocalLyricsPath('content://media/track/1'), true);
|
||||
assert.equal(isLocalLyricsPath('file:///music/track.mp3'), true);
|
||||
assert.equal(isLocalLyricsPath('subsonic://server/track/1'), false);
|
||||
assert.equal(isLocalLyricsPath('jellyfin://server/track/1'), false);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { EmbeddedLyricsReadResult } from '../../modules/astra-library-scanner';
|
||||
import {
|
||||
createLyricsPayload,
|
||||
parseLyricsText,
|
||||
sanitizeLyricsLines,
|
||||
toPlainLyricsFromLines,
|
||||
} from './parsing.ts';
|
||||
import type { LyricsPayload } from './types';
|
||||
|
||||
export type EmbeddedLyricsResolution =
|
||||
| { status: 'hit'; lyrics: LyricsPayload }
|
||||
| { status: 'missing' | 'unavailable' | 'not_local' };
|
||||
|
||||
/** Pure bridge adapter kept separate from the native module for Node tests. */
|
||||
export function createEmbeddedLyricsPayload(
|
||||
result: Extract<EmbeddedLyricsReadResult, { status: 'hit' }>
|
||||
): LyricsPayload | null {
|
||||
const parsedText = result.text
|
||||
? parseLyricsText(result.text, 'embedded', 'lrc')
|
||||
: null;
|
||||
const syncedLines = sanitizeLyricsLines(result.syncText.map((entry) => ({
|
||||
timestampMs: entry.timestampMs,
|
||||
text: entry.text,
|
||||
})));
|
||||
|
||||
if (syncedLines.length === 0) return parsedText;
|
||||
|
||||
return createLyricsPayload(
|
||||
'embedded',
|
||||
null,
|
||||
'lrc',
|
||||
parsedText?.plainLyrics ?? toPlainLyricsFromLines(syncedLines),
|
||||
null,
|
||||
syncedLines
|
||||
);
|
||||
}
|
||||
|
||||
export function isLocalLyricsPath(path: string): boolean {
|
||||
return path.startsWith('content://') || path.startsWith('file://');
|
||||
}
|
||||
+13
-12
@@ -4,16 +4,17 @@
|
||||
// over online lookup (see the orchestrator ordering in lyrics.ts).
|
||||
|
||||
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
||||
import {
|
||||
createEmbeddedLyricsPayload,
|
||||
isLocalLyricsPath,
|
||||
type EmbeddedLyricsResolution,
|
||||
} from './embedded';
|
||||
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;
|
||||
if (!isLocalLyricsPath(trackPath)) return null;
|
||||
try {
|
||||
const sidecar = await AstraLibraryScanner.readSidecarLyrics(trackPath);
|
||||
if (!sidecar?.text) return null;
|
||||
@@ -24,15 +25,15 @@ export async function resolveSidecarLyrics(trackPath: string): Promise<LyricsPay
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** Resolve embedded lyrics from a local track while preserving miss vs I/O failure. */
|
||||
export async function resolveEmbeddedLyrics(trackPath: string): Promise<EmbeddedLyricsResolution> {
|
||||
if (!isLocalLyricsPath(trackPath)) return { status: 'not_local' };
|
||||
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');
|
||||
if (embedded.status !== 'hit') return embedded;
|
||||
const lyrics = createEmbeddedLyricsPayload(embedded);
|
||||
return lyrics ? { status: 'hit', lyrics } : { status: 'missing' };
|
||||
} catch {
|
||||
return null;
|
||||
return { status: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { LyricsCacheEntry } from '../db/lyricsQueries.ts';
|
||||
import type { EmbeddedLyricsResolution } from './embedded.ts';
|
||||
import {
|
||||
resolveLyricsWithDependencies,
|
||||
type LyricsProviderLookupResult,
|
||||
type LyricsResolverDependencies,
|
||||
} from './resolver.ts';
|
||||
import type { LyricsPayload, LyricsSource, LyricsTrackQuery } from './types.ts';
|
||||
|
||||
const QUERY: LyricsTrackQuery = {
|
||||
path: 'content://music/track.mp3',
|
||||
title: 'Track',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
durationSeconds: 180,
|
||||
};
|
||||
|
||||
function payload(source: LyricsSource, text: string): LyricsPayload {
|
||||
return {
|
||||
source,
|
||||
provider: source === 'xlrcdb' || source === 'lrclib' ? source : null,
|
||||
format: 'plain',
|
||||
plainLyrics: text,
|
||||
syncedLyrics: null,
|
||||
syncedLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function cache(source: LyricsSource, text: string): LyricsCacheEntry {
|
||||
return {
|
||||
status: 'hit',
|
||||
source,
|
||||
provider: source === 'xlrcdb' || source === 'lrclib' ? source : null,
|
||||
format: 'plain',
|
||||
plainLyrics: text,
|
||||
syncedLyrics: null,
|
||||
syncedLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function dependencies(overrides: Partial<LyricsResolverDependencies> = {}) {
|
||||
const calls = {
|
||||
sidecar: 0,
|
||||
embedded: 0,
|
||||
cache: 0,
|
||||
deleteCache: 0,
|
||||
cachedHits: [] as LyricsPayload[],
|
||||
notFound: 0,
|
||||
xlrcdb: 0,
|
||||
lrclib: 0,
|
||||
};
|
||||
const deps: LyricsResolverDependencies = {
|
||||
resolveSidecar: async () => {
|
||||
calls.sidecar += 1;
|
||||
return null;
|
||||
},
|
||||
resolveEmbedded: async (): Promise<EmbeddedLyricsResolution> => {
|
||||
calls.embedded += 1;
|
||||
return { status: 'missing' };
|
||||
},
|
||||
getCache: async () => {
|
||||
calls.cache += 1;
|
||||
return null;
|
||||
},
|
||||
deleteCache: async () => {
|
||||
calls.deleteCache += 1;
|
||||
},
|
||||
cacheHit: async (lyrics) => {
|
||||
calls.cachedHits.push(lyrics);
|
||||
},
|
||||
cacheNotFound: async () => {
|
||||
calls.notFound += 1;
|
||||
},
|
||||
lookupXlrcdb: async (): Promise<LyricsProviderLookupResult> => {
|
||||
calls.xlrcdb += 1;
|
||||
return { status: 'not_found' };
|
||||
},
|
||||
lookupLrclib: async (): Promise<LyricsProviderLookupResult> => {
|
||||
calls.lrclib += 1;
|
||||
return { status: 'not_found' };
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { calls, deps };
|
||||
}
|
||||
|
||||
test('sidecar remains the highest-priority source', async () => {
|
||||
const sidecar = payload('lrc', 'Sidecar');
|
||||
const { calls, deps } = dependencies({ resolveSidecar: async () => sidecar });
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: true },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.equal(result.status === 'hit' ? result.lyrics.source : '', 'lrc');
|
||||
assert.equal(calls.embedded, 0);
|
||||
assert.equal(calls.cache, 0);
|
||||
});
|
||||
|
||||
test('fresh embedded lyrics beat a cached online result and replace it', async () => {
|
||||
const embedded = payload('embedded', 'Local');
|
||||
const { calls, deps } = dependencies({
|
||||
resolveEmbedded: async () => ({ status: 'hit', lyrics: embedded }),
|
||||
getCache: async () => cache('xlrcdb', 'Online'),
|
||||
});
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: true },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.equal(result.status === 'hit' ? result.lyrics.source : '', 'embedded');
|
||||
assert.equal(calls.cache, 0);
|
||||
assert.deepEqual(calls.cachedHits, [embedded]);
|
||||
assert.equal(calls.xlrcdb, 0);
|
||||
});
|
||||
|
||||
test('a confirmed embedded miss removes a stale embedded cache row', async () => {
|
||||
const { calls, deps } = dependencies({
|
||||
getCache: async () => cache('embedded', 'Removed'),
|
||||
});
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: false },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { status: 'not_found', reason: 'online-disabled' });
|
||||
assert.equal(calls.deleteCache, 1);
|
||||
});
|
||||
|
||||
test('an unavailable metadata reader preserves and returns cached lyrics', async () => {
|
||||
const { calls, deps } = dependencies({
|
||||
resolveEmbedded: async () => ({ status: 'unavailable' }),
|
||||
getCache: async () => cache('embedded', 'Cached local'),
|
||||
});
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: false },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.equal(result.status === 'hit' ? result.lyrics.plainLyrics : '', 'Cached local');
|
||||
assert.equal(result.status === 'hit' ? result.cached : false, true);
|
||||
assert.equal(calls.deleteCache, 0);
|
||||
});
|
||||
|
||||
test('local misses fall through to XLRCDB then LRCLIB and cache definitive misses', async () => {
|
||||
const { calls, deps } = dependencies();
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: true },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { status: 'not_found', reason: 'provider-not-found' });
|
||||
assert.equal(calls.xlrcdb, 1);
|
||||
assert.equal(calls.lrclib, 1);
|
||||
assert.equal(calls.notFound, 1);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { LyricsCacheEntry } from '../db/lyricsQueries';
|
||||
import type { EmbeddedLyricsResolution } from './embedded';
|
||||
import { createLyricsPayload } from './parsing.ts';
|
||||
import type { LyricsLookupResult, LyricsPayload, LyricsTrackQuery } from './types';
|
||||
|
||||
export type LyricsProviderLookupResult =
|
||||
| { status: 'hit'; lyrics: LyricsPayload }
|
||||
| { status: 'not_found' }
|
||||
| { status: 'skipped'; reason: string }
|
||||
| { status: 'provider_unavailable' }
|
||||
| { status: 'transient_error'; message: string; code?: string };
|
||||
|
||||
export interface LyricsResolverDependencies {
|
||||
resolveSidecar: (trackPath: string) => Promise<LyricsPayload | null>;
|
||||
resolveEmbedded: (trackPath: string) => Promise<EmbeddedLyricsResolution>;
|
||||
getCache: () => Promise<LyricsCacheEntry | null>;
|
||||
deleteCache: () => Promise<void>;
|
||||
cacheHit: (payload: LyricsPayload) => Promise<void>;
|
||||
cacheNotFound: () => Promise<void>;
|
||||
lookupXlrcdb: (query: LyricsTrackQuery, forceRefresh: boolean) => Promise<LyricsProviderLookupResult>;
|
||||
lookupLrclib: (query: LyricsTrackQuery, forceRefresh: boolean) => Promise<LyricsProviderLookupResult>;
|
||||
}
|
||||
|
||||
export interface LyricsResolverOptions {
|
||||
forceRefresh: boolean;
|
||||
onlineEnabled: boolean;
|
||||
}
|
||||
|
||||
export function resultFromLyricsCache(cache: LyricsCacheEntry): LyricsLookupResult | null {
|
||||
if (cache.status === 'hit') {
|
||||
const payload = createLyricsPayload(
|
||||
cache.source,
|
||||
cache.provider,
|
||||
cache.format,
|
||||
cache.plainLyrics,
|
||||
cache.syncedLyrics,
|
||||
cache.syncedLines
|
||||
);
|
||||
if (!payload) return null;
|
||||
return { status: 'hit', lyrics: payload, cached: true };
|
||||
}
|
||||
return {
|
||||
status: 'not_found',
|
||||
reason: cache.source === 'embedded' ? 'embedded-missing' : 'provider-not-found',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-order policy isolated from Expo/native/database imports so it can be
|
||||
* exercised directly under Node. Local sources always run before persisted or
|
||||
* online results.
|
||||
*/
|
||||
export async function resolveLyricsWithDependencies(
|
||||
query: LyricsTrackQuery,
|
||||
options: LyricsResolverOptions,
|
||||
dependencies: LyricsResolverDependencies
|
||||
): Promise<LyricsLookupResult> {
|
||||
const { forceRefresh, onlineEnabled } = options;
|
||||
|
||||
const sidecar = await dependencies.resolveSidecar(query.path);
|
||||
if (sidecar) return { status: 'hit', lyrics: sidecar, cached: false };
|
||||
|
||||
const embedded = await dependencies.resolveEmbedded(query.path);
|
||||
if (embedded.status === 'hit') {
|
||||
await dependencies.cacheHit(embedded.lyrics);
|
||||
return { status: 'hit', lyrics: embedded.lyrics, cached: false };
|
||||
}
|
||||
|
||||
let cached = await dependencies.getCache();
|
||||
if (embedded.status === 'missing' && cached?.source === 'embedded') {
|
||||
await dependencies.deleteCache();
|
||||
cached = null;
|
||||
}
|
||||
|
||||
let lrclibCached: LyricsCacheEntry | null = null;
|
||||
if ((!forceRefresh || !onlineEnabled) && cached) {
|
||||
// Preserve the existing migration behavior: an older LRCLIB cache hit waits
|
||||
// until XLRCDB has had one chance to provide the preferred result.
|
||||
if (cached.status === 'hit' && cached.source === 'lrclib' && onlineEnabled) {
|
||||
lrclibCached = cached;
|
||||
} else {
|
||||
const cachedResult = resultFromLyricsCache(cached);
|
||||
if (cachedResult) return cachedResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (!onlineEnabled) return { status: 'not_found', reason: 'online-disabled' };
|
||||
|
||||
const xlrcdb = await dependencies.lookupXlrcdb(query, forceRefresh);
|
||||
if (xlrcdb.status === 'hit') {
|
||||
await dependencies.cacheHit(xlrcdb.lyrics);
|
||||
return { status: 'hit', lyrics: xlrcdb.lyrics, cached: false };
|
||||
}
|
||||
|
||||
if (lrclibCached) {
|
||||
const cachedResult = resultFromLyricsCache(lrclibCached);
|
||||
if (cachedResult) return cachedResult;
|
||||
}
|
||||
|
||||
const lrclib = await dependencies.lookupLrclib(query, forceRefresh);
|
||||
if (lrclib.status === 'hit') {
|
||||
await dependencies.cacheHit(lrclib.lyrics);
|
||||
return { status: 'hit', lyrics: lrclib.lyrics, cached: false };
|
||||
}
|
||||
if (lrclib.status === 'provider_unavailable') {
|
||||
return { status: 'not_found', reason: 'provider-unavailable' };
|
||||
}
|
||||
if (lrclib.status === 'transient_error') {
|
||||
return { status: 'transient_error', message: lrclib.message, code: lrclib.code };
|
||||
}
|
||||
|
||||
if (xlrcdb.status === 'not_found') await dependencies.cacheNotFound();
|
||||
return { status: 'not_found', reason: 'provider-not-found' };
|
||||
}
|
||||
Reference in New Issue
Block a user