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:
2026-10-14 21:19:56 -04:00
co-authored by Claude Opus 5.5
parent d00eb6674e
commit 68ec5a8d99
22 changed files with 2299 additions and 95 deletions
+14
View File
@@ -290,6 +290,20 @@ export const passwordResets = pgTable(
(t) => [index('password_resets_user_idx').on(t.userId)],
);
/** Lyrics looked up per track (misses too, so we don't keep asking). */
export const lyrics = pgTable('lyrics', {
trackId: uuid('track_id')
.primaryKey()
.references(() => tracks.id, { onDelete: 'cascade' }),
/** LRC text with timestamps. */
synced: text('synced'),
plain: text('plain'),
instrumental: boolean('instrumental').notNull().default(false),
/** Where they came from (the LRCLIB record), null when none were found. */
sourceUrl: text('source_url'),
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
});
const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' });
/** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */
+5
View File
@@ -26,6 +26,11 @@ const schema = z.object({
/** Enables "Sign in with Last.fm" (web auth). Without it, users type their username. */
LASTFM_SHARED_SECRET: optional,
ODESLI_API_KEY: optional,
/** Lyrics come from LRCLIB (open source, self-hostable). Point this at your own instance, or set it empty to turn lyrics off. */
LRCLIB_URL: z
.string()
.default('https://lrclib.net')
.transform((v) => v.replace(/\/+$/, '')),
/** Apple Music (MusicKit): Team ID, MusicKit key ID, and the key's .p8 contents (newlines may be written as \n). */
APPLE_TEAM_ID: optional,
APPLE_MUSICKIT_KEY_ID: optional,
+18
View File
@@ -1,9 +1,11 @@
import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { STREAMING_SERVICES, openUrl, type StreamingService, type TrackSearchResultDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import { HttpError } from '../lib/http';
import { deezerSearch } from '../providers/catalog';
import { getLyrics } from '../services/lyrics';
import { resolveTrackLinks } from '../services/resolver';
import { getTrackDTOs } from '../services/tracks';
@@ -32,6 +34,22 @@ export async function trackRoutes(app: FastifyInstance) {
return track;
});
/** Lyrics (synced when available) or null. Looked up once per song, then cached. */
app.get<{ Params: { id: string } }>(
'/tracks/:id/lyrics',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (req, reply) => {
if (!z.uuid().safeParse(req.params.id).success) throw new HttpError(404, 'Track not found');
try {
return reply.send(await getLyrics(req.params.id));
} catch (err) {
if (err instanceof HttpError && err.status === 404) throw err;
req.log.warn(err, 'lyrics lookup failed');
throw new HttpError(503, "Couldn't get lyrics right now");
}
},
);
/** Shareable redirect: /tracks/:id/open?service=spotify (defaults to the viewer's preferred service). */
app.get<{ Params: { id: string }; Querystring: { service?: string } }>('/tracks/:id/open', async (req, reply) => {
const service = (req.query.service ?? req.user?.preferredService ?? 'spotify') as StreamingService;
+85
View File
@@ -0,0 +1,85 @@
import { eq } from 'drizzle-orm';
import { parseLrc, type LyricsDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import { env } from '../env';
import { HttpError, fetchJson, normalize, primaryArtist } from '../lib/http';
/** Look again for songs that had none after this long; lyrics get added to LRCLIB all the time. */
const RETRY_MISS_MS = 7 * 86_400_000;
/** A search result has to be this close in length to count as the same recording. */
const DURATION_SLACK_S = 4;
interface LrclibRecord {
id: number;
trackName: string;
artistName: string;
duration: number | null;
instrumental: boolean;
plainLyrics: string | null;
syncedLyrics: string | null;
}
type Row = typeof schema.lyrics.$inferSelect;
/** The track's lyrics, from our cache or LRCLIB. Null when there are none (or lyrics are turned off). */
export async function getLyrics(trackId: string): Promise<LyricsDTO | null> {
if (!env.LRCLIB_URL) return null;
const [track] = await db.select().from(schema.tracks).where(eq(schema.tracks.id, trackId));
if (!track) throw new HttpError(404, 'Track not found');
let [row] = await db.select().from(schema.lyrics).where(eq(schema.lyrics.trackId, trackId));
const stale = row && !row.sourceUrl && Date.now() - row.fetchedAt.getTime() > RETRY_MISS_MS;
if (!row || stale) {
const found = await lookup(track);
const values = {
synced: found?.syncedLyrics || null,
plain: found?.plainLyrics || null,
instrumental: found?.instrumental ?? false,
sourceUrl: found ? `${env.LRCLIB_URL}/api/get/${found.id}` : null,
fetchedAt: new Date(),
};
[row] = await db
.insert(schema.lyrics)
.values({ trackId, ...values })
.onConflictDoUpdate({ target: schema.lyrics.trackId, set: values })
.returning();
}
return toDTO(row!);
}
function toDTO(row: Row): LyricsDTO | null {
if (!row.sourceUrl || (!row.synced && !row.plain && !row.instrumental)) return null;
const synced = row.synced ? parseLrc(row.synced) : null;
return {
synced: synced?.length ? synced : null,
plain: row.plain,
instrumental: row.instrumental,
source: { name: 'LRCLIB', url: 'https://lrclib.net' },
};
}
async function lookup(track: { title: string; artist: string; album: string | null; durationMs: number | null }) {
const artist = primaryArtist(track.artist);
const headers = { 'user-agent': 'musicshare (https://musicshare.binarygnome.com)' };
// Exact lookup needs the length; LRCLIB matches it within a couple of seconds.
if (track.durationMs) {
const params = new URLSearchParams({ track_name: track.title, artist_name: artist, duration: String(Math.round(track.durationMs / 1000)) });
if (track.album) params.set('album_name', track.album);
const exact = await fetchJson<LrclibRecord>(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch((e: unknown) => {
if (e instanceof HttpError && e.status === 404) return null;
throw e;
});
if (exact) return exact;
}
const params = new URLSearchParams({ track_name: track.title, artist_name: artist });
const results = (await fetchJson<LrclibRecord[]>(`${env.LRCLIB_URL}/api/search?${params}`, { headers })) ?? [];
const title = normalize(track.title);
const matches = results.filter(
(r) =>
normalize(r.trackName) === title &&
normalize(r.artistName).includes(normalize(artist)) &&
(!track.durationMs || !r.duration || Math.abs(r.duration - track.durationMs / 1000) <= DURATION_SLACK_S),
);
// Prefer synced lyrics, then anything with words.
return matches.find((r) => r.syncedLyrics) ?? matches.find((r) => r.plainLyrics || r.instrumental) ?? null;
}