diff --git a/CHANGELOG.md b/CHANGELOG.md index cc004b9..2ba05f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 4.9.20 - 2026-08-16 + +- 占位海报 SVG 现在根据系统 `prefers-color-scheme` 自动切换浅色与深色配色(#241)。 +- 播放器视频简介会安全移除 HTML 标签、解码常见与数字实体并归一化空白,不再显示大量字面量 ` `(#242)。 +- 电视剧和综艺搜索结果的普通卡片、多源分组卡片恢复显示视频源提供的更新/集数标识(#243)。 +- 新增对应的 HTML 文本、占位海报和搜索卡片回归测试,并通过 Next.js 与 Cloudflare Pages 生产构建。 + ## 4.9.19 - 2026-07-31 - 修复 Docker / Node 自托管下 `/api/user/sync` 与 `/api/user/config` 无法从 `process.env` 读取 Upstash 凭据、持续返回 500 的问题;Redis 客户端改为按请求环境惰性创建,未配置同步时明确返回 503(#226 / PR #227)。 diff --git a/app-release.json b/app-release.json index cb28c16..6dd65ea 100644 --- a/app-release.json +++ b/app-release.json @@ -4,8 +4,19 @@ "name": "KVideo", "branch": "main" }, - "currentVersion": "4.9.19", + "currentVersion": "4.9.20", "releases": [ + { + "version": "4.9.20", + "publishedAt": "2026-08-16", + "title": "修复搜索更新标识、简介实体和占位图深色模式", + "notes": [ + "占位海报 SVG 现在根据系统 prefers-color-scheme 自动切换浅色与深色配色(#241)。", + "播放器视频简介会安全移除 HTML 标签、解码常见与数字实体并归一化空白,不再显示大量字面量  (#242)。", + "电视剧和综艺搜索结果的普通卡片、多源分组卡片恢复显示视频源提供的更新/集数标识(#243)。", + "新增对应的 HTML 文本、占位海报和搜索卡片回归测试,并通过 Next.js 与 Cloudflare Pages 生产构建。" + ] + }, { "version": "4.9.19", "publishedAt": "2026-07-31", diff --git a/components/player/VideoMetadata.tsx b/components/player/VideoMetadata.tsx index bd06426..15d2e51 100644 --- a/components/player/VideoMetadata.tsx +++ b/components/player/VideoMetadata.tsx @@ -4,6 +4,7 @@ import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; import { getSourceName } from '@/lib/utils/source-names'; +import { htmlToText } from '@/lib/utils/html'; /** * Split person names by common delimiters (comma, Chinese comma, slash). @@ -21,6 +22,8 @@ interface VideoMetadataProps { } export function VideoMetadata({ videoData, source, title }: VideoMetadataProps) { + const description = htmlToText(videoData?.vod_content); + return (
@@ -82,9 +85,9 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps) )}
- {videoData?.vod_content && ( + {description && (

- {videoData.vod_content.replace(/<[^>]*>/g, '')} + {description}

)} {videoData?.vod_actor && ( diff --git a/components/search/VideoCard.tsx b/components/search/VideoCard.tsx index 7cdc6b0..b9c5627 100644 --- a/components/search/VideoCard.tsx +++ b/components/search/VideoCard.tsx @@ -10,6 +10,7 @@ import { LatencyBadge } from '@/components/ui/LatencyBadge'; import { FavoriteButton } from '@/components/favorites/FavoriteButton'; import { Video } from '@/lib/types'; +import { htmlToText } from '@/lib/utils/html'; import { parseVideoTitle } from '@/lib/utils/video'; import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe'; @@ -37,6 +38,7 @@ export const VideoCard = memo(({ isProbing = false, }) => { const displayLatency = latencies[video.source] ?? video.latency; + const displayRemarks = htmlToText(video.vod_remarks); return (
(({

{cleanTitle}

+ {displayRemarks && ( +

+ {displayRemarks} +

+ )}
{resolution ? ( @@ -201,4 +211,3 @@ export const VideoCard = memo(({ }); VideoCard.displayName = 'VideoCard'; - diff --git a/components/search/VideoGroupCard.tsx b/components/search/VideoGroupCard.tsx index a1b7add..77ce1a1 100644 --- a/components/search/VideoGroupCard.tsx +++ b/components/search/VideoGroupCard.tsx @@ -14,6 +14,7 @@ import { Icons } from '@/components/ui/Icon'; import { LatencyBadge } from '@/components/ui/LatencyBadge'; import { FavoriteButton } from '@/components/favorites/FavoriteButton'; import { Video } from '@/lib/types'; +import { htmlToText } from '@/lib/utils/html'; import { parseVideoTitle } from '@/lib/utils/video'; import { storeGroupedSources } from '@/lib/utils/grouped-sources-cache'; import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe'; @@ -49,6 +50,11 @@ export const VideoGroupCard = memo(({ isProbing = false, }) => { const { representative, videos, name } = group; + const displayRemarks = useMemo(() => { + const preferredVideo = videos.find((video) => video === representative && video.vod_remarks) + ?? videos.find((video) => video.vod_remarks); + return htmlToText(preferredVideo?.vod_remarks); + }, [representative, videos]); // Best latency from the group, preferring real-time updates const bestLatency = useMemo(() => { @@ -214,6 +220,14 @@ export const VideoGroupCard = memo(({

{cleanTitle}

+ {displayRemarks && ( +

+ {displayRemarks} +

+ )}
{resolution ? ( diff --git a/lib/utils/html.ts b/lib/utils/html.ts new file mode 100644 index 0000000..bd49ea3 --- /dev/null +++ b/lib/utils/html.ts @@ -0,0 +1,53 @@ +const NAMED_HTML_ENTITIES: Record = { + amp: '&', + apos: "'", + hellip: '…', + lt: '<', + mdash: '—', + middot: '·', + nbsp: ' ', + ndash: '–', + gt: '>', + quot: '"', +}; + +const HTML_ENTITY_PATTERN = /&(#x[\da-f]+|#\d+|[a-z][a-z\d]+);/gi; +const BLOCK_END_TAG_PATTERN = /<\/(?:p|div|li|h[1-6]|tr)>/gi; + +function decodeHtmlEntity(match: string, entity: string): string { + if (!entity.startsWith('#')) { + return NAMED_HTML_ENTITIES[entity.toLowerCase()] ?? match; + } + + const isHex = entity[1].toLowerCase() === 'x'; + const numberText = entity.slice(isHex ? 2 : 1); + const codePoint = Number.parseInt(numberText, isHex ? 16 : 10); + + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return match; + } + + return String.fromCodePoint(codePoint); +} + +/** + * Convert source-provided HTML descriptions or remarks to safe display text. + * Tags are removed, common/numeric entities are decoded, and layout whitespace + * is normalized without ever injecting markup into the React tree. + */ +export function htmlToText(value?: string | null): string { + if (!value) return ''; + + const withLineBreaks = value + .replace(/<\s*br\s*\/?>/gi, '\n') + .replace(BLOCK_END_TAG_PATTERN, '\n'); + const withoutTags = withLineBreaks.replace(/<[^>]*>/g, ''); + const decoded = withoutTags.replace(HTML_ENTITY_PATTERN, decodeHtmlEntity); + + return decoded + .replace(/\u00a0/g, ' ') + .replace(/[ \t]+/g, ' ') + .replace(/[ \t]*\n[ \t]*/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/package-lock.json b/package-lock.json index 0d3f2fb..16116b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.9.19", + "version": "4.9.20", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.9.19", + "version": "4.9.20", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -3300,6 +3300,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3333,7 +3336,7 @@ "arm64" ], "libc": [ - "glibc" + "musl" ], "license": "MIT", "optional": true, @@ -3351,9 +3354,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index be2e709..417dd13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.9.19", + "version": "4.9.20", "private": true, "scripts": { "dev": "node scripts/next-with-lan-access.mjs dev", diff --git a/public/placeholder-poster.svg b/public/placeholder-poster.svg index ec8f049..1ad177c 100644 --- a/public/placeholder-poster.svg +++ b/public/placeholder-poster.svg @@ -1,42 +1,59 @@ - - + + + - + - + - - - - - + + + + + - - - - - + + + + + - + - + Image Not Available diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 65be611..fcd4bdd 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -18,21 +18,22 @@ import { } from '@/lib/auth/permissions'; function withNodeEnv(value: string | undefined, callback: () => T): T { - const previous = process.env.NODE_ENV; + const environment = process.env as Record; + const previous = environment.NODE_ENV; if (value === undefined) { - delete process.env.NODE_ENV; + delete environment.NODE_ENV; } else { - process.env.NODE_ENV = value; + environment.NODE_ENV = value; } try { return callback(); } finally { if (previous === undefined) { - delete process.env.NODE_ENV; + delete environment.NODE_ENV; } else { - process.env.NODE_ENV = previous; + environment.NODE_ENV = previous; } } } diff --git a/tests/html-utils.test.ts b/tests/html-utils.test.ts new file mode 100644 index 0000000..03291f6 --- /dev/null +++ b/tests/html-utils.test.ts @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { htmlToText } from '@/lib/utils/html'; + +test('htmlToText removes markup, decodes entities, and preserves meaningful breaks', () => { + assert.equal( + htmlToText('

更新  至 12集


下一段 & 片段
'), + '更新 至 12集\n\n下一段 & 片段', + ); +}); + +test('htmlToText decodes numeric entities and leaves invalid or unknown entities unchanged', () => { + assert.equal( + htmlToText('一二 🎬 &unknown; �'), + '一二 🎬 &unknown; �', + ); +}); + +test('htmlToText normalizes empty and whitespace-only input', () => { + assert.equal(htmlToText(null), ''); + assert.equal(htmlToText(''), ''); + assert.equal(htmlToText('

内容

\n\n\n'), '内容'); +}); diff --git a/tests/issue-241-243.test.ts b/tests/issue-241-243.test.ts new file mode 100644 index 0000000..f7aced8 --- /dev/null +++ b/tests/issue-241-243.test.ts @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const projectRoot = process.cwd(); + +test('issue 241 placeholder poster declares light and system-dark palettes', () => { + const source = readFileSync(join(projectRoot, 'public/placeholder-poster.svg'), 'utf8'); + + assert.match(source, /prefers-color-scheme:\s*dark/); + assert.match(source, /\.poster-bg-start\s*\{\s*stop-color:\s*#1c1c1e/); + assert.match(source, /\.poster-label\s*\{\s*fill:\s*#aeaeb2/); +}); + +test('issue 243 search cards render source-provided update remarks', () => { + const videoCard = readFileSync(join(projectRoot, 'components/search/VideoCard.tsx'), 'utf8'); + const groupCard = readFileSync(join(projectRoot, 'components/search/VideoGroupCard.tsx'), 'utf8'); + + assert.match(videoCard, /const displayRemarks = htmlToText\(video\.vod_remarks\)/); + assert.match(videoCard, /title=\{displayRemarks\}/); + assert.match(groupCard, /const displayRemarks = useMemo/); + assert.match(groupCard, /title=\{displayRemarks\}/); +}); diff --git a/verification/tests/regression/html-utils.test.ts b/verification/tests/regression/html-utils.test.ts new file mode 100644 index 0000000..f132d0c --- /dev/null +++ b/verification/tests/regression/html-utils.test.ts @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { htmlToText } from '../../../lib/utils/html'; + +test('GH-ISSUE-242: htmlToText removes markup and decodes nbsp content', () => { + assert.equal( + htmlToText('

简介   内容


第二行 & 片段'), + '简介 内容\n\n第二行 & 片段', + ); +}); + +test('htmlToText handles numeric, unknown, invalid, and empty entities', () => { + assert.equal(htmlToText('一二 🎬 &unknown; �'), '一二 🎬 &unknown; �'); + assert.equal(htmlToText(null), ''); +}); diff --git a/verification/tests/regression/issue-241-243.test.ts b/verification/tests/regression/issue-241-243.test.ts new file mode 100644 index 0000000..6deba28 --- /dev/null +++ b/verification/tests/regression/issue-241-243.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; + +const projectRoot = process.cwd(); + +test('issue 241 placeholder poster has a system dark-mode branch', () => { + const source = readFileSync(join(projectRoot, 'public/placeholder-poster.svg'), 'utf8'); + + assert.match(source, /prefers-color-scheme:\s*dark/); + assert.match(source, /#1c1c1e/); + assert.match(source, /#aeaeb2/); +}); + +test('issue 243 search cards keep update remarks visible', () => { + const normalCard = readFileSync(join(projectRoot, 'components/search/VideoCard.tsx'), 'utf8'); + const groupedCard = readFileSync(join(projectRoot, 'components/search/VideoGroupCard.tsx'), 'utf8'); + + assert.match(normalCard, /htmlToText\(video\.vod_remarks\)/); + assert.match(normalCard, /\{displayRemarks && \(/); + assert.match(groupedCard, /htmlToText\(preferredVideo\?\.vod_remarks\)/); + assert.match(groupedCard, /\{displayRemarks && \(/); +});