feat: Add Douban tags API, enhance popular movies hook, and improve Chinese localization.

This commit is contained in:
kuekhaoyang
2026-01-02 13:42:20 +08:00
parent f379c52e76
commit 8dd9bb67b5
17 changed files with 200 additions and 78 deletions
+5 -4
View File
@@ -1,4 +1,4 @@
# KVideo
# 视频聚合平台 (KVideo)
![KVideo Banner](public/icon.png)
@@ -46,6 +46,7 @@
### 🎬 豆瓣集成
- **电影 & 电视剧分类**:支持在电影和电视剧之间无缝切换,方便查找不同类型的影视资源
- **详细影视信息**:自动获取豆瓣评分、演员阵容、剧情简介等详细信息
- **推荐系统**:基于豆瓣数据的相关推荐
- **专业评价**:展示豆瓣用户评价和专业影评
@@ -125,9 +126,9 @@ docker run -d -p 3000:3000 -e ACCESS_PASSWORD=your_secret_password --name kvideo
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `Multi-source video aggregation platform with beautiful Liquid Glass UI` |
| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` |
| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `视频聚合平台 - KVideo` |
| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `专属视频聚合播放平台,具备美观的 Liquid Glass UI` |
| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `视频聚合平台` |
### 配置示例:
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const type = searchParams.get('type') || 'movie'; // movie or tv
try {
const url = `https://movie.douban.com/j/search_tags?type=${type}&source=index`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Referer': 'https://movie.douban.com/',
},
next: { revalidate: 86400 }, // Cache tags for 24 hours
});
if (!response.ok) {
throw new Error(`Douban API returned ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Douban Tags API error:', error);
return NextResponse.json(
{ tags: [], error: 'Failed to fetch tags' },
{ status: 500 }
);
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="zh-CN" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
suppressHydrationWarning
+3 -3
View File
@@ -20,7 +20,7 @@ export function ThemeSwitcher() {
: 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]'
}
`}
aria-label="Set light theme"
aria-label="设为浅色主题"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"></circle>
@@ -47,7 +47,7 @@ export function ThemeSwitcher() {
: 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]'
}
`}
aria-label="Set dark theme"
aria-label="设为深色主题"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
@@ -66,7 +66,7 @@ export function ThemeSwitcher() {
: 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]'
}
`}
aria-label="Set system theme"
aria-label="设为系统主题"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
+32 -1
View File
@@ -18,9 +18,11 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
const {
tags,
selectedTag,
contentType,
newTagInput,
showTagManager,
justAddedTag,
setContentType,
setSelectedTag,
setNewTagInput,
setShowTagManager,
@@ -29,6 +31,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
handleDeleteTag,
handleRestoreDefaults,
handleDragEnd,
isLoadingTags,
} = useTagManager();
const {
@@ -37,7 +40,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
hasMore,
prefetchRef,
loadMoreRef,
} = usePopularMovies(selectedTag, tags);
} = usePopularMovies(selectedTag, tags, contentType);
const handleMovieClick = (movie: any) => {
if (onSearch) {
@@ -47,6 +50,33 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
return (
<div className="animate-fade-in">
{/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */}
<div className="mb-10 flex justify-center">
<div className="relative w-80 p-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-full grid grid-cols-2 backdrop-blur-2xl shadow-lg ring-1 ring-white/10 overflow-hidden">
{/* Sliding Indicator */}
<div
className="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-[var(--accent-color)] rounded-full transition-transform duration-400 cubic-bezier(0.4, 0, 0.2, 1) shadow-[0_0_15px_rgba(0,122,255,0.4)]"
style={{
transform: `translateX(${contentType === 'movie' ? '4px' : 'calc(100% + 4px)'})`,
}}
/>
<button
onClick={() => setContentType('movie')}
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'movie' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
}`}
>
</button>
<button
onClick={() => setContentType('tv')}
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'tv' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
}`}
>
</button>
</div>
</div>
<TagManager
tags={tags}
selectedTag={selectedTag}
@@ -67,6 +97,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
onAddTag={handleAddTag}
onDragEnd={handleDragEnd}
onJustAddedTagHandled={() => setJustAddedTag(false)}
isLoadingTags={isLoadingTags}
/>
<MovieGrid
+19 -10
View File
@@ -18,6 +18,7 @@ interface TagManagerProps {
onAddTag: () => void;
onDragEnd: (event: DragEndEvent) => void;
onJustAddedTagHandled: () => void;
isLoadingTags?: boolean;
}
export function TagManager({
@@ -34,6 +35,7 @@ export function TagManager({
onAddTag,
onDragEnd,
onJustAddedTagHandled,
isLoadingTags,
}: TagManagerProps) {
return (
<>
@@ -67,16 +69,23 @@ export function TagManager({
)}
{/* Tag Filter */}
<TagList
tags={tags}
selectedTag={selectedTag}
showTagManager={showTagManager}
justAddedTag={justAddedTag}
onTagSelect={onTagSelect}
onTagDelete={onTagDelete}
onDragEnd={onDragEnd}
onJustAddedTagHandled={onJustAddedTagHandled}
/>
{isLoadingTags ? (
<div className="flex items-center gap-2 py-4">
<Icons.RefreshCw size={16} className="animate-spin text-[var(--accent-color)]" />
<span className="text-sm text-[var(--text-color-secondary)]">...</span>
</div>
) : (
<TagList
tags={tags}
selectedTag={selectedTag}
showTagManager={showTagManager}
justAddedTag={justAddedTag}
onTagSelect={onTagSelect}
onTagDelete={onTagDelete}
onDragEnd={onDragEnd}
onJustAddedTagHandled={onJustAddedTagHandled}
/>
)}
</>
);
}
+4 -4
View File
@@ -11,7 +11,7 @@ interface DoubanMovie {
const PAGE_LIMIT = 20;
export function usePopularMovies(selectedTag: string, tags: any[]) {
export function usePopularMovies(selectedTag: string, tags: any[], contentType: 'movie' | 'tv' = 'movie') {
const [movies, setMovies] = useState<DoubanMovie[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
@@ -24,7 +24,7 @@ export function usePopularMovies(selectedTag: string, tags: any[]) {
try {
const tagValue = tags.find(t => t.id === tag)?.value || '热门';
const response = await fetch(
`/api/douban/recommend?tag=${encodeURIComponent(tagValue)}&page_limit=${PAGE_LIMIT}&page_start=${pageStart}`
`/api/douban/recommend?type=${contentType}&tag=${encodeURIComponent(tagValue)}&page_limit=${PAGE_LIMIT}&page_start=${pageStart}`
);
if (!response.ok) throw new Error('Failed to fetch');
@@ -40,14 +40,14 @@ export function usePopularMovies(selectedTag: string, tags: any[]) {
} finally {
setLoading(false);
}
}, [loading, tags]);
}, [loading, tags, contentType]);
useEffect(() => {
setPage(0);
setMovies([]);
setHasMore(true);
loadMovies(selectedTag, 0, false);
}, [selectedTag]); // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedTag, contentType]); // eslint-disable-line react-hooks/exhaustive-deps
const { prefetchRef, loadMoreRef } = useInfiniteScroll({
hasMore,
+86 -38
View File
@@ -2,50 +2,72 @@ import { useState, useEffect } from 'react';
import { DragEndEvent } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
const DEFAULT_TAGS = [
{ id: 'popular', label: '热门', value: '热门' },
{ id: 'latest', label: '最新', value: '最新' },
{ id: 'classic', label: '经典', value: '经典' },
{ id: 'highscore', label: '豆瓣高分', value: '豆瓣高分' },
{ id: 'underrated', label: '冷门佳片', value: '冷门佳片' },
{ id: 'chinese', label: '华语', value: '华语' },
{ id: 'western', label: '欧美', value: '欧美' },
{ id: 'korean', label: '韩国', value: '韩国' },
{ id: 'japanese', label: '日本', value: '日本' },
{ id: 'action', label: '动作', value: '动作' },
{ id: 'comedy', label: '喜剧', value: '喜剧' },
{ id: 'variety', label: '综艺', value: '综艺' },
{ id: 'romance', label: '爱情', value: '爱情' },
{ id: 'scifi', label: '科幻', value: '科幻' },
{ id: 'thriller', label: '悬疑', value: '悬疑' },
{ id: 'horror', label: '恐怖', value: '恐怖' },
{ id: 'healing', label: '治愈', value: '治愈' },
];
const DEFAULT_TAG = { id: 'popular', label: '热门', value: '热门' };
const STORAGE_KEY = 'kvideo_custom_tags';
const STORAGE_KEY_PREFIX = 'kvideo_custom_tags_';
export function useTagManager() {
const [selectedTag, setSelectedTag] = useState('popular');
const [tags, setTags] = useState(DEFAULT_TAGS);
const [contentType, setContentType] = useState<'movie' | 'tv'>('movie');
const [selectedTag, setSelectedTag] = useState(DEFAULT_TAG.value);
const [tags, setTags] = useState<any[]>([]);
const [isLoadingTags, setIsLoadingTags] = useState(false);
const [newTagInput, setNewTagInput] = useState('');
const [showTagManager, setShowTagManager] = useState(false);
const [justAddedTag, setJustAddedTag] = useState(false);
// Load custom tags from localStorage
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
setTags(JSON.parse(saved));
} catch (e) {
console.error('Failed to parse saved tags', e);
}
}
}, []);
const storageKey = `${STORAGE_KEY_PREFIX}${contentType}`;
const saveTags = (newTags: typeof DEFAULT_TAGS) => {
// Load custom tags or fetch from Douban
useEffect(() => {
const loadTags = async () => {
const saved = localStorage.getItem(storageKey);
if (saved) {
try {
setTags(JSON.parse(saved));
return;
} catch (e) {
console.error('Failed to parse saved tags', e);
}
}
// If no saved tags, fetch from Douban
setIsLoadingTags(true);
try {
const response = await fetch(`/api/douban/tags?type=${contentType}`);
const data = await response.json();
if (data.tags && Array.isArray(data.tags)) {
const mappedTags = data.tags.map((label: string) => ({
id: label === '热门' ? 'popular' : `tag_${label}`,
label,
value: label,
}));
// If "热门" isn't in the list, add it to the front
if (!mappedTags.some((t: any) => t.value === '热门')) {
mappedTags.unshift(DEFAULT_TAG);
}
setTags(mappedTags);
// Also save to localStorage to avoid repeated fetches if desired
// Actually, let's just keep them in memory for now unless they customize
} else {
setTags([DEFAULT_TAG]);
}
} catch (error) {
console.error('Fetch tags error:', error);
setTags([DEFAULT_TAG]);
} finally {
setIsLoadingTags(false);
}
};
loadTags();
setSelectedTag(DEFAULT_TAG.value);
}, [contentType, storageKey]);
const saveTags = (newTags: any[]) => {
setTags(newTags);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newTags));
localStorage.setItem(storageKey, JSON.stringify(newTags));
};
const handleAddTag = () => {
@@ -67,9 +89,32 @@ export function useTagManager() {
}
};
const handleRestoreDefaults = () => {
saveTags(DEFAULT_TAGS);
setSelectedTag('popular');
const handleRestoreDefaults = async () => {
localStorage.removeItem(storageKey);
// Refresh by re-fetching
setIsLoadingTags(true);
try {
const response = await fetch(`/api/douban/tags?type=${contentType}`);
const data = await response.json();
if (data.tags && Array.isArray(data.tags)) {
const mappedTags = data.tags.map((label: string) => ({
id: label === '热门' ? 'popular' : `tag_${label}`,
label,
value: label,
}));
if (!mappedTags.some((t: any) => t.value === '热门')) {
mappedTags.unshift(DEFAULT_TAG);
}
setTags(mappedTags);
} else {
setTags([DEFAULT_TAG]);
}
} catch (error) {
setTags([DEFAULT_TAG]);
} finally {
setIsLoadingTags(false);
}
setSelectedTag(DEFAULT_TAG.value);
setShowTagManager(false);
};
@@ -86,9 +131,12 @@ export function useTagManager() {
return {
tags,
selectedTag,
contentType,
newTagInput,
showTagManager,
justAddedTag,
isLoadingTags,
setContentType,
setSelectedTag,
setNewTagInput,
setShowTagManager,
+1 -1
View File
@@ -48,7 +48,7 @@ export function Navbar({ onReset, isSecretMode = false }: NavbarProps) {
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer hidden sm:flex"
aria-label="GitHub"
aria-label="GitHub 仓库"
>
<Icons.Github size={20} />
</a>
@@ -236,7 +236,7 @@ export function DesktopMoreMenu({
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="group flex items-center justify-center w-12 h-12 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95"
aria-label="More options"
aria-label="更多选项"
title="更多选项"
>
<Icons.MoreHorizontal className="text-white/80 group-hover:text-white" size={24} />
+3 -3
View File
@@ -146,7 +146,7 @@ export function DesktopOverlay({
onSkipBackward();
}}
className="group flex items-center justify-center w-10 h-10 md:w-16 md:h-16 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95"
aria-label="Skip Backward 10s"
aria-label="后退 10"
>
<Icons.SkipBack className="w-5 h-5 md:w-8 md:h-8 text-white/80 group-hover:text-white" />
</button>
@@ -164,7 +164,7 @@ export function DesktopOverlay({
onSkipForward();
}}
className="group flex items-center justify-center w-10 h-10 md:w-16 md:h-16 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95"
aria-label="Skip Forward 10s"
aria-label="前进 10"
>
<Icons.FastForward className="w-5 h-5 md:w-8 md:h-8 text-white/80 group-hover:text-white" />
</button>
@@ -176,7 +176,7 @@ export function DesktopOverlay({
<button
onClick={onTogglePlay}
className="pointer-events-auto w-12 h-12 md:w-20 md:h-20 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm flex items-center justify-center transition-all duration-300 hover:scale-110 active:scale-95 cursor-pointer"
aria-label="Play"
aria-label="播放"
>
<Icons.Play className="w-6 h-6 md:w-10 md:h-10 text-white ml-1" />
</button>
@@ -34,7 +34,7 @@ export function DesktopRightControls({
<button
onClick={onTogglePictureInPicture}
className="btn-icon"
aria-label="Picture-in-Picture"
aria-label="画中画"
title="画中画"
>
<Icons.PictureInPicture size={20} />
@@ -74,7 +74,7 @@ export function DesktopRightControls({
<button
onClick={onToggleFullscreen}
className="btn-icon"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? '退出全屏' : '全屏'}
>
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
</button>
@@ -85,7 +85,7 @@ export function DesktopSpeedMenu({
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="group flex items-center justify-center w-12 h-12 rounded-full bg-black/40 hover:bg-black/60 backdrop-blur-sm transition-all duration-300 hover:scale-110 active:scale-95 text-white/90 font-medium text-sm"
aria-label="Playback speed"
aria-label="播放速度"
>
{playbackRate}x
</button>
+5 -5
View File
@@ -47,7 +47,7 @@ export function SourceManager({
<button
onClick={() => handleToggle(source.id)}
className="relative inline-block w-12 h-7 flex-shrink-0 cursor-pointer"
aria-label={`Toggle ${source.name}`}
aria-label={`切换 ${source.name} 状态`}
>
<span
className={`absolute inset-0 rounded-[var(--radius-full)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) ${source.enabled
@@ -79,7 +79,7 @@ export function SourceManager({
onClick={() => handlePriorityChange(source.id, 'up')}
disabled={index === 0}
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-200 cursor-pointer"
aria-label="Move up"
aria-label="上移"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 19V5M5 12l7-7 7 7" />
@@ -90,7 +90,7 @@ export function SourceManager({
onClick={() => handlePriorityChange(source.id, 'down')}
disabled={index === sources.length - 1}
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-200 cursor-pointer"
aria-label="Move down"
aria-label="下移"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M19 12l-7 7-7-7" />
@@ -102,7 +102,7 @@ export function SourceManager({
<button
onClick={() => onEdit(source)}
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
aria-label="Edit source"
aria-label="编辑视频源"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
@@ -115,7 +115,7 @@ export function SourceManager({
<button
onClick={() => handleDelete(source.id)}
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all duration-200 cursor-pointer"
aria-label="Delete source"
aria-label="删除视频源"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
+1 -1
View File
@@ -16,6 +16,6 @@ export interface SiteConfig {
*/
export const siteConfig: SiteConfig = {
title: process.env.NEXT_PUBLIC_SITE_TITLE || "KVideo - 视频聚合平台",
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION || "Multi-source video aggregation platform with beautiful Liquid Glass UI",
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION || "视频聚合平台",
name: process.env.NEXT_PUBLIC_SITE_NAME || "KVideo",
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "3.8.7",
"version": "3.8.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "3.8.7",
"version": "3.8.8",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "3.8.7",
"version": "3.8.8",
"private": true,
"scripts": {
"dev": "next dev",