// Publishes a music event from the terminal: a community.lexicon.calendar.event // record followed by the app.rocksky.event.music record that marks it as one // and names the lineup. Both land in the given repo, which has to be one of // the EVENT_PUBLISHER_DIDS for jetstream to index them. // // bun event -- // // A worked example is in event.md next to this file. import chalk from "chalk"; import { consola } from "consola"; import { ctx } from "context"; import { and, eq, isNull, sql } from "drizzle-orm"; import type * as CalendarEvent from "lexicon/types/community/lexicon/calendar/event"; import type * as EventMusic from "lexicon/types/app/rocksky/event/music"; import { createAgent } from "lib/agent"; import prompts from "prompts"; import tables from "schema"; import sharp from "sharp"; const CALENDAR_EVENT = "community.lexicon.calendar.event"; const EVENT_MUSIC = "app.rocksky.event.music"; // The `media` array is not in the published calendar lexicon yet, but it is // what Smoke Signal and the other calendar apps read and write: a blob per // role ("thumbnail", "header") with its aspect ratio. type EventMedia = { $type: "community.lexicon.calendar.event#media"; role: "thumbnail" | "header"; alt?: string; content: unknown; aspect_ratio: { width: number; height: number }; }; type CalendarRecord = CalendarEvent.Record & { media?: EventMedia[] }; const args = process.argv.slice(2); if (args.length === 0) { consola.error("Please provide the publisher identifier (handle or DID)."); console.log(`Usage: ${chalk.cyan("bun event -- ")}`); process.exit(1); } // prompts resolves with an empty object on Ctrl-C; treat that as a cancel // everywhere rather than carrying `undefined` into the record. const onCancel = () => { consola.info("Event creation cancelled."); process.exit(0); }; const ask = async (question: Omit) => ( await prompts({ ...question, name: "value" } as prompts.PromptObject, { onCancel, }) ).value as T; const text = (message: string, initial?: string) => ask({ type: "text", message, initial }).then((v) => v?.trim() ?? ""); const optional = (value: string) => (value === "" ? undefined : value); const datetime = async (message: string, required: boolean) => { for (;;) { const value = await text(message); if (!value && !required) return undefined; const date = new Date(value); if (!Number.isNaN(date.getTime())) return date.toISOString(); consola.warn("Not a date. Use ISO-8601, e.g. 2026-12-01T20:00:00+01:00"); } }; const IMAGE_TYPES: Record = { jpeg: "image/jpeg", jpg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", avif: "image/avif", }; const ACCEPTED_TYPES = new Set(Object.values(IMAGE_TYPES)); // What the server says the URL serves, or what its extension says when the // host refuses HEAD. Only a known picture type passes: the file is downloaded // and uploaded as a blob, so anything else would end up in the record. async function imageType(url: string): Promise { let parsed: URL; try { parsed = new URL(url); } catch { return undefined; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return undefined; } try { const head = await fetch(url, { method: "HEAD", redirect: "follow" }); const type = head.headers .get("content-type") ?.split(";")[0] .trim() .toLowerCase(); if (type && ACCEPTED_TYPES.has(type)) return type; if (head.ok && type) return undefined; } catch { // Fall through to the extension. } const ext = parsed.pathname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1]; return ext ? IMAGE_TYPES[ext] : undefined; } const pictureUrl = async (message: string): Promise => { for (;;) { const value = await text(message); if (!value) return undefined; if (await imageType(value)) return value; consola.warn( "Not a picture URL. Expected a jpeg, png, webp, gif or avif image.", ); } }; const choose = ( message: string, choices: { title: string; value: T }[], initial = 0, ) => ask({ type: "select", message, choices, initial }); // --- the calendar event ---------------------------------------------------- const name = await text("Event name"); if (name.length < 1) { consola.error("The event needs a name."); process.exit(1); } const description = optional(await text("Description (optional)")); const startsAt = await datetime("Starts at (ISO-8601)", true); const endsAt = await datetime("Ends at (ISO-8601, optional)", false); const mode = await choose("Attendance mode", [ { title: "In person", value: "community.lexicon.calendar.event#inperson" }, { title: "Virtual", value: "community.lexicon.calendar.event#virtual" }, { title: "Hybrid", value: "community.lexicon.calendar.event#hybrid" }, ]); const status = await choose("Status", [ { title: "Scheduled", value: "community.lexicon.calendar.event#scheduled" }, { title: "Planned", value: "community.lexicon.calendar.event#planned" }, { title: "Rescheduled", value: "community.lexicon.calendar.event#rescheduled", }, { title: "Postponed", value: "community.lexicon.calendar.event#postponed" }, { title: "Cancelled", value: "community.lexicon.calendar.event#cancelled" }, ]); const locations: CalendarEvent.Record["locations"] = []; if (mode !== "community.lexicon.calendar.event#virtual") { const venue = await text("Venue name"); const country = ( await text("Country (ISO 3166 code, e.g. FR)") ).toUpperCase(); if (country.length < 2) { consola.error("A venue needs at least a country code."); process.exit(1); } locations.push({ $type: "community.lexicon.location.address", name: optional(venue), country, locality: optional(await text("City (optional)")), region: optional(await text("Region / state (optional)")), street: optional(await text("Street address (optional)")), postalCode: optional(await text("Postal code (optional)")), }); const latitude = optional(await text("Latitude (optional)")); const longitude = optional(await text("Longitude (optional)")); if (latitude && longitude) { locations.push({ $type: "community.lexicon.location.geo", name: optional(venue), latitude, longitude, }); } } if (mode !== "community.lexicon.calendar.event#inperson") { const stream = optional(await text("Stream / online venue URL")); if (stream) { locations.push({ $type: "community.lexicon.calendar.event#uri", uri: stream, name: "Online", }); } } const uris: CalendarEvent.Record["uris"] = []; const website = optional(await text("Event website (optional)")); if (website) uris.push({ uri: website, name: "Website" }); // --- the music annotation -------------------------------------------------- const kind = await choose("Kind of event", [ { title: "Concert", value: "concert" }, { title: "Festival", value: "festival" }, { title: "Club night", value: "club-night" }, { title: "DJ set", value: "dj-set" }, { title: "Livestream", value: "livestream" }, { title: "Release party", value: "release-party" }, { title: "Listening party", value: "listening-party" }, ]); const genre = optional(await text("Main genre (optional)")); const tags = ( await ask({ type: "list", message: "Tags, comma-separated (optional)", separator: ",", }) ) .map((t) => t.trim()) .filter(Boolean); // A billed name is linked to the Rocksky artist page when one exists, so the // record carries the artist's AT-URI and the appview does not have to guess. const artistUri = async (artistName: string) => { const row = await ctx.db .select({ uri: tables.artists.uri }) .from(tables.artists) .where(sql`lower(${tables.artists.name}) = lower(${artistName})`) .limit(1) .then((rows) => rows[0]); return row?.uri ?? undefined; }; const artists: EventMusic.Artist[] = []; for (;;) { const artistName = await text( artists.length === 0 ? "Artist name (headliner)" : "Next artist (leave empty to finish)", ); if (!artistName) { if (artists.length === 0) { consola.error("A music event needs at least one artist."); process.exit(1); } break; } const known = await artistUri(artistName); const uri = optional(await text("Artist AT-URI (optional)", known)); if (known) consola.info(`Linked to ${chalk.cyan(known)}`); const role = await choose( "Role", [ { title: "Headliner", value: "headliner" }, { title: "Support", value: "support" }, { title: "Opener", value: "opener" }, { title: "DJ", value: "dj" }, { title: "Special guest", value: "special-guest" }, { title: "Performer", value: "performer" }, ], artists.length === 0 ? 0 : 1, ); const stage = kind === "festival" ? optional(await text("Stage (optional)")) : undefined; const setTime = await datetime("Set time (ISO-8601, optional)", false); const mbid = optional(await text("MusicBrainz artist id (optional)")); artists.push({ name: artistName, uri, role, stage, startsAt: setTime, mbid }); } const externalIds: EventMusic.ExternalIds = {}; for (const [key, label] of [ ["ticketmaster", "Ticketmaster"], ["songkick", "Songkick"], ["bandsintown", "Bandsintown"], ["residentAdvisor", "Resident Advisor"], ["setlistfm", "setlist.fm"], ["dice", "DICE"], ["eventbrite", "Eventbrite"], ] as const) { const id = optional(await text(`${label} event id (optional)`)); if (id) externalIds[key] = id; } const ticketsUrl = optional(await text("Tickets URL (optional)")); const imageUrl = await pictureUrl("Poster image URL (optional)"); // --- likely duplicates ----------------------------------------------------- // The appview folds a second record for the same show onto the first, so this // is a courtesy rather than a guard: the same headliner on the same day is // usually the same concert. const day = startsAt.slice(0, 10); const similar = await ctx.db .select({ name: tables.events.name, uri: tables.events.uri }) .from(tables.events) .innerJoin( tables.eventArtists, eq(tables.eventArtists.eventId, tables.events.id), ) .where( and( isNull(tables.events.duplicateOf), eq(tables.eventArtists.position, 0), sql`lower(${tables.eventArtists.name}) = lower(${artists[0].name})`, sql`to_char(${tables.events.startsAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD') = ${day}`, ), ) .limit(5) .execute(); if (similar.length > 0) { consola.warn( `${artists[0].name} already has an event on ${day}; this one will be folded onto it as a duplicate:`, ); for (const event of similar) { consola.warn(` ${event.name} ${chalk.cyan(event.uri)}`); } const anyway = await ask({ type: "confirm", message: "Publish anyway?", initial: false, }); if (!anyway) onCancel(); } // --- confirm and publish --------------------------------------------------- const createdAt = new Date().toISOString(); const calendarRecord: CalendarRecord = { $type: CALENDAR_EVENT, name, description, createdAt, startsAt, endsAt, mode, status, locations, uris, }; consola.info("Creating event with the following details:"); consola.info("---"); console.log(JSON.stringify(calendarRecord, null, 2)); console.log( JSON.stringify( { $type: EVENT_MUSIC, kind, genre, tags, artists, externalIds, ticketsUrl, imageUrl, }, null, 2, ), ); const confirm = await ask({ type: "confirm", message: "Do you want to proceed?", initial: true, }); if (!confirm) onCancel(); let publisherDid = args[0]; if (!publisherDid.startsWith("did:")) { publisherDid = await ctx.baseIdResolver.handle.resolve(publisherDid); } const agent = await createAgent(ctx.oauthClient, publisherDid); // The poster goes into the publisher's repo as a blob, so calendar apps show // it from the record itself; the URL stays on the music record for Rocksky. if (imageUrl) { const media = await uploadPoster(imageUrl); if (media) calendarRecord.media = [media]; } async function uploadPoster(url: string): Promise { consola.info(`Downloading poster ${chalk.cyan(url)}...`); const response = await fetch(url); if (!response.ok) { consola.warn( `Poster download failed (${response.status}); publishing without it.`, ); return undefined; } const bytes = new Uint8Array(await response.arrayBuffer()); const { width, height, format } = await sharp(bytes).metadata(); if (!width || !height || !format) { consola.warn( "Poster is not an image sharp can read; publishing without it.", ); return undefined; } const mimeType = response.headers.get("content-type")?.split(";")[0].trim() || `image/${format === "jpg" ? "jpeg" : format}`; const upload = await agent.com.atproto.repo.uploadBlob(bytes, { encoding: mimeType, }); consola.info( `Uploaded poster: ${width}x${height} ${mimeType}, ${(bytes.byteLength / 1024).toFixed(0)} KiB`, ); return { $type: "community.lexicon.calendar.event#media", role: "thumbnail", alt: name, content: upload.data.blob, aspect_ratio: { width, height }, }; } consola.info(`Writing ${chalk.greenBright(CALENDAR_EVENT)} record...`); const calendar = await agent.com.atproto.repo.createRecord({ repo: agent.assertDid, collection: CALENDAR_EVENT, record: calendarRecord, }); consola.info(`Record created at: ${chalk.cyan(calendar.data.uri)}`); const musicRecord: EventMusic.Record = { $type: EVENT_MUSIC, subject: { uri: calendar.data.uri, cid: calendar.data.cid }, kind, artists, genre, tags: tags.length ? tags : undefined, externalIds: Object.keys(externalIds).length ? externalIds : undefined, ticketsUrl, imageUrl, createdAt, }; consola.info(`Writing ${chalk.greenBright(EVENT_MUSIC)} record...`); const music = await agent.com.atproto.repo.createRecord({ repo: agent.assertDid, collection: EVENT_MUSIC, record: musicRecord, }); consola.info(chalk.greenBright("Event created successfully!")); consola.info(`Record created at: ${chalk.cyan(music.data.uri)}`); consola.info( `Delete the ${EVENT_MUSIC} record to remove the event from Rocksky; the calendar event stays yours.`, ); process.exit(0);