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:
2026-10-03 09:59:02 -04:00
co-authored by Claude Opus 5.5
parent 57c1733bab
commit 6791b62d87
45 changed files with 4990 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { env } from '../env';
import * as schema from './schema';
export const sql = postgres(env.DATABASE_URL, { max: 10 });
export const db = drizzle(sql, { schema });
export { schema };
+6
View File
@@ -0,0 +1,6 @@
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { db, sql } from './index';
await migrate(db, { migrationsFolder: new URL('../../drizzle', import.meta.url).pathname });
console.log('migrations applied');
await sql.end();
+160
View File
@@ -0,0 +1,160 @@
import { sql } from 'drizzle-orm';
import {
boolean,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import type { NowPlayingProvider, Plan, StreamingService } from '@musicshare/shared';
const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
username: varchar('username', { length: 32 }).notNull().unique(),
email: varchar('email', { length: 320 }).notNull().unique(),
passwordHash: text('password_hash').notNull(),
displayName: varchar('display_name', { length: 64 }),
bio: varchar('bio', { length: 280 }),
avatarUrl: varchar('avatar_url', { length: 500 }),
preferredService: text('preferred_service').$type<StreamingService>(),
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
plan: text('plan').$type<Plan>().notNull().default('free'),
planExpiresAt: timestamp('plan_expires_at', { withTimezone: true }),
createdAt: createdAt(),
});
export const sessions = pgTable(
'sessions',
{
/** sha256 of the session token; the raw token only lives in the client. */
id: text('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: createdAt(),
},
(t) => [index('sessions_user_idx').on(t.userId)],
);
export const follows = pgTable(
'follows',
{
followerId: uuid('follower_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
followeeId: uuid('followee_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Set when the follower pins this friend to the top of their feed. */
pinnedAt: timestamp('pinned_at', { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.followerId, t.followeeId] }), index('follows_followee_idx').on(t.followeeId)],
);
export const connections = pgTable(
'connections',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
provider: text('provider').$type<NowPlayingProvider>().notNull(),
externalUserId: text('external_user_id').notNull(),
displayName: text('display_name'),
/** AES-256-GCM encrypted, see lib/crypto.ts */
accessTokenEnc: text('access_token_enc'),
refreshTokenEnc: text('refresh_token_enc'),
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
status: text('status').$type<'active' | 'error'>().notNull().default('active'),
lastError: text('last_error'),
lastPolledAt: timestamp('last_polled_at', { withTimezone: true }),
/** Provider-specific state, e.g. Jellyfin server URL or discovered Plex servers. */
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
/** Secret path segment for inbound webhooks (/webhooks/:provider/:secret). */
webhookSecret: text('webhook_secret').unique(),
createdAt: createdAt(),
},
(t) => [uniqueIndex('connections_user_provider_idx').on(t.userId, t.provider)],
);
export const tracks = pgTable(
'tracks',
{
id: uuid('id').primaryKey().defaultRandom(),
isrc: varchar('isrc', { length: 12 }),
/** normalized "artist|title", used to dedupe tracks that arrive without an ISRC */
matchKey: text('match_key').notNull(),
title: text('title').notNull(),
artist: text('artist').notNull(),
album: text('album'),
durationMs: integer('duration_ms'),
artworkUrl: text('artwork_url'),
linksResolvedAt: timestamp('links_resolved_at', { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [index('tracks_isrc_idx').on(t.isrc), index('tracks_match_key_idx').on(t.matchKey)],
);
export const trackLinks = pgTable(
'track_links',
{
trackId: uuid('track_id')
.notNull()
.references(() => tracks.id, { onDelete: 'cascade' }),
service: text('service').$type<StreamingService>().notNull(),
externalId: text('external_id'),
url: text('url').notNull(),
/** how we found it: straight from the provider, via ISRC, fuzzy search, or song.link */
matchType: text('match_type').$type<'provider' | 'isrc' | 'search' | 'odesli'>().notNull(),
createdAt: createdAt(),
},
(t) => [
primaryKey({ columns: [t.trackId, t.service] }),
index('track_links_external_idx').on(t.service, t.externalId),
],
);
export const nowPlaying = pgTable(
'now_playing',
{
userId: uuid('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
trackId: uuid('track_id')
.notNull()
.references(() => tracks.id),
source: text('source').$type<NowPlayingProvider>().notNull(),
isPlaying: boolean('is_playing').notNull(),
progressMs: integer('progress_ms'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('now_playing_track_idx').on(t.trackId)],
);
export const featuredSongs = pgTable('featured_songs', {
userId: uuid('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
trackId: uuid('track_id')
.notNull()
.references(() => tracks.id),
comment: varchar('comment', { length: 280 }),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.default(sql`now()`),
});
export type User = typeof users.$inferSelect;
export type Connection = typeof connections.$inferSelect;
export type Track = typeof tracks.$inferSelect;
+42
View File
@@ -0,0 +1,42 @@
import 'dotenv/config';
import { z } from 'zod';
const optional = z
.string()
.optional()
.transform((v) => (v ? v : undefined));
const schema = z.object({
NODE_ENV: z.string().default('development'),
DATABASE_URL: z.string().default('postgres://musicshare:musicshare@localhost:5447/musicshare'),
REDIS_URL: z.string().default('redis://localhost:6380'),
PORT: z.coerce.number().default(4000),
WEB_URL: z.string().default('http://localhost:3737'),
API_PUBLIC_URL: z.string().default('http://127.0.0.1:4000'),
COOKIE_SECURE: z
.string()
.default('false')
.transform((v) => v === 'true'),
TOKEN_ENCRYPTION_KEY: z
.string()
.refine((v) => Buffer.from(v, 'base64').length === 32, 'TOKEN_ENCRYPTION_KEY must be 32 bytes, base64-encoded'),
SPOTIFY_CLIENT_ID: optional,
SPOTIFY_CLIENT_SECRET: optional,
LASTFM_API_KEY: optional,
ODESLI_API_KEY: optional,
/** Identifies this app to Plex. Keep it stable: changing it invalidates users' Plex authorizations. */
PLEX_CLIENT_ID: z.string().default('musicshare-dev'),
/**
* Allow Jellyfin/Plex server URLs on private networks. Only enable for local
* dev or self-hosting: in a hosted deployment it lets users make the server
* request internal addresses (SSRF).
*/
ALLOW_PRIVATE_SERVER_URLS: z
.string()
.default('false')
.transform((v) => v === 'true'),
});
export const env = schema.parse(process.env);
export const spotifyConfigured = Boolean(env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET);
+55
View File
@@ -0,0 +1,55 @@
import { and, eq, gt } from 'drizzle-orm';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { env } from '../env';
import { randomToken, sha256 } from './crypto';
import { HttpError } from './http';
export const SESSION_COOKIE = 'ms_session';
const SESSION_DAYS = 30;
declare module 'fastify' {
interface FastifyRequest {
user: User | null;
sessionId: string | null;
}
}
export async function createSession(userId: string, reply: FastifyReply): Promise<string> {
const token = randomToken();
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86_400_000);
await db.insert(schema.sessions).values({ id: sha256(token), userId, expiresAt });
reply.setCookie(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure: env.COOKIE_SECURE,
path: '/',
expires: expiresAt,
});
return token;
}
/** Populates request.user from the session cookie (web) or a bearer token (mobile). */
export async function loadUser(req: FastifyRequest) {
req.user = null;
req.sessionId = null;
const header = req.headers.authorization;
const token = header?.startsWith('Bearer ') ? header.slice(7) : req.cookies[SESSION_COOKIE];
if (!token) return;
const id = sha256(token);
const [row] = await db
.select({ user: schema.users })
.from(schema.sessions)
.innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId))
.where(and(eq(schema.sessions.id, id), gt(schema.sessions.expiresAt, new Date())));
if (row) {
req.user = row.user;
req.sessionId = id;
}
}
export function requireUser(req: FastifyRequest): User {
if (!req.user) throw new HttpError(401, 'Not signed in');
return req.user;
}
+22
View File
@@ -0,0 +1,22 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import { env } from '../env';
const key = Buffer.from(env.TOKEN_ENCRYPTION_KEY, 'base64');
/** AES-256-GCM. Output: base64(iv | tag | ciphertext). */
export function encrypt(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
}
export function decrypt(payload: string): string {
const buf = Buffer.from(payload, 'base64');
const decipher = createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
decipher.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8');
}
export const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
export const randomToken = (bytes = 32) => randomBytes(bytes).toString('base64url');
+9
View File
@@ -0,0 +1,9 @@
import { PLAN_ENTITLEMENTS, type Entitlements, type Plan } from '@musicshare/shared';
export function effectivePlan(user: { plan: Plan; planExpiresAt: Date | null }): Plan {
if (user.plan !== 'free' && user.planExpiresAt && user.planExpiresAt < new Date()) return 'free';
return user.plan;
}
export const entitlementsFor = (user: { plan: Plan; planExpiresAt: Date | null }): Entitlements =>
PLAN_ENTITLEMENTS[effectivePlan(user)];
+35
View File
@@ -0,0 +1,35 @@
export class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
/** fetch + JSON with a timeout; throws HttpError on non-2xx. Returns null on 204. */
export async function fetchJson<T>(url: string, init: RequestInit = {}, timeoutMs = 8000): Promise<T | null> {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
if (res.status === 204) return null;
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new HttpError(res.status, `${res.status} from ${new URL(url).host}: ${body.slice(0, 200)}`);
}
return (await res.json()) as T;
}
/** Normalize a title/artist for fuzzy matching across services. */
export function normalize(s: string): string {
return s
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/\s*[([].*?(remaster|version|edit|mono|stereo|live|deluxe|feat\.?|ft\.?).*?[)\]]/g, '')
.replace(/\s+-\s+.*(remaster|version|edit).*$/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
export const primaryArtist = (artist: string) => artist.split(/,|&| feat\.? | ft\.? | x /i)[0]!.trim();
export const matchKey = (artist: string, title: string) => `${normalize(primaryArtist(artist))}|${normalize(title)}`;
+10
View File
@@ -0,0 +1,10 @@
import { Redis } from 'ioredis';
import { env } from '../env';
/** BullMQ requires maxRetriesPerRequest: null on its connections. */
export const createRedis = () => new Redis(env.REDIS_URL, { maxRetriesPerRequest: null });
export const redis = createRedis();
export const nowPlayingChannel = (userId: string) => `np:${userId}`;
export const FOLLOWS_CHANGED_CHANNEL = 'follows-changed';
+95
View File
@@ -0,0 +1,95 @@
/**
* Guards for user-supplied server URLs (Jellyfin, Plex). Without these a
* user could make our servers call internal addresses such as cloud metadata
* endpoints, the database, or other private services.
*/
import dns from 'node:dns';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { Agent } from 'undici';
import { env } from '../env';
export class UnsafeUrlError extends Error {}
function isPrivateV4(ip: string): boolean {
const [a, b] = ip.split('.').map(Number) as [number, number];
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 192 && b === 0) ||
(a === 198 && (b === 18 || b === 19)) ||
a >= 224
);
}
function isPrivateIp(ip: string): boolean {
if (isIP(ip) === 4) return isPrivateV4(ip);
const v6 = ip.toLowerCase();
// IPv4-mapped (::ffff:10.0.0.1), which URL parsing normalizes to hex (::ffff:a00:1)
const dotted = v6.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (dotted) return isPrivateV4(dotted[1]!);
const hex = v6.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
if (hex) {
const n = (parseInt(hex[1]!, 16) << 16) | parseInt(hex[2]!, 16);
return isPrivateV4([n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.'));
}
return (
v6 === '::' ||
v6 === '::1' ||
/^f[cd]/.test(v6) || // unique local
/^fe[89ab]/.test(v6) || // link local
v6.startsWith('64:ff9b:') || // NAT64, can embed any IPv4
v6.startsWith('::ffff:') // anything else mapped we couldn't parse
);
}
/** Parses and validates a server URL; throws UnsafeUrlError. Returns the normalized origin + path. */
export async function assertSafeServerUrl(raw: string): Promise<string> {
let url: URL;
try {
url = new URL(raw.trim());
} catch {
throw new UnsafeUrlError('Enter a full URL, like https://jellyfin.example.com');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new UnsafeUrlError('URL must start with http(s)://');
if (url.username || url.password) throw new UnsafeUrlError('URL must not contain credentials');
if (!env.ALLOW_PRIVATE_SERVER_URLS) {
const host = url.hostname.replace(/^\[|\]$/g, '');
const addresses = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
if (addresses.length === 0) throw new UnsafeUrlError(`Couldn't resolve ${host}`);
if (addresses.some(isPrivateIp)) {
throw new UnsafeUrlError('That server is on a private network. It must be reachable from the internet.');
}
}
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
}
/**
* Re-checks the address at connect time. assertSafeServerUrl alone is open to
* DNS rebinding: a hostile domain can resolve to a public IP for the check and
* a private one for the actual request.
*/
const guardedAgent = new Agent({
connect: {
lookup(hostname, options, callback) {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return callback(err, address as string, family as number);
const all = Array.isArray(address) ? address : [{ address, family }];
if (all.some((a) => isPrivateIp(a.address))) {
return callback(new UnsafeUrlError(`${hostname} resolves to a private address`), '', 0);
}
callback(null, address as string, family as number);
});
},
},
});
/** Spread into fetch() options for any request to a user-supplied server. */
export const serverFetchOptions = (): RequestInit =>
(env.ALLOW_PRIVATE_SERVER_URLS ? { redirect: 'error' } : { redirect: 'error', dispatcher: guardedAgent }) as RequestInit;
+108
View File
@@ -0,0 +1,108 @@
/**
* Public catalogs used to match a track across services. None of these need
* user auth; Deezer is our canonical ISRC source because it's free and keyless.
*/
import type { StreamingService } from '@musicshare/shared';
import { env } from '../env';
import { HttpError, fetchJson, primaryArtist } from '../lib/http';
import { redis } from '../lib/redis';
import { findBestMatch } from './match';
export interface DeezerTrack {
id: number;
title: string;
duration: number;
isrc?: string;
link: string;
artist: { name: string };
album: { title: string; cover_xl?: string; cover_big?: string };
}
export async function deezerTrack(id: string): Promise<DeezerTrack | null> {
const t = await fetchJson<DeezerTrack & { error?: unknown }>(`https://api.deezer.com/track/${id}`);
return t && !t.error ? t : null;
}
export async function deezerByIsrc(isrc: string): Promise<DeezerTrack | null> {
return deezerTrack(`isrc:${encodeURIComponent(isrc)}`);
}
export async function deezerSearch(q: string, limit = 10): Promise<DeezerTrack[]> {
const res = await fetchJson<{ data?: DeezerTrack[] }>(
`https://api.deezer.com/search?limit=${limit}&q=${encodeURIComponent(q)}`,
);
return res?.data ?? [];
}
/** Find a track on Deezer by text; fetches full details (search results lack the ISRC). */
export async function deezerFind(target: { title: string; artist: string; durationMs?: number | null }) {
// Deezer's advanced syntax (artist:"x" track:"y") currently returns nothing; plain text + our matcher works.
const results = await deezerSearch(`${primaryArtist(target.artist)} ${target.title}`);
const best = findBestMatch(
results.map((r) => ({ r, title: r.title, artist: r.artist.name, durationMs: r.duration * 1000 })),
target,
);
return best ? deezerTrack(String(best.r.id)) : null;
}
interface ItunesSong {
trackId: number;
trackName: string;
artistName: string;
trackTimeMillis?: number;
trackViewUrl: string;
}
/** Apple Music via the keyless iTunes Search API. Upgrade to MusicKit `filter[isrc]` once you have a developer token. */
export async function appleMusicFind(target: { title: string; artist: string; durationMs?: number | null }) {
const term = encodeURIComponent(`${target.artist} ${target.title}`);
const res = await fetchJson<{ results: ItunesSong[] }>(
`https://itunes.apple.com/search?media=music&entity=song&limit=10&term=${term}`,
);
const best = findBestMatch(
(res?.results ?? []).map((s) => ({ s, title: s.trackName, artist: s.artistName, durationMs: s.trackTimeMillis })),
target,
);
if (!best) return null;
// trackViewUrl points at music.apple.com and opens the Music app via universal links
const url = best.s.trackViewUrl.replace(/[?&]uo=\d+/, '');
return { id: String(best.s.trackId), url };
}
const ODESLI_PLATFORMS: Partial<Record<string, StreamingService>> = {
spotify: 'spotify',
appleMusic: 'apple_music',
youtubeMusic: 'youtube_music',
deezer: 'deezer',
tidal: 'tidal',
};
/**
* song.link requires an API key. Calls are rate limited globally across
* workers through Redis; when exhausted (or unconfigured) we skip and clients
* fall back to search links.
*/
async function takeOdesliToken(): Promise<boolean> {
const key = `odesli:rl:${Math.floor(Date.now() / 60_000)}`;
const n = await redis.incr(key);
if (n === 1) await redis.expire(key, 70);
return n <= 9;
}
export async function odesliLinks(sourceUrl: string): Promise<Partial<Record<StreamingService, string>> | null> {
if (!env.ODESLI_API_KEY || !(await takeOdesliToken())) return null;
try {
const res = await fetchJson<{ linksByPlatform: Record<string, { url: string }> }>(
`https://api.song.link/v1-alpha.1/links?songIfSingle=true&key=${env.ODESLI_API_KEY}&url=${encodeURIComponent(sourceUrl)}`,
);
const out: Partial<Record<StreamingService, string>> = {};
for (const [platform, link] of Object.entries(res?.linksByPlatform ?? {})) {
const service = ODESLI_PLATFORMS[platform];
if (service) out[service] = link.url;
}
return out;
} catch (e) {
if (e instanceof HttpError && (e.status === 404 || e.status === 400)) return {};
throw e;
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { NowPlayingProvider } from '@musicshare/shared';
import { jellyfinProvider } from './jellyfin';
import { lastfmProvider } from './lastfm';
import { listenbrainzProvider } from './listenbrainz';
import { plexProvider } from './plex';
import { spotifyProvider } from './spotify';
import type { PollingProvider } from './types';
/** Providers the server polls. Push-only sources (the Android app) don't appear here. */
export const pollingProviders: Partial<Record<NowPlayingProvider, PollingProvider>> = {
spotify: spotifyProvider,
lastfm: lastfmProvider,
listenbrainz: listenbrainzProvider,
plex: plexProvider,
jellyfin: jellyfinProvider,
};
export const POLLED_PROVIDERS = Object.keys(pollingProviders) as NowPlayingProvider[];
+108
View File
@@ -0,0 +1,108 @@
/**
* Jellyfin. The user signs in with their server URL + credentials; we keep
* only the access token Jellyfin issues (never the password) and poll
* /Sessions. The Jellyfin Webhook plugin can hit our webhook URL to trigger
* an immediate poll.
*/
import type { Connection } from '../db/schema';
import { decrypt } from '../lib/crypto';
import { HttpError, fetchJson } from '../lib/http';
import { assertSafeServerUrl, serverFetchOptions } from '../lib/ssrf';
import { ProviderAuthError, type PollingProvider } from './types';
export interface JellyfinMeta {
serverUrl: string;
serverName: string;
deviceId: string;
}
const authHeader = (deviceId: string, token?: string) =>
`MediaBrowser Client="musicshare", Device="musicshare", DeviceId="${deviceId}", Version="1.0.0"` +
(token ? `, Token="${token}"` : '');
export async function jellyfinPublicInfo(serverUrl: string) {
const info = await fetchJson<{ ServerName: string; ProductName?: string; Version: string }>(
`${serverUrl}/System/Info/Public`,
{ ...serverFetchOptions() },
);
if (!info?.Version) throw new Error('not a Jellyfin server');
return info;
}
export async function jellyfinAuthenticate(serverUrl: string, deviceId: string, username: string, password: string) {
try {
return (await fetchJson<{ AccessToken: string; User: { Id: string; Name: string } }>(
`${serverUrl}/Users/AuthenticateByName`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader(deviceId) },
body: JSON.stringify({ Username: username, Pw: password }),
...serverFetchOptions(),
},
))!;
} catch (e) {
if (e instanceof HttpError && e.status === 401) return null;
throw e;
}
}
/** Revoke our token on the server when the user disconnects. Best-effort. */
export async function jellyfinLogout(conn: Connection) {
const meta = conn.meta as unknown as JellyfinMeta;
if (!conn.accessTokenEnc || !meta.serverUrl) return;
const url = await assertSafeServerUrl(meta.serverUrl);
await fetch(`${url}/Sessions/Logout`, {
method: 'POST',
headers: { Authorization: authHeader(meta.deviceId, decrypt(conn.accessTokenEnc)) },
...serverFetchOptions(),
signal: AbortSignal.timeout(5000),
}).catch(() => {});
}
interface JellyfinSession {
UserId: string;
NowPlayingItem?: {
Type: string;
Name: string;
Artists?: string[];
AlbumArtist?: string;
Album?: string;
RunTimeTicks?: number;
};
PlayState?: { IsPaused?: boolean; PositionTicks?: number };
}
const ticksToMs = (ticks?: number) => (ticks ? Math.round(ticks / 10_000) : null);
export const jellyfinProvider: PollingProvider = {
id: 'jellyfin',
async poll(conn) {
const meta = conn.meta as unknown as JellyfinMeta;
const url = await assertSafeServerUrl(meta.serverUrl);
let sessions: JellyfinSession[] | null;
try {
sessions = await fetchJson<JellyfinSession[]>(
`${url}/Sessions?ActiveWithinSeconds=960`,
{ headers: { Authorization: authHeader(meta.deviceId, decrypt(conn.accessTokenEnc!)) }, ...serverFetchOptions() },
6000,
);
} catch (e) {
if (e instanceof HttpError && e.status === 401) throw new ProviderAuthError('Jellyfin session expired or revoked');
throw e;
}
const mine = (sessions ?? []).filter((s) => s.UserId === conn.externalUserId && s.NowPlayingItem?.Type === 'Audio');
const session = mine.find((s) => !s.PlayState?.IsPaused) ?? mine[0];
const item = session?.NowPlayingItem;
if (!session || !item) return null;
return {
track: {
title: item.Name,
artist: item.Artists?.length ? item.Artists.join(', ') : (item.AlbumArtist ?? 'Unknown artist'),
album: item.Album ?? null,
durationMs: ticksToMs(item.RunTimeTicks),
},
isPlaying: !session.PlayState?.IsPaused,
progressMs: ticksToMs(session.PlayState?.PositionTicks),
};
},
};
+54
View File
@@ -0,0 +1,54 @@
import { env } from '../env';
import { HttpError, fetchJson } from '../lib/http';
import { ProviderAuthError, type PollingProvider } from './types';
interface LastfmTrack {
name: string;
artist: { '#text': string };
album: { '#text': string };
image: { size: string; '#text': string }[];
'@attr'?: { nowplaying?: string };
}
const api = (params: Record<string, string>) =>
`https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({ ...params, api_key: env.LASTFM_API_KEY!, format: 'json' })}`;
export async function lastfmUserExists(username: string): Promise<{ name: string } | null> {
try {
const res = await fetchJson<{ user: { name: string } }>(api({ method: 'user.getinfo', user: username }));
return res ? { name: res.user.name } : null;
} catch (e) {
if (e instanceof HttpError && e.status === 404) return null;
throw e;
}
}
export const lastfmProvider: PollingProvider = {
id: 'lastfm',
async poll(conn) {
let res;
try {
res = await fetchJson<{ recenttracks: { track: LastfmTrack[] | LastfmTrack } }>(
api({ method: 'user.getrecenttracks', user: conn.externalUserId, limit: '1' }),
);
} catch (e) {
if (e instanceof HttpError && e.status === 404) throw new ProviderAuthError('last.fm user not found');
throw e;
}
const raw = res?.recenttracks.track;
const first = Array.isArray(raw) ? raw[0] : raw;
if (!first || first['@attr']?.nowplaying !== 'true') return null;
const art = first.image.find((i) => i.size === 'extralarge')?.['#text'] || null;
return {
track: {
title: first.name,
artist: first.artist['#text'],
album: first.album['#text'] || null,
// Last.fm serves a grey star placeholder when it has no art
artworkUrl: art && !art.includes('2a96cbd8b46e442fc41c2b86b821562f') ? art : null,
},
isPlaying: true,
progressMs: null,
};
},
};
+60
View File
@@ -0,0 +1,60 @@
import { HttpError, fetchJson } from '../lib/http';
import { ProviderAuthError, type PollingProvider } from './types';
interface LbPlayingNow {
payload: {
listens: {
track_metadata: {
artist_name: string;
track_name: string;
release_name?: string;
additional_info?: { isrc?: string; duration_ms?: number; spotify_id?: string };
};
}[];
};
}
const playingNow = (username: string) =>
// ListenBrainz is regularly slow to respond; give it more headroom than the default
fetchJson<LbPlayingNow>(`https://api.listenbrainz.org/1/user/${encodeURIComponent(username)}/playing-now`, {}, 15_000);
export async function listenbrainzUserExists(username: string): Promise<boolean> {
try {
await playingNow(username);
return true;
} catch (e) {
if (e instanceof HttpError && e.status === 404) return false;
throw e;
}
}
export const listenbrainzProvider: PollingProvider = {
id: 'listenbrainz',
async poll(conn) {
let res;
try {
res = await playingNow(conn.externalUserId);
} catch (e) {
if (e instanceof HttpError && e.status === 404) throw new ProviderAuthError('listenbrainz user not found');
throw e;
}
const meta = res?.payload.listens[0]?.track_metadata;
if (!meta) return null;
const info = meta.additional_info ?? {};
const spotifyId = info.spotify_id?.match(/track\/([A-Za-z0-9]+)/)?.[1];
return {
track: {
title: meta.track_name,
artist: meta.artist_name,
album: meta.release_name ?? null,
durationMs: info.duration_ms ?? null,
isrc: info.isrc ?? null,
links: spotifyId
? { spotify: { id: spotifyId, url: `https://open.spotify.com/track/${spotifyId}` } }
: undefined,
},
isPlaying: true,
progressMs: null,
};
},
};
+33
View File
@@ -0,0 +1,33 @@
import { normalize, primaryArtist } from '../lib/http';
export interface Candidate {
title: string;
artist: string;
durationMs?: number | null;
}
/** Pick the candidate that is plausibly the same recording, or null. */
export function findBestMatch<T extends Candidate>(
candidates: T[],
target: { title: string; artist: string; durationMs?: number | null },
): T | null {
const title = normalize(target.title);
const artist = normalize(primaryArtist(target.artist));
const scored = candidates
.map((c) => {
const ct = normalize(c.title);
const ca = normalize(primaryArtist(c.artist));
if (ca !== artist && !normalize(c.artist).includes(artist)) return null;
if (ct !== title && !ct.startsWith(title) && !title.startsWith(ct)) return null;
let score = ct === title ? 2 : 1;
if (target.durationMs && c.durationMs) {
const diff = Math.abs(target.durationMs - c.durationMs);
if (diff > 15_000) return null;
if (diff < 3_000) score += 2;
}
return { c, score };
})
.filter((x): x is { c: T; score: number } => x !== null)
.sort((a, b) => b.score - a.score);
return scored[0]?.c ?? null;
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Plex. Auth is Plex's PIN flow (no Plex Pass needed). We discover the
* user's servers via plex.tv and poll each one's /status/sessions for music
* the user is playing. Plex Pass users can also point a Plex webhook at us,
* which triggers an immediate poll.
*/
import { eq } from 'drizzle-orm';
import { db, schema } from '../db';
import type { Connection } from '../db/schema';
import { env } from '../env';
import { decrypt, encrypt } from '../lib/crypto';
import { HttpError, fetchJson } from '../lib/http';
import { UnsafeUrlError, assertSafeServerUrl, serverFetchOptions } from '../lib/ssrf';
import { ProviderAuthError, type PollingProvider, type PollResult } from './types';
const PLEX_TV = 'https://plex.tv/api/v2';
const DISCOVERY_TTL_MS = 6 * 3600_000;
const MAX_SERVERS = 5;
const plexHeaders = (token?: string): Record<string, string> => ({
Accept: 'application/json',
'X-Plex-Product': 'musicshare',
'X-Plex-Client-Identifier': env.PLEX_CLIENT_ID,
...(token ? { 'X-Plex-Token': token } : {}),
});
// ---- auth (PIN flow) ----
export async function createPin(): Promise<{ id: number; code: string }> {
return (await fetchJson<{ id: number; code: string }>(`${PLEX_TV}/pins?strong=true`, {
method: 'POST',
headers: plexHeaders(),
}))!;
}
export function authAppUrl(code: string, forwardUrl: string): string {
const params = new URLSearchParams({
clientID: env.PLEX_CLIENT_ID,
code,
'context[device][product]': 'musicshare',
forwardUrl,
});
return `https://app.plex.tv/auth#?${params}`;
}
/** Returns the auth token once the user has approved the PIN, else null. */
export async function checkPin(id: number): Promise<string | null> {
const pin = await fetchJson<{ authToken: string | null }>(`${PLEX_TV}/pins/${id}`, { headers: plexHeaders() });
return pin?.authToken ?? null;
}
export async function getPlexAccount(token: string) {
return (await fetchJson<{ id: number; uuid: string; username: string; title: string }>(`${PLEX_TV}/user`, {
headers: plexHeaders(token),
}))!;
}
// ---- server discovery ----
interface PlexServer {
name: string;
owned: boolean;
tokenEnc: string;
/** candidate base URLs, best first */
uris: string[];
}
interface PlexMeta {
servers?: PlexServer[];
discoveredAt?: number;
}
interface PlexResource {
name: string;
provides: string;
owned: boolean;
accessToken: string;
connections: { uri: string; local: boolean; relay: boolean }[];
}
async function discoverServers(accountToken: string): Promise<PlexServer[]> {
let resources: PlexResource[] | null;
try {
resources = await fetchJson<PlexResource[]>(`${PLEX_TV}/resources?includeHttps=1&includeRelay=1`, {
headers: plexHeaders(accountToken),
});
} catch (e) {
if (e instanceof HttpError && e.status === 401) throw new ProviderAuthError('Plex authorization was revoked');
throw e;
}
return (resources ?? [])
.filter((r) => r.provides.split(',').includes('server'))
.slice(0, MAX_SERVERS)
.map((r) => ({
name: r.name,
owned: r.owned,
tokenEnc: encrypt(r.accessToken),
// direct remote first, then Plex relay, then LAN (only usable when self-hosting)
uris: [...r.connections]
.sort((a, b) => Number(a.local) - Number(b.local) || Number(a.relay) - Number(b.relay))
.map((c) => c.uri),
}));
}
// ---- polling ----
interface PlexSessionTrack {
type: string;
title: string;
grandparentTitle?: string; // album artist
originalTitle?: string; // track artist, when it differs
parentTitle?: string; // album
duration?: number;
viewOffset?: number;
User?: { id: string; title: string };
Player?: { state: 'playing' | 'paused' | 'buffering' };
}
/** Try each candidate URI until one answers; moves the working one to the front. */
async function fetchSessions(server: PlexServer): Promise<PlexSessionTrack[]> {
const token = decrypt(server.tokenEnc);
let lastError: unknown;
for (const [i, uri] of server.uris.entries()) {
try {
const base = await assertSafeServerUrl(uri);
const res = await fetchJson<{ MediaContainer: { Metadata?: PlexSessionTrack[] } }>(
`${base}/status/sessions`,
{ headers: plexHeaders(token), ...serverFetchOptions() },
5000,
);
if (i > 0) server.uris = [uri, ...server.uris.filter((u) => u !== uri)];
return res?.MediaContainer.Metadata ?? [];
} catch (e) {
lastError = e;
if (e instanceof HttpError && e.status === 401) break; // token no longer valid for this server
}
}
throw lastError instanceof Error ? lastError : new Error(`${server.name}: unreachable`);
}
function isUsersSession(s: PlexSessionTrack, accountId: string, server: PlexServer): boolean {
// On a server you own, your sessions report the local admin id "1" rather than your plex.tv id.
return s.User?.id === accountId || (server.owned && s.User?.id === '1');
}
export const plexProvider: PollingProvider = {
id: 'plex',
async poll(conn: Connection) {
const meta = conn.meta as PlexMeta;
const accountToken = decrypt(conn.accessTokenEnc!);
let servers = meta.servers;
if (!servers?.length || !meta.discoveredAt || Date.now() - meta.discoveredAt > DISCOVERY_TTL_MS) {
servers = await discoverServers(accountToken);
meta.discoveredAt = Date.now();
}
const results = await Promise.allSettled(servers.map((s) => fetchSessions(s)));
await db
.update(schema.connections)
.set({ meta: { ...meta, servers } })
.where(eq(schema.connections.id, conn.id));
if (servers.length > 0 && results.every((r) => r.status === 'rejected')) {
// force rediscovery next time; server addresses may have changed
await db
.update(schema.connections)
.set({ meta: { ...meta, servers, discoveredAt: 0 } })
.where(eq(schema.connections.id, conn.id));
const reason = (results[0] as PromiseRejectedResult).reason;
if (reason instanceof UnsafeUrlError) throw new Error('Your Plex server is only reachable on a private network');
throw new Error(`Couldn't reach your Plex server${servers.length > 1 ? 's' : ''}: ${reason?.message ?? reason}`);
}
const mine = results
.flatMap((r, i) => (r.status === 'fulfilled' ? r.value.map((s) => ({ s, server: servers[i]! })) : []))
.filter(({ s, server }) => s.type === 'track' && isUsersSession(s, conn.externalUserId, server))
.map(({ s }) => s);
const session = mine.find((s) => s.Player?.state === 'playing') ?? mine[0];
if (!session) return null;
return {
track: {
title: session.title,
artist: session.originalTitle || session.grandparentTitle || 'Unknown artist',
album: session.parentTitle ?? null,
durationMs: session.duration ?? null,
},
isPlaying: session.Player?.state !== 'paused',
progressMs: session.viewOffset ?? null,
} satisfies PollResult;
},
};
+154
View File
@@ -0,0 +1,154 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db';
import type { Connection } from '../db/schema';
import { env } from '../env';
import { decrypt, encrypt } from '../lib/crypto';
import { HttpError, fetchJson } from '../lib/http';
import { redis } from '../lib/redis';
import { findBestMatch } from './match';
import { ProviderAuthError, ProviderRateLimitError, type IncomingTrack, type PollingProvider } from './types';
const SCOPES = ['user-read-currently-playing', 'user-read-playback-state'];
export const redirectUri = () => `${env.API_PUBLIC_URL}/connections/spotify/callback`;
interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
}
interface SpotifyTrack {
id: string;
name: string;
duration_ms: number;
artists: { name: string }[];
album: { name: string; images: { url: string }[] };
external_ids?: { isrc?: string };
external_urls: { spotify: string };
}
const basicAuth = () =>
'Basic ' + Buffer.from(`${env.SPOTIFY_CLIENT_ID}:${env.SPOTIFY_CLIENT_SECRET}`).toString('base64');
export function authorizeUrl(state: string): string {
const params = new URLSearchParams({
client_id: env.SPOTIFY_CLIENT_ID!,
response_type: 'code',
redirect_uri: redirectUri(),
scope: SCOPES.join(' '),
state,
});
return `https://accounts.spotify.com/authorize?${params}`;
}
async function tokenRequest(body: Record<string, string>): Promise<TokenResponse> {
const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: { Authorization: basicAuth(), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(body),
signal: AbortSignal.timeout(8000),
});
if (res.status === 400 || res.status === 401) throw new ProviderAuthError(`spotify token: ${await res.text()}`);
if (!res.ok) throw new HttpError(res.status, `spotify token: ${res.status}`);
return (await res.json()) as TokenResponse;
}
export const exchangeCode = (code: string) =>
tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: redirectUri() });
export async function getProfile(accessToken: string) {
return (await fetchJson<{ id: string; display_name: string | null }>('https://api.spotify.com/v1/me', {
headers: { Authorization: `Bearer ${accessToken}` },
}))!;
}
/** Returns a valid access token for the connection, refreshing and persisting if needed. */
async function userAccessToken(conn: Connection): Promise<string> {
if (conn.accessTokenEnc && conn.tokenExpiresAt && conn.tokenExpiresAt.getTime() > Date.now() + 60_000) {
return decrypt(conn.accessTokenEnc);
}
if (!conn.refreshTokenEnc) throw new ProviderAuthError('no refresh token');
const tok = await tokenRequest({ grant_type: 'refresh_token', refresh_token: decrypt(conn.refreshTokenEnc) });
await db
.update(schema.connections)
.set({
accessTokenEnc: encrypt(tok.access_token),
refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : conn.refreshTokenEnc,
tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000),
})
.where(eq(schema.connections.id, conn.id));
return tok.access_token;
}
async function spotifyGet<T>(path: string, token: string): Promise<T | null> {
const res = await fetch(`https://api.spotify.com/v1${path}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(8000),
});
if (res.status === 204) return null;
if (res.status === 429) throw new ProviderRateLimitError(Number(res.headers.get('retry-after') ?? 30) * 1000);
if (res.status === 401 || res.status === 403) throw new ProviderAuthError(`spotify ${res.status}: ${await res.text()}`);
if (!res.ok) throw new HttpError(res.status, `spotify ${res.status}`);
return (await res.json()) as T;
}
function toIncoming(t: SpotifyTrack): IncomingTrack {
return {
title: t.name,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.name,
durationMs: t.duration_ms,
artworkUrl: t.album.images[0]?.url ?? null,
isrc: t.external_ids?.isrc ?? null,
links: { spotify: { id: t.id, url: t.external_urls.spotify } },
};
}
export const spotifyProvider: PollingProvider = {
id: 'spotify',
async poll(conn) {
const token = await userAccessToken(conn);
const data = await spotifyGet<{
is_playing: boolean;
progress_ms: number | null;
currently_playing_type: string;
item: SpotifyTrack | null;
}>('/me/player/currently-playing', token);
// Podcasts/ads come back with a null or non-track item.
if (!data?.item || data.currently_playing_type !== 'track') return null;
return { track: toIncoming(data.item), isPlaying: data.is_playing, progressMs: data.progress_ms };
},
};
// ---- catalog lookups (client credentials), used for cross-service linking ----
async function appAccessToken(): Promise<string> {
const cached = await redis.get('spotify:app-token');
if (cached) return cached;
const tok = await tokenRequest({ grant_type: 'client_credentials' });
await redis.set('spotify:app-token', tok.access_token, 'EX', tok.expires_in - 60);
return tok.access_token;
}
export async function searchSpotify(
track: { title: string; artist: string; durationMs?: number | null },
isrc: string | null,
): Promise<{ track: IncomingTrack; matchType: 'isrc' | 'search' } | null> {
const token = await appAccessToken();
if (isrc) {
const res = await spotifyGet<{ tracks: { items: SpotifyTrack[] } }>(
`/search?type=track&limit=1&q=${encodeURIComponent(`isrc:${isrc}`)}`,
token,
);
const hit = res?.tracks.items[0];
if (hit) return { track: toIncoming(hit), matchType: 'isrc' };
}
const q = `track:${track.title} artist:${track.artist}`;
const res = await spotifyGet<{ tracks: { items: SpotifyTrack[] } }>(
`/search?type=track&limit=10&q=${encodeURIComponent(q)}`,
token,
);
const items = (res?.tracks.items ?? []).map((t) => ({ ...toIncoming(t), title: t.name, artist: t.artists[0]!.name }));
const best = findBestMatch(items, track);
return best ? { track: best, matchType: 'search' } : null;
}
+35
View File
@@ -0,0 +1,35 @@
import type { NowPlayingProvider, StreamingService } from '@musicshare/shared';
import type { Connection } from '../db/schema';
export interface IncomingTrack {
title: string;
artist: string;
album?: string | null;
durationMs?: number | null;
artworkUrl?: string | null;
isrc?: string | null;
/** Links we already know from the source, e.g. the Spotify track itself. */
links?: Partial<Record<StreamingService, { id: string | null; url: string }>>;
}
export interface PollResult {
track: IncomingTrack;
isPlaying: boolean;
progressMs: number | null;
}
export interface PollingProvider {
id: NowPlayingProvider;
/** null = nothing playing */
poll(conn: Connection): Promise<PollResult | null>;
}
/** The user must reconnect; stop polling this connection. */
export class ProviderAuthError extends Error {}
/** Back off until retryAfterMs. */
export class ProviderRateLimitError extends Error {
constructor(public retryAfterMs: number) {
super(`rate limited, retry after ${retryAfterMs}ms`);
}
}
+44
View File
@@ -0,0 +1,44 @@
import { hash, verify } from '@node-rs/argon2';
import { eq, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { loginSchema, registerSchema } from '@musicshare/shared';
import { db, schema } from '../db';
import { SESSION_COOKIE, createSession } from '../lib/auth';
import { HttpError } from '../lib/http';
import { toMeDTO } from './me';
const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } };
export async function authRoutes(app: FastifyInstance) {
app.post('/auth/register', authRateLimit, async (req, reply) => {
const input = registerSchema.parse(req.body);
const [taken] = await db
.select({ username: schema.users.username, email: schema.users.email })
.from(schema.users)
.where(or(eq(schema.users.username, input.username), eq(schema.users.email, input.email)));
if (taken) throw new HttpError(409, taken.email === input.email ? 'Email already registered' : 'Username taken');
const [user] = await db
.insert(schema.users)
.values({ username: input.username, email: input.email, passwordHash: await hash(input.password) })
.returning();
const token = await createSession(user!.id, reply);
return { user: toMeDTO(user!), token };
});
app.post('/auth/login', authRateLimit, async (req, reply) => {
const input = loginSchema.parse(req.body);
const [user] = await db.select().from(schema.users).where(eq(schema.users.email, input.email));
if (!user || !(await verify(user.passwordHash, input.password))) {
throw new HttpError(401, 'Invalid email or password');
}
const token = await createSession(user.id, reply);
return { user: toMeDTO(user), token };
});
app.post('/auth/logout', async (req, reply) => {
if (req.sessionId) await db.delete(schema.sessions).where(eq(schema.sessions.id, req.sessionId));
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return { ok: true };
});
}
+213
View File
@@ -0,0 +1,213 @@
import { and, count, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { env, spotifyConfigured } from '../env';
import { requireUser } from '../lib/auth';
import { encrypt, randomToken } from '../lib/crypto';
import { entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http';
import { redis } from '../lib/redis';
import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf';
import { jellyfinAuthenticate, jellyfinLogout, jellyfinPublicInfo, type JellyfinMeta } from '../providers/jellyfin';
import { lastfmUserExists } from '../providers/lastfm';
import { listenbrainzUserExists } from '../providers/listenbrainz';
import * as plex from '../providers/plex';
import * as spotify from '../providers/spotify';
import { toConnectionDTO, upsertConnection } from '../services/connections';
import { unschedulePoll } from '../services/scheduler';
const oauthStateKey = (state: string) => `oauth:state:${state}`;
const authorizeBodySchema = z.object({
client: z.enum(['web', 'mobile']).default('web'),
/** Mobile only: deep link to return to (the app scheme, or Expo Go's exp:// URL in development). */
returnTo: z.string().max(500).optional(),
});
/** Where to send the user after OAuth. Only our own web app or app scheme is allowed (no open redirects). */
function resolveReturnTo(body: z.infer<typeof authorizeBodySchema>): string {
if (body.client === 'web') return `${env.WEB_URL}/settings`;
const allowed = body.returnTo?.startsWith('musicshare://') || (env.NODE_ENV !== 'production' && body.returnTo?.startsWith('exp://'));
return allowed ? body.returnTo! : 'musicshare://settings';
}
async function assertCanConnect(user: User, provider: NowPlayingProvider) {
const [existing] = await db
.select({ id: schema.connections.id })
.from(schema.connections)
.where(and(eq(schema.connections.userId, user.id), eq(schema.connections.provider, provider)));
if (existing) return; // reconnecting doesn't count against the limit
const [row] = await db.select({ n: count() }).from(schema.connections).where(eq(schema.connections.userId, user.id));
if ((row?.n ?? 0) >= entitlementsFor(user).maxConnections) {
throw new HttpError(402, 'Connection limit reached for your plan');
}
}
export async function connectionRoutes(app: FastifyInstance) {
app.get('/connections', async (req) => {
const user = requireUser(req);
const rows = await db.select().from(schema.connections).where(eq(schema.connections.userId, user.id));
return rows.map(toConnectionDTO);
});
// ---- Spotify (OAuth) ----
app.post('/connections/spotify/authorize', async (req) => {
const user = requireUser(req);
if (!spotifyConfigured) throw new HttpError(501, 'Spotify is not configured on this server');
await assertCanConnect(user, 'spotify');
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
const state = randomToken(24);
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo }), 'EX', 600);
return { url: spotify.authorizeUrl(state) };
});
// The callback is identified by `state`, not the session cookie: Spotify
// requires a 127.0.0.1 redirect in dev, which won't carry localhost cookies.
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
'/connections/spotify/callback',
async (req, reply) => {
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
if (!raw) throw new HttpError(400, 'Invalid or expired OAuth state');
const { userId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string };
const returnTo = new URL(state.returnTo);
if (req.query.error || !req.query.code) {
returnTo.searchParams.set('error', req.query.error ?? 'spotify_denied');
return reply.redirect(returnTo.toString());
}
const tok = await spotify.exchangeCode(req.query.code);
const profile = await spotify.getProfile(tok.access_token);
await upsertConnection({
userId,
provider: 'spotify',
externalUserId: profile.id,
displayName: profile.display_name,
accessTokenEnc: encrypt(tok.access_token),
refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : null,
tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000),
});
returnTo.searchParams.set('connected', 'spotify');
return reply.redirect(returnTo.toString());
},
);
// ---- Plex (PIN flow) ----
app.post('/connections/plex/authorize', async (req) => {
const user = requireUser(req);
await assertCanConnect(user, 'plex');
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
const pin = await plex.createPin();
const state = randomToken(24);
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo, pinId: pin.id }), 'EX', 900);
return { url: plex.authAppUrl(pin.code, `${env.API_PUBLIC_URL}/connections/plex/callback?state=${state}`) };
});
app.get<{ Querystring: { state?: string } }>('/connections/plex/callback', async (req, reply) => {
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
if (!raw) throw new HttpError(400, 'Invalid or expired Plex login');
const { userId, pinId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string; pinId: number };
const returnTo = new URL(state.returnTo);
// Plex forwards right after approval; the token can lag by a moment
let token: string | null = null;
for (let i = 0; i < 3 && !token; i++) {
token = await plex.checkPin(pinId);
if (!token) await new Promise((r) => setTimeout(r, 1000));
}
if (!token) {
returnTo.searchParams.set('error', 'Plex sign-in was cancelled');
return reply.redirect(returnTo.toString());
}
const account = await plex.getPlexAccount(token);
await upsertConnection({
userId,
provider: 'plex',
externalUserId: String(account.id),
displayName: account.username || account.title,
accessTokenEnc: encrypt(token),
meta: {},
});
returnTo.searchParams.set('connected', 'plex');
return reply.redirect(returnTo.toString());
});
// ---- Jellyfin (server URL + credentials) ----
app.post('/connections/jellyfin', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (req) => {
const user = requireUser(req);
await assertCanConnect(user, 'jellyfin');
const input = jellyfinConnectSchema.parse(req.body);
let serverUrl: string;
try {
serverUrl = await assertSafeServerUrl(input.serverUrl);
} catch (e) {
if (e instanceof UnsafeUrlError) throw new HttpError(400, e.message);
throw e;
}
const info = await jellyfinPublicInfo(serverUrl).catch(() => {
throw new HttpError(400, `Couldn't reach a Jellyfin server at ${serverUrl}`);
});
const deviceId = `musicshare-${randomToken(12)}`;
const auth = await jellyfinAuthenticate(serverUrl, deviceId, input.username, input.password);
if (!auth) throw new HttpError(401, 'Jellyfin rejected that username or password');
const meta: JellyfinMeta = { serverUrl, serverName: info.ServerName, deviceId };
return toConnectionDTO(
await upsertConnection({
userId: user.id,
provider: 'jellyfin',
externalUserId: auth.User.Id,
displayName: `${auth.User.Name} on ${info.ServerName}`,
accessTokenEnc: encrypt(auth.AccessToken),
meta: { ...meta },
}),
);
});
// ---- username-based scrobblers ----
app.post('/connections/lastfm', async (req) => {
const user = requireUser(req);
if (!env.LASTFM_API_KEY) throw new HttpError(501, 'Last.fm is not configured on this server');
await assertCanConnect(user, 'lastfm');
const { username } = connectUsernameSchema.parse(req.body);
const lfm = await lastfmUserExists(username);
if (!lfm) throw new HttpError(404, 'Last.fm user not found');
return toConnectionDTO(
await upsertConnection({ userId: user.id, provider: 'lastfm', externalUserId: lfm.name, displayName: lfm.name }),
);
});
app.post('/connections/listenbrainz', async (req) => {
const user = requireUser(req);
await assertCanConnect(user, 'listenbrainz');
const { username } = connectUsernameSchema.parse(req.body);
if (!(await listenbrainzUserExists(username))) throw new HttpError(404, 'ListenBrainz user not found');
return toConnectionDTO(
await upsertConnection({ userId: user.id, provider: 'listenbrainz', externalUserId: username, displayName: username }),
);
});
app.delete<{ Params: { provider: string } }>('/connections/:provider', async (req) => {
const user = requireUser(req);
const [deleted] = await db
.delete(schema.connections)
.where(
and(
eq(schema.connections.userId, user.id),
eq(schema.connections.provider, req.params.provider as NowPlayingProvider),
),
)
.returning();
if (deleted) {
await unschedulePoll(deleted.id);
if (deleted.provider === 'jellyfin') await jellyfinLogout(deleted);
}
return { ok: true };
});
}
+85
View File
@@ -0,0 +1,85 @@
import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { setFeaturedSchema, updateMeSchema, type MeDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { SESSION_COOKIE, requireUser } from '../lib/auth';
import { effectivePlan, entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http';
import { deezerTrack } from '../providers/catalog';
import { enqueueResolve } from '../services/queues';
import { unschedulePoll } from '../services/scheduler';
import { upsertTrack } from '../services/tracks';
export const toMeDTO = (u: User): MeDTO => ({
id: u.id,
username: u.username,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
email: u.email,
bio: u.bio,
preferredService: u.preferredService,
plan: effectivePlan(u),
entitlements: entitlementsFor(u),
});
export async function meRoutes(app: FastifyInstance) {
app.get('/me', async (req) => toMeDTO(requireUser(req)));
app.patch('/me', async (req) => {
const user = requireUser(req);
const input = updateMeSchema.parse(req.body);
const [updated] = await db.update(schema.users).set(input).where(eq(schema.users.id, user.id)).returning();
return toMeDTO(updated!);
});
/** Required by the App Store: users must be able to delete their account in-app. */
app.delete('/me', async (req, reply) => {
const user = requireUser(req);
const conns = await db
.select({ id: schema.connections.id })
.from(schema.connections)
.where(eq(schema.connections.userId, user.id));
await Promise.all(conns.map((c) => unschedulePoll(c.id)));
await db.delete(schema.users).where(eq(schema.users.id, user.id));
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return { ok: true };
});
app.put('/me/featured', async (req) => {
const user = requireUser(req);
const input = setFeaturedSchema.parse(req.body);
let trackId: string;
if ('deezerId' in input) {
const dz = await deezerTrack(input.deezerId);
if (!dz) throw new HttpError(404, 'Track not found');
const t = await upsertTrack({
title: dz.title,
artist: dz.artist.name,
album: dz.album.title,
durationMs: dz.duration * 1000,
artworkUrl: dz.album.cover_xl ?? dz.album.cover_big ?? null,
isrc: dz.isrc ?? null,
links: { deezer: { id: String(dz.id), url: dz.link } },
});
trackId = t.id;
} else {
const [t] = await db.select({ id: schema.tracks.id }).from(schema.tracks).where(eq(schema.tracks.id, input.trackId));
if (!t) throw new HttpError(404, 'Track not found');
trackId = t.id;
}
const values = { trackId, comment: input.comment || null, updatedAt: new Date() };
await db
.insert(schema.featuredSongs)
.values({ userId: user.id, ...values })
.onConflictDoUpdate({ target: schema.featuredSongs.userId, set: values });
await enqueueResolve(trackId);
return { ok: true };
});
app.delete('/me/featured', async (req) => {
const user = requireUser(req);
await db.delete(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id));
return { ok: true };
});
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Live now-playing updates over WebSocket.
*
* Workers publish to Redis channel `np:<userId>`; every API instance
* pattern-subscribes and forwards to its own sockets that follow that user.
* Instances share nothing else, so you can run as many as you like behind a
* load balancer.
*/
import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { db, schema } from '../db';
import { FOLLOWS_CHANGED_CHANNEL, createRedis } from '../lib/redis';
import { watchUsers } from '../services/connections';
const watchers = new Map<string, Set<WebSocket>>(); // watched userId -> sockets
const socketsByOwner = new Map<string, Set<WebSocket>>(); // viewer userId -> their sockets
const watching = new Map<WebSocket, Set<string>>();
function addTo<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
let set = map.get(key);
if (!set) map.set(key, (set = new Set()));
set.add(value);
}
function removeFrom<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
const set = map.get(key);
set?.delete(value);
if (set?.size === 0) map.delete(key);
}
async function subscribeSocket(socket: WebSocket, viewerId: string) {
const rows = await db
.select({ id: schema.follows.followeeId })
.from(schema.follows)
.where(eq(schema.follows.followerId, viewerId));
const ids = new Set([viewerId, ...rows.map((r) => r.id)]);
for (const old of watching.get(socket) ?? []) removeFrom(watchers, old, socket);
for (const id of ids) addTo(watchers, id, socket);
watching.set(socket, ids);
await watchUsers([...ids]);
}
export async function realtimeRoutes(app: FastifyInstance) {
const sub = createRedis();
await sub.psubscribe('np:*');
await sub.subscribe(FOLLOWS_CHANGED_CHANNEL);
sub.on('pmessage', (_pattern, channel, message) => {
const userId = channel.slice(3);
const payload = `{"type":"now_playing","data":${message}}`;
for (const socket of watchers.get(userId) ?? []) socket.send(payload);
});
sub.on('message', (channel, viewerId) => {
if (channel !== FOLLOWS_CHANGED_CHANNEL) return;
for (const socket of socketsByOwner.get(viewerId) ?? []) {
subscribeSocket(socket, viewerId).catch((e) => app.log.error(e));
}
});
// Keep presence alive so workers keep polling watched users at the fast rate.
const heartbeat = setInterval(() => {
watchUsers([...watchers.keys()]).catch((e) => app.log.error(e));
for (const socket of watching.keys()) socket.ping();
}, 30_000);
app.addHook('onClose', async () => {
clearInterval(heartbeat);
sub.disconnect();
});
app.get('/ws', { websocket: true }, (socket, req) => {
const viewer = req.user;
if (!viewer) {
socket.close(4401, 'unauthorized');
return;
}
addTo(socketsByOwner, viewer.id, socket);
subscribeSocket(socket, viewer.id).catch((e) => app.log.error(e));
socket.on('close', () => {
for (const id of watching.get(socket) ?? []) removeFrom(watchers, id, socket);
watching.delete(socket);
removeFrom(socketsByOwner, viewer.id, socket);
});
});
}
+53
View File
@@ -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);
});
}
+127
View File
@@ -0,0 +1,127 @@
import { and, count, eq, ilike, isNotNull, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import { requireUser } from '../lib/auth';
import { entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http';
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
import { watchUsers } from '../services/connections';
import { getNowPlaying, toUserSummary } from '../services/nowPlaying';
import { getTrackDTOs } from '../services/tracks';
async function findByUsername(username: string) {
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, username.toLowerCase()));
if (!user) throw new HttpError(404, 'User not found');
return user;
}
export async function userRoutes(app: FastifyInstance) {
app.get<{ Querystring: { q?: string } }>('/users/search', async (req) => {
const q = (req.query.q ?? '').trim().replace(/[%_\\]/g, '');
if (q.length < 2) return [];
const rows = await db
.select()
.from(schema.users)
.where(or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`)))
.limit(20);
return rows.map(toUserSummary);
});
app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise<ProfileDTO> => {
const user = await findByUsername(req.params.username);
const viewer = req.user;
const [[followers], [following], [follow], [featured], nowPlaying] = await Promise.all([
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)),
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)),
viewer
? db
.select({ pinnedAt: schema.follows.pinnedAt })
.from(schema.follows)
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id)))
: Promise.resolve([]),
db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
getNowPlaying([user.id]),
]);
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
await watchUsers([user.id]);
return {
user: { ...toUserSummary(user), bio: user.bio },
followerCount: followers?.n ?? 0,
followingCount: following?.n ?? 0,
isFollowing: Boolean(follow),
isPinned: Boolean(follow?.pinnedAt),
isSelf: viewer?.id === user.id,
featured:
featured && featuredTrack
? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() }
: null,
nowPlaying: nowPlaying[0] ?? null,
};
});
app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
const viewer = requireUser(req);
const target = await findByUsername(req.params.username);
if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself");
await db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing();
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
return { ok: true };
});
app.delete<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
const viewer = requireUser(req);
const target = await findByUsername(req.params.username);
await db
.delete(schema.follows)
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
return { ok: true };
});
app.put<{ Params: { username: string } }>('/users/:username/pin', async (req) => {
const viewer = requireUser(req);
const target = await findByUsername(req.params.username);
const [follow] = await db
.select({ pinnedAt: schema.follows.pinnedAt })
.from(schema.follows)
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
if (!follow) throw new HttpError(400, 'Follow someone before pinning them');
if (follow.pinnedAt) return { ok: true };
const [pinned] = await db
.select({ n: count() })
.from(schema.follows)
.where(and(eq(schema.follows.followerId, viewer.id), isNotNull(schema.follows.pinnedAt)));
const max = entitlementsFor(viewer).maxPinnedFriends;
if ((pinned?.n ?? 0) >= max) throw new HttpError(402, `You can pin up to ${max} friends on your plan`);
await db
.update(schema.follows)
.set({ pinnedAt: new Date() })
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
return { ok: true };
});
app.delete<{ Params: { username: string } }>('/users/:username/pin', async (req) => {
const viewer = requireUser(req);
const target = await findByUsername(req.params.username);
await db
.update(schema.follows)
.set({ pinnedAt: null })
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
return { ok: true };
});
/** Now-playing state for everyone the viewer follows, pinned friends first. */
app.get('/feed', async (req): Promise<FeedItemDTO[]> => {
const viewer = requireUser(req);
const followees = await db
.select({ id: schema.follows.followeeId, pinnedAt: schema.follows.pinnedAt })
.from(schema.follows)
.where(eq(schema.follows.followerId, viewer.id));
const pins = new Map(followees.map((f) => [f.id, f.pinnedAt?.toISOString() ?? null]));
await watchUsers([...pins.keys()]);
const items = await getNowPlaying([...pins.keys()]);
return sortFeed(items.map((np) => ({ ...np, pinnedAt: pins.get(np.user.id) ?? null })));
});
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Inbound webhooks from Plex (Plex Pass) and the Jellyfin Webhook plugin.
*
* We don't trust or parse the payload: a webhook only means "something
* changed", so we poll that connection right away. That keeps one code path
* for state, works with any Jellyfin template, and means a forged request can
* at most cause an extra poll. The secret in the URL identifies the connection.
*/
import { and, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import type { NowPlayingProvider } from '@musicshare/shared';
import { db, schema } from '../db';
import { pollSoon } from '../services/scheduler';
export async function webhookRoutes(app: FastifyInstance) {
// Plex posts multipart/form-data (sometimes with a thumbnail attached). We
// ignore bodies entirely, so accept any content type without parsing.
app.addContentTypeParser('*', { bodyLimit: 10 * 1024 * 1024 }, (_req, payload, done) => {
payload.resume();
payload.on('end', () => done(null, undefined));
payload.on('error', done);
});
app.removeContentTypeParser('application/json');
app.addContentTypeParser('application/json', (_req, payload, done) => {
payload.resume();
payload.on('end', () => done(null, undefined));
});
app.post<{ Params: { provider: string; secret: string } }>(
'/webhooks/:provider/:secret',
{ config: { rateLimit: { max: 120, timeWindow: '1 minute' } } },
async (req, reply) => {
const [conn] = await db
.select({ id: schema.connections.id })
.from(schema.connections)
.where(
and(
eq(schema.connections.webhookSecret, req.params.secret),
eq(schema.connections.provider, req.params.provider as NowPlayingProvider),
eq(schema.connections.status, 'active'),
),
);
if (conn) await pollSoon([conn.id]);
// Same response either way, so secrets can't be probed.
return reply.status(204).send();
},
);
}
+69
View File
@@ -0,0 +1,69 @@
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import websocket from '@fastify/websocket';
import Fastify from 'fastify';
import { ZodError } from 'zod';
import { sql } from './db';
import { env } from './env';
import { loadUser } from './lib/auth';
import { HttpError } from './lib/http';
import { redis } from './lib/redis';
import { authRoutes } from './routes/auth';
import { connectionRoutes } from './routes/connections';
import { meRoutes } from './routes/me';
import { realtimeRoutes } from './routes/realtime';
import { trackRoutes } from './routes/tracks';
import { userRoutes } from './routes/users';
import { webhookRoutes } from './routes/webhooks';
const app = Fastify({ logger: true, trustProxy: true });
await app.register(cors, {
origin: [env.WEB_URL],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
});
await app.register(cookie);
// Rate limits are per-route (see `config.rateLimit`); the Redis store makes them global across instances.
await app.register(rateLimit, { global: false, redis });
await app.register(websocket);
app.decorateRequest('user', null);
app.decorateRequest('sessionId', null);
app.addHook('onRequest', loadUser);
app.setErrorHandler((err, req, reply) => {
if (err instanceof ZodError) {
return reply.status(400).send({ error: err.issues[0]?.message ?? 'Invalid input', issues: err.issues });
}
if (err instanceof HttpError) return reply.status(err.status).send({ error: err.message });
const status = (err as { statusCode?: number }).statusCode;
if (status && status < 500) return reply.status(status).send({ error: (err as Error).message });
req.log.error(err);
return reply.status(500).send({ error: 'Internal server error' });
});
app.get('/health', async () => {
await Promise.all([sql`select 1`, redis.ping()]);
return { ok: true };
});
await app.register(authRoutes);
await app.register(meRoutes);
await app.register(userRoutes);
await app.register(trackRoutes);
await app.register(connectionRoutes);
await app.register(realtimeRoutes);
// registered as its own plugin so its permissive body parsers stay scoped to webhook routes
await app.register(webhookRoutes);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, async () => {
await app.close();
await sql.end();
process.exit(0);
});
}
await app.listen({ port: env.PORT, host: '0.0.0.0' });
+52
View File
@@ -0,0 +1,52 @@
import { and, eq, inArray } from 'drizzle-orm';
import type { ConnectionDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { Connection } from '../db/schema';
import { env } from '../env';
import { randomToken } from '../lib/crypto';
import { POLLED_PROVIDERS } from '../providers';
import { markWatched, pollSoon, schedulePoll } from './scheduler';
export const toConnectionDTO = (c: Connection): ConnectionDTO => ({
provider: c.provider,
externalUserId: c.externalUserId,
displayName: c.displayName,
status: c.status,
lastError: c.lastError,
lastPolledAt: c.lastPolledAt?.toISOString() ?? null,
webhookUrl: c.webhookSecret ? `${env.API_PUBLIC_URL}/webhooks/${c.provider}/${c.webhookSecret}` : null,
});
/** Providers that can notify us via webhook (which triggers an immediate poll). */
const WEBHOOK_PROVIDERS = new Set(['plex', 'jellyfin']);
export async function upsertConnection(
values: Omit<typeof schema.connections.$inferInsert, 'id' | 'createdAt' | 'status' | 'lastError' | 'webhookSecret'>,
) {
const set = { ...values, status: 'active' as const, lastError: null };
const [row] = await db
.insert(schema.connections)
// on reconnect the existing webhook secret is kept, so URLs users pasted into Plex/Jellyfin keep working
.values({ ...set, webhookSecret: WEBHOOK_PROVIDERS.has(values.provider) ? randomToken(24) : null })
.onConflictDoUpdate({ target: [schema.connections.userId, schema.connections.provider], set })
.returning();
await schedulePoll(row!.id);
return row!;
}
/** Someone started looking at these users: poll them now instead of waiting for the idle interval. */
export async function watchUsers(userIds: string[]) {
const fresh = await markWatched(userIds);
if (fresh.length === 0) return;
const rows = await db
.select({ id: schema.connections.id })
.from(schema.connections)
.where(
and(
inArray(schema.connections.userId, fresh),
eq(schema.connections.status, 'active'),
inArray(schema.connections.provider, POLLED_PROVIDERS),
),
);
await pollSoon(rows.map((r) => r.id));
}
+88
View File
@@ -0,0 +1,88 @@
import { eq, inArray } from 'drizzle-orm';
import type { NowPlayingDTO, NowPlayingProvider, UserSummaryDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { nowPlayingChannel, redis } from '../lib/redis';
import type { PollResult } from '../providers/types';
import { enqueueResolve } from './queues';
import { getTrackDTOs, upsertTrack } from './tracks';
export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' | 'avatarUrl'>): UserSummaryDTO => ({
id: u.id,
username: u.username,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
});
export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]> {
if (userIds.length === 0) return [];
const rows = await db
.select({ np: schema.nowPlaying, user: schema.users })
.from(schema.nowPlaying)
.innerJoin(schema.users, eq(schema.users.id, schema.nowPlaying.userId))
.where(inArray(schema.nowPlaying.userId, userIds));
const tracks = await getTrackDTOs(rows.map((r) => r.np.trackId));
return rows
.map(({ np, user }) => ({
user: toUserSummary(user),
track: tracks.get(np.trackId)!,
isPlaying: np.isPlaying,
source: np.source,
progressMs: np.progressMs,
updatedAt: np.updatedAt.toISOString(),
}))
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
}
export async function publishNowPlaying(userId: string) {
const [dto] = await getNowPlaying([userId]);
if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto));
}
/** Apply one provider's poll result to the user's now-playing state. Returns whether they're playing. */
export async function applyPollResult(
userId: string,
source: NowPlayingProvider,
result: PollResult | null,
): Promise<boolean> {
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
const now = new Date();
if (!result || !result.isPlaying) {
// Only the source that reported playback may mark it stopped; otherwise an
// idle Last.fm connection would clobber an active Spotify session.
if (current?.isPlaying && current.source === source) {
await db
.update(schema.nowPlaying)
.set({ isPlaying: false, updatedAt: now })
.where(eq(schema.nowPlaying.userId, userId));
await publishNowPlaying(userId);
}
return false;
}
const track = await upsertTrack(result.track);
const changed = !current || current.trackId !== track.id || !current.isPlaying;
const values = {
trackId: track.id,
source,
isPlaying: true,
progressMs: result.progressMs,
updatedAt: now,
...(changed ? { startedAt: now } : {}),
};
await db
.insert(schema.nowPlaying)
.values({ userId, ...values })
.onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values });
if (changed) {
await publishNowPlaying(userId);
const [t] = await db
.select({ resolved: schema.tracks.linksResolvedAt })
.from(schema.tracks)
.where(eq(schema.tracks.id, track.id));
if (!t?.resolved) await enqueueResolve(track.id);
}
return true;
}
+24
View File
@@ -0,0 +1,24 @@
import { Queue } from 'bullmq';
import { createRedis } from '../lib/redis';
const connection = createRedis();
export const POLL_QUEUE = 'poll';
export const RESOLVE_QUEUE = 'resolve-track';
export const pollQueue = new Queue<{ connectionId: string }>(POLL_QUEUE, { connection });
export const resolveQueue = new Queue<{ trackId: string }>(RESOLVE_QUEUE, { connection });
export function enqueueResolve(trackId: string) {
return resolveQueue.add(
'resolve',
{ trackId },
{
jobId: `resolve-${trackId}`,
attempts: 3,
backoff: { type: 'exponential', delay: 30_000 },
removeOnComplete: true,
removeOnFail: 1000,
},
);
}
+76
View File
@@ -0,0 +1,76 @@
import { eq } from 'drizzle-orm';
import type { StreamingService } from '@musicshare/shared';
import { db, schema } from '../db';
import { spotifyConfigured } from '../env';
import { appleMusicFind, deezerByIsrc, deezerFind, odesliLinks, type DeezerTrack } from '../providers/catalog';
import { searchSpotify } from '../providers/spotify';
import { publishNowPlaying } from './nowPlaying';
import { addLinks } from './tracks';
/**
* Fill in links to every streaming service for a track.
* ISRC is the backbone: Deezer gives us one for free, and Spotify can be
* searched by it exactly. Apple Music (without MusicKit) and YouTube Music
* need fuzzy matching or song.link.
*/
export async function resolveTrackLinks(trackId: string) {
const [track] = await db.select().from(schema.tracks).where(eq(schema.tracks.id, trackId));
if (!track) return;
const existing = await db.select().from(schema.trackLinks).where(eq(schema.trackLinks.trackId, trackId));
const links = new Map<StreamingService, string>(existing.map((l) => [l.service, l.url]));
let isrc = track.isrc;
// Deezer: canonical ISRC + artwork source
if (!links.has('deezer') || !isrc) {
let dz: DeezerTrack | null = isrc ? await deezerByIsrc(isrc) : null;
const matchType = dz ? 'isrc' : 'search';
dz ??= await deezerFind(track);
if (dz) {
await addLinks(trackId, { deezer: { id: String(dz.id), url: dz.link } }, matchType);
links.set('deezer', dz.link);
const patch: Partial<typeof track> = {};
if (!isrc && dz.isrc) isrc = patch.isrc = dz.isrc;
if (!track.artworkUrl) patch.artworkUrl = dz.album.cover_xl ?? dz.album.cover_big ?? null;
if (!track.durationMs) patch.durationMs = dz.duration * 1000;
if (!track.album) patch.album = dz.album.title;
if (Object.keys(patch).length) await db.update(schema.tracks).set(patch).where(eq(schema.tracks.id, trackId));
}
}
if (!links.has('spotify') && spotifyConfigured) {
const sp = await searchSpotify(track, isrc);
const link = sp?.track.links?.spotify;
if (sp && link) {
await addLinks(trackId, { spotify: link }, sp.matchType);
links.set('spotify', link.url);
}
}
if (!links.has('apple_music')) {
const am = await appleMusicFind(track);
if (am) {
await addLinks(trackId, { apple_music: am }, 'search');
links.set('apple_music', am.url);
}
}
// song.link covers YouTube Music and TIDAL, which have no keyless search
if (!links.has('youtube_music') || !links.has('tidal')) {
const source = links.get('spotify') ?? links.get('deezer') ?? links.get('apple_music');
// best-effort: a song.link outage shouldn't block the links we already found
const found = source ? await odesliLinks(source).catch((e) => (console.warn('odesli failed', e.message), null)) : null;
if (found) {
const missing = Object.entries(found).filter(([s]) => !links.has(s as StreamingService));
await addLinks(trackId, Object.fromEntries(missing.map(([s, url]) => [s, { id: null, url }])), 'odesli');
}
}
await db.update(schema.tracks).set({ linksResolvedAt: new Date() }).where(eq(schema.tracks.id, trackId));
// push the new links to anyone currently looking at this track
const listeners = await db
.select({ userId: schema.nowPlaying.userId })
.from(schema.nowPlaying)
.where(eq(schema.nowPlaying.trackId, trackId));
await Promise.all(listeners.map((l) => publishNowPlaying(l.userId)));
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Poll scheduling. Each connection has a "next poll at" score in a Redis
* sorted set. Any number of workers can run `claimDue` concurrently: the Lua
* script claims atomically, so no leader election is needed and polling
* scales horizontally. Intervals adapt to whether anyone is watching the user,
* which is what keeps third-party API usage proportional to real engagement.
*/
import { redis } from '../lib/redis';
const DUE_KEY = 'poll:due';
const WATCHED_TTL_SECONDS = 90;
export const IDLE_POLL_SECONDS = 180;
export const PLAYING_UNWATCHED_POLL_SECONDS = 60;
export function schedulePoll(connectionId: string, delayMs = 0) {
return redis.zadd(DUE_KEY, Date.now() + delayMs, connectionId);
}
/** Pull forward (never push back) a connection's next poll. */
export function pollSoon(connectionIds: string[]) {
if (connectionIds.length === 0) return;
const now = Date.now();
return redis.zadd(DUE_KEY, 'LT', ...connectionIds.flatMap((id) => [now, id]));
}
/** Add without overriding an existing schedule. Used to reconcile from the DB. */
export function ensureScheduled(connectionIds: string[]) {
if (connectionIds.length === 0) return;
const now = Date.now();
return redis.zadd(DUE_KEY, 'NX', ...connectionIds.flatMap((id) => [now, id]));
}
export const unschedulePoll = (connectionId: string) => redis.zrem(DUE_KEY, connectionId);
const CLAIM_SCRIPT = `
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2])
if #ids > 0 then
-- park claimed items far in the future; the worker reschedules after polling
for _, id in ipairs(ids) do redis.call('ZADD', KEYS[1], ARGV[3], id) end
end
return ids`;
/** Claims due connections. Claimed items get a safety reschedule in case the worker dies. */
export async function claimDue(limit = 500): Promise<string[]> {
const now = Date.now();
return (await redis.eval(CLAIM_SCRIPT, 1, DUE_KEY, now, limit, now + 5 * 60_000)) as string[];
}
// ---- presence: "someone is looking at this user right now" ----
const watchedKey = (userId: string) => `watched:${userId}`;
/** Marks users as watched; returns the ones that weren't already watched. */
export async function markWatched(userIds: string[]): Promise<string[]> {
if (userIds.length === 0) return [];
const pipeline = redis.pipeline();
for (const id of userIds) pipeline.set(watchedKey(id), '1', 'EX', WATCHED_TTL_SECONDS, 'NX');
const res = await pipeline.exec();
const fresh = userIds.filter((_, i) => res?.[i]?.[1] === 'OK');
// refresh TTL on the rest
const refresh = redis.pipeline();
for (const id of userIds) refresh.expire(watchedKey(id), WATCHED_TTL_SECONDS);
await refresh.exec();
return fresh;
}
export async function isWatched(userId: string): Promise<boolean> {
return (await redis.exists(watchedKey(userId))) === 1;
}
+104
View File
@@ -0,0 +1,104 @@
import { and, eq, inArray, or } from 'drizzle-orm';
import type { StreamingService, TrackDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { Track } from '../db/schema';
import { matchKey } from '../lib/http';
import type { IncomingTrack } from '../providers/types';
type LinkRow = typeof schema.trackLinks.$inferSelect;
export function toTrackDTO(track: Track, links: LinkRow[]): TrackDTO {
return {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
artworkUrl: track.artworkUrl,
durationMs: track.durationMs,
isrc: track.isrc,
links: Object.fromEntries(links.map((l) => [l.service, l.url])) as Partial<Record<StreamingService, string>>,
};
}
export async function getTrackDTOs(ids: string[]): Promise<Map<string, TrackDTO>> {
const unique = [...new Set(ids)];
if (unique.length === 0) return new Map();
const [rows, links] = await Promise.all([
db.select().from(schema.tracks).where(inArray(schema.tracks.id, unique)),
db.select().from(schema.trackLinks).where(inArray(schema.trackLinks.trackId, unique)),
]);
return new Map(rows.map((t) => [t.id, toTrackDTO(t, links.filter((l) => l.trackId === t.id))]));
}
export async function addLinks(
trackId: string,
links: Partial<Record<StreamingService, { id: string | null; url: string }>>,
matchType: LinkRow['matchType'],
) {
const rows = Object.entries(links).map(([service, l]) => ({
trackId,
service: service as StreamingService,
externalId: l.id,
url: l.url,
matchType,
}));
if (rows.length) await db.insert(schema.trackLinks).values(rows).onConflictDoNothing();
}
/**
* Find-or-create the canonical track for something a provider reported.
* Identity, strongest first: ISRC → a known provider ID → normalized artist|title.
*/
export async function upsertTrack(incoming: IncomingTrack): Promise<{ id: string; created: boolean }> {
const key = matchKey(incoming.artist, incoming.title);
let existing: Track | undefined;
if (incoming.isrc) {
[existing] = await db.select().from(schema.tracks).where(eq(schema.tracks.isrc, incoming.isrc)).limit(1);
}
const knownIds = Object.entries(incoming.links ?? {}).filter(([, l]) => l.id);
if (!existing && knownIds.length) {
const [hit] = await db
.select({ track: schema.tracks })
.from(schema.trackLinks)
.innerJoin(schema.tracks, eq(schema.tracks.id, schema.trackLinks.trackId))
.where(
or(
...knownIds.map(([service, l]) =>
and(eq(schema.trackLinks.service, service as StreamingService), eq(schema.trackLinks.externalId, l.id!)),
),
),
)
.limit(1);
existing = hit?.track;
}
if (!existing) {
[existing] = await db.select().from(schema.tracks).where(eq(schema.tracks.matchKey, key)).limit(1);
}
if (existing) {
const patch: Partial<Track> = {};
if (!existing.isrc && incoming.isrc) patch.isrc = incoming.isrc;
if (!existing.artworkUrl && incoming.artworkUrl) patch.artworkUrl = incoming.artworkUrl;
if (!existing.album && incoming.album) patch.album = incoming.album;
if (!existing.durationMs && incoming.durationMs) patch.durationMs = incoming.durationMs;
if (Object.keys(patch).length) await db.update(schema.tracks).set(patch).where(eq(schema.tracks.id, existing.id));
if (incoming.links) await addLinks(existing.id, incoming.links, 'provider');
return { id: existing.id, created: false };
}
const [created] = await db
.insert(schema.tracks)
.values({
matchKey: key,
title: incoming.title,
artist: incoming.artist,
album: incoming.album ?? null,
durationMs: incoming.durationMs ?? null,
artworkUrl: incoming.artworkUrl ?? null,
isrc: incoming.isrc ?? null,
})
.returning({ id: schema.tracks.id });
if (incoming.links) await addLinks(created!.id, incoming.links, 'provider');
return { id: created!.id, created: true };
}
+121
View File
@@ -0,0 +1,121 @@
import { Worker } from 'bullmq';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema, sql } from './db';
import { entitlementsFor } from './lib/entitlements';
import { createRedis } from './lib/redis';
import { POLLED_PROVIDERS, pollingProviders } from './providers';
import { ProviderAuthError, ProviderRateLimitError } from './providers/types';
import { applyPollResult } from './services/nowPlaying';
import { POLL_QUEUE, RESOLVE_QUEUE, pollQueue } from './services/queues';
import { resolveTrackLinks } from './services/resolver';
import {
IDLE_POLL_SECONDS,
PLAYING_UNWATCHED_POLL_SECONDS,
claimDue,
ensureScheduled,
isWatched,
schedulePoll,
unschedulePoll,
} from './services/scheduler';
const log = (...args: unknown[]) => console.log(new Date().toISOString(), ...args);
async function pollConnection(connectionId: string) {
const [row] = await db
.select({ conn: schema.connections, user: schema.users })
.from(schema.connections)
.innerJoin(schema.users, eq(schema.users.id, schema.connections.userId))
.where(eq(schema.connections.id, connectionId));
const provider = row && pollingProviders[row.conn.provider];
if (!row || row.conn.status !== 'active' || !provider) {
await unschedulePoll(connectionId);
return;
}
const { conn, user } = row;
try {
const result = await provider.poll(conn);
const playing = await applyPollResult(user.id, conn.provider, result);
await db
.update(schema.connections)
.set({ lastPolledAt: new Date(), lastError: null })
.where(eq(schema.connections.id, conn.id));
const seconds = (await isWatched(user.id))
? entitlementsFor(user).activePollSeconds
: playing
? PLAYING_UNWATCHED_POLL_SECONDS
: IDLE_POLL_SECONDS;
await schedulePoll(conn.id, seconds * 1000);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (err instanceof ProviderAuthError) {
log(`connection ${conn.id} (${conn.provider}) needs reconnect: ${message}`);
await db
.update(schema.connections)
.set({ status: 'error', lastError: message })
.where(eq(schema.connections.id, conn.id));
await unschedulePoll(conn.id);
return;
}
await db.update(schema.connections).set({ lastError: message }).where(eq(schema.connections.id, conn.id));
await schedulePoll(conn.id, err instanceof ProviderRateLimitError ? err.retryAfterMs : 60_000);
if (!(err instanceof ProviderRateLimitError)) log(`poll ${conn.provider} ${conn.id} failed: ${message}`);
}
}
/** Self-heal: make sure every active connection is in the schedule (e.g. after a Redis flush). */
async function reconcile() {
const rows = await db
.select({ id: schema.connections.id })
.from(schema.connections)
.where(and(eq(schema.connections.status, 'active'), inArray(schema.connections.provider, POLLED_PROVIDERS)));
await ensureScheduled(rows.map((r) => r.id));
}
let ticking = false;
async function tick() {
if (ticking) return;
ticking = true;
try {
const ids = await claimDue();
if (ids.length) {
await pollQueue.addBulk(
ids.map((connectionId) => ({
name: 'poll',
data: { connectionId },
opts: { jobId: `poll-${connectionId}`, removeOnComplete: true, removeOnFail: true },
})),
);
}
} catch (err) {
log('scheduler tick failed', err);
} finally {
ticking = false;
}
}
const pollWorker = new Worker<{ connectionId: string }>(POLL_QUEUE, (job) => pollConnection(job.data.connectionId), {
connection: createRedis(),
concurrency: 50,
});
const resolveWorker = new Worker<{ trackId: string }>(RESOLVE_QUEUE, (job) => resolveTrackLinks(job.data.trackId), {
connection: createRedis(),
concurrency: 5,
});
for (const w of [pollWorker, resolveWorker]) w.on('failed', (job, err) => log(`${w.name} job ${job?.id} failed`, err.message));
await reconcile();
const tickTimer = setInterval(tick, 1000);
const reconcileTimer = setInterval(() => reconcile().catch((e) => log('reconcile failed', e)), 10 * 60_000);
log('worker started');
async function shutdown() {
clearInterval(tickTimer);
clearInterval(reconcileTimer);
await Promise.all([pollWorker.close(), resolveWorker.close()]);
await sql.end();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);