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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452// 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 -- <handle|did>//// 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 -- <handle|did>")}`); 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 <T>(question: Omit<prompts.PromptObject, "name">) => ( await prompts({ ...question, name: "value" } as prompts.PromptObject, { onCancel, }) ).value as T;
const text = (message: string, initial?: string) => ask<string>({ 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<string, string> = { 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<string | undefined> { 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<string | undefined> => { 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 = <T extends string>( message: string, choices: { title: string; value: T }[], initial = 0,) => ask<T>({ 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<string[]>({ 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<boolean>({ 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<boolean>({ 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<EventMedia | undefined> { 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);