Something went wrong. Try again.
A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452import { type HandlerAuth, InvalidRequestError } from "@atproto/xrpc-server";import type { Context } from "context";import { and, asc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm";import type { ArtistView, EventView, LocationView, MediaView, RsvpCounts, RsvpView, UriView,} from "lexicon/types/app/rocksky/event/defs";import type { ExternalIds } from "lexicon/types/app/rocksky/event/music";import tables from "schema";import type { SelectArtist } from "schema/artists";import type { SelectEventArtist } from "schema/event-artists";import type { SelectEventRsvp } from "schema/event-rsvps";import type { SelectEvent } from "schema/events";import type { SelectUser } from "schema/users";
export const RSVP_GOING = "community.lexicon.calendar.rsvp#going";export const RSVP_INTERESTED = "community.lexicon.calendar.rsvp#interested";export const RSVP_NOT_GOING = "community.lexicon.calendar.rsvp#notgoing";
export type EventRow = { events: SelectEvent; users: SelectUser };
// Every event query is public; a token only fills in `viewerRsvp`.export const viewerDid = (auth: HandlerAuth): string | undefined => auth?.credentials?.did as string | undefined;
// Drops undefined and null members so a view only carries the fields it has;// the lexicon marks everything but the identity fields optional.function compact<T extends object>(value: T): T { return Object.fromEntries( Object.entries(value).filter(([, v]) => v !== undefined && v !== null), ) as T;}
const asString = (value: unknown): string | undefined => typeof value === "string" ? value : undefined;
// The calendar record's `locations` is a union of five shapes keyed by $type;// each is flattened onto the one view so clients get a single object to read.export function toLocationView(raw: unknown): LocationView | null { if (!raw || typeof raw !== "object") return null; const value = raw as Record<string, unknown>; const type = asString(value.$type); const name = asString(value.name); switch (type) { case "community.lexicon.location.address": return compact({ type, name, country: asString(value.country), postalCode: asString(value.postalCode), region: asString(value.region), locality: asString(value.locality), street: asString(value.street), }); case "community.lexicon.location.geo": return compact({ type, name, latitude: asString(value.latitude), longitude: asString(value.longitude), altitude: asString(value.altitude), }); case "community.lexicon.location.fsq": return compact({ type, name, fsqPlaceId: asString(value.fsq_place_id), latitude: asString(value.latitude), longitude: asString(value.longitude), }); case "community.lexicon.location.hthree": return compact({ type, name, h3: asString(value.value) }); case "community.lexicon.calendar.event#uri": return compact({ type, name, uri: asString(value.uri) }); default: return null; }}
const asInt = (value: unknown): number | undefined => typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
// A media entry is a blob in the publisher's repo; the Bluesky CDN serves any// PDS blob by DID and CID, which spares clients a PDS lookup.export function toMediaView(raw: unknown, did: string): MediaView | null { if (!raw || typeof raw !== "object") return null; const value = raw as Record<string, unknown>; const content = value.content as Record<string, unknown> | undefined; const ref = content?.ref as Record<string, unknown> | undefined; const cid = asString(ref?.$link); const role = asString(value.role); if (!cid || !role) return null; const ratio = value.aspect_ratio as Record<string, unknown> | undefined; return compact({ role, url: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`, cid, mimeType: asString(content?.mimeType) ?? "application/octet-stream", size: asInt(content?.size), width: asInt(ratio?.width), height: asInt(ratio?.height), alt: asString(value.alt), });}
function toUriView(raw: unknown): UriView | null { if (!raw || typeof raw !== "object") return null; const uri = asString((raw as Record<string, unknown>).uri); return uri ? compact({ uri, name: asString((raw as Record<string, unknown>).name) }) : null;}
export function toArtistView( row: SelectEventArtist, artist: SelectArtist | null,): ArtistView { return compact({ id: artist?.id, uri: artist?.uri ?? undefined, sha256: artist?.sha256, name: row.name, picture: artist?.picture ?? undefined, mbid: row.mbid ?? undefined, role: row.role ?? undefined, stage: row.stage ?? undefined, startsAt: row.startsAt?.toISOString(), });}
export function toRsvpView(row: SelectEventRsvp, user: SelectUser): RsvpView { return compact({ uri: row.uri, cid: row.cid ?? undefined, status: row.status, did: user.did, handle: user.handle, displayName: user.displayName, avatar: user.avatar || undefined, createdAt: row.createdAt.toISOString(), });}
export function toEventView( row: EventRow, artists: ArtistView[], rsvpCounts: RsvpCounts, viewerRsvp?: string,): EventView { const { events: event, users: organizer } = row; const media = (event.media ?? []) .map((m) => toMediaView(m, organizer.did)) .filter((m): m is MediaView => m !== null); const thumbnail = media.find((m) => m.role === "thumbnail") ?? media[0]; return compact({ id: event.id, uri: event.uri, cid: event.cid ?? undefined, musicUri: event.musicUri, musicCid: event.musicCid ?? undefined, name: event.name, description: event.description ?? undefined, kind: event.kind ?? undefined, genre: event.genre ?? undefined, tags: event.tags ?? undefined, startsAt: event.startsAt?.toISOString(), endsAt: event.endsAt?.toISOString(), mode: event.mode ?? undefined, status: event.status ?? undefined, imageUrl: event.imageUrl ?? thumbnail?.url, media, ticketsUrl: event.ticketsUrl ?? undefined, locations: (event.locations ?? []) .map(toLocationView) .filter((l): l is LocationView => l !== null), uris: (event.uris ?? []) .map(toUriView) .filter((u): u is UriView => u !== null), artists, externalIds: (event.externalIds as ExternalIds | null) ?? undefined, organizerDid: organizer.did, organizerHandle: organizer.handle, organizerName: organizer.displayName, organizerAvatarUrl: organizer.avatar || undefined, rsvpCounts, viewerRsvp, createdAt: (event.createdAt ?? event.xataCreatedAt).toISOString(), updatedAt: event.updatedAt.toISOString(), });}
// The canonical event an RSVP belongs to: its own event, or the one that// event was folded onto as a duplicate. RSVPs posted against either record of// the same show count together.const rsvpGroup = sql<string>`coalesce(${tables.events.duplicateOf}, ${tables.events.id})`;
// Builds the views for a page of events in three queries (lineups, rsvp// tallies, the viewer's own rsvps) rather than three per event.export async function hydrateEvents( ctx: Context, rows: EventRow[], viewerDid?: string,): Promise<EventView[]> { if (rows.length === 0) return []; const ids = rows.map((row) => row.events.id);
const [lineup, tallies, viewerRsvps] = await Promise.all([ ctx.db .select({ entry: tables.eventArtists, artist: tables.artists }) .from(tables.eventArtists) .leftJoin( tables.artists, eq(tables.eventArtists.artistId, tables.artists.id), ) .where(inArray(tables.eventArtists.eventId, ids)) .orderBy(asc(tables.eventArtists.position)) .execute(), ctx.db .select({ eventId: rsvpGroup.as("event_id"), status: tables.eventRsvps.status, count: sql<number>`count(*)::int`, }) .from(tables.eventRsvps) .innerJoin(tables.events, eq(tables.eventRsvps.eventId, tables.events.id)) .where(inArray(rsvpGroup, ids)) .groupBy(rsvpGroup, tables.eventRsvps.status) .execute(), viewerDid ? ctx.db .select({ eventId: rsvpGroup.as("event_id"), status: tables.eventRsvps.status, }) .from(tables.eventRsvps) .innerJoin( tables.events, eq(tables.eventRsvps.eventId, tables.events.id), ) .innerJoin( tables.users, eq(tables.eventRsvps.userId, tables.users.id), ) .where(and(inArray(rsvpGroup, ids), eq(tables.users.did, viewerDid))) .execute() : Promise.resolve([]), ]);
const artistsByEvent = new Map<string, ArtistView[]>(); for (const { entry, artist } of lineup) { const list = artistsByEvent.get(entry.eventId) ?? []; list.push(toArtistView(entry, artist)); artistsByEvent.set(entry.eventId, list); }
const countsByEvent = new Map<string, RsvpCounts>(); for (const { eventId, status, count } of tallies) { const counts = countsByEvent.get(eventId) ?? { going: 0, interested: 0, notGoing: 0, }; if (status === RSVP_GOING) counts.going += count; else if (status === RSVP_INTERESTED) counts.interested += count; else if (status === RSVP_NOT_GOING) counts.notGoing += count; countsByEvent.set(eventId, counts); }
const viewerByEvent = new Map( viewerRsvps.map(({ eventId, status }) => [eventId, status]), );
return rows.map((row) => toEventView( row, artistsByEvent.get(row.events.id) ?? [], countsByEvent.get(row.events.id) ?? { going: 0, interested: 0, notGoing: 0, }, viewerByEvent.get(row.events.id), ), );}
// Clients build the URI from whatever identifies the actor in the route,// which is often a handle; stored URIs always carry the DID.export async function canonicalAtUri( ctx: Context, uri: string,): Promise<string> { const [authority, collection, rkey] = uri.replace(/^at:\/\//, "").split("/"); if (!authority || authority.startsWith("did:") || !rkey) return uri; const user = await ctx.db .select({ did: tables.users.did }) .from(tables.users) .where(eq(tables.users.handle, authority)) .limit(1) .then((rows) => rows[0]); return user ? `at://${user.did}/${collection}/${rkey}` : uri;}
// An event is addressed by either of its records. A record that was folded// onto another event as a duplicate resolves to that canonical event.export async function findEvent( ctx: Context, uri: string,): Promise<EventRow | undefined> { const canonical = await canonicalAtUri(ctx, uri); const row = await ctx.db .select({ events: tables.events, users: tables.users }) .from(tables.events) .innerJoin(tables.users, eq(tables.events.createdBy, tables.users.id)) .where( sql`${tables.events.uri} = ${canonical} OR ${tables.events.musicUri} = ${canonical}`, ) .limit(1) .then((rows) => rows[0]); if (!row?.events.duplicateOf) return row; return ctx.db .select({ events: tables.events, users: tables.users }) .from(tables.events) .innerJoin(tables.users, eq(tables.events.createdBy, tables.users.id)) .where(eq(tables.events.id, row.events.duplicateOf)) .limit(1) .then((rows) => rows[0] ?? row);}
// Every row of the show: the canonical event and the duplicates folded onto it.export function eventGroupIds(eventId: string) { return sql`(SELECT ${tables.events.id} FROM ${tables.events} WHERE ${tables.events.id} = ${eventId} OR ${tables.events.duplicateOf} = ${eventId})`;}
export type EventFilters = { artist?: string; did?: string; rsvpBy?: string; kind?: string; genre?: string; from?: string; to?: string; includePast?: boolean; limit: number; offset: number;};
function parseDate(value: string | undefined, name: string): Date | undefined { if (value === undefined) return undefined; const date = new Date(value); if (Number.isNaN(date.getTime())) { throw new InvalidRequestError(`\`${name}\` is not a valid datetime`); } return date;}
// The start of the current UTC day: the default list keeps everything that// has not expired, and an event that started earlier today still counts.export function startOfToday(now = new Date()): Date { const day = new Date(now); day.setUTCHours(0, 0, 0, 0); return day;}
// Without a window the list is "what has not expired": events that end (or,// lacking an end, start) today or later, plus undated ones, which are// presumably announced for later. `includePast` flips it into a history.function eventConditions(filters: EventFilters) { const from = parseDate(filters.from, "from"); const to = parseDate(filters.to, "to"); const upcomingOnly = !from && !to && !filters.includePast; const lowerBound = from ?? (upcomingOnly ? startOfToday() : undefined);
const notEndedBefore = (bound: Date) => or( isNull(tables.events.startsAt), sql`coalesce(${tables.events.endsAt}, ${tables.events.startsAt}) >= ${bound}`, );
// An artist is addressed the way the rest of the API does it (AT-URI), // with the row id and content hash accepted as well. const artistMatches = (artist: string) => exists( sql`(SELECT 1 FROM ${tables.eventArtists} JOIN ${tables.artists} ON ${tables.artists.id} = ${tables.eventArtists.artistId} WHERE ${tables.eventArtists.eventId} = ${tables.events.id} AND (${tables.artists.uri} = ${artist} OR ${tables.artists.id} = ${artist} OR ${tables.artists.sha256} = ${artist}))`, );
// An RSVP against a duplicate record counts for the canonical event. const rsvpBy = (did: string) => exists( sql`(SELECT 1 FROM ${tables.eventRsvps} JOIN ${tables.events} AS attended ON attended.xata_id = ${tables.eventRsvps.eventId} JOIN ${tables.users} AS attendee ON attendee.xata_id = ${tables.eventRsvps.userId} WHERE coalesce(attended.duplicate_of, attended.xata_id) = ${tables.events.id} AND attendee.did = ${did} AND ${tables.eventRsvps.status} IN (${RSVP_GOING}, ${RSVP_INTERESTED}))`, );
return { where: and( // Duplicates are reachable through getEvent but never listed. isNull(tables.events.duplicateOf), filters.artist ? artistMatches(filters.artist) : undefined, filters.did ? eq(tables.users.did, filters.did) : undefined, filters.rsvpBy ? rsvpBy(filters.rsvpBy) : undefined, filters.kind ? eq(tables.events.kind, filters.kind) : undefined, filters.genre ? sql`lower(${tables.events.genre}) = lower(${filters.genre})` : undefined, lowerBound ? notEndedBefore(lowerBound) : undefined, to ? sql`${tables.events.startsAt} <= ${to}` : undefined, ), // Upcoming lists read soonest first; a history reads most recent first. // Raw SQL only for NULLS LAST, which drizzle's asc/desc helpers omit. order: filters.includePast && !from ? sql`${tables.events.startsAt} DESC NULLS LAST` : sql`${tables.events.startsAt} ASC NULLS LAST`, };}
export async function listEvents( ctx: Context, filters: EventFilters, viewer?: string,): Promise<EventView[]> { const { where, order } = eventConditions(filters); const rows = await ctx.db .select({ events: tables.events, users: tables.users }) .from(tables.events) .innerJoin(tables.users, eq(tables.events.createdBy, tables.users.id)) .where(where) .orderBy(order, asc(tables.events.id)) .limit(filters.limit) .offset(filters.offset) .execute(); return hydrateEvents(ctx, rows, viewer);}