Add API and worker: accounts, follows, now-playing, cross-service links
- Fastify API with cookie (web) and bearer (mobile) sessions, follows, pinned friends, featured songs, account deletion, Redis-backed rate limits - Providers: Spotify (OAuth), Last.fm, ListenBrainz, Plex (PIN flow and server discovery), Jellyfin (token auth, revoked on disconnect) - Worker polls on an adaptive schedule in a Redis sorted set, so polling scales horizontally and API usage tracks whether anyone is watching - Track resolver links songs across services via ISRC (Deezer, Spotify), iTunes search and song.link - Realtime updates over WebSocket fan out through Redis pub/sub - User-supplied server URLs are restricted to public addresses, checked both up front and at connect time to defeat DNS rebinding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user