diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..ca45345 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,15 @@ +{ + "name": "@musicshare/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p ." + }, + "dependencies": { + "zod": "^4.6.5" + } +} diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts new file mode 100644 index 0000000..13e07a8 --- /dev/null +++ b/packages/shared/src/client.ts @@ -0,0 +1,86 @@ +import type { + ConnectionDTO, + FeedItemDTO, + MeDTO, + ProfileDTO, + TrackSearchResultDTO, + UserSummaryDTO, +} from './dto'; +import type { LoginInput, RegisterInput, SetFeaturedInput, UpdateMeInput } from './schemas'; + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +export interface ApiClientOptions { + baseUrl: string; + /** Bearer token (mobile). Web relies on the session cookie instead. */ + getToken?: () => string | null | undefined; + /** 'include' for browsers talking to the API cross-origin. */ + credentials?: 'include' | 'same-origin' | 'omit'; +} + +export type AuthResponse = { user: MeDTO; token: string }; + +/** Typed client for the musicshare API, shared by web and mobile. */ +export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOptions) { + async function request(path: string, init: { method?: string; body?: unknown } = {}): Promise { + const headers: Record = {}; + const token = getToken?.(); + if (token) headers.Authorization = `Bearer ${token}`; + if (init.body !== undefined) headers['Content-Type'] = 'application/json'; + const res = await fetch(baseUrl + path, { + method: init.method ?? (init.body !== undefined ? 'POST' : 'GET'), + headers, + credentials, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(res.status, (data as { error?: string }).error ?? `Request failed (${res.status})`); + return data as T; + } + + const q = encodeURIComponent; + + return { + request, + /** For SWR-style fetchers keyed by path. */ + get: (path: string) => request(path), + + register: (input: RegisterInput) => request('/auth/register', { body: input }), + login: (input: LoginInput) => request('/auth/login', { body: input }), + logout: () => request('/auth/logout', { method: 'POST', body: {} }), + + me: () => request('/me'), + updateMe: (input: UpdateMeInput) => request('/me', { method: 'PATCH', body: input }), + deleteAccount: () => request('/me', { method: 'DELETE' }), + setFeatured: (input: SetFeaturedInput) => request('/me/featured', { method: 'PUT', body: input }), + clearFeatured: () => request('/me/featured', { method: 'DELETE' }), + + feed: () => request('/feed'), + profile: (username: string) => request(`/users/${q(username)}`), + searchUsers: (query: string) => request(`/users/search?q=${q(query)}`), + follow: (username: string) => request(`/users/${q(username)}/follow`, { method: 'POST', body: {} }), + unfollow: (username: string) => request(`/users/${q(username)}/follow`, { method: 'DELETE' }), + pin: (username: string) => request(`/users/${q(username)}/pin`, { method: 'PUT', body: {} }), + unpin: (username: string) => request(`/users/${q(username)}/pin`, { method: 'DELETE' }), + + searchTracks: (query: string) => request(`/tracks/search?q=${q(query)}`), + + connections: () => request('/connections'), + authorizeUrl: (provider: 'spotify' | 'plex', body: { client: 'web' | 'mobile'; returnTo?: string }) => + request<{ url: string }>(`/connections/${provider}/authorize`, { body }), + connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) => + request(`/connections/${provider}`, { body: { username } }), + connectJellyfin: (input: { serverUrl: string; username: string; password: string }) => + request('/connections/jellyfin', { body: input }), + disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }), + }; +} + +export type ApiClient = ReturnType; diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts new file mode 100644 index 0000000..cef1d75 --- /dev/null +++ b/packages/shared/src/dto.ts @@ -0,0 +1,93 @@ +import type { Entitlements, Plan } from './plans'; +import type { NowPlayingProvider, StreamingService } from './services'; + +export interface TrackDTO { + id: string; + title: string; + artist: string; + album: string | null; + artworkUrl: string | null; + durationMs: number | null; + isrc: string | null; + links: Partial>; +} + +export interface UserSummaryDTO { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; +} + +export interface MeDTO extends UserSummaryDTO { + email: string; + bio: string | null; + preferredService: StreamingService | null; + plan: Plan; + entitlements: Entitlements; +} + +export interface NowPlayingDTO { + user: UserSummaryDTO; + track: TrackDTO; + isPlaying: boolean; + source: NowPlayingProvider; + progressMs: number | null; + updatedAt: string; +} + +/** A feed entry. pinnedAt is set when the viewer pinned this friend. */ +export interface FeedItemDTO extends NowPlayingDTO { + pinnedAt: string | null; +} + +/** Pinned first (oldest pin on top, so order is stable), then live listeners, then most recent. */ +export function sortFeed(items: T[]): T[] { + return [...items].sort( + (a, b) => + Number(Boolean(b.pinnedAt)) - Number(Boolean(a.pinnedAt)) || + (a.pinnedAt && b.pinnedAt ? a.pinnedAt.localeCompare(b.pinnedAt) : 0) || + Number(b.isPlaying) - Number(a.isPlaying) || + b.updatedAt.localeCompare(a.updatedAt), + ); +} + +export interface FeaturedSongDTO { + track: TrackDTO; + comment: string | null; + updatedAt: string; +} + +export interface ProfileDTO { + user: UserSummaryDTO & { bio: string | null }; + followerCount: number; + followingCount: number; + isFollowing: boolean; + isPinned: boolean; + isSelf: boolean; + featured: FeaturedSongDTO | null; + nowPlaying: NowPlayingDTO | null; +} + +export interface ConnectionDTO { + provider: NowPlayingProvider; + externalUserId: string; + displayName: string | null; + status: 'active' | 'error'; + lastError: string | null; + lastPolledAt: string | null; + /** Secret URL the user can paste into Plex/Jellyfin for instant updates. */ + webhookUrl: string | null; +} + +export interface TrackSearchResultDTO { + deezerId: string; + title: string; + artist: string; + album: string | null; + artworkUrl: string | null; + durationMs: number | null; +} + +/** Messages pushed over the realtime websocket. */ +export type RealtimeMessage = { type: 'now_playing'; data: NowPlayingDTO }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..7408290 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,6 @@ +export * from './services'; +export * from './dto'; +export * from './schemas'; +export * from './plans'; +export * from './client'; +export * from './time'; diff --git a/packages/shared/src/plans.ts b/packages/shared/src/plans.ts new file mode 100644 index 0000000..2fd1254 --- /dev/null +++ b/packages/shared/src/plans.ts @@ -0,0 +1,22 @@ +export const PLANS = ['free', 'plus'] as const; +export type Plan = (typeof PLANS)[number]; + +/** + * Every feature gate lives here so paid tiers can be reshaped without + * hunting through the codebase. Clients read these to show upsells. + */ +export interface Entitlements { + /** How often we refresh now-playing while someone is watching you. */ + activePollSeconds: number; + maxConnections: number; + /** Friends you can pin to the top of your feed. */ + maxPinnedFriends: number; + /** Placeholder for future paid features. */ + listeningHistory: boolean; + profileThemes: boolean; +} + +export const PLAN_ENTITLEMENTS: Record = { + free: { activePollSeconds: 15, maxConnections: 3, maxPinnedFriends: 3, listeningHistory: false, profileThemes: false }, + plus: { activePollSeconds: 5, maxConnections: 10, maxPinnedFriends: 20, listeningHistory: true, profileThemes: true }, +}; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts new file mode 100644 index 0000000..38fdf48 --- /dev/null +++ b/packages/shared/src/schemas.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { STREAMING_SERVICES } from './services'; + +export const usernameSchema = z + .string() + .trim() + .toLowerCase() + .regex(/^[a-z0-9_]{3,32}$/, 'Username must be 3-32 characters: letters, numbers, underscores'); + +export const registerSchema = z.object({ + username: usernameSchema, + email: z.email().trim().toLowerCase(), + password: z.string().min(8, 'Password must be at least 8 characters').max(200), +}); + +export const loginSchema = z.object({ + email: z.email().trim().toLowerCase(), + password: z.string().min(1).max(200), +}); + +export const updateMeSchema = z.object({ + displayName: z.string().trim().max(64).nullable().optional(), + bio: z.string().trim().max(280).nullable().optional(), + avatarUrl: z.url().max(500).nullable().optional(), + preferredService: z.enum(STREAMING_SERVICES).nullable().optional(), +}); + +export const setFeaturedSchema = z.union([ + z.object({ deezerId: z.string().regex(/^\d+$/), comment: z.string().trim().max(280).nullable().optional() }), + z.object({ trackId: z.uuid(), comment: z.string().trim().max(280).nullable().optional() }), +]); + +export const connectUsernameSchema = z.object({ + username: z.string().trim().min(1).max(64), +}); + +export type RegisterInput = z.infer; +export type LoginInput = z.infer; +export type UpdateMeInput = z.infer; +export type SetFeaturedInput = z.infer; + +export const jellyfinConnectSchema = z.object({ + serverUrl: z.string().trim().min(1).max(500), + username: z.string().trim().min(1).max(128), + password: z.string().max(256).default(''), +}); diff --git a/packages/shared/src/services.ts b/packages/shared/src/services.ts new file mode 100644 index 0000000..f5a6ce1 --- /dev/null +++ b/packages/shared/src/services.ts @@ -0,0 +1,64 @@ +/** Services a listener can open a song in. */ +export const STREAMING_SERVICES = [ + 'spotify', + 'apple_music', + 'youtube_music', + 'deezer', + 'tidal', +] as const; +export type StreamingService = (typeof STREAMING_SERVICES)[number]; + +/** Sources we can read "now playing" from. */ +export const NOW_PLAYING_PROVIDERS = [ + 'spotify', + 'lastfm', + 'listenbrainz', + 'plex', + 'jellyfin', + 'apple_music', + 'android_media_session', +] as const; +export type NowPlayingProvider = (typeof NOW_PLAYING_PROVIDERS)[number]; + +export const SERVICE_LABELS: Record = { + spotify: 'Spotify', + apple_music: 'Apple Music', + youtube_music: 'YouTube Music', + deezer: 'Deezer', + tidal: 'TIDAL', + lastfm: 'Last.fm', + listenbrainz: 'ListenBrainz', + plex: 'Plex', + jellyfin: 'Jellyfin', + android_media_session: 'Android', +}; + +/** Fallback when we have no direct link: a search on that service. */ +export function searchUrl(service: StreamingService, title: string, artist: string): string { + const q = encodeURIComponent(`${artist} ${title}`); + switch (service) { + case 'spotify': + return `https://open.spotify.com/search/${q}`; + case 'apple_music': + return `https://music.apple.com/search?term=${q}`; + case 'youtube_music': + return `https://music.youtube.com/search?q=${q}`; + case 'deezer': + return `https://www.deezer.com/search/${q}`; + case 'tidal': + return `https://listen.tidal.com/search?q=${q}`; + } +} + +/** + * The URL to open a track in the given service. All direct links are https + * universal/app links, so they open the native app when installed. + */ +export function openUrl( + track: { title: string; artist: string; links: Partial> }, + service: StreamingService, +): { url: string; exact: boolean } { + const direct = track.links[service]; + if (direct) return { url: direct, exact: true }; + return { url: searchUrl(service, track.title, track.artist), exact: false }; +} diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts new file mode 100644 index 0000000..f1f8b3f --- /dev/null +++ b/packages/shared/src/time.ts @@ -0,0 +1,7 @@ +export function timeAgo(iso: string): string { + const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000); + if (s < 60) return 'just now'; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..df310e1 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src" + ], + "compilerOptions": { + "lib": [ + "ES2023", + "DOM" + ] + } +}