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:
@@ -19,6 +19,8 @@ LASTFM_API_KEY=
|
||||
LASTFM_SHARED_SECRET=
|
||||
# song.link API key (request at https://odesli.co). Enables YouTube Music/TIDAL links; without it those fall back to search.
|
||||
ODESLI_API_KEY=
|
||||
# Lyrics source: LRCLIB (open source; run your own and point this at it). Empty turns lyrics off.
|
||||
LRCLIB_URL=https://lrclib.net
|
||||
# Stable identifier for this app in Plex's auth flow
|
||||
PLEX_CLIENT_ID=musicshare-dev
|
||||
# Let users connect Jellyfin/Plex servers on private networks (LAN). Never enable in a hosted deployment.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "lyrics" (
|
||||
"track_id" uuid PRIMARY KEY NOT NULL,
|
||||
"synced" text,
|
||||
"plain" text,
|
||||
"instrumental" boolean DEFAULT false NOT NULL,
|
||||
"source_url" text,
|
||||
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "lyrics" ADD CONSTRAINT "lyrics_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
||||
"when": 1792025011207,
|
||||
"tag": "0014_user_services",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1792025274882,
|
||||
"tag": "0015_lyrics",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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