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
+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;