Feed and profile cards now show one slim line (small artwork, title, artist, Open button) with the progress bar and reactions. The arrow opens the rest: album, your other services, Share, and lyrics. Lyrics come from LRCLIB (open source; LRCLIB_URL can point at a self-hosted copy, or be empty to turn lyrics off). The server looks each song up once and caches it, including misses (retried after a week). Timed lyrics follow along with the song; otherwise the first lines show with "All lyrics". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
import { sql } from 'drizzle-orm';
|
|
import {
|
|
boolean,
|
|
customType,
|
|
index,
|
|
integer,
|
|
jsonb,
|
|
pgTable,
|
|
primaryKey,
|
|
text,
|
|
timestamp,
|
|
uniqueIndex,
|
|
uuid,
|
|
varchar,
|
|
} from 'drizzle-orm/pg-core';
|
|
import type { NowPlayingProvider, Plan, ReportReason, 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(),
|
|
/** Set once they click the link we email them; null until then. */
|
|
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
|
/** A new address waiting for its confirmation link; `email` changes only once it's clicked. */
|
|
pendingEmail: varchar('pending_email', { length: 320 }),
|
|
displayName: varchar('display_name', { length: 64 }),
|
|
bio: varchar('bio', { length: 280 }),
|
|
/** Path of the uploaded picture (`/avatars/<id>?v=<version>`), resolved against API_PUBLIC_URL when sent. */
|
|
avatarUrl: varchar('avatar_url', { length: 500 }),
|
|
preferredService: text('preferred_service').$type<StreamingService>(),
|
|
/** The services they use, so songs only offer those; null until they choose (offer all). */
|
|
services: text('services').array().$type<StreamingService[]>(),
|
|
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
|
|
/** The first account on a server becomes admin (moderation). */
|
|
role: text('role').$type<'user' | 'admin'>().notNull().default('user'),
|
|
/** Suspended by a moderator: can't sign in, profile hidden, sharing stopped. */
|
|
suspendedAt: timestamp('suspended_at', { withTimezone: true }),
|
|
/** Set when the user finishes (or skips through) onboarding; null shows it on next launch. */
|
|
onboardedAt: timestamp('onboarded_at', { withTimezone: true }),
|
|
/** While in the future, nothing new is shared: services aren't polled and phone reports are dropped. */
|
|
sharingPausedUntil: timestamp('sharing_paused_until', { withTimezone: true }),
|
|
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)],
|
|
);
|
|
|
|
/** Blocking removes follows both ways and hides each user from the other. */
|
|
export const blocks = pgTable(
|
|
'blocks',
|
|
{
|
|
blockerId: uuid('blocker_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
blockedId: uuid('blocked_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.blockerId, t.blockedId] }), index('blocks_blocked_idx').on(t.blockedId)],
|
|
);
|
|
|
|
/** User reports for moderation (App Store guideline 1.2 requires a way to report users). */
|
|
export const reports = pgTable(
|
|
'reports',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
reporterId: uuid('reporter_id').references(() => users.id, { onDelete: 'set null' }),
|
|
reportedId: uuid('reported_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
reason: text('reason').$type<ReportReason>().notNull(),
|
|
note: varchar('note', { length: 500 }),
|
|
status: text('status').$type<'open' | 'resolved'>().notNull().default('open'),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [index('reports_status_idx').on(t.status)],
|
|
);
|
|
|
|
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 }),
|
|
/** Last time this connection reported something playing; used to spot links that silently broke. */
|
|
lastActivityAt: timestamp('last_activity_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(),
|
|
/** App name when a phone reported it, e.g. "Spotify". */
|
|
sourceApp: text('source_app'),
|
|
/** Where the data came from, for attribution (e.g. the user's Last.fm profile). */
|
|
sourceUrl: text('source_url'),
|
|
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)],
|
|
);
|
|
|
|
/** Phones that report playback directly (Android media sessions). Each has its own revocable token. */
|
|
export const devices = pgTable(
|
|
'devices',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
userId: uuid('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
/** sha256 of the device token; the token can only report playback. */
|
|
tokenHash: text('token_hash').notNull().unique(),
|
|
platform: text('platform').$type<'android' | 'ios'>().notNull(),
|
|
name: varchar('name', { length: 100 }).notNull(),
|
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [index('devices_user_idx').on(t.userId)],
|
|
);
|
|
|
|
/** Recent plays per user, for "Recently played" on profiles. Trimmed to the latest LISTEN_HISTORY_LIMIT. */
|
|
export const listens = pgTable(
|
|
'listens',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
userId: uuid('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
trackId: uuid('track_id')
|
|
.notNull()
|
|
.references(() => tracks.id),
|
|
source: text('source').$type<NowPlayingProvider>().notNull(),
|
|
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(t) => [index('listens_user_started_idx').on(t.userId, t.startedAt.desc())],
|
|
);
|
|
|
|
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()`),
|
|
});
|
|
|
|
/** One emoji per person per play. Deleted with the play (only the latest 50 plays are kept). */
|
|
export const reactions = pgTable(
|
|
'reactions',
|
|
{
|
|
listenId: uuid('listen_id')
|
|
.notNull()
|
|
.references(() => listens.id, { onDelete: 'cascade' }),
|
|
userId: uuid('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
emoji: varchar('emoji', { length: 32 }).notNull(),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [primaryKey({ columns: [t.listenId, t.userId] })],
|
|
);
|
|
|
|
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
|
|
export const emailVerifications = pgTable(
|
|
'email_verifications',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
userId: uuid('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
email: varchar('email', { length: 320 }).notNull(),
|
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [index('email_verifications_user_idx').on(t.userId)],
|
|
);
|
|
|
|
/** One-time password reset links. Only a hash of the token is stored. */
|
|
export const passwordResets = pgTable(
|
|
'password_resets',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
userId: uuid('user_id')
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
usedAt: timestamp('used_at', { withTimezone: true }),
|
|
createdAt: createdAt(),
|
|
},
|
|
(t) => [index('password_resets_user_idx').on(t.userId)],
|
|
);
|
|
|
|
/** Lyrics looked up per track (misses too, so we don't keep asking). */
|
|
export const lyrics = pgTable('lyrics', {
|
|
trackId: uuid('track_id')
|
|
.primaryKey()
|
|
.references(() => tracks.id, { onDelete: 'cascade' }),
|
|
/** LRC text with timestamps. */
|
|
synced: text('synced'),
|
|
plain: text('plain'),
|
|
instrumental: boolean('instrumental').notNull().default(false),
|
|
/** Where they came from (the LRCLIB record), null when none were found. */
|
|
sourceUrl: text('source_url'),
|
|
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' });
|
|
|
|
/** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */
|
|
export const avatars = pgTable('avatars', {
|
|
userId: uuid('user_id')
|
|
.primaryKey()
|
|
.references(() => users.id, { onDelete: 'cascade' }),
|
|
image: bytea('image').notNull(),
|
|
contentType: text('content_type').notNull(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export type User = typeof users.$inferSelect;
|
|
export type Connection = typeof connections.$inferSelect;
|
|
export type Track = typeof tracks.$inferSelect;
|