diff --git a/tmdb/README.md b/tmdb/README.md new file mode 100644 index 0000000..a8a7400 --- /dev/null +++ b/tmdb/README.md @@ -0,0 +1,287 @@ +# TMDB Cloudflare Worker Proxy + +一个基于 Cloudflare Worker 的 TMDB API 与图片代理服务。 + +支持: + +* TMDB API 代理 +* TMDB 图片代理 +* 隐藏 API Key +* Cloudflare Edge Cache +* KV 二级缓存 +* 多 API Key 轮换 +* 自定义域名 + +--- + +# 文件说明 + +项目包含两个版本: + +| 文件 | 说明 | 适合场景 | +| -------- | --- | --------------------------- | +| index.js | 轻量版 | 个人使用、小规模部署 | +| tmdb.js | 生产版 | ATV-Player、影视TV、TvBox 等公开服务 | + +--- + +## index.js(轻量版) + +特点: + +* 单文件 +* 无 KV 依赖 +* API 代理 +* 图片代理 +* Edge Cache +* API Key 隐藏 + +适合: + +* 自用 +* 测试 +* 小规模用户 + +部署时直接复制到 Cloudflare Worker 即可。 + +--- + +## tmdb.js(生产版) + +特点: + +* API 代理 +* 图片代理 +* API Key 隐藏 +* Cloudflare Edge Cache +* KV 二级缓存 +* 多 API Key 自动轮换 +* IP 限流 +* CORS +* 热门接口缓存 + +适合: + +* ATV-Player +* TvBox +* 影视TV +* 公共 API 服务 + +推荐使用 Wrangler 部署。 + +--- + +# 获取 TMDB API Key + +注册: + +https://www.themoviedb.org/ + +创建 API Key: + +https://www.themoviedb.org/settings/api + +--- + +# 部署方式 + +## 方案一:Dashboard(推荐新手) + +### 创建 Worker + +Cloudflare Dashboard + +Workers & Pages + +Create Worker + +--- + +### 使用轻量版 + +复制: + +```text +index.js +``` + +内容到 Worker。 + +--- + +### 添加环境变量 + +Settings + +Variables + +新增: + +```text +TMDB_KEYS +``` + +值: + +```text +your_tmdb_api_key +``` + +部署即可。 + +--- + +## 方案二:Wrangler(推荐生产环境) + +### 安装 + +```bash +npm install -g wrangler +``` + +--- + +### 登录 + +```bash +wrangler login +``` + +--- + +### 创建 KV + +```bash +wrangler kv namespace create TMDB_CACHE +``` + +记录返回的 Namespace ID。 + +--- + +### wrangler.toml + +```toml +name = "tmdb-proxy" +main = "tmdb.js" +compatibility_date = "2026-06-06" + +[[kv_namespaces]] +binding = "TMDB_CACHE" +id = "YOUR_KV_NAMESPACE_ID" +``` + +--- + +### 配置 API Key + +```bash +wrangler secret put TMDB_KEYS +``` + +输入: + +```text +key1,key2,key3 +``` + +支持多个 Key。 + +--- + +### 本地运行 + +```bash +wrangler dev +``` + +--- + +### 部署 + +```bash +wrangler deploy +``` + +--- + +# API 示例 + +请求: + +```text +https://tmdb.example.com/3/movie/550 +``` + +实际访问: + +```text +https://api.themoviedb.org/3/movie/550 +``` + +并自动附加 API Key。 + +--- + +# 图片示例 + +请求: + +```text +https://tmdb.example.com/t/p/w500/abc.jpg +``` + +实际访问: + +```text +https://image.tmdb.org/t/p/w500/abc.jpg +``` + +--- + +# ATV-Player 配置 + +```text +TMDB API URL: +https://tmdb.example.com + +TMDB Image URL: +https://tmdb.example.com +``` + +--- + +# 缓存策略 + +轻量版: + +| 类型 | 缓存 | +| --- | --- | +| API | 1小时 | +| 图片 | 30天 | + +生产版: + +| 类型 | 缓存 | +| ---------- | ---- | +| Edge Cache | 1小时 | +| KV Cache | 24小时 | +| 图片 | 30天 | + +--- + +# 推荐选择 + +个人使用: + +```text +index.js +``` + +公开服务: + +```text +tmdb.js +``` + +如果预计用户超过 1000 人,推荐直接使用生产版。 diff --git a/tmdb/index.js b/tmdb/index.js new file mode 100644 index 0000000..f880590 --- /dev/null +++ b/tmdb/index.js @@ -0,0 +1,117 @@ +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + + // 只允许 GET / HEAD 缓存 + const cacheable = + request.method === "GET" || + request.method === "HEAD"; + + const cache = caches.default; + + if (cacheable) { + const cached = await cache.match(request); + if (cached) { + const headers = new Headers(cached.headers); + headers.set("X-TMDB-Cache", "HIT"); + return new Response(cached.body, { + status: cached.status, + headers + }); + } + } + + let targetUrl; + let cacheTTL; + + // ========================== + // API + // https://tmdb.xxx.com/3/movie/550 + // ========================== + if ( + url.pathname.startsWith("/3/") || + url.pathname.startsWith("/4/") + ) { + targetUrl = + "https://api.themoviedb.org" + + url.pathname + + url.search; + + cacheTTL = 3600; + } + + // ========================== + // 图片 + // https://tmdb.xxx.com/t/p/w500/xxx.jpg + // ========================== + else if ( + url.pathname.startsWith("/t/p/") + ) { + targetUrl = + "https://image.tmdb.org" + + url.pathname; + + cacheTTL = 2592000; // 30天 + } + + else { + return new Response( + JSON.stringify({ + service: "TMDB Proxy", + api: "/3/*", + image: "/t/p/*" + }), + { + headers: { + "content-type": "application/json" + } + } + ); + } + + const upstream = await fetch(targetUrl, { + method: request.method, + headers: request.headers + }); + + const headers = new Headers(upstream.headers); + + headers.set( + "Cache-Control", + `public, s-maxage=${cacheTTL}` + ); + + headers.set( + "Access-Control-Allow-Origin", + "*" + ); + + headers.set( + "X-TMDB-Cache", + "MISS" + ); + + const response = new Response( + upstream.body, + { + status: upstream.status, + statusText: upstream.statusText, + headers + } + ); + + if ( + cacheable && + upstream.ok + ) { + ctx.waitUntil( + cache.put( + request, + response.clone() + ) + ); + } + + return response; + } +}; diff --git a/tmdb/tmdb.js b/tmdb/tmdb.js new file mode 100644 index 0000000..46994ca --- /dev/null +++ b/tmdb/tmdb.js @@ -0,0 +1,378 @@ +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() + } + } + ); +} diff --git a/tmdb/wrangler.toml.example b/tmdb/wrangler.toml.example new file mode 100644 index 0000000..60bdd2e --- /dev/null +++ b/tmdb/wrangler.toml.example @@ -0,0 +1,8 @@ +name = "tmdb-proxy" + +[[kv_namespaces]] +binding = "TMDB_CACHE" +id = "YOUR_KV_ID" + +[vars] +TMDB_KEYS = "key1,key2,key3"