mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
55
apps/web-react/app/modules/twitch/fetch.ts
Normal file
55
apps/web-react/app/modules/twitch/fetch.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { logger } from "~/utils/logger";
|
||||
import { getToken, purgeCachedToken } from "./token";
|
||||
import { getTwitchEnvVars } from "./utils.server";
|
||||
|
||||
const MAX_RATE_LIMIT_RETRIES = 5;
|
||||
const MAX_RATE_LIMIT_WAIT_MS = 60_000;
|
||||
|
||||
export async function twitchFetch(
|
||||
url: string,
|
||||
{ isRetry = false, rateLimitRetries = 0 } = {},
|
||||
): Promise<Response> {
|
||||
const { TWITCH_CLIENT_ID } = getTwitchEnvVars();
|
||||
const token = await getToken();
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: [
|
||||
["Authorization", `Bearer ${token}`],
|
||||
["Client-Id", TWITCH_CLIENT_ID],
|
||||
],
|
||||
});
|
||||
|
||||
if (res.status === 401 && !isRetry) {
|
||||
purgeCachedToken();
|
||||
return twitchFetch(url, { isRetry: true });
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
if (rateLimitRetries >= MAX_RATE_LIMIT_RETRIES) {
|
||||
throw new Error(
|
||||
`Twitch API rate limited after ${MAX_RATE_LIMIT_RETRIES} retries`,
|
||||
);
|
||||
}
|
||||
|
||||
const resetHeader = res.headers.get("Ratelimit-Reset");
|
||||
const resetTimestamp = resetHeader ? Number(resetHeader) : 0;
|
||||
const waitMs = Math.max(resetTimestamp * 1000 - Date.now(), 1000);
|
||||
|
||||
if (waitMs > MAX_RATE_LIMIT_WAIT_MS) {
|
||||
throw new Error(
|
||||
`Twitch API rate limit reset too far in the future (${Math.ceil(waitMs / 1000)}s)`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(`Twitch rate limited, waiting ${Math.ceil(waitMs / 1000)}s`);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
|
||||
return twitchFetch(url, { rateLimitRetries: rateLimitRetries + 1 });
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Twitch API request failed with status: ${res.status}`);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
1
apps/web-react/app/modules/twitch/index.ts
Normal file
1
apps/web-react/app/modules/twitch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getStreams } from "./streams";
|
||||
62
apps/web-react/app/modules/twitch/schemas.ts
Normal file
62
apps/web-react/app/modules/twitch/schemas.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { z } from "zod";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
|
||||
export const streamsSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
user_login: z.string(),
|
||||
user_name: z.string(),
|
||||
game_id: z.string(),
|
||||
game_name: z.string(),
|
||||
type: z.string(),
|
||||
title: z.string(),
|
||||
viewer_count: z.number(),
|
||||
started_at: z.string(),
|
||||
language: z.string(),
|
||||
thumbnail_url: z.string(),
|
||||
tag_ids: z.array(z.unknown()),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
is_mature: z.boolean(),
|
||||
}),
|
||||
),
|
||||
pagination: z.object({ cursor: z.string().nullish() }),
|
||||
});
|
||||
|
||||
export const tokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
token_type: z.string(),
|
||||
});
|
||||
|
||||
export const usersSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
login: z.string(),
|
||||
display_name: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const videosSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
user_login: z.string(),
|
||||
title: z.string(),
|
||||
created_at: z.string(),
|
||||
duration: z.string(),
|
||||
view_count: z.number(),
|
||||
type: z.string(),
|
||||
}),
|
||||
),
|
||||
pagination: z.object({ cursor: z.string().nullish() }),
|
||||
});
|
||||
|
||||
export type StreamsResponse = z.infer<typeof streamsSchema>;
|
||||
export type RawStream = Unpacked<z.infer<typeof streamsSchema>["data"]>;
|
||||
export type UsersResponse = z.infer<typeof usersSchema>;
|
||||
export type RawVideo = Unpacked<z.infer<typeof videosSchema>["data"]>;
|
||||
147
apps/web-react/app/modules/twitch/streams.ts
Normal file
147
apps/web-react/app/modules/twitch/streams.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { twitchFetch } from "./fetch";
|
||||
import { type RawStream, type StreamsResponse, streamsSchema } from "./schemas";
|
||||
|
||||
// const STREAMS_MOCK = [
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_777_jared-{width}x{height}.jpg",
|
||||
// twitchUserName: "777_jared",
|
||||
// viewerCount: 129,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_ano_rta-{width}x{height}.jpg",
|
||||
// twitchUserName: "ano_rta",
|
||||
// viewerCount: 50,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_mikashita0104-{width}x{height}.jpg",
|
||||
// twitchUserName: "mikashita0104",
|
||||
// viewerCount: 49,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_veenam-{width}x{height}.jpg",
|
||||
// twitchUserName: "veenam",
|
||||
// viewerCount: 42,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_minijutopia-{width}x{height}.jpg",
|
||||
// twitchUserName: "minijutopia",
|
||||
// viewerCount: 25,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_liligravybread-{width}x{height}.jpg",
|
||||
// twitchUserName: "liligravybread",
|
||||
// viewerCount: 25,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_ajx_live-{width}x{height}.jpg",
|
||||
// twitchUserName: "ajx_live",
|
||||
// viewerCount: 19,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_yuzuki729-{width}x{height}.jpg",
|
||||
// twitchUserName: "yuzuki729",
|
||||
// viewerCount: 18,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_noname_nosplat-{width}x{height}.jpg",
|
||||
// twitchUserName: "noname_nosplat",
|
||||
// viewerCount: 14,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_dosankoneet-{width}x{height}.jpg",
|
||||
// twitchUserName: "dosankoneet",
|
||||
// viewerCount: 13,
|
||||
// },
|
||||
// ];
|
||||
|
||||
export async function getStreams() {
|
||||
try {
|
||||
const result = await cachified({
|
||||
key: "twitch-streams",
|
||||
cache,
|
||||
// 2 minutes
|
||||
ttl: 1000 * 60 * 2,
|
||||
// 10 minutes
|
||||
staleWhileRevalidate: 1000 * 60 * 5 * 2,
|
||||
async getFreshValue() {
|
||||
return (await getAllStreams())
|
||||
.map(mapRawStream)
|
||||
.sort((a, b) => b.viewerCount - a.viewerCount);
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mapRawStream(stream: RawStream) {
|
||||
return {
|
||||
thumbnailUrl: stream.thumbnail_url,
|
||||
twitchUserName: stream.user_login.toLowerCase(),
|
||||
viewerCount: stream.viewer_count,
|
||||
};
|
||||
}
|
||||
|
||||
const SPLATOON_3_TWITCH_GAME_ID = "1158884259";
|
||||
async function getAllStreams() {
|
||||
if (process.env.NODE_ENV === "test" || IS_E2E_TEST_RUN) return [];
|
||||
|
||||
const result: RawStream[] = [];
|
||||
|
||||
let cursor: string | undefined;
|
||||
let count = 0;
|
||||
while (true) {
|
||||
if (count === 50) {
|
||||
throw new Error("Stuck getting streams");
|
||||
}
|
||||
const { data, pagination } = await getStreamsChunk(cursor);
|
||||
|
||||
result.push(
|
||||
// filter to ensure each streamer appears only once
|
||||
...data.filter(
|
||||
(stream) =>
|
||||
!result.some(
|
||||
(existingStream) => existingStream.user_id === stream.user_id,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!pagination.cursor) {
|
||||
return result;
|
||||
}
|
||||
|
||||
cursor = pagination.cursor;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamsChunk(cursor?: string): Promise<StreamsResponse> {
|
||||
const res = await twitchFetch(
|
||||
`https://api.twitch.tv/helix/streams?game_id=${SPLATOON_3_TWITCH_GAME_ID}&first=100&after=${
|
||||
cursor ?? ""
|
||||
}`,
|
||||
);
|
||||
|
||||
const parsed = streamsSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.message);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
39
apps/web-react/app/modules/twitch/token.ts
Normal file
39
apps/web-react/app/modules/twitch/token.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { tokenResponseSchema } from "./schemas";
|
||||
import { getTwitchEnvVars } from "./utils.server";
|
||||
|
||||
async function getFreshToken() {
|
||||
const { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET } = getTwitchEnvVars();
|
||||
|
||||
const res = await fetch(
|
||||
`https://id.twitch.tv/oauth2/token?client_id=${TWITCH_CLIENT_ID}&client_secret=${TWITCH_CLIENT_SECRET}&grant_type=client_credentials`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Getting Twitch token failed with status code: ${res.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = tokenResponseSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error("Token response schema validation failed");
|
||||
}
|
||||
|
||||
return parsed.data.access_token;
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return cachified({
|
||||
key: "twitch-token",
|
||||
cache,
|
||||
getFreshValue() {
|
||||
return getFreshToken();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function purgeCachedToken() {
|
||||
cache.delete("twitch-token");
|
||||
}
|
||||
16
apps/web-react/app/modules/twitch/utils.server.ts
Normal file
16
apps/web-react/app/modules/twitch/utils.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
||||
export const hasTwitchEnvVars = () =>
|
||||
Boolean(ServerConfig.twitch.clientId && ServerConfig.twitch.clientSecret);
|
||||
|
||||
export const getTwitchEnvVars = () => {
|
||||
const { clientId, clientSecret } = ServerConfig.twitch;
|
||||
invariant(clientId, "Missing TWITCH_CLIENT_ID env var, showing no streams");
|
||||
invariant(
|
||||
clientSecret,
|
||||
"Missing TWITCH_CLIENT_SECRET env var, showing no streams",
|
||||
);
|
||||
|
||||
return { TWITCH_CLIENT_ID: clientId, TWITCH_CLIENT_SECRET: clientSecret };
|
||||
};
|
||||
2
apps/web-react/app/modules/twitch/utils.ts
Normal file
2
apps/web-react/app/modules/twitch/utils.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const twitchThumbnailUrlToSrc = (url: string) =>
|
||||
url.replace("{width}", "640").replace("{height}", "360");
|
||||
40
apps/web-react/app/modules/twitch/vods.test.ts
Normal file
40
apps/web-react/app/modules/twitch/vods.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseTwitchDuration } from "./vods";
|
||||
|
||||
describe("parseTwitchDuration()", () => {
|
||||
test("parses hours, minutes and seconds", () => {
|
||||
expect(parseTwitchDuration("1h2m3s")).toBe(3723);
|
||||
});
|
||||
|
||||
test("parses hours only", () => {
|
||||
expect(parseTwitchDuration("2h")).toBe(7200);
|
||||
});
|
||||
|
||||
test("parses minutes only", () => {
|
||||
expect(parseTwitchDuration("45m")).toBe(2700);
|
||||
});
|
||||
|
||||
test("parses seconds only", () => {
|
||||
expect(parseTwitchDuration("30s")).toBe(30);
|
||||
});
|
||||
|
||||
test("parses hours and minutes without seconds", () => {
|
||||
expect(parseTwitchDuration("1h30m")).toBe(5400);
|
||||
});
|
||||
|
||||
test("parses hours and seconds without minutes", () => {
|
||||
expect(parseTwitchDuration("2h15s")).toBe(7215);
|
||||
});
|
||||
|
||||
test("parses minutes and seconds without hours", () => {
|
||||
expect(parseTwitchDuration("5m10s")).toBe(310);
|
||||
});
|
||||
|
||||
test("returns 0 for empty string", () => {
|
||||
expect(parseTwitchDuration("")).toBe(0);
|
||||
});
|
||||
|
||||
test("parses large values", () => {
|
||||
expect(parseTwitchDuration("99h59m59s")).toBe(359999);
|
||||
});
|
||||
});
|
||||
79
apps/web-react/app/modules/twitch/vods.ts
Normal file
79
apps/web-react/app/modules/twitch/vods.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as R from "remeda";
|
||||
import { twitchFetch } from "./fetch";
|
||||
import {
|
||||
type RawVideo,
|
||||
type UsersResponse,
|
||||
usersSchema,
|
||||
videosSchema,
|
||||
} from "./schemas";
|
||||
|
||||
export async function getUsersByLogin(
|
||||
logins: string[],
|
||||
): Promise<UsersResponse["data"]> {
|
||||
if (logins.length === 0) return [];
|
||||
|
||||
const results: UsersResponse["data"] = [];
|
||||
|
||||
for (const batch of R.chunk(logins, 100)) {
|
||||
const params = batch.map((l) => `login=${encodeURIComponent(l)}`).join("&");
|
||||
|
||||
const res = await twitchFetch(
|
||||
`https://api.twitch.tv/helix/users?${params}`,
|
||||
);
|
||||
|
||||
const parsed = usersSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Twitch users schema validation failed: ${parsed.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
results.push(...parsed.data.data);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getArchiveVideos(userId: string): Promise<RawVideo[]> {
|
||||
const results: RawVideo[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("https://api.twitch.tv/helix/videos");
|
||||
url.searchParams.set("user_id", userId);
|
||||
url.searchParams.set("type", "archive");
|
||||
url.searchParams.set("first", "100");
|
||||
if (cursor) {
|
||||
url.searchParams.set("after", cursor);
|
||||
}
|
||||
|
||||
const res = await twitchFetch(url.toString());
|
||||
|
||||
const parsed = videosSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Twitch videos schema validation failed: ${parsed.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
results.push(...parsed.data.data);
|
||||
|
||||
if (!parsed.data.pagination.cursor) break;
|
||||
cursor = parsed.data.pagination.cursor;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const DURATION_REGEX = /(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/;
|
||||
|
||||
export function parseTwitchDuration(duration: string): number {
|
||||
const match = DURATION_REGEX.exec(duration);
|
||||
if (!match) return 0;
|
||||
|
||||
const hours = Number(match[1] ?? 0);
|
||||
const minutes = Number(match[2] ?? 0);
|
||||
const seconds = Number(match[3] ?? 0);
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
Reference in New Issue
Block a user