Slim song cards with an expand arrow, and lyrics
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>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user