fix: harden sync and image fallback; release 4.9.19

This commit is contained in:
kuekhaoyang
2026-07-31 23:17:50 +08:00
parent 4983a9e2ab
commit f1a0086701
13 changed files with 172 additions and 46 deletions
+7
View File
@@ -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)。
+12 -1
View File
@@ -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",
+2 -28
View File
@@ -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 {
+4 -2
View File
@@ -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 <FavoritesEmptyState />;
}
return (
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-2 scroll-smooth">
{keepRenderableFavorites(favorites).map((item) => (
{renderableFavorites.map((item) => (
<FavoritesItem
key={`${item.source}:${item.videoId}`}
item={item}
+4 -2
View File
@@ -10,16 +10,18 @@ interface HistoryListProps {
}
export function HistoryList({ history, onRemove, isPremium = false }: HistoryListProps) {
const renderableHistory = keepRenderableHistory(history);
return (
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
transform: 'translate3d(0, 0, 0)',
WebkitOverflowScrolling: 'touch'
}}>
{history.length === 0 ? (
{renderableHistory.length === 0 ? (
<HistoryEmptyState />
) : (
<div className="space-y-3">
{keepRenderableHistory(history).map((item) => (
{renderableHistory.map((item) => (
<HistoryItem
key={item.showIdentifier}
item={item}
+30
View File
@@ -0,0 +1,30 @@
// Douban's imgN.doubanio.com hosts mirror the same paths but use different
// network routes. Some routes are unreachable from overseas deployments, so a
// failed request can retry the same path through known reachable mirrors.
const DOUBAN_IMAGE_HOSTS = [
'img9.doubanio.com',
'img3.doubanio.com',
'img2.doubanio.com',
];
export function buildDoubanImageCandidates(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 {
// Leave malformed URLs to fetch so the route returns its normal proxy error.
}
return candidates;
}
+3 -4
View File
@@ -2,7 +2,7 @@ import { Redis } from '@upstash/redis/cloudflare';
import { getRuntimeEnvValue } from '@/lib/server/runtime-env';
let cachedRedis: Redis | null | undefined;
let cachedRedis: Redis | undefined;
/**
* Build the shared Upstash client from whichever environment source is available.
@@ -17,15 +17,14 @@ let cachedRedis: Redis | null | undefined;
* "server-side sync unavailable" rather than as a request failure.
*/
export function getRedisClient(): Redis | null {
if (cachedRedis !== undefined) {
if (cachedRedis) {
return cachedRedis;
}
const url = getRuntimeEnvValue('UPSTASH_REDIS_REST_URL');
const token = getRuntimeEnvValue('UPSTASH_REDIS_REST_TOKEN');
if (!url || !token) {
cachedRedis = null;
return cachedRedis;
return null;
}
cachedRedis = new Redis({
+1 -1
View File
@@ -16,5 +16,5 @@ export function getRuntimeEnvValue(name: string, fallback = ''): string {
// Outside Cloudflare's request runtime, fall back to process.env.
}
return process.env[name] || fallback;
return typeof process !== 'undefined' ? process.env[name] || fallback : fallback;
}
+11 -5
View File
@@ -4,19 +4,25 @@ 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';
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 {
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+54
View File
@@ -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',
]);
});
+41
View File
@@ -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), []);
});