Skip to content
Merged
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
103 changes: 103 additions & 0 deletions apps/web/src/app/api/oembed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { type NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { EMBED_HEIGHT, EMBED_WIDTH, SITE_URL, iframeSnippet, joinCodeFromUrl } from '@/lib/embed';
import type { PublicSessionDetail } from '@pairux/shared-types';

/**
* oEmbed provider endpoint (https://oembed.com) for PairUX lives.
*
* Consumers (Slack, Notion, WordPress, Ghost, Discord) discover this via the
* <link rel="alternate" type="application/json+oembed"> tag on /l/<joinCode>
* and turn a pasted permalink into the embedded player.
*/

export const dynamic = 'force-dynamic';

/** Height of the title bar under the 16:9 video in the embed page. */
const CHROME_HEIGHT = EMBED_HEIGHT - Math.round((EMBED_WIDTH * 9) / 16);

async function getSession(joinCode: string): Promise<PublicSessionDetail | null> {
try {
const supabase = await createClient();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
const { data, error } = await (supabase.rpc as any)('get_public_session', {
p_join_code: joinCode,
});
if (error) return null;
return (data as PublicSessionDetail[] | null)?.[0] ?? null;
} catch {
return null;
}
}

/** Parse a positive integer query param, ignoring junk. */
function positiveInt(raw: string | null): number | null {
if (!raw) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : null;
}

export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;

const url = searchParams.get('url');
if (!url) {
return NextResponse.json({ error: 'Missing required "url" parameter' }, { status: 400 });
}

// The spec allows providers to support only json; anything else is a 501.
const format = searchParams.get('format');
if (format && format !== 'json') {
return NextResponse.json({ error: `Unsupported format "${format}"` }, { status: 501 });
}

const joinCode = joinCodeFromUrl(url);
if (!joinCode) {
return NextResponse.json({ error: 'Not a PairUX live URL' }, { status: 404 });
}

const session = await getSession(joinCode);
if (!session) {
return NextResponse.json({ error: 'Live not found' }, { status: 404 });
}

// Honour maxwidth/maxheight, keeping the 16:9 video plus the title bar.
const maxWidth = positiveInt(searchParams.get('maxwidth'));
const maxHeight = positiveInt(searchParams.get('maxheight'));
let width = Math.min(maxWidth ?? EMBED_WIDTH, EMBED_WIDTH);
let height = Math.round((width * 9) / 16) + CHROME_HEIGHT;
if (maxHeight && height > maxHeight) {
height = maxHeight;
width = Math.round(((height - CHROME_HEIGHT) * 16) / 9);
}

const title = session.subject ?? 'Live on PairUX';
const authorName = session.channel_name ?? session.host_display_name ?? session.host_username;
const authorUrl = session.channel_handle
? `${SITE_URL}/@${session.channel_handle}`
: session.host_username
? `${SITE_URL}/u/${session.host_username}`
: null;

const payload: Record<string, string | number> = {
type: 'video',
version: '1.0',
provider_name: 'PairUX',
provider_url: SITE_URL,
title,
html: iframeSnippet(session.join_code, { width, height, title }),
width,
height,
};
if (authorName) payload.author_name = authorName;
if (authorUrl) payload.author_url = authorUrl;
if (session.banner_url) payload.thumbnail_url = session.banner_url;

return NextResponse.json(payload, {
headers: {
// Live state changes; a short cache keeps unfurl services from hammering us
// without pinning a stale "Live now" for long.
'cache-control': 'public, max-age=60, s-maxage=60',
},
});
}
39 changes: 25 additions & 14 deletions apps/web/src/app/c/[handle]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Radio, Eye, Circle, PlayCircle, User as UserIcon } from 'lucide-react';
import { Radio, Eye, Circle, PlayCircle, Rss, User as UserIcon } from 'lucide-react';
import { Header } from '@/components/header';
import { Footer } from '@/components/footer';
import { createClient, getAuthenticatedUser } from '@/lib/supabase/server';
import { renderDescriptionHtml } from '@/lib/markdown';
import { SITE_URL, clockDuration } from '@/lib/embed';
import type { Channel, ChannelStream, ChannelRecording } from '@pairux/shared-types';
import { SubscribeButton } from './SubscribeButton';
import { ShareButtons } from './ShareButtons';
Expand Down Expand Up @@ -66,14 +67,8 @@ async function getRecordings(handle: string): Promise<ChannelRecording[]> {
}
}

function formatDuration(seconds: number | null): string | null {
if (!seconds || seconds < 1) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const pad = (n: number): string => n.toString().padStart(2, '0');
return h > 0 ? `${String(h)}:${pad(m)}:${pad(s)}` : `${String(m)}:${pad(s)}`;
}
// Shared with the RSS feed's itunes:duration so both read the same.
const formatDuration = clockDuration;

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { handle } = await params;
Expand All @@ -91,7 +86,13 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
return {
title,
description,
alternates: { canonical: url },
alternates: {
canonical: url,
// Feed readers and podcast apps autodiscover the channel's back catalogue.
types: {
'application/rss+xml': `${SITE_URL}/c/${encodeURIComponent(ch.handle)}/rss.xml`,
},
},
openGraph: {
title,
description,
Expand Down Expand Up @@ -282,10 +283,20 @@ export default async function ChannelPage({ params }: PageProps) {
{recordings.length > 0 && (
<section className="border-t border-gray-100 py-10">
<div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold text-gray-900">
<PlayCircle className="h-5 w-5 text-gray-400" />
Recordings
</h2>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-lg font-semibold text-gray-900">
<PlayCircle className="h-5 w-5 text-gray-400" />
Recordings
</h2>
<a
href={`/c/${encodeURIComponent(channel.handle)}/rss.xml`}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
title="Subscribe in a podcast app or feed reader"
>
<Rss className="h-3.5 w-3.5 text-orange-500" />
RSS
</a>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{recordings.map((r) => {
const dur = formatDuration(r.duration_seconds);
Expand Down
126 changes: 126 additions & 0 deletions apps/web/src/app/c/[handle]/rss.xml/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { createClient } from '@/lib/supabase/server';
import { SITE_URL, clockDuration, escapeXml, liveUrl } from '@/lib/embed';

/**
* Per-channel RSS feed of finished recordings.
*
* This is what makes a PairUX channel subscribable outside pairux.com — the
* itunes:* tags and the <enclosure> on each item let podcast apps (Apple
* Podcasts, Overcast, Pocket Casts) and ordinary feed readers treat a channel
* as a show whose episodes are its past lives.
*/

export const dynamic = 'force-dynamic';

interface ChannelRow {
id: string;
handle: string;
name: string;
description: string | null;
avatar_url: string | null;
banner_url: string | null;
}

interface RecordingRow {
id: string;
join_code: string;
subject: string | null;
banner_url: string | null;
playback_url: string;
duration_seconds: number | null;
created_at: string;
}

async function getChannel(handle: string): Promise<ChannelRow | null> {
try {
const supabase = await createClient();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
const { data, error } = await (supabase.rpc as any)('get_channel', { p_handle: handle });
if (error) return null;
return (data as ChannelRow[] | null)?.[0] ?? null;
} catch {
return null;
}
}

async function getRecordings(handle: string): Promise<RecordingRow[]> {
try {
const supabase = await createClient();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
const { data, error } = await (supabase.rpc as any)('list_channel_recordings', {
p_handle: handle,
p_limit: 100,
});
if (error) return [];
return (data as RecordingRow[] | null) ?? [];
} catch {
return [];
}
}

function buildItem(channel: ChannelRow, r: RecordingRow): string {
const title = r.subject ?? 'Untitled live';
const permalink = liveUrl(r.join_code);
const image = r.banner_url ?? channel.banner_url ?? channel.avatar_url;
const duration = clockDuration(r.duration_seconds);

return [
' <item>',
` <title>${escapeXml(title)}</title>`,
` <link>${escapeXml(permalink)}</link>`,
` <guid isPermaLink="false">${escapeXml(r.id)}</guid>`,
` <pubDate>${new Date(r.created_at).toUTCString()}</pubDate>`,
` <description>${escapeXml(`${title} — a live from ${channel.name} on PairUX.`)}</description>`,
// length is required by the RSS spec but the public RPC does not expose
// size_bytes; 0 is the conventional "unknown" and clients tolerate it.
` <enclosure url="${escapeXml(r.playback_url)}" type="video/mp4" length="0"/>`,
duration ? ` <itunes:duration>${duration}</itunes:duration>` : null,
image ? ` <itunes:image href="${escapeXml(image)}"/>` : null,
` <itunes:author>${escapeXml(channel.name)}</itunes:author>`,
' </item>',
]
.filter((line): line is string => line !== null)
.join('\n');
}

export async function GET(_request: Request, { params }: { params: Promise<{ handle: string }> }) {
const { handle } = await params;

const channel = await getChannel(handle);
if (!channel) {
return new Response('Channel not found', { status: 404 });
}

const recordings = await getRecordings(handle);
const channelUrl = `${SITE_URL}/@${channel.handle}`;
const feedUrl = `${SITE_URL}/c/${encodeURIComponent(channel.handle)}/rss.xml`;
const description = channel.description ?? `Past lives from ${channel.name}, recorded on PairUX.`;
const artwork = channel.avatar_url ?? channel.banner_url;

const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:atom="http://www.w3.org/2005/Atom">',
' <channel>',
` <title>${escapeXml(channel.name)}</title>`,
` <link>${escapeXml(channelUrl)}</link>`,
` <description>${escapeXml(description)}</description>`,
' <language>en</language>',
` <atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml"/>`,
` <itunes:author>${escapeXml(channel.name)}</itunes:author>`,
` <itunes:summary>${escapeXml(description)}</itunes:summary>`,
' <itunes:explicit>false</itunes:explicit>',
artwork ? ` <itunes:image href="${escapeXml(artwork)}"/>` : null,
...recordings.map((r) => buildItem(channel, r)),
' </channel>',
'</rss>',
]
.filter((line): line is string => line !== null)
.join('\n');

return new Response(xml, {
headers: {
'content-type': 'application/rss+xml; charset=utf-8',
'cache-control': 'public, max-age=300, s-maxage=300',
},
});
}
Loading
Loading