From 72743754bb4479f7392e3fbc4e4bd545e1afacc5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 10 Oct 2026 23:39:48 -0700 Subject: [PATCH 1/2] Add public API for a channel's latest VOD and markers Co-Authored-By: Claude Opus 5.5 --- README.md | 15 ++++ .../channels/[username]/latest-vod/route.ts | 32 +++++++ src/utils/twitch-server.test.ts | 59 ++++++++++++- src/utils/twitch-server.ts | 86 +++++++++++++++---- 4 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 src/app/api/channels/[username]/latest-vod/route.ts diff --git a/README.md b/README.md index 4f85142..77dc9b6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,21 @@ tl;dr - grab csv files from your Twitch [Stream Markers](https://help.twitch.tv/s/article/creating-highlights-and-stream-markers?language=en_US#:~:text=in%20light%20purple.-,Stream%20Markers,-Stream%20Markers%20are) to use with [LosslessCut](https://github.com/mifi/lossless-cut) +## Public API + +`GET https://markerthing.com/api/channels//latest-vod` + +Returns the channel's latest finished stream VOD and all its markers as JSON. No sign-in needed. The creator must have signed in to MarkerThing once. + +```json +{ + "vod": { "id": "...", "title": "...", "url": "...", "created_at": "...", "duration": "3h2m1s", "user_login": "theo" }, + "markers": [{ "id": "...", "created_at": "...", "description": "Talking about Chrome", "position_seconds": 600, "URL": "..." }] +} +``` + +Returns 404 when there is no finished VOD, and 502 when Twitch or Clerk fails. Responses are cached for 60 seconds. + ## Dev Setup 1. Fork & clone repo diff --git a/src/app/api/channels/[username]/latest-vod/route.ts b/src/app/api/channels/[username]/latest-vod/route.ts new file mode 100644 index 0000000..a3dd2fc --- /dev/null +++ b/src/app/api/channels/[username]/latest-vod/route.ts @@ -0,0 +1,32 @@ +import { getLatestVodWithMarkers } from "~/utils/twitch-server"; + +// Public, read-only API for agents and scripts. No sign-in needed. +// GET /api/channels//latest-vod +// Returns { vod, markers } for the creator's latest finished stream. The +// creator must have signed in to MarkerThing once, so their token can read +// markers. The token stays on the server. +export async function GET( + _req: Request, + ctx: RouteContext<"/api/channels/[username]/latest-vod"> +) { + const { username } = await ctx.params; + + try { + const result = await getLatestVodWithMarkers(username); + if (!result) { + return Response.json({ error: "No finished VOD found" }, { status: 404 }); + } + // Let the CDN absorb repeat calls, so they do not spend the creator's + // Twitch rate limit. + return Response.json(result, { + headers: { + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", + }, + }); + } catch (e) { + console.error("LATEST VOD API ERROR", e); + // These messages only name the failed step, never a token + const message = e instanceof Error ? e.message : "Unknown error"; + return Response.json({ error: message }, { status: 502 }); + } +} diff --git a/src/utils/twitch-server.test.ts b/src/utils/twitch-server.test.ts index 7333644..b4956f0 100644 --- a/src/utils/twitch-server.test.ts +++ b/src/utils/twitch-server.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getVodWithMarkers } from "./twitch-server"; +import { getLatestVodWithMarkers, getVodWithMarkers } from "./twitch-server"; // The creator has signed in before, so Clerk has their Twitch token vi.mock("@clerk/nextjs/server", () => ({ @@ -53,3 +53,60 @@ describe("getVodWithMarkers", () => { }); }); }); + +describe("getLatestVodWithMarkers", () => { + const video = (id: string, stream_id: string) => ({ + id, + stream_id, + title: `Stream ${id}`, + url: `https://www.twitch.tv/videos/${id}`, + created_at: "2026-10-07T00:00:00Z", + duration: "1h0m0s", + user_login: "creator", + view_count: 10, + }); + const marker = (id: string) => ({ + id, + created_at: "2026-10-07T00:10:00Z", + description: `Topic ${id}`, + position_seconds: 600, + URL: "", + }); + + it("skips the live VOD and loads every marker page", async () => { + const fetch = vi.fn(async (url: string) => { + const { pathname, searchParams } = new URL(url); + if (pathname.endsWith("/users")) + return Response.json({ data: [{ id: "u1" }] }); + if (pathname.endsWith("/videos")) { + return Response.json({ + data: [video("live", "s2"), video("done", "s1")], + }); + } + if (pathname.endsWith("/streams")) + return Response.json({ data: [{ id: "s2" }] }); + // Markers: two pages, only for the finished VOD + expect(searchParams.get("video_id")).toBe("done"); + return searchParams.get("after") + ? Response.json({ data: [{ videos: [{ markers: [marker("b")] }] }] }) + : Response.json({ + data: [{ videos: [{ markers: [marker("a")] }] }], + pagination: { cursor: "next" }, + }); + }); + vi.stubGlobal("fetch", fetch); + + const { url, title, created_at, duration } = video("done", "s1"); + await expect(getLatestVodWithMarkers("creator")).resolves.toEqual({ + vod: { + id: "done", + title, + url, + created_at, + duration, + user_login: "creator", + }, + markers: [marker("a"), marker("b")], + }); + }); +}); diff --git a/src/utils/twitch-server.ts b/src/utils/twitch-server.ts index 73abc3d..15b397f 100644 --- a/src/utils/twitch-server.ts +++ b/src/utils/twitch-server.ts @@ -134,6 +134,73 @@ const fetchTwitchLive = async (path: string, token: string) => { return (await res.json()) as T; }; +// Loads every marker for a user (latest VOD) or a video_id. Markers come +// oldest first, max 100 per page, so walk to the last page. The page cap is +// only a guard against a bad cursor loop. +const getAllMarkers = async ( + query: { user_id: string } | { video_id: string }, + token: string +) => { + const markers: VOD["markers"] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page++) { + const params = new URLSearchParams({ ...query, first: "100" }); + if (cursor) params.set("after", cursor); + const res = await fetchTwitchLive( + `/streams/markers?${params}`, + token + ); + + markers.push( + ...(res?.data?.[0]?.videos?.flatMap((video) => video.markers ?? []) ?? []) + ); + cursor = res?.pagination?.cursor; + if (!cursor) break; + } + return markers; +}; + +type TwitchVideo = { + id: string; + stream_id: string | null; + title: string; + url: string; + created_at: string; + duration: string; + user_login: string; +}; + +// Gets a creator's latest finished stream VOD and all its markers, for the +// public API. Skips the VOD of a stream that is still live. Returns null +// when the creator has no finished VODs. +export const getLatestVodWithMarkers = async (creatorName: string) => { + const token = await getValidTokenForCreator(creatorName); + const userId = await getTwitchUserId(creatorName, token); + if (!userId) return null; + + // While live, the newest archive is the live stream, so 2 is enough. + const [videos, streams] = await Promise.all([ + fetchTwitchLive<{ data: TwitchVideo[] }>( + `/videos?user_id=${userId}&type=archive&first=2`, + token + ), + fetchTwitchLive<{ data: { id: string }[] }>( + `/streams?user_id=${userId}`, + token + ), + ]); + const liveStreamId = streams?.data[0]?.id; + const video = videos?.data.find((v) => v.stream_id !== liveStreamId); + if (!video) return null; + + const markers = await getAllMarkers({ video_id: video.id }, token); + const { id, title, url, created_at, duration, user_login } = video; + return { + vod: { id, title, url, created_at, duration, user_login }, + markers, + }; +}; + // Gets the current topic for a live creator, from the latest marker on the // live stream. Returns null when the creator is not live. export const getLiveTopic = async ( @@ -150,24 +217,7 @@ export const getLiveTopic = async ( const stream = streamRes?.data[0]; if (!stream) return null; - // Markers come oldest first, max 100 per page, so walk to the last page. - // The page cap is only a guard against a bad cursor loop. - const markers: VOD["markers"] = []; - let cursor: string | undefined; - for (let page = 0; page < 10; page++) { - const res = await fetchTwitchLive( - `/streams/markers?user_id=${userId}&first=100${ - cursor ? `&after=${cursor}` : "" - }`, - token - ); - - markers.push( - ...(res?.data?.[0]?.videos?.flatMap((video) => video.markers ?? []) ?? []) - ); - cursor = res?.pagination?.cursor; - if (!cursor) break; - } + const markers = await getAllMarkers({ user_id: userId }, token); return getLiveTopicFromMarkers({ markers, From 090de097c1bb58e758007c2d52de5ea6ff18b23a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 10 Oct 2026 23:40:05 -0700 Subject: [PATCH 2/2] Format README Co-Authored-By: Claude Opus 5.5 --- README.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 77dc9b6..a585223 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,23 @@ Returns the channel's latest finished stream VOD and all its markers as JSON. No ```json { - "vod": { "id": "...", "title": "...", "url": "...", "created_at": "...", "duration": "3h2m1s", "user_login": "theo" }, - "markers": [{ "id": "...", "created_at": "...", "description": "Talking about Chrome", "position_seconds": 600, "URL": "..." }] + "vod": { + "id": "...", + "title": "...", + "url": "...", + "created_at": "...", + "duration": "3h2m1s", + "user_login": "theo" + }, + "markers": [ + { + "id": "...", + "created_at": "...", + "description": "Talking about Chrome", + "position_seconds": 600, + "URL": "..." + } + ] } ```