mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-18 02:03:43 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3447fceb4b |
@@ -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)。
|
||||
|
||||
+12
-1
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<Card hover={false}>
|
||||
<div className="flex flex-col sm:flex-row items-start gap-4">
|
||||
@@ -82,9 +85,9 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{videoData?.vod_content && (
|
||||
{description && (
|
||||
<p className="text-sm sm:text-base text-[var(--text-secondary)]">
|
||||
{videoData.vod_content.replace(/<[^>]*>/g, '')}
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{videoData?.vod_actor && (
|
||||
|
||||
@@ -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<VideoCardProps>(({
|
||||
isProbing = false,
|
||||
}) => {
|
||||
const displayLatency = latencies[video.source] ?? video.latency;
|
||||
const displayRemarks = htmlToText(video.vod_remarks);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -171,6 +173,14 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] mb-1">
|
||||
{cleanTitle}
|
||||
</h4>
|
||||
{displayRemarks && (
|
||||
<p
|
||||
className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1"
|
||||
title={displayRemarks}
|
||||
>
|
||||
{displayRemarks}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{resolution ? (
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold text-white ${resolution.color}`}>
|
||||
@@ -201,4 +211,3 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
});
|
||||
|
||||
VideoCard.displayName = 'VideoCard';
|
||||
|
||||
|
||||
@@ -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<VideoGroupCardProps>(({
|
||||
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<VideoGroupCardProps>(({
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] mb-1">
|
||||
{cleanTitle}
|
||||
</h4>
|
||||
{displayRemarks && (
|
||||
<p
|
||||
className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1"
|
||||
title={displayRemarks}
|
||||
>
|
||||
{displayRemarks}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{resolution ? (
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold text-white ${resolution.color}`}>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const NAMED_HTML_ENTITIES: Record<string, string> = {
|
||||
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();
|
||||
}
|
||||
Generated
+6
-6
@@ -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": [
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,42 +1,59 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 450" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="bg-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f0f2f5;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#e0e4e8;stop-opacity:1" />
|
||||
<stop class="poster-bg-start" offset="0%" stop-opacity="1" />
|
||||
<stop class="poster-bg-end" offset="100%" stop-opacity="1" />
|
||||
</linearGradient>
|
||||
<style>
|
||||
.poster-bg-start { stop-color: #f0f2f5; }
|
||||
.poster-bg-end { stop-color: #e0e4e8; }
|
||||
.poster-overlay { fill: #ffffff; fill-opacity: 0.5; }
|
||||
.poster-frame { fill: none; stroke: #6e6e73; }
|
||||
.poster-icon { fill: #6e6e73; }
|
||||
.poster-label { fill: #6e6e73; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.poster-bg-start { stop-color: #1c1c1e; }
|
||||
.poster-bg-end { stop-color: #2c2c2e; }
|
||||
.poster-overlay { fill: #ffffff; fill-opacity: 0.06; }
|
||||
.poster-frame { stroke: #aeaeb2; }
|
||||
.poster-icon { fill: #aeaeb2; }
|
||||
.poster-label { fill: #aeaeb2; }
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="300" height="450" fill="url(#bg-gradient)" rx="24"/>
|
||||
|
||||
<!-- Glass effect overlay -->
|
||||
<rect width="300" height="450" fill="rgba(255, 255, 255, 0.5)" rx="24"/>
|
||||
<rect class="poster-overlay" width="300" height="450" rx="24"/>
|
||||
|
||||
<!-- Film icon -->
|
||||
<g transform="translate(150, 225)">
|
||||
<!-- Film reel -->
|
||||
<rect x="-60" y="-80" width="120" height="160" rx="8" fill="none" stroke="#6e6e73" stroke-width="4"/>
|
||||
<rect class="poster-frame" x="-60" y="-80" width="120" height="160" rx="8" stroke-width="4"/>
|
||||
|
||||
<!-- Film strip holes -->
|
||||
<rect x="-60" y="-70" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect x="-60" y="-40" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect x="-60" y="-10" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect x="-60" y="20" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect x="-60" y="50" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect class="poster-icon" x="-60" y="-70" width="15" height="15" rx="3"/>
|
||||
<rect class="poster-icon" x="-60" y="-40" width="15" height="15" rx="3"/>
|
||||
<rect class="poster-icon" x="-60" y="-10" width="15" height="15" rx="3"/>
|
||||
<rect class="poster-icon" x="-60" y="20" width="15" height="15" rx="3"/>
|
||||
<rect class="poster-icon" x="-60" y="50" width="15" height="15" rx="3"/>
|
||||
|
||||
<rect x="45" y="-70" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect x="45" y="-40" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect x="45" y="-10" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect x="45" y="20" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect x="45" y="50" width="15" height="15" rx="3" fill="#6e6e73"/>
|
||||
<rect class="poster-icon" x="45" y="-70" width="15" height="15" rx="3"/>
|
||||
<rect class="poster-icon" x="45" y="-40" width="15" height="15" rx="3"/>
|
||||
<rect class="poster-icon" x="45" y="-10" width="15" height="15" rx="3"/>
|
||||
<rect class="poster-icon" x="45" y="20" width="15" height="15" rx="3"/>
|
||||
<rect class="poster-icon" x="45" y="50" width="15" height="15" rx="3"/>
|
||||
|
||||
<!-- Play icon in center -->
|
||||
<circle cx="0" cy="0" r="35" fill="#007aff" opacity="0.9"/>
|
||||
<circle class="poster-play" cx="0" cy="0" r="35" fill="#007aff" opacity="0.9"/>
|
||||
<path d="M -10,-15 L -10,15 L 15,0 Z" fill="white"/>
|
||||
</g>
|
||||
|
||||
<!-- "Image Not Available" text -->
|
||||
<text x="150" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="16" font-weight="600" fill="#6e6e73" text-anchor="middle">
|
||||
<text class="poster-label" x="150" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="16" font-weight="600" text-anchor="middle">
|
||||
Image Not Available
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
+6
-5
@@ -18,21 +18,22 @@ import {
|
||||
} from '@/lib/auth/permissions';
|
||||
|
||||
function withNodeEnv<T>(value: string | undefined, callback: () => T): T {
|
||||
const previous = process.env.NODE_ENV;
|
||||
const environment = process.env as Record<string, string | undefined>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('<p>更新 至 12集</p><br><div>下一段 & 片段</div>'),
|
||||
'更新 至 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(' <p> 内容 </p>\n\n\n'), '内容');
|
||||
});
|
||||
@@ -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\}/);
|
||||
});
|
||||
@@ -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('<p>简介 内容</p><br>第二行 & 片段'),
|
||||
'简介 内容\n\n第二行 & 片段',
|
||||
);
|
||||
});
|
||||
|
||||
test('htmlToText handles numeric, unknown, invalid, and empty entities', () => {
|
||||
assert.equal(htmlToText('一二 🎬 &unknown; �'), '一二 🎬 &unknown; �');
|
||||
assert.equal(htmlToText(null), '');
|
||||
});
|
||||
@@ -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 && \(/);
|
||||
});
|
||||
Reference in New Issue
Block a user