From 81c46d754e1dbdd055df73fe0afcce37cbf5f57f Mon Sep 17 00:00:00 2001 From: can4hou6joeng4 Date: Fri, 31 Jul 2026 10:12:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(sync):=20=E4=BF=AE=E5=A4=8D=E8=87=AA?= =?UTF-8?q?=E6=89=98=E7=AE=A1=E4=B8=8B=E6=97=A0=E6=B3=95=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=20Upstash=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/api/user/{sync,config} 用 @upstash/redis/cloudflare 的 Redis.fromEnv() 创建客户端。该实现只读传入的 env 参数或 Cloudflare 全局绑定,从不回退到 process.env,因此在 Docker / Node 自托管下 url 与 token 恒为 undefined,两个接口必定返回 500,跨设备同步 (收藏、历史、设置)完全不可用。认证走的是 auth.ts 里的 new Redis({ url, token }),所以登录正常,只有用户数据读写失败。 把 auth.ts 中已有的双源回退抽成 lib/server/runtime-env.ts,Redis 客户端抽成 lib/server/redis.ts,两个路由改为惰性获取共享客户端。 惰性创建同时修正了另一个隐患:Cloudflare 的 per-request 绑定在 模块求值阶段不可达,而原先客户端是在模块作用域创建的。 未配置 Upstash 时返回 503 并给出明确说明,而不是当成请求失败。 Fixes #226 --- app/api/user/config/route.ts | 21 +++++++++++++++++--- app/api/user/sync/route.ts | 23 ++++++++++++++++++---- lib/server/auth.ts | 37 ++---------------------------------- lib/server/redis.ts | 36 +++++++++++++++++++++++++++++++++++ lib/server/runtime-env.ts | 20 +++++++++++++++++++ 5 files changed, 95 insertions(+), 42 deletions(-) create mode 100644 lib/server/redis.ts create mode 100644 lib/server/runtime-env.ts diff --git a/app/api/user/config/route.ts b/app/api/user/config/route.ts index e980c6a..eb0a22b 100644 --- a/app/api/user/config/route.ts +++ b/app/api/user/config/route.ts @@ -5,20 +5,25 @@ * so they persist across browsers, devices, and PWA installs. */ -import { Redis } from '@upstash/redis/cloudflare'; import { NextRequest, NextResponse } from 'next/server'; import { authenticationRequiredResponse } from '@/lib/server/api-responses'; import { getServerSession } from '@/lib/server/auth'; +import { getRedisClient } from '@/lib/server/redis'; export const runtime = 'edge'; -const redis = Redis.fromEnv(); - function redisKey(profileId: string): string { const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, ''); return `user:config:${safe}`; } +function syncUnavailableResponse() { + return NextResponse.json( + { error: 'Server-side sync is not configured on this deployment' }, + { status: 503 } + ); +} + export async function GET(request: NextRequest) { const session = await getServerSession(request); const profileId = session?.profileId; @@ -27,6 +32,11 @@ export async function GET(request: NextRequest) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const data = await redis.get(redisKey(profileId)); return NextResponse.json({ success: true, data: data || null }); @@ -47,6 +57,11 @@ export async function POST(request: NextRequest) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const body = await request.json(); const key = redisKey(profileId); diff --git a/app/api/user/sync/route.ts b/app/api/user/sync/route.ts index e52aee3..f83032a 100644 --- a/app/api/user/sync/route.ts +++ b/app/api/user/sync/route.ts @@ -1,21 +1,31 @@ -import { Redis } from '@upstash/redis/cloudflare'; import { NextRequest, NextResponse } from 'next/server'; import { authenticationRequiredResponse } from '@/lib/server/api-responses'; import { getServerSession } from '@/lib/server/auth'; +import { getRedisClient } from '@/lib/server/redis'; // 确保这行代码在整个文件中只出现一次 export const runtime = 'edge'; -const redis = Redis.fromEnv(); +function syncUnavailableResponse() { + return NextResponse.json( + { error: 'Server-side sync is not configured on this deployment' }, + { status: 503 } + ); +} export async function GET(request: NextRequest) { const session = await getServerSession(request); const profileId = session?.profileId; - + if (!profileId) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const data = await redis.get(`user:sync:${profileId}`); return NextResponse.json({ @@ -31,11 +41,16 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { const session = await getServerSession(request); const profileId = session?.profileId; - + if (!profileId) { return authenticationRequiredResponse(); } + const redis = getRedisClient(); + if (!redis) { + return syncUnavailableResponse(); + } + try { const body = await request.json(); const { history, favorites } = body; diff --git a/lib/server/auth.ts b/lib/server/auth.ts index 375af6a..d2611da 100644 --- a/lib/server/auth.ts +++ b/lib/server/auth.ts @@ -1,6 +1,6 @@ -import { Redis } from '@upstash/redis/cloudflare'; -import { getOptionalRequestContext } from '@cloudflare/next-on-pages'; import { NextRequest, NextResponse } from 'next/server'; +import { getRedisClient } from '@/lib/server/redis'; +import { getRuntimeEnvValue } from '@/lib/server/runtime-env'; import { getRuntimeFeatures } from '@/lib/server/runtime-features'; import { createStoredAccount, @@ -87,25 +87,11 @@ const DANMAKU_API_URL = process.env.DANMAKU_API_URL || process.env.NEXT_PUBLIC_D const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const MANAGED_AUTH_FORCED = process.env.MANAGED_AUTH_ENABLED === 'true'; -function getRuntimeEnvValue(name: string, fallback = ''): string { - try { - const runtimeEnv = getOptionalRequestContext()?.env as unknown as Record | undefined; - const value = runtimeEnv?.[name]; - if (typeof value === 'string') return value; - } catch { - // Outside Cloudflare's request runtime, fall back to process.env. - } - - return process.env[name] || fallback; -} - function getEffectiveAdminPassword(): string { return getRuntimeEnvValue('ADMIN_PASSWORD', ADMIN_PASSWORD) || getRuntimeEnvValue('ACCESS_PASSWORD', ACCESS_PASSWORD); } -let cachedRedis: Redis | null | undefined; - export class ManagedAuthStorageError extends Error { constructor(operation: 'read' | 'write', cause?: unknown) { super(`Managed auth storage ${operation} failed`, { cause }); @@ -113,25 +99,6 @@ export class ManagedAuthStorageError extends Error { } } -function getRedisClient(): Redis | null { - if (cachedRedis !== undefined) { - return cachedRedis; - } - - const url = getRuntimeEnvValue('UPSTASH_REDIS_REST_URL'); - const token = getRuntimeEnvValue('UPSTASH_REDIS_REST_TOKEN'); - if (!url || !token) { - cachedRedis = null; - return cachedRedis; - } - - cachedRedis = new Redis({ - url, - token, - }); - return cachedRedis; -} - function isManagedAuthEnabled(): boolean { return !!getRuntimeEnvValue('AUTH_SECRET', AUTH_SECRET) && !!getRedisClient(); } diff --git a/lib/server/redis.ts b/lib/server/redis.ts new file mode 100644 index 0000000..3fabeb6 --- /dev/null +++ b/lib/server/redis.ts @@ -0,0 +1,36 @@ +import { Redis } from '@upstash/redis/cloudflare'; + +import { getRuntimeEnvValue } from '@/lib/server/runtime-env'; + +let cachedRedis: Redis | null | undefined; + +/** + * Build the shared Upstash client from whichever environment source is available. + * + * `Redis.fromEnv()` is deliberately not used here: the `@upstash/redis/cloudflare` + * implementation only reads Cloudflare's global bindings and never falls back to + * `process.env`, so it always resolves to `undefined` on Docker / Node + * self-hosted deployments. The client is also created lazily because Cloudflare's + * per-request bindings are not reachable while a module is being evaluated. + * + * Returns `null` when Upstash is not configured, which callers should treat as + * "server-side sync unavailable" rather than as a request failure. + */ +export function getRedisClient(): Redis | null { + if (cachedRedis !== undefined) { + return cachedRedis; + } + + const url = getRuntimeEnvValue('UPSTASH_REDIS_REST_URL'); + const token = getRuntimeEnvValue('UPSTASH_REDIS_REST_TOKEN'); + if (!url || !token) { + cachedRedis = null; + return cachedRedis; + } + + cachedRedis = new Redis({ + url, + token, + }); + return cachedRedis; +} diff --git a/lib/server/runtime-env.ts b/lib/server/runtime-env.ts new file mode 100644 index 0000000..7f08bff --- /dev/null +++ b/lib/server/runtime-env.ts @@ -0,0 +1,20 @@ +import { getOptionalRequestContext } from '@cloudflare/next-on-pages'; + +/** + * Read an environment value that may live in either runtime environment. + * + * On Cloudflare, bindings and secrets are only reachable through the + * per-request context. On Docker / Node self-hosting there is no such context, + * so the value comes from `process.env`. + */ +export function getRuntimeEnvValue(name: string, fallback = ''): string { + try { + const runtimeEnv = getOptionalRequestContext()?.env as unknown as Record | undefined; + const value = runtimeEnv?.[name]; + if (typeof value === 'string') return value; + } catch { + // Outside Cloudflare's request runtime, fall back to process.env. + } + + return process.env[name] || fallback; +} From b63f94fed45e2de2003a121f764f20dfadf211ca Mon Sep 17 00:00:00 2001 From: can4hou6joeng4 Date: Fri, 31 Jul 2026 18:28:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(sync):=20=E4=B8=A2=E5=BC=83=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=20videoId=20=E7=9A=84=E5=90=8C=E6=AD=A5=E8=AE=B0?= =?UTF-8?q?=E5=BD=95,=E9=81=BF=E5=85=8D=E6=95=B4=E9=A1=B5=E5=B4=A9?= =?UTF-8?q?=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端同步恢复回来的记录不保证结构完整(旧结构或半截写入)。缺少 videoId 的记录会让 HistoryItem.tsx:24 与 FavoritesItem.tsx:21 在渲染期拼播放地址 时对 undefined 调用 toString(),抛出未捕获异常。由于 getVideoUrl() 是在 渲染 href 时调用的,React 会直接卸载整棵树,首页白屏且刷新无效——该账号 只能靠直接改库才能恢复。 新增 lib/utils/sync-records.ts 做可渲染性判定(要求 videoId、source、 title 齐备),在云端拉取入口 useCloudSync 与 HistoryList / FavoritesList 各过滤一次:脏数据进不来,已经落库的也渲染不出来。 --- components/favorites/FavoritesList.tsx | 3 ++- components/history/HistoryList.tsx | 3 ++- lib/hooks/useCloudSync.ts | 12 ++++++---- lib/utils/sync-records.ts | 32 ++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 lib/utils/sync-records.ts diff --git a/components/favorites/FavoritesList.tsx b/components/favorites/FavoritesList.tsx index 52e8c43..ff8a058 100644 --- a/components/favorites/FavoritesList.tsx +++ b/components/favorites/FavoritesList.tsx @@ -5,6 +5,7 @@ import type { FavoriteItem } from '@/lib/types'; import { FavoritesItem } from './FavoritesItem'; import { FavoritesEmptyState } from './FavoritesEmptyState'; +import { keepRenderableFavorites } from '@/lib/utils/sync-records'; interface FavoritesListProps { favorites: FavoriteItem[]; @@ -19,7 +20,7 @@ export function FavoritesList({ favorites, onRemove, isPremium = false }: Favori return (
- {favorites.map((item) => ( + {keepRenderableFavorites(favorites).map((item) => ( ) : (
- {history.map((item) => ( + {keepRenderableHistory(history).map((item) => ( 0) { - historyStore.getState().importHistory(result.data.history); + const history = keepRenderableHistory(result.data.history); + const favorites = keepRenderableFavorites(result.data.favorites); + + if (history.length > 0) { + historyStore.getState().importHistory(history); } - if (result.data.favorites?.length > 0) { - favoritesStore.getState().importFavorites(result.data.favorites); + if (favorites.length > 0) { + favoritesStore.getState().importFavorites(favorites); } } } catch (error) { diff --git a/lib/utils/sync-records.ts b/lib/utils/sync-records.ts new file mode 100644 index 0000000..0d3dc7c --- /dev/null +++ b/lib/utils/sync-records.ts @@ -0,0 +1,32 @@ +import type { FavoriteItem, VideoHistoryItem } from '@/lib/types'; + +function hasIdentity(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const record = value as { videoId?: unknown; source?: unknown; title?: unknown }; + const videoId = record.videoId; + const hasVideoId = typeof videoId === 'string' ? videoId.length > 0 : typeof videoId === 'number'; + + return hasVideoId && typeof record.source === 'string' && typeof record.title === 'string'; +} + +/** + * Records restored from server-side sync are not guaranteed to be well formed — + * they may come from an older schema or a partial write. Rendering one without + * `videoId` throws while building the player URL, which takes down the whole + * page, so anything lacking a usable identity is dropped on the way in. + */ +export function isRenderableHistoryItem(value: unknown): value is VideoHistoryItem { + return hasIdentity(value); +} + +export function isRenderableFavoriteItem(value: unknown): value is FavoriteItem { + return hasIdentity(value); +} + +export function keepRenderableHistory(items: unknown): VideoHistoryItem[] { + return Array.isArray(items) ? items.filter(isRenderableHistoryItem) : []; +} + +export function keepRenderableFavorites(items: unknown): FavoriteItem[] { + return Array.isArray(items) ? items.filter(isRenderableFavoriteItem) : []; +} From 3bf72e4b6676144dc6d114078a66bfee6a297b62 Mon Sep 17 00:00:00 2001 From: can4hou6joeng4 Date: Fri, 31 Jul 2026 18:32:34 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(douban):=20=E5=9B=BE=E7=89=87=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=8C=89=E9=95=9C=E5=83=8F=E5=9B=9E=E9=80=80,?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A2=83=E5=A4=96=E9=83=A8=E7=BD=B2=E6=B5=B7?= =?UTF-8?q?=E6=8A=A5=E5=A4=A7=E9=87=8F=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit imgN.doubanio.com 互为镜像但解析到不同线路:img1 当前解析到国内 IP 180.97.198.41,在境外主机上不可达。此时 fetch 是直接抛异常而不是返回 状态码,于是走进 catch 一律返回 500,首页会有相当比例的海报加载失败。 改为在 imgN.doubanio.com 之间按 img9 -> img3 -> img2 顺序回退,同一 路径换个镜像即可取到。同时保留最后一次的真实状态码,不再把上游的 403 或 404 也统一伪装成 500。 --- app/api/douban/image/route.ts | 84 +++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/app/api/douban/image/route.ts b/app/api/douban/image/route.ts index a51deef..2a8bc70 100644 --- a/app/api/douban/image/route.ts +++ b/app/api/douban/image/route.ts @@ -2,6 +2,40 @@ import { NextResponse } from 'next/server'; export const runtime = 'edge'; +const REQUEST_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8', + Referer: 'https://movie.douban.com/', +}; + +// 豆瓣的 imgN.doubanio.com 互为镜像,但各自解析到不同线路。部分线路(例如 img1 +// 的国内 IP)在境外主机上不可达,fetch 会直接抛错而不是返回状态码,海报因此变成 +// 500。同一路径换个镜像通常就能取到,所以失败时按顺序回退。 +const DOUBAN_IMAGE_HOSTS = ['img9.doubanio.com', 'img3.doubanio.com', 'img2.doubanio.com']; + +function buildCandidates(rawUrl: string): string[] { + const candidates = [rawUrl]; + + try { + const parsed = new URL(rawUrl); + if (!/^img\d+\.doubanio\.com$/.test(parsed.hostname)) { + return candidates; + } + + for (const host of DOUBAN_IMAGE_HOSTS) { + if (host === parsed.hostname) continue; + const alternate = new URL(parsed.toString()); + alternate.hostname = host; + candidates.push(alternate.toString()); + } + } catch { + // 非法 URL 留给 fetch 去报错 + } + + return candidates; +} + export async function GET(request: Request) { const { searchParams } = new URL(request.url); const imageUrl = searchParams.get('url'); @@ -10,51 +44,43 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Missing image URL' }, { status: 400 }); } - try { - const imageResponse = await fetch(imageUrl, { - headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', - Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8', - Referer: 'https://movie.douban.com/', - }, - }); + let lastStatus = 502; + let lastError = 'Error fetching image'; + + for (const candidate of buildCandidates(imageUrl)) { + let imageResponse: Response; + + try { + imageResponse = await fetch(candidate, { headers: REQUEST_HEADERS }); + } catch { + // 线路不可达,换下一个镜像 + continue; + } if (!imageResponse.ok) { - return NextResponse.json( - { error: imageResponse.statusText }, - { status: imageResponse.status } - ); + lastStatus = imageResponse.status; + lastError = imageResponse.statusText || 'Error fetching image'; + continue; } - const contentType = imageResponse.headers.get('content-type'); - if (!imageResponse.body) { - return NextResponse.json( - { error: 'Image response has no body' }, - { status: 500 } - ); + lastStatus = 500; + lastError = 'Image response has no body'; + continue; } - // 创建响应头 const headers = new Headers(); + const contentType = imageResponse.headers.get('content-type'); if (contentType) { headers.set('Content-Type', contentType); } - - // 设置缓存头 headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000'); - // 直接返回图片流 - // @ts-ignore return new Response(imageResponse.body, { status: 200, headers, }); - } catch (error) { - return NextResponse.json( - { error: 'Error fetching image' }, - { status: 500 } - ); } + + return NextResponse.json({ error: lastError }, { status: lastStatus }); } From f1a00867013c9e50b9c9de598aa9893d4856e37d Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Fri, 31 Jul 2026 23:17:50 +0800 Subject: [PATCH 4/4] fix: harden sync and image fallback; release 4.9.19 --- CHANGELOG.md | 7 ++++ app-release.json | 13 ++++++- app/api/douban/image/route.ts | 30 +------------- components/favorites/FavoritesList.tsx | 6 ++- components/history/HistoryList.tsx | 6 ++- lib/server/douban-image.ts | 30 ++++++++++++++ lib/server/redis.ts | 7 ++-- lib/server/runtime-env.ts | 2 +- lib/utils/sync-records.ts | 16 +++++--- package-lock.json | 4 +- package.json | 2 +- tests/douban-image-fallback.test.ts | 54 ++++++++++++++++++++++++++ tests/sync-records.test.ts | 41 +++++++++++++++++++ 13 files changed, 172 insertions(+), 46 deletions(-) create mode 100644 lib/server/douban-image.ts create mode 100644 tests/douban-image-fallback.test.ts create mode 100644 tests/sync-records.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d9ed53c..cc004b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 4.9.19 - 2026-07-31 + +- 修复 Docker / Node 自托管下 `/api/user/sync` 与 `/api/user/config` 无法从 `process.env` 读取 Upstash 凭据、持续返回 500 的问题;Redis 客户端改为按请求环境惰性创建,未配置同步时明确返回 503(#226 / PR #227)。 +- 云端恢复和历史/收藏列表渲染会过滤缺少 `videoId`、`source`、`title` 或有效 `episodeIndex` 的损坏记录;全部记录无效时正常显示空状态,不再让账户首页因渲染异常永久崩溃(#228 / PR #229)。 +- 豆瓣图片代理在 `imgN.doubanio.com` 线路不可达时按 `img9`、`img3`、`img2` 镜像回退,并保留最后一次上游响应状态,修复境外部署海报批量 500(PR #230)。 +- 新增同步记录和豆瓣镜像回退回归测试;真实 Next.js standalone + Upstash REST 兼容环境下的托管登录、同步读写与配置读写均验证通过。 + ## 4.9.18 - 2026-07-29 - 修复移动端播放器控制栏在窄屏下被裁切、最右侧“系统全屏”按钮不可见的问题(#224)。 diff --git a/app-release.json b/app-release.json index 5a6b90d..cb28c16 100644 --- a/app-release.json +++ b/app-release.json @@ -4,8 +4,19 @@ "name": "KVideo", "branch": "main" }, - "currentVersion": "4.9.18", + "currentVersion": "4.9.19", "releases": [ + { + "version": "4.9.19", + "publishedAt": "2026-07-31", + "title": "修复自托管同步与豆瓣海报回退", + "notes": [ + "修复 Docker / Node 自托管下用户同步与配置接口无法从 process.env 读取 Upstash 凭据、持续返回 500 的问题(#226 / PR #227)。", + "云端恢复与列表渲染会丢弃缺少 videoId、source、title 或有效 episodeIndex 的损坏记录,避免账户首页被永久崩溃数据锁死(#228 / PR #229)。", + "豆瓣图片代理会在 imgN.doubanio.com 线路不可达时按镜像回退,并保留上游最终状态码,修复境外部署海报批量 500(PR #230)。", + "新增同步记录与豆瓣镜像回退回归测试,并通过真实 Next.js standalone + Upstash REST 兼容环境的登录、同步和配置读写验证。" + ] + }, { "version": "4.9.18", "publishedAt": "2026-07-29", diff --git a/app/api/douban/image/route.ts b/app/api/douban/image/route.ts index 2a8bc70..b11c62b 100644 --- a/app/api/douban/image/route.ts +++ b/app/api/douban/image/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server'; +import { buildDoubanImageCandidates } from '@/lib/server/douban-image'; export const runtime = 'edge'; @@ -9,33 +10,6 @@ const REQUEST_HEADERS = { Referer: 'https://movie.douban.com/', }; -// 豆瓣的 imgN.doubanio.com 互为镜像,但各自解析到不同线路。部分线路(例如 img1 -// 的国内 IP)在境外主机上不可达,fetch 会直接抛错而不是返回状态码,海报因此变成 -// 500。同一路径换个镜像通常就能取到,所以失败时按顺序回退。 -const DOUBAN_IMAGE_HOSTS = ['img9.doubanio.com', 'img3.doubanio.com', 'img2.doubanio.com']; - -function buildCandidates(rawUrl: string): string[] { - const candidates = [rawUrl]; - - try { - const parsed = new URL(rawUrl); - if (!/^img\d+\.doubanio\.com$/.test(parsed.hostname)) { - return candidates; - } - - for (const host of DOUBAN_IMAGE_HOSTS) { - if (host === parsed.hostname) continue; - const alternate = new URL(parsed.toString()); - alternate.hostname = host; - candidates.push(alternate.toString()); - } - } catch { - // 非法 URL 留给 fetch 去报错 - } - - return candidates; -} - export async function GET(request: Request) { const { searchParams } = new URL(request.url); const imageUrl = searchParams.get('url'); @@ -47,7 +21,7 @@ export async function GET(request: Request) { let lastStatus = 502; let lastError = 'Error fetching image'; - for (const candidate of buildCandidates(imageUrl)) { + for (const candidate of buildDoubanImageCandidates(imageUrl)) { let imageResponse: Response; try { diff --git a/components/favorites/FavoritesList.tsx b/components/favorites/FavoritesList.tsx index ff8a058..9be1b23 100644 --- a/components/favorites/FavoritesList.tsx +++ b/components/favorites/FavoritesList.tsx @@ -14,13 +14,15 @@ interface FavoritesListProps { } export function FavoritesList({ favorites, onRemove, isPremium = false }: FavoritesListProps) { - if (favorites.length === 0) { + const renderableFavorites = keepRenderableFavorites(favorites); + + if (renderableFavorites.length === 0) { return ; } return (
- {keepRenderableFavorites(favorites).map((item) => ( + {renderableFavorites.map((item) => ( - {history.length === 0 ? ( + {renderableHistory.length === 0 ? ( ) : (
- {keepRenderableHistory(history).map((item) => ( + {renderableHistory.map((item) => ( 0 : typeof videoId === 'number'; + const hasVideoId = typeof videoId === 'string' + ? videoId.trim().length > 0 + : typeof videoId === 'number' && Number.isFinite(videoId); - return hasVideoId && typeof record.source === 'string' && typeof record.title === 'string'; + return hasVideoId && + typeof record.source === 'string' && record.source.trim().length > 0 && + typeof record.title === 'string' && record.title.trim().length > 0; } /** * Records restored from server-side sync are not guaranteed to be well formed — * they may come from an older schema or a partial write. Rendering one without - * `videoId` throws while building the player URL, which takes down the whole - * page, so anything lacking a usable identity is dropped on the way in. + * `videoId` or `episodeIndex` throws while building the player URL, which takes + * down the whole page, so anything lacking the required URL fields is dropped. */ export function isRenderableHistoryItem(value: unknown): value is VideoHistoryItem { - return hasIdentity(value); + if (!hasIdentity(value)) return false; + const episodeIndex = (value as { episodeIndex?: unknown }).episodeIndex; + return typeof episodeIndex === 'number' && Number.isInteger(episodeIndex) && episodeIndex >= 0; } export function isRenderableFavoriteItem(value: unknown): value is FavoriteItem { diff --git a/package-lock.json b/package-lock.json index 174dc86..0d3f2fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.18", + "version": "4.9.19", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.18", + "version": "4.9.19", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 6f7b193..be2e709 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.18", + "version": "4.9.19", "private": true, "scripts": { "dev": "node scripts/next-with-lan-access.mjs dev", diff --git a/tests/douban-image-fallback.test.ts b/tests/douban-image-fallback.test.ts new file mode 100644 index 0000000..c15f121 --- /dev/null +++ b/tests/douban-image-fallback.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GET } from '@/app/api/douban/image/route'; +import { buildDoubanImageCandidates } from '@/lib/server/douban-image'; + +test('Douban image candidates preserve the path and try reachable mirrors', () => { + const candidates = buildDoubanImageCandidates( + 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + ); + + assert.deepEqual(candidates, [ + 'https://img1.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + 'https://img9.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + 'https://img3.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + 'https://img2.doubanio.com/view/photo/s_ratio_poster/public/p123.webp?x=1', + ]); +}); + +test('non-Douban and malformed URLs are attempted only once', () => { + assert.deepEqual(buildDoubanImageCandidates('https://example.com/poster.jpg'), [ + 'https://example.com/poster.jpg', + ]); + assert.deepEqual(buildDoubanImageCandidates('not a url'), ['not a url']); +}); + +test('image proxy falls back after a mirror network failure', async (context) => { + const requested: string[] = []; + + context.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => { + const url = input.toString(); + requested.push(url); + + if (url.includes('img1.doubanio.com')) { + throw new TypeError('fetch failed'); + } + + return new Response('image-bytes', { + status: 200, + headers: { 'content-type': 'image/webp' }, + }); + }); + + const target = encodeURIComponent('https://img1.doubanio.com/poster.webp'); + const response = await GET(new Request(`https://kvideo.test/api/douban/image?url=${target}`)); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'image/webp'); + assert.equal(await response.text(), 'image-bytes'); + assert.deepEqual(requested, [ + 'https://img1.doubanio.com/poster.webp', + 'https://img9.doubanio.com/poster.webp', + ]); +}); diff --git a/tests/sync-records.test.ts b/tests/sync-records.test.ts new file mode 100644 index 0000000..963f7ec --- /dev/null +++ b/tests/sync-records.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + keepRenderableFavorites, + keepRenderableHistory, +} from '@/lib/utils/sync-records'; + +const validIdentity = { + videoId: 'video-1', + source: 'source-a', + title: 'Example', +}; + +test('sync record filters reject malformed identities', () => { + assert.deepEqual(keepRenderableFavorites([ + validIdentity, + { ...validIdentity, videoId: undefined }, + { ...validIdentity, videoId: Number.NaN }, + { ...validIdentity, source: '' }, + { ...validIdentity, title: ' ' }, + null, + ]), [validIdentity]); +}); + +test('history records require a usable episode index', () => { + const validHistory = { ...validIdentity, episodeIndex: 0 }; + + assert.deepEqual(keepRenderableHistory([ + validHistory, + { ...validIdentity }, + { ...validIdentity, episodeIndex: -1 }, + { ...validIdentity, episodeIndex: 1.5 }, + { ...validIdentity, episodeIndex: '0' }, + ]), [validHistory]); +}); + +test('sync record filters tolerate non-array payloads', () => { + assert.deepEqual(keepRenderableHistory({ history: [] }), []); + assert.deepEqual(keepRenderableFavorites(undefined), []); +});