Files
tvboxzt/tmdb/tmdb.js
T
2026-06-06 15:07:35 +08:00

379 lines
5.4 KiB
JavaScript

const API_CACHE_TTL = 3600; // 1小时
const IMAGE_CACHE_TTL = 2592000; // 30天
const KV_CACHE_TTL = 86400; // 1天
const RATE_LIMIT = 300; // 每分钟
export default {
async fetch(request, env, ctx) {
try {
return await handle(request, env, ctx);
} catch (e) {
return json(
{
error: e.message,
},
500
);
}
}
};
async function handle(request, env, ctx) {
const url = new URL(request.url);
// -------------------
// CORS
// -------------------
if (request.method === "OPTIONS") {
return new Response(null, {
headers: corsHeaders()
});
}
// -------------------
// Rate Limit
// -------------------
const ip =
request.headers.get("CF-Connecting-IP") ||
"unknown";
const rlKey =
`rl:${ip}:${Math.floor(Date.now()/60000)}`;
if (env.TMDB_CACHE) {
const count =
parseInt(
await env.TMDB_CACHE.get(rlKey) || "0"
);
if (count > RATE_LIMIT) {
return json(
{
error: "rate limit exceeded"
},
429
);
}
ctx.waitUntil(
env.TMDB_CACHE.put(
rlKey,
String(count + 1),
{
expirationTtl: 120
}
)
);
}
// -------------------
// Routing
// -------------------
if (
url.pathname.startsWith("/t/p/")
) {
return imageProxy(
request,
url,
ctx
);
}
return apiProxy(
request,
url,
env,
ctx
);
}
async function apiProxy(
request,
url,
env,
ctx
) {
const cache = caches.default;
const cacheKey =
new Request(
request.url,
request
);
let cached =
await cache.match(cacheKey);
if (cached) {
return addCacheHeader(
cached,
"EDGE-HIT"
);
}
const kvKey =
`api:${url.pathname}${url.search}`;
if (env.TMDB_CACHE) {
const kvData =
await env.TMDB_CACHE.get(
kvKey
);
if (kvData) {
return new Response(
kvData,
{
headers: {
"content-type":
"application/json",
...corsHeaders(),
"X-TMDB-Cache":
"KV-HIT"
}
}
);
}
}
const keys =
(env.TMDB_KEYS || "")
.split(",")
.filter(Boolean);
if (!keys.length) {
throw new Error(
"TMDB_KEYS missing"
);
}
const key =
keys[
Math.floor(
Date.now()/60000
) % keys.length
];
const upstreamUrl =
new URL(
"https://api.themoviedb.org" +
url.pathname
);
url.searchParams.forEach(
(v, k) =>
upstreamUrl.searchParams.set(
k,
v
)
);
upstreamUrl.searchParams.set(
"api_key",
key
);
const upstream =
await fetch(
upstreamUrl.toString(),
{
headers: {
accept:
"application/json"
}
}
);
const text =
await upstream.text();
const headers =
new Headers();
headers.set(
"content-type",
"application/json"
);
headers.set(
"cache-control",
`public,max-age=${API_CACHE_TTL}`
);
Object.entries(
corsHeaders()
).forEach(
([k, v]) =>
headers.set(k, v)
);
headers.set(
"X-TMDB-Cache",
"MISS"
);
const response =
new Response(
text,
{
status:
upstream.status,
headers
}
);
if (upstream.ok) {
ctx.waitUntil(
cache.put(
cacheKey,
response.clone()
)
);
if (env.TMDB_CACHE) {
ctx.waitUntil(
env.TMDB_CACHE.put(
kvKey,
text,
{
expirationTtl:
KV_CACHE_TTL
}
)
);
}
}
return response;
}
async function imageProxy(
request,
url,
ctx
) {
const cache =
caches.default;
const cached =
await cache.match(
request
);
if (cached) {
return addCacheHeader(
cached,
"EDGE-HIT"
);
}
const upstream =
await fetch(
"https://image.tmdb.org" +
url.pathname,
{
headers: request.headers
}
);
const headers =
new Headers(
upstream.headers
);
headers.set(
"cache-control",
`public,max-age=${IMAGE_CACHE_TTL}`
);
headers.set(
"Access-Control-Allow-Origin",
"*"
);
headers.set(
"X-TMDB-Cache",
"MISS"
);
const response =
new Response(
upstream.body,
{
status:
upstream.status,
headers
}
);
if (upstream.ok) {
ctx.waitUntil(
cache.put(
request,
response.clone()
)
);
}
return response;
}
function addCacheHeader(
response,
value
) {
const headers =
new Headers(
response.headers
);
headers.set(
"X-TMDB-Cache",
value
);
return new Response(
response.body,
{
status:
response.status,
headers
}
);
}
function corsHeaders() {
return {
"Access-Control-Allow-Origin":
"*",
"Access-Control-Allow-Methods":
"GET,HEAD,OPTIONS",
"Access-Control-Allow-Headers":
"*"
};
}
function json(
data,
status = 200
) {
return new Response(
JSON.stringify(data),
{
status,
headers: {
"content-type":
"application/json",
...corsHeaders()
}
}
);
}