Repository navigation
Add public API for a channel's latest VOD and markers #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<username>/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 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -134,6 +134,73 @@ const fetchTwitchLive = async <T>(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++) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Do not return a partial marker list as complete. If Twitch supplies an eleventh marker page, this loop stops after page ten and returns only the markers collected so far. Twitch allows up to 100 markers per page, so the public endpoint can omit markers despite promising all of them. Continue until pagination ends, or return an error when a deliberate safety limit is reached. (dev.twitch.tv) 🤖 Prompt for AI AgentsThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If Twitch returns more than ten marker pages, Prompt To Fix With AIThis is a comment left during a code review.
Path: src/utils/twitch-server.ts
Line: 146
Comment:
**Markers can be silently omitted**
If Twitch returns more than ten marker pages, `getAllMarkers` stops and returns success even when another cursor remains. The new API promises all markers, so scripts cannot tell that the list is incomplete. Detect repeated cursors, or return an error when the page limit is reached with more pages remaining.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
| const params = new URLSearchParams({ ...query, first: "100" }); | ||
| if (cursor) params.set("after", cursor); | ||
| const res = await fetchTwitchLive<TwitchMarkersResponse>( | ||
| `/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<TwitchMarkersResponse>( | ||
| `/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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Return a fixed public error message.
If the creator lookup or a dependency throws, this unauthenticated route sends
e.messageto the caller. The message can expose internal Clerk or Twitch failure details; the creator lookup already throws"User not found in Clerk". Keep the detailed server log, but return a fixed 502 message. (dev.twitch.tv)Based on learnings, public API responses must not return internal exception details.
🤖 Prompt for AI Agents
Source: Learnings