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
+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`);
}
}