Feed and profile cards now show one slim line (small artwork, title, artist, Open button) with the progress bar and reactions. The arrow opens the rest: album, your other services, Share, and lyrics. Lyrics come from LRCLIB (open source; LRCLIB_URL can point at a self-hosted copy, or be empty to turn lyrics off). The server looks each song up once and caches it, including misses (retried after a week). Timed lyrics follow along with the song; otherwise the first lines show with "All lyrics". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
69 lines
2.8 KiB
TypeScript
69 lines
2.8 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { upsertTrack } from '../src/services/tracks';
|
|
import { json, routeFetch, useTestApp } from './helpers';
|
|
|
|
const app = useTestApp();
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
const record = (over: object = {}) => ({
|
|
id: 70627,
|
|
trackName: 'Midnight City',
|
|
artistName: 'M83',
|
|
duration: 241,
|
|
instrumental: false,
|
|
plainLyrics: 'Waiting in a car\nWaiting for a ride in the dark',
|
|
syncedLyrics: '[00:01.00] Waiting in a car\n[00:04.50] Waiting for a ride in the dark',
|
|
...over,
|
|
});
|
|
const lyrics = (id: string) => app().inject({ url: `/tracks/${id}/lyrics` });
|
|
|
|
describe('lyrics', () => {
|
|
it('looks up synced lyrics once and serves them from the cache after', async () => {
|
|
const { id } = await upsertTrack({ title: 'Midnight City', artist: 'M83', durationMs: 241_000 });
|
|
const calls: string[] = [];
|
|
vi.stubGlobal('fetch', routeFetch([[/lrclib\.net\/api\/get\?/, (url) => (calls.push(url), json(record()))]]));
|
|
|
|
const first = (await lyrics(id)).json();
|
|
expect(first.synced).toEqual([
|
|
{ timeMs: 1000, text: 'Waiting in a car' },
|
|
{ timeMs: 4500, text: 'Waiting for a ride in the dark' },
|
|
]);
|
|
expect(first.source.name).toBe('LRCLIB');
|
|
expect(new URL(calls[0]!).searchParams.get('duration')).toBe('241');
|
|
expect((await lyrics(id)).json()).toEqual(first);
|
|
expect(calls).toHaveLength(1);
|
|
});
|
|
|
|
it('falls back to a search, skipping recordings of a different length', async () => {
|
|
const { id } = await upsertTrack({ title: 'Song', artist: 'Band', durationMs: 200_000 });
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
routeFetch([
|
|
[/\/api\/get\?/, () => json({ code: 404 }, 404)],
|
|
[
|
|
/\/api\/search\?/,
|
|
() =>
|
|
json([
|
|
record({ id: 1, trackName: 'Song', artistName: 'Band', duration: 320, plainLyrics: 'extended mix' }),
|
|
record({ id: 2, trackName: 'Song', artistName: 'Band', duration: 201, syncedLyrics: null, plainLyrics: 'the one' }),
|
|
]),
|
|
],
|
|
]),
|
|
);
|
|
const body = (await lyrics(id)).json();
|
|
expect(body.synced).toBeNull();
|
|
expect(body.plain).toBe('the one');
|
|
});
|
|
|
|
it('returns null when nothing matches, and 503 when LRCLIB is down', async () => {
|
|
const { id } = await upsertTrack({ title: 'Unknown', artist: 'Nobody', durationMs: 100_000 });
|
|
vi.stubGlobal('fetch', routeFetch([[/lrclib/, () => json({ message: 'down' }, 500)]]));
|
|
expect((await lyrics(id)).statusCode).toBe(503);
|
|
vi.stubGlobal('fetch', routeFetch([[/\/api\/get\?/, () => json({}, 404)], [/\/api\/search\?/, () => json([])]]));
|
|
const res = await lyrics(id);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toBeNull();
|
|
expect((await lyrics('not-a-uuid')).statusCode).toBe(404);
|
|
});
|
|
});
|