mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Merge pull request #231 from KuekHaoYang/agent/release-4.9.19
fix: ship self-hosted sync and image fallback (v4.9.19)
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { buildDoubanImageCandidates } from '@/lib/server/douban-image';
|
||||
|
||||
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/',
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const imageUrl = searchParams.get('url');
|
||||
@@ -10,51 +18,43 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
let lastStatus = 502;
|
||||
let lastError = 'Error fetching image';
|
||||
|
||||
for (const candidate of buildDoubanImageCandidates(imageUrl)) {
|
||||
let imageResponse: Response;
|
||||
|
||||
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/',
|
||||
},
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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);
|
||||
@@ -16,6 +21,11 @@ export async function GET(request: NextRequest) {
|
||||
return authenticationRequiredResponse();
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return syncUnavailableResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await redis.get(`user:sync:${profileId}`);
|
||||
return NextResponse.json({
|
||||
@@ -36,6 +46,11 @@ export async function POST(request: NextRequest) {
|
||||
return authenticationRequiredResponse();
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return syncUnavailableResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { history, favorites } = body;
|
||||
|
||||
@@ -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[];
|
||||
@@ -13,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">
|
||||
{favorites.map((item) => (
|
||||
{renderableFavorites.map((item) => (
|
||||
<FavoritesItem
|
||||
key={`${item.source}:${item.videoId}`}
|
||||
item={item}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HistoryItem } from './HistoryItem';
|
||||
import { HistoryEmptyState } from './HistoryEmptyState';
|
||||
import type { VideoHistoryItem } from '@/lib/types';
|
||||
import { keepRenderableHistory } from '@/lib/utils/sync-records';
|
||||
|
||||
interface HistoryListProps {
|
||||
history: VideoHistoryItem[];
|
||||
@@ -9,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">
|
||||
{history.map((item) => (
|
||||
{renderableHistory.map((item) => (
|
||||
<HistoryItem
|
||||
key={item.showIdentifier}
|
||||
item={item}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useHistoryStore, usePremiumHistoryStore } from '@/lib/store/history-store';
|
||||
import { useFavoritesStore, usePremiumFavoritesStore } from '@/lib/store/favorites-store';
|
||||
import { keepRenderableFavorites, keepRenderableHistory } from '@/lib/utils/sync-records';
|
||||
import { getProfileId } from '@/lib/store/auth-store';
|
||||
|
||||
export function useCloudSync(isPremium = false) {
|
||||
@@ -19,11 +20,14 @@ export function useCloudSync(isPremium = false) {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
if (result.data.history?.length > 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) {
|
||||
|
||||
+2
-35
@@ -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<string, unknown> | 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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Redis } from '@upstash/redis/cloudflare';
|
||||
|
||||
import { getRuntimeEnvValue } from '@/lib/server/runtime-env';
|
||||
|
||||
let cachedRedis: Redis | 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) {
|
||||
return cachedRedis;
|
||||
}
|
||||
|
||||
const url = getRuntimeEnvValue('UPSTASH_REDIS_REST_URL');
|
||||
const token = getRuntimeEnvValue('UPSTASH_REDIS_REST_TOKEN');
|
||||
if (!url || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
cachedRedis = new Redis({
|
||||
url,
|
||||
token,
|
||||
});
|
||||
return cachedRedis;
|
||||
}
|
||||
@@ -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<string, unknown> | undefined;
|
||||
const value = runtimeEnv?.[name];
|
||||
if (typeof value === 'string') return value;
|
||||
} catch {
|
||||
// Outside Cloudflare's request runtime, fall back to process.env.
|
||||
}
|
||||
|
||||
return typeof process !== 'undefined' ? process.env[name] || fallback : fallback;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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.trim().length > 0
|
||||
: typeof videoId === 'number' && Number.isFinite(videoId);
|
||||
|
||||
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` 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 {
|
||||
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 {
|
||||
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) : [];
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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), []);
|
||||
});
|
||||
Reference in New Issue
Block a user