Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# --- Slack song requests ---
# From your Slack app: OAuth & Permissions (Bot User OAuth Token, needs
# chat:write + im:write) and Basic Information (Signing Secret).
SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=...

# --- DynamoDB (request log + should-purchase wishlist) ---
# On Vercel, prefer the AWS integration or scoped access keys.
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
SONG_REQUESTS_TABLE=compute-fm-song-requests

# --- Station / behavior ---
# Live365 station id to watch (defaults to the first entry in lib/channels.ts).
COMPUTE_FM_STATION_ID=
# How often the workflow checks what's airing, in seconds.
SONG_REQUEST_POLL_SECONDS=180
# How long a request waits for its song before going to should-purchase.
SONG_REQUEST_TTL_DAYS=14
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ npm-debug.log*
.env*.local
.vercel
next-env.d.ts
/.swc
tsconfig.tsbuildinfo
62 changes: 62 additions & 0 deletions app/api/slack/request/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { start } from "workflow/api";
import { matchCatalog, trackKey } from "@/lib/requests/catalog";
import { verifySlackSignature } from "@/lib/slack";
import type { SongRequest } from "@/lib/requests/types";
import { songRequestWorkflow } from "@/workflows/song-request";

export const dynamic = "force-dynamic";

const TTL_DAYS = Number(process.env.SONG_REQUEST_TTL_DAYS || "14");

// Slash-command handler for `/request <song>`. Slack requires a response within
// 3 seconds, so we verify, kick off the durable workflow, and ack immediately.
export async function POST(req: Request) {
const raw = await req.text();
const timestamp = req.headers.get("x-slack-request-timestamp");
const signature = req.headers.get("x-slack-signature");

if (!verifySlackSignature(raw, timestamp, signature)) {
return NextResponse.json({ error: "invalid signature" }, { status: 401 });
}

const params = new URLSearchParams(raw);
const query = (params.get("text") || "").trim();
const userId = params.get("user_id") || "";
const channelId = params.get("channel_id") || "";
const userName = params.get("user_name") || undefined;

if (!query) {
return NextResponse.json({
response_type: "ephemeral",
text: "Usage: `/request <song name>` — I'll ping you when it airs.",
});
}

const match = matchCatalog(query);
const now = Date.now();
const request: SongRequest = {
id: randomUUID(),
trackKey: match ? match.trackKey : trackKey(query),
query,
matchedTitle: match?.title,
matchedArtist: match?.artist,
slackUserId: userId,
slackChannelId: channelId,
slackUserName: userName,
status: "pending",
createdAt: now,
updatedAt: now,
expiresAt: now + TTL_DAYS * 86_400_000,
};

// Fire-and-forget: the workflow runs durably in the background.
await start(songRequestWorkflow, [request]);

const ack = match
? `Got it — I'll DM you when *${match.title}* airs on compute.fm. It won't skip the queue; it plays when it naturally comes up. 🎧`
: `"${query}" isn't in the library yet, so I've noted it for the should-purchase list. 🛒`;

return NextResponse.json({ response_type: "ephemeral", text: ack });
}
26 changes: 26 additions & 0 deletions app/api/wishlist/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getStore } from "@/lib/requests/store";

export const dynamic = "force-dynamic";

// Public read of the should-purchase list, so the site can surface what
// listeners want that isn't in the library yet.
export async function GET() {
try {
const items = await getStore().listWishlist();
return NextResponse.json(
{ items },
{
headers: {
"Cache-Control": "no-store, max-age=0",
"Access-Control-Allow-Origin": "*",
},
}
);
} catch {
return NextResponse.json(
{ error: "Failed to load wishlist" },
{ status: 502 }
);
}
}
52 changes: 52 additions & 0 deletions lib/requests/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { playlist, Track } from "@/lib/playlist";

export interface CatalogMatch {
title: string;
artist: string;
trackKey: string;
}

// Lowercase, strip punctuation and collapse whitespace so that user input and
// Live365 metadata compare cleanly ("Midnight Drive!" === "midnight drive").
export function normalize(value: string): string {
return value
.toLowerCase()
.replace(/\([^)]*\)|\[[^\]]*\]/g, " ") // drop "(remix)", "[live]" etc.
.replace(/[^a-z0-9]+/g, " ")
.trim();
}

export function trackKey(title: string, artist?: string): string {
return `${normalize(title)}|${normalize(artist ?? "")}`;
}

// A request matches an owned track when the normalized query contains the
// track's normalized title (and, if given, is consistent with the artist).
// This is intentionally conservative: only tracks we actually own can play.
export function matchCatalog(query: string): CatalogMatch | null {
const q = normalize(query);
if (!q) return null;

let best: { track: Track; score: number } | null = null;
for (const track of playlist) {
const title = normalize(track.title);
const artist = normalize(track.artist);
if (!title) continue;

let score = 0;
if (q === title) score = 100;
else if (q.includes(title)) score = 60 + title.length;
else if (title.includes(q) && q.length >= 4) score = 40 + q.length;

if (score > 0 && artist && q.includes(artist)) score += 10;

if (score > 0 && (!best || score > best.score)) best = { track, score };
}

if (!best) return null;
return {
title: best.track.title,
artist: best.track.artist,
trackKey: trackKey(best.track.title, best.track.artist),
};
}
32 changes: 32 additions & 0 deletions lib/requests/nowplaying.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { channels } from "@/lib/channels";

export interface CurrentTrack {
title: string;
artist: string;
}

function stationId(): string {
return process.env.COMPUTE_FM_STATION_ID || channels[0]?.stationId || "";
}

// Reads what is airing right now from Live365. Returns null on any failure so
// callers (the polling workflow) simply try again on the next tick.
export async function fetchCurrentTrack(): Promise<CurrentTrack | null> {
const id = stationId();
if (!id) return null;
try {
const res = await fetch(`https://api.live365.com/station/${id}`, {
headers: { "User-Agent": "compute.fm/1.0" },
cache: "no-store",
});
if (!res.ok) return null;
const data = (await res.json()) as {
"current-track"?: { title?: string; artist?: string };
};
const current = data["current-track"];
if (!current?.title) return null;
return { title: current.title, artist: current.artist ?? "" };
} catch {
return null;
}
}
127 changes: 127 additions & 0 deletions lib/requests/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
QueryCommand,
UpdateCommand,
} from "@aws-sdk/lib-dynamodb";
import type { RequestStatus, SongRequest, WishlistItem } from "./types";

// Persistence for the *aggregate* view: a log of requests plus the
// should-purchase wishlist. The per-request "who to notify" state lives inside
// the durable workflow, so this store only needs simple writes and list reads.
export interface RequestStore {
createRequest(request: SongRequest): Promise<void>;
updateRequestStatus(
id: string,
status: RequestStatus,
patch?: Partial<Pick<SongRequest, "reason">>
): Promise<void>;
listRequests(limit?: number): Promise<SongRequest[]>;
addToWishlist(item: Omit<WishlistItem, "requestCount" | "firstRequestedAt" | "lastRequestedAt">): Promise<void>;
listWishlist(): Promise<WishlistItem[]>;
}

const TABLE = process.env.SONG_REQUESTS_TABLE || "compute-fm-song-requests";
const REQUEST_PK = "REQUEST";
const WISHLIST_PK = "WISHLIST";

let docClient: DynamoDBDocumentClient | null = null;
function client(): DynamoDBDocumentClient {
if (!docClient) {
docClient = DynamoDBDocumentClient.from(
new DynamoDBClient({ region: process.env.AWS_REGION || "us-east-1" }),
{ marshallOptions: { removeUndefinedValues: true } }
);
}
return docClient;
}

class DynamoRequestStore implements RequestStore {
async createRequest(request: SongRequest): Promise<void> {
await client().send(
new PutCommand({
TableName: TABLE,
Item: { pk: REQUEST_PK, sk: request.id, ...request },
})
);
}

async updateRequestStatus(
id: string,
status: RequestStatus,
patch?: Partial<Pick<SongRequest, "reason">>
): Promise<void> {
const names: Record<string, string> = { "#s": "status", "#u": "updatedAt" };
const values: Record<string, unknown> = { ":s": status, ":u": Date.now() };
let expr = "SET #s = :s, #u = :u";
if (patch?.reason) {
names["#r"] = "reason";
values[":r"] = patch.reason;
expr += ", #r = :r";
}
await client().send(
new UpdateCommand({
TableName: TABLE,
Key: { pk: REQUEST_PK, sk: id },
UpdateExpression: expr,
ExpressionAttributeNames: names,
ExpressionAttributeValues: values,
})
);
}

async listRequests(limit = 100): Promise<SongRequest[]> {
const res = await client().send(
new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "pk = :pk",
ExpressionAttributeValues: { ":pk": REQUEST_PK },
ScanIndexForward: false,
Limit: limit,
})
);
return (res.Items ?? []) as SongRequest[];
}

async addToWishlist(
item: Omit<WishlistItem, "requestCount" | "firstRequestedAt" | "lastRequestedAt">
): Promise<void> {
const now = Date.now();
await client().send(
new UpdateCommand({
TableName: TABLE,
Key: { pk: WISHLIST_PK, sk: item.trackKey },
UpdateExpression:
"SET #t = :t, #a = :a, trackKey = :k, lastRequestedAt = :now, firstRequestedAt = if_not_exists(firstRequestedAt, :now) ADD requestCount :one",
ExpressionAttributeNames: { "#t": "title", "#a": "artist" },
ExpressionAttributeValues: {
":t": item.title,
":a": item.artist ?? "",
":k": item.trackKey,
":now": now,
":one": 1,
},
})
);
}

async listWishlist(): Promise<WishlistItem[]> {
const res = await client().send(
new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "pk = :pk",
ExpressionAttributeValues: { ":pk": WISHLIST_PK },
})
);
const items = (res.Items ?? []) as WishlistItem[];
return items.sort((a, b) => b.requestCount - a.requestCount);
}
}

let store: RequestStore | null = null;
export function getStore(): RequestStore {
if (!store) store = new DynamoRequestStore();
return store;
}
36 changes: 36 additions & 0 deletions lib/requests/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type RequestStatus =
| "pending" // waiting for the song to air naturally
| "notifying" // watcher matched it; workflow is delivering the notification
| "played" // aired and the requester was notified
| "should-purchase"; // never aired in the window, or not in the catalog

export interface SongRequest {
id: string;
// Normalized "title|artist" key used to match against now-playing.
trackKey: string;
// What the requester typed, preserved verbatim.
query: string;
// Resolved catalog metadata when the request matched an owned track.
matchedTitle?: string;
matchedArtist?: string;
slackUserId: string;
slackChannelId: string;
slackUserName?: string;
status: RequestStatus;
// Why it landed in should-purchase, when applicable.
reason?: "not-in-catalog" | "expired";
createdAt: number;
updatedAt: number;
// Absolute epoch ms after which a pending request expires to should-purchase.
expiresAt: number;
}

export interface WishlistItem {
// Same normalized key as SongRequest.trackKey.
trackKey: string;
title: string;
artist?: string;
requestCount: number;
firstRequestedAt: number;
lastRequestedAt: number;
}
Loading