feat(yt): cache based on lookback instead of constant period
Build and Push Image / build-backend (push) Successful in 43s
Build and Push Image / build-frontend (push) Successful in 1m51s
Build and Push Image / push (push) Has been cancelled

This commit is contained in:
2026-07-19 03:12:14 +07:00 Unverified
parent c29726a8e4
commit 3fda5d212c
2 changed files with 102 additions and 8 deletions
+8 -8
View File
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { sse } from "@/lib/db/sse-endpoints";
export const revalidate = 900;
import { youtubeCache } from "@/lib/adaptive-cache";
export type YoutubeLiveInfo =
| {
@@ -60,13 +59,10 @@ export async function GET() {
if (!apiKey || !channelId) return NextResponse.json<YoutubeLiveInfo>("none");
const response = await fetch(
const response = await youtubeCache.fetch(
`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`,
{
next: {
revalidate: 900, // refresh every 15 minutes
},
cache: "force-cache",
cache: "no-store",
},
);
@@ -74,7 +70,10 @@ export async function GET() {
throw new Error(`YouTube API error: ${response.statusText}`);
const data: APISearchResponse = await response.json();
if (data.items.length <= 0) return NextResponse.json<YoutubeLiveInfo>("none");
if (data.items.length === 0)
return NextResponse.json<YoutubeLiveInfo>("none");
const live = data.items[0];
const res: YoutubeLiveInfo = {
@@ -85,5 +84,6 @@ export async function GET() {
};
sse.active.pub("live", res);
return NextResponse.json<YoutubeLiveInfo>(res);
}
+94
View File
@@ -0,0 +1,94 @@
// gpt'd at 3am, used as-is by another module
interface AdaptiveCacheOptions {
/** Maximum requests allowed per period. */
maxRequests: number;
/** Usually one day. */
period: number;
/** Rolling window used to estimate request rate. Defaults to period / 2. */
lookback?: number;
/** Enable caching once projected usage exceeds this fraction. Defaults to 0.5. */
threshold?: number;
/** How long cached entries live. */
ttl: number;
/** Cache key generator. */
key?(req: Request): string;
}
export class AdaptiveCache {
private readonly cache = new Map<
string,
{
expires: number;
response: Response;
}
>();
private readonly requests: number[] = [];
constructor(private readonly options: AdaptiveCacheOptions) {
options.lookback ??= options.period / 2;
options.threshold ??= 0.5;
options.key ??= (req) => req.url;
}
async fetch(
input: RequestInfo | URL,
init?: RequestInit,
fetcher: typeof fetch = fetch,
): Promise<Response> {
const req = input instanceof Request ? input : new Request(input, init);
const now = Date.now();
this.cleanup(now);
const key = this.options.key!(req);
if (this.shouldCache()) {
const cached = this.cache.get(key);
if (cached && cached.expires > now) return cached.response.clone();
}
const res = await fetcher(req);
if (res.ok) {
this.requests.push(now);
this.cache.set(key, {
expires: now + this.options.ttl,
response: res.clone(),
});
}
return res;
}
private shouldCache() {
const projected =
this.requests.length * (this.options.period / this.options.lookback!);
return projected >= this.options.maxRequests * this.options.threshold!;
}
private cleanup(now: number) {
const cutoff = now - this.options.lookback!;
while (this.requests.length && this.requests[0] < cutoff)
this.requests.shift();
for (const [key, value] of this.cache)
if (value.expires <= now) this.cache.delete(key);
}
}
export const youtubeCache = new AdaptiveCache({
maxRequests: 100, // YouTube search.list requests/day
period: 24 * 60 * 60_000,
ttl: 15 * 60_000,
});