Add API and worker: accounts, follows, now-playing, cross-service links
- Fastify API with cookie (web) and bearer (mobile) sessions, follows, pinned friends, featured songs, account deletion, Redis-backed rate limits - Providers: Spotify (OAuth), Last.fm, ListenBrainz, Plex (PIN flow and server discovery), Jellyfin (token auth, revoked on disconnect) - Worker polls on an adaptive schedule in a Redis sorted set, so polling scales horizontally and API usage tracks whether anyone is watching - Track resolver links songs across services via ISRC (Deezer, Spotify), iTunes search and song.link - Realtime updates over WebSocket fan out through Redis pub/sub - User-supplied server URLs are restricted to public addresses, checked both up front and at connect time to defeat DNS rebinding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
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 { resolveTrackLinks } from '../services/resolver';
|
||||
import { getTrackDTOs } from '../services/tracks';
|
||||
|
||||
export async function trackRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: { q?: string } }>(
|
||||
'/tracks/search',
|
||||
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
|
||||
async (req): Promise<TrackSearchResultDTO[]> => {
|
||||
const q = (req.query.q ?? '').trim();
|
||||
if (q.length < 2) return [];
|
||||
const results = await deezerSearch(q, 15);
|
||||
return results.map((r) => ({
|
||||
deezerId: String(r.id),
|
||||
title: r.title,
|
||||
artist: r.artist.name,
|
||||
album: r.album.title,
|
||||
artworkUrl: r.album.cover_big ?? null,
|
||||
durationMs: r.duration * 1000,
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>('/tracks/:id', async (req) => {
|
||||
const track = (await getTrackDTOs([req.params.id])).get(req.params.id);
|
||||
if (!track) throw new HttpError(404, 'Track not found');
|
||||
return track;
|
||||
});
|
||||
|
||||
/** 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;
|
||||
if (!STREAMING_SERVICES.includes(service)) throw new HttpError(400, 'Unknown service');
|
||||
let track = (await getTrackDTOs([req.params.id])).get(req.params.id);
|
||||
if (!track) throw new HttpError(404, 'Track not found');
|
||||
if (!track.links[service]) {
|
||||
const [row] = await db
|
||||
.select({ resolved: schema.tracks.linksResolvedAt })
|
||||
.from(schema.tracks)
|
||||
.where(eq(schema.tracks.id, track.id));
|
||||
if (!row?.resolved) {
|
||||
await resolveTrackLinks(track.id).catch((e) => req.log.warn(e, 'inline resolve failed'));
|
||||
track = (await getTrackDTOs([track.id])).get(track.id)!;
|
||||
}
|
||||
}
|
||||
return reply.redirect(openUrl(track, service).url);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user