Compare commits

12 Commits
main ... dev

Author SHA1 Message Date
mtvpls
91cdb7a1d2 移除首页的收藏夹切换卡,改成从用户菜单进入 2025-12-27 12:31:26 +08:00
mtvpls
c961d0999c 首页增加tmdb热门轮播图 2025-12-27 11:37:43 +08:00
mtvpls
e071d6fff2 增加更多推荐 2025-12-27 01:28:35 +08:00
mtvpls
83bbf78545 首页视频卡片增加评分 2025-12-26 23:54:27 +08:00
mtvpls
fb9f55136d 修复/api/server-config暴露externalServerAuth的安全问题 2025-12-26 22:13:13 +08:00
mtvpls
3050cd48a9 变更私人影库的key值方式 2025-12-26 21:14:31 +08:00
mtvpls
df78abaf11 支持设置linuxdo信任等级 2025-12-26 16:57:08 +08:00
mtvpls
0b37e663fe tmdb匹配前先替换下划线为空格,提升匹配概率 2025-12-26 02:20:19 +08:00
mtvpls
d66538e992 修复换源列表,私人影库不显示图像 2025-12-26 00:59:32 +08:00
mtvpls
712fc7489e 修复无法切换源到私人影库 2025-12-26 00:54:58 +08:00
mtvpls
5d593bafb2 私人影库扫描增加年份辅助扫描 2025-12-25 23:51:18 +08:00
mtvpls
76b3349aa2 私人影库扫描增加季度支持 2025-12-25 23:34:54 +08:00
43 changed files with 1989 additions and 271 deletions

View File

@@ -1,7 +1,7 @@
## [204.0.0] - 2025-12-25
### Added
- ⚠️⚠️⚠️更新此版本前务必进行备份!!!⚠️⚠️⚠️
- ⚠️⚠️⚠️更新此版本前务必进行备份!!!⚠️⚠️⚠️
- 新增私人影视库功能(实验性)
- 增加弹幕热力图
- 增加盘搜搜索资源

View File

@@ -58,6 +58,7 @@
- [配置文件](#配置文件)
- [自动更新](#自动更新)
- [环境变量](#环境变量)
- [外部观影室服务器部署](#外部观影室服务器部署)
- [弹幕后端部署](#弹幕后端部署)
- [超分功能说明](#超分功能说明)
- [AndroidTV 使用](#androidtv-使用)
@@ -251,7 +252,10 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 43203天 |
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
| WATCH_ROOM_ENABLED | 是否启用观影室功能vercel部署不支持该功能后续可能会开发外部服务器) | true/false | false |
| WATCH_ROOM_ENABLED | 是否启用观影室功能vercel部署不支持该功能可使用外部服务器) | true/false | false |
| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal |
| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) |
| WATCH_ROOM_EXTERNAL_SERVER_AUTH | 外部观影室服务器认证令牌(当 SERVER_TYPE 为 external 时必填) | 任意字符串 | (空) |
| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
@@ -279,6 +283,24 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
- webrtc-fallback使用 WebRTC P2P 连接,失败时自动回退到服务器中转(推荐)
- server-only仅使用服务器中转适用于无法建立 P2P 连接的网络环境)
### 外部观影室服务器部署
如果您在 Vercel 等无法运行 WebSocket 服务器的平台部署,或希望将观影室服务器独立部署,可以使用外部观影室服务器。
推荐使用由 [tgs9915](https://github.com/tgs9915) 开发的 [watch-room-server](https://github.com/tgs9915/watch-room-server) 项目进行部署。
**配置步骤:**
1. 按照 [watch-room-server](https://github.com/tgs9915/watch-room-server) 的文档部署外部服务器
2. 在 MoonTVPlus 中设置以下环境变量:
```env
WATCH_ROOM_ENABLED=true
WATCH_ROOM_SERVER_TYPE=external
WATCH_ROOM_EXTERNAL_SERVER_URL=wss://your-watch-room-server.com
WATCH_ROOM_EXTERNAL_SERVER_AUTH=your_secure_token
```
3. 重启应用即可使用外部观影室服务器
## 弹幕后端部署

View File

@@ -30,7 +30,7 @@
"@upstash/redis": "^1.25.0",
"@vidstack/react": "^1.12.13",
"anime4k-webgpu": "^1.0.0",
"artplayer": "^5.2.5",
"artplayer": "^5.3.0",
"artplayer-plugin-danmuku": "^5.2.0",
"bs58": "^6.0.0",
"cheerio": "^1.1.2",

View File

@@ -2959,7 +2959,7 @@ const OpenListConfigComponent = ({
});
};
const handleDeleteVideo = async (folder: string, title: string) => {
const handleDeleteVideo = async (key: string, title: string) => {
// 显示确认对话框,直接在 onConfirm 中执行删除操作
showAlert({
type: 'warning',
@@ -2971,7 +2971,7 @@ const OpenListConfigComponent = ({
const response = await fetch('/api/openlist/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder }),
body: JSON.stringify({ key }),
});
if (!response.ok) {
@@ -3226,6 +3226,9 @@ const OpenListConfigComponent = ({
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
</th>
@@ -3257,6 +3260,15 @@ const OpenListConfigComponent = ({
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.mediaType === 'movie' ? '电影' : '剧集'}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.seasonNumber ? (
<span className='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200' title={video.seasonName || `${video.seasonNumber}`}>
S{video.seasonNumber}
</span>
) : (
'-'
)}
</td>
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
{video.releaseDate ? video.releaseDate.split('-')[0] : '-'}
</td>
@@ -3283,7 +3295,7 @@ const OpenListConfigComponent = ({
{video.failed ? '立即纠错' : '纠错'}
</button>
<button
onClick={() => handleDeleteVideo(video.folder, video.title)}
onClick={() => handleDeleteVideo(video.id, video.title)}
className={buttonStyles.dangerSmall}
>
@@ -3319,7 +3331,7 @@ const OpenListConfigComponent = ({
<CorrectDialog
isOpen={correctDialogOpen}
onClose={() => setCorrectDialogOpen(false)}
folder={selectedVideo.folder}
videoKey={selectedVideo.id}
currentTitle={selectedVideo.title}
onCorrect={handleCorrectSuccess}
/>
@@ -6169,11 +6181,11 @@ const SiteConfigComponent = ({
</h3>
{/* 开启评论 */}
{/* 开启评论与相似推荐 */}
<div>
<div className='flex items-center justify-between'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<button
type='button'
@@ -6196,7 +6208,7 @@ const SiteConfigComponent = ({
</button>
</div>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
</div>
@@ -6241,7 +6253,7 @@ const SiteConfigComponent = ({
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<button
onClick={() => setShowEnableCommentsModal(false)}
@@ -6328,6 +6340,7 @@ const RegistrationConfigComponent = ({
OIDCClientId: string;
OIDCClientSecret: string;
OIDCButtonText: string;
OIDCMinTrustLevel: number;
}>({
EnableRegistration: false,
RegistrationRequireTurnstile: false,
@@ -6344,6 +6357,7 @@ const RegistrationConfigComponent = ({
OIDCClientId: '',
OIDCClientSecret: '',
OIDCButtonText: '',
OIDCMinTrustLevel: 0,
});
useEffect(() => {
@@ -6364,6 +6378,7 @@ const RegistrationConfigComponent = ({
OIDCClientId: config.SiteConfig.OIDCClientId || '',
OIDCClientSecret: config.SiteConfig.OIDCClientSecret || '',
OIDCButtonText: config.SiteConfig.OIDCButtonText || '',
OIDCMinTrustLevel: config.SiteConfig.OIDCMinTrustLevel ?? 0,
});
}
}, [config]);
@@ -6922,7 +6937,31 @@ const RegistrationConfigComponent = ({
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OIDC登录按钮显示的文字"使用企业账号登录""使用SSO登录""使用OIDC登录"
OIDC登录按钮显示的文字,"使用企业账号登录""使用SSO登录""使用OIDC登录"
</p>
</div>
{/* OIDC最低信任等级 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='number'
min='0'
max='4'
placeholder='0'
value={registrationSettings.OIDCMinTrustLevel === 0 ? '' : registrationSettings.OIDCMinTrustLevel}
onChange={(e) =>
setRegistrationSettings((prev) => ({
...prev,
OIDCMinTrustLevel: e.target.value === '' ? 0 : parseInt(e.target.value),
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
LinuxDo网站有效01-4
</p>
</div>
</div>

View File

@@ -64,6 +64,7 @@ export async function POST(request: NextRequest) {
OIDCClientId,
OIDCClientSecret,
OIDCButtonText,
OIDCMinTrustLevel,
} = body as {
SiteName: string;
Announcement: string;
@@ -100,6 +101,7 @@ export async function POST(request: NextRequest) {
OIDCClientId?: string;
OIDCClientSecret?: string;
OIDCButtonText?: string;
OIDCMinTrustLevel?: number;
};
// 参数校验
@@ -135,7 +137,8 @@ export async function POST(request: NextRequest) {
(OIDCUserInfoEndpoint !== undefined && typeof OIDCUserInfoEndpoint !== 'string') ||
(OIDCClientId !== undefined && typeof OIDCClientId !== 'string') ||
(OIDCClientSecret !== undefined && typeof OIDCClientSecret !== 'string') ||
(OIDCButtonText !== undefined && typeof OIDCButtonText !== 'string')
(OIDCButtonText !== undefined && typeof OIDCButtonText !== 'string') ||
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number')
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -190,6 +193,7 @@ export async function POST(request: NextRequest) {
OIDCClientId,
OIDCClientSecret,
OIDCButtonText,
OIDCMinTrustLevel,
};
// 写入数据库

View File

@@ -213,6 +213,7 @@ export async function GET(request: NextRequest) {
sub: oidcSub,
email: userInfo.email,
name: userInfo.name,
trust_level: userInfo.trust_level, // 提取trust_level字段
timestamp: Date.now(),
};

View File

@@ -102,6 +102,18 @@ export async function POST(request: NextRequest) {
);
}
// 检查最低信任等级
const minTrustLevel = siteConfig.OIDCMinTrustLevel || 0;
if (minTrustLevel > 0) {
const userTrustLevel = oidcSession.trust_level ?? 0;
if (userTrustLevel < minTrustLevel) {
return NextResponse.json(
{ error: `您的信任等级(${userTrustLevel})不满足最低要求(${minTrustLevel})` },
{ status: 403 }
);
}
}
// 检查是否与站长同名
if (username === process.env.USERNAME) {
return NextResponse.json(

View File

@@ -35,6 +35,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
email: oidcSession.email,
name: oidcSession.name,
trust_level: oidcSession.trust_level,
});
} catch (error) {
return NextResponse.json(

View File

@@ -307,12 +307,12 @@ async function handleOpenListProxy(request: NextRequest) {
if (wd) {
const results = Object.entries(metaInfo.folders)
.filter(
([folderName, info]) =>
folderName.toLowerCase().includes(wd.toLowerCase()) ||
([key, info]) =>
info.folderName.toLowerCase().includes(wd.toLowerCase()) ||
info.title.toLowerCase().includes(wd.toLowerCase())
)
.map(([folderName, info]) => ({
vod_id: folderName,
.map(([key, info]) => ({
vod_id: key,
vod_name: info.title,
vod_pic: getTMDBImageUrl(info.poster_path),
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
@@ -333,8 +333,8 @@ async function handleOpenListProxy(request: NextRequest) {
// 详情模式
if (ids) {
const folderName = ids;
const info = metaInfo.folders[folderName];
const key = ids;
const info = metaInfo.folders[key];
if (!info) {
return NextResponse.json(
@@ -343,6 +343,8 @@ async function handleOpenListProxy(request: NextRequest) {
);
}
const folderName = info.folderName;
// 获取视频详情
try {
const detailResponse = await fetch(
@@ -376,7 +378,7 @@ async function handleOpenListProxy(request: NextRequest) {
total: 1,
list: [
{
vod_id: folderName,
vod_id: key,
vod_name: info.title,
vod_pic: getTMDBImageUrl(info.poster_path),
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
@@ -399,8 +401,8 @@ async function handleOpenListProxy(request: NextRequest) {
// 默认返回所有视频
const results = Object.entries(metaInfo.folders).map(
([folderName, info]) => ({
vod_id: folderName,
([key, info]) => ({
vod_id: key,
vod_name: info.title,
vod_pic: getTMDBImageUrl(info.poster_path),
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',

View File

@@ -37,10 +37,10 @@ export async function GET(request: NextRequest) {
}
const rootPath = openListConfig.RootPath || '/';
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${id}`;
// 1. 读取 metainfo 获取元数据
let metaInfo: any = null;
let folderMeta: any = null;
try {
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
const { db } = await import('@/lib/db');
@@ -54,10 +54,20 @@ export async function GET(request: NextRequest) {
setCachedMetaInfo(rootPath, metaInfo);
}
}
// 使用 key 查找文件夹信息
folderMeta = metaInfo?.folders?.[id];
if (!folderMeta) {
throw new Error('未找到该视频信息');
}
} catch (error) {
// 忽略错误
throw new Error('读取视频信息失败: ' + (error as Error).message);
}
// 使用 folderName 构建实际路径
const folderName = folderMeta.folderName;
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
// 2. 直接调用 OpenList 客户端获取视频列表
const { OpenListClient } = await import('@/lib/openlist.client');
const { getCachedVideoInfo, setCachedVideoInfo } = await import('@/lib/openlist-cache');
@@ -71,24 +81,6 @@ export async function GET(request: NextRequest) {
let videoInfo = getCachedVideoInfo(folderPath);
if (!videoInfo) {
try {
const videoinfoPath = `${folderPath}/videoinfo.json`;
const fileResponse = await client.getFile(videoinfoPath);
if (fileResponse.code === 200 && fileResponse.data.raw_url) {
const contentResponse = await fetch(fileResponse.data.raw_url);
const content = await contentResponse.text();
videoInfo = JSON.parse(content);
if (videoInfo) {
setCachedVideoInfo(folderPath, videoInfo);
}
}
} catch (error) {
// 忽略错误
}
}
const listResponse = await client.listDirectory(folderPath);
if (listResponse.code !== 200) {
@@ -138,19 +130,18 @@ export async function GET(request: NextRequest) {
.sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName));
// 3. 从 metainfo 中获取元数据
const folderMeta = metaInfo?.folders?.[id];
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
const result = {
source: 'openlist',
source_name: '私人影库',
id: id,
title: folderMeta?.title || id,
title: folderMeta?.title || folderName,
poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '',
year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '',
douban_id: 0,
desc: folderMeta?.overview || '',
episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(id)}&fileName=${encodeURIComponent(ep.fileName)}`),
episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`),
episodes_titles: episodes.map((ep) => ep.title),
};

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import * as cheerio from 'cheerio';
export const runtime = 'nodejs';
interface DoubanRecommendation {
doubanId: string;
title: string;
poster: string;
rating: string;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const doubanId = searchParams.get('id');
if (!doubanId) {
return NextResponse.json({ error: 'Missing douban ID' }, { status: 400 });
}
try {
// 请求豆瓣电影页面使用和其他豆瓣API相同的请求头
const url = `https://movie.douban.com/subject/${doubanId}/`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(url, {
signal: controller.signal,
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',
Referer: 'https://movie.douban.com/',
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
Origin: 'https://movie.douban.com',
},
});
clearTimeout(timeoutId);
if (!response.ok) {
return NextResponse.json(
{ error: 'Failed to fetch douban page' },
{ status: response.status }
);
}
const html = await response.text();
const $ = cheerio.load(html);
const recommendations: DoubanRecommendation[] = [];
console.log('开始解析豆瓣推荐');
// 解析推荐模块
$('.recommendations-bd dl').each((index, element) => {
const $dl = $(element);
// 提取链接和豆瓣ID
const $link = $dl.find('dt a');
const href = $link.attr('href') || '';
const doubanIdMatch = href.match(/subject\/(\d+)/);
const recDoubanId = doubanIdMatch ? doubanIdMatch[1] : '';
// 提取图片 - 返回原始豆瓣URL由客户端processImageUrl根据配置处理
const poster = $link.find('img').attr('src') || '';
// 提取标题
const title = $dl.find('dd a').first().text().trim();
// 提取评分
const rating = $dl.find('dd .subject-rate').text().trim();
if (recDoubanId && title) {
recommendations.push({
doubanId: recDoubanId,
title,
poster,
rating,
});
}
});
console.log('解析到推荐数:', recommendations.length);
return NextResponse.json(
{
recommendations,
},
{
headers: {
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
}
);
} catch (error) {
console.error('Douban recommendations fetch error:', error);
return NextResponse.json(
{ error: 'Failed to parse douban recommendations' },
{ status: 500 }
);
}
}

View File

@@ -14,9 +14,9 @@ export async function GET(request: Request) {
try {
const imageResponse = await fetch(imageUrl, {
headers: {
Referer: 'https://movie.douban.com/',
'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',
},
});

View File

@@ -27,9 +27,20 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
const { folder, tmdbId, title, posterPath, releaseDate, overview, voteAverage, mediaType } = body;
const {
key,
tmdbId,
title,
posterPath,
releaseDate,
overview,
voteAverage,
mediaType,
seasonNumber,
seasonName,
} = body;
if (!folder || !tmdbId) {
if (!key || !tmdbId) {
return NextResponse.json(
{ error: '缺少必要参数' },
{ status: 400 }
@@ -86,8 +97,20 @@ export async function POST(request: NextRequest) {
);
}
// 检查 key 是否存在
if (!metaInfo.folders[key]) {
return NextResponse.json(
{ error: '视频不存在' },
{ status: 404 }
);
}
// 保留原始文件夹名称
const folderName = metaInfo.folders[key].folderName;
// 更新视频信息
metaInfo.folders[folder] = {
metaInfo.folders[key] = {
folderName: folderName,
tmdb_id: tmdbId,
title: title,
poster_path: posterPath,
@@ -97,6 +120,8 @@ export async function POST(request: NextRequest) {
media_type: mediaType,
last_updated: Date.now(),
failed: false, // 纠错后标记为成功
season_number: seasonNumber, // 季度编号(可选)
season_name: seasonName, // 季度名称(可选)
};
// 保存 metainfo 到数据库

View File

@@ -28,10 +28,10 @@ export async function POST(request: NextRequest) {
// 获取请求参数
const body = await request.json();
const { folder } = body;
const { key } = body;
if (!folder) {
return NextResponse.json({ error: '缺少 folder 参数' }, { status: 400 });
if (!key) {
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
}
// 获取配置
@@ -62,16 +62,16 @@ export async function POST(request: NextRequest) {
const metaInfo: MetaInfo = JSON.parse(metainfoContent);
// 检查文件夹是否存在
if (!metaInfo.folders[folder]) {
// 检查 key 是否存在
if (!metaInfo.folders[key]) {
return NextResponse.json(
{ error: '未找到该视频记录' },
{ status: 404 }
);
}
// 删除文件夹记录
delete metaInfo.folders[folder];
// 删除记录
delete metaInfo.folders[key];
// 保存到数据库
const updatedMetainfoContent = JSON.stringify(metaInfo);

View File

@@ -125,19 +125,23 @@ export async function GET(request: NextRequest) {
const allVideos = Object.entries(metaInfo.folders)
.filter(([, info]) => includeFailed || !info.failed) // 根据参数过滤失败的视频
.map(
([folderName, info]) => ({
id: folderName,
folder: folderName,
tmdbId: info.tmdb_id,
title: info.title,
poster: getTMDBImageUrl(info.poster_path),
releaseDate: info.release_date,
overview: info.overview,
voteAverage: info.vote_average,
mediaType: info.media_type,
lastUpdated: info.last_updated,
failed: info.failed || false,
})
([key, info]) => {
return {
id: key,
folder: info.folderName,
tmdbId: info.tmdb_id,
title: info.title,
poster: getTMDBImageUrl(info.poster_path),
releaseDate: info.release_date,
overview: info.overview,
voteAverage: info.vote_average,
mediaType: info.media_type,
lastUpdated: info.last_updated,
failed: info.failed || false,
seasonNumber: info.season_number,
seasonName: info.season_name,
};
}
);
// 按更新时间倒序排序

View File

@@ -48,15 +48,6 @@ export async function POST(request: NextRequest) {
openListConfig.Password
);
// 删除 videoinfo.json
const videoinfoPath = `${folderPath}/videoinfo.json`;
try {
await client.deleteFile(videoinfoPath);
} catch (error) {
console.log('videoinfo.json 不存在或删除失败');
}
// 清除缓存
invalidateVideoInfoCache(folderPath);

View File

@@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { generateFolderKey } from '@/lib/crypto';
import { db } from '@/lib/db';
import { OpenListClient } from '@/lib/openlist.client';
import {
@@ -19,7 +20,8 @@ import {
failScanTask,
updateScanTaskProgress,
} from '@/lib/scan-task';
import { searchTMDB } from '@/lib/tmdb.search';
import { parseSeasonFromTitle } from '@/lib/season-parser';
import { searchTMDB, getTVSeasonDetails } from '@/lib/tmdb.search';
export const runtime = 'nodejs';
@@ -187,6 +189,15 @@ async function performScan(
let existingCount = 0;
let errorCount = 0;
// 收集已存在的 key用于冲突检测
const existingKeys = new Set<string>(Object.keys(metaInfo.folders));
// 创建文件夹名到 key 的映射(用于查找已存在的文件夹)
const folderNameToKey = new Map<string, string>();
for (const [key, info] of Object.entries(metaInfo.folders)) {
folderNameToKey.set(info.folderName, key);
}
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
@@ -194,23 +205,37 @@ async function performScan(
updateScanTaskProgress(taskId, i + 1, folders.length, folder.name);
// 如果是立即扫描(不清空 metainfo且文件夹已存在跳过
if (!clearMetaInfo && metaInfo.folders[folder.name]) {
if (!clearMetaInfo && folderNameToKey.has(folder.name)) {
existingCount++;
continue;
}
// 生成文件夹的 key
const folderKey = generateFolderKey(folder.name, existingKeys);
existingKeys.add(folderKey);
try {
// 搜索 TMDB
// 解析文件夹名称,提取季度信息和年份
const seasonInfo = parseSeasonFromTitle(folder.name);
const searchQuery = seasonInfo.cleanTitle || folder.name;
console.log(`[OpenList Refresh] 处理文件夹: ${folder.name}`);
console.log(`[OpenList Refresh] 清理后标题: ${searchQuery}, 季度: ${seasonInfo.seasonNumber}, 年份: ${seasonInfo.year}`);
// 搜索 TMDB使用清理后的标题和年份
const searchResult = await searchTMDB(
tmdbApiKey,
folder.name,
tmdbProxy
searchQuery,
tmdbProxy,
seasonInfo.year || undefined
);
if (searchResult.code === 200 && searchResult.result) {
const result = searchResult.result;
metaInfo.folders[folder.name] = {
// 基础信息
const folderInfo: any = {
folderName: folder.name, // 保存原始文件夹名称
tmdb_id: result.id,
title: result.title || result.name || folder.name,
poster_path: result.poster_path,
@@ -222,10 +247,55 @@ async function performScan(
failed: false,
};
// 如果是电视剧且识别到季度编号,获取该季度的详细信息
if (result.media_type === 'tv' && seasonInfo.seasonNumber) {
try {
const seasonDetails = await getTVSeasonDetails(
tmdbApiKey,
result.id,
seasonInfo.seasonNumber,
tmdbProxy
);
if (seasonDetails.code === 200 && seasonDetails.season) {
folderInfo.season_number = seasonDetails.season.season_number;
folderInfo.season_name = seasonDetails.season.name;
// 如果是第二季及以后替换标题和ID
if (seasonDetails.season.season_number > 1) {
folderInfo.title = `${folderInfo.title} ${seasonDetails.season.name}`;
}
// 使用季度的海报(如果有)
if (seasonDetails.season.poster_path) {
folderInfo.poster_path = seasonDetails.season.poster_path;
}
// 使用季度的简介(如果有)
if (seasonDetails.season.overview) {
folderInfo.overview = seasonDetails.season.overview;
}
// 使用季度的首播日期(如果有)
if (seasonDetails.season.air_date) {
folderInfo.release_date = seasonDetails.season.air_date;
}
} else {
console.warn(`[OpenList Refresh] 获取季度 ${seasonInfo.seasonNumber} 详情失败`);
// 即使获取季度详情失败,也保存季度编号
folderInfo.season_number = seasonInfo.seasonNumber;
}
} catch (error) {
console.error(`[OpenList Refresh] 获取季度详情异常:`, error);
// 即使出错,也保存季度编号
folderInfo.season_number = seasonInfo.seasonNumber;
}
}
metaInfo.folders[folderKey] = folderInfo;
newCount++;
} else {
// 记录失败的文件夹
metaInfo.folders[folder.name] = {
metaInfo.folders[folderKey] = {
folderName: folder.name, // 保存原始文件夹名称
tmdb_id: 0,
title: folder.name,
poster_path: null,
@@ -244,7 +314,8 @@ async function performScan(
} catch (error) {
console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error);
// 记录失败的文件夹
metaInfo.folders[folder.name] = {
metaInfo.folders[folderKey] = {
folderName: folder.name, // 保存原始文件夹名称
tmdb_id: 0,
title: folder.name,
poster_path: null,

View File

@@ -72,13 +72,13 @@ export async function GET(request: NextRequest) {
if (metaInfo && metaInfo.folders) {
openlistResults = Object.entries(metaInfo.folders)
.filter(([folderName, info]: [string, any]) => {
const matchFolder = folderName.toLowerCase().includes(query.toLowerCase());
.filter(([key, info]: [string, any]) => {
const matchFolder = info.folderName.toLowerCase().includes(query.toLowerCase());
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
return matchFolder || matchTitle;
})
.map(([folderName, info]: [string, any]) => ({
id: folderName,
.map(([key, info]: [string, any]) => ({
id: key,
source: 'openlist',
source_name: '私人影库',
title: info.title,

View File

@@ -109,13 +109,13 @@ export async function GET(request: NextRequest) {
if (metaInfo && metaInfo.folders) {
const openlistResults = Object.entries(metaInfo.folders)
.filter(([folderName, info]: [string, any]) => {
const matchFolder = folderName.toLowerCase().includes(query.toLowerCase());
.filter(([key, info]: [string, any]) => {
const matchFolder = info.folderName.toLowerCase().includes(query.toLowerCase());
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
return matchFolder || matchTitle;
})
.map(([folderName, info]: [string, any]) => ({
id: folderName,
.map(([key, info]: [string, any]) => ({
id: key,
source: 'openlist',
source_name: '私人影库',
title: info.title,

View File

@@ -13,11 +13,12 @@ export async function GET(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
// 观影室配置从环境变量读取
// 注意:不要暴露 externalServerAuth 到前端,这是敏感凭据
const watchRoomConfig = {
enabled: process.env.WATCH_ROOM_ENABLED === 'true',
serverType: (process.env.WATCH_ROOM_SERVER_TYPE as 'internal' | 'external') || 'internal',
externalServerUrl: process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
externalServerAuth: process.env.WATCH_ROOM_EXTERNAL_SERVER_AUTH,
// externalServerAuth 不应该暴露给前端
};
// 如果使用 localStorage返回默认配置

View File

@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getTVSeasons } from '@/lib/tmdb.search';
export const runtime = 'nodejs';
/**
* GET /api/tmdb/seasons?tvId=xxx
* 获取电视剧的季度列表
*/
export async function GET(request: NextRequest) {
try {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const tvIdStr = searchParams.get('tvId');
if (!tvIdStr) {
return NextResponse.json({ error: '缺少 tvId 参数' }, { status: 400 });
}
const tvId = parseInt(tvIdStr, 10);
if (isNaN(tvId)) {
return NextResponse.json({ error: 'tvId 必须是数字' }, { status: 400 });
}
const config = await getConfig();
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
const tmdbProxy = config.SiteConfig.TMDBProxy;
if (!tmdbApiKey) {
return NextResponse.json(
{ error: 'TMDB API Key 未配置' },
{ status: 400 }
);
}
const result = await getTVSeasons(tmdbApiKey, tvId, tmdbProxy);
if (result.code === 200 && result.seasons) {
return NextResponse.json({
success: true,
seasons: result.seasons,
});
} else {
return NextResponse.json(
{ error: '获取季度列表失败', code: result.code },
{ status: result.code }
);
}
} catch (error) {
console.error('获取季度列表失败:', error);
return NextResponse.json(
{ error: '获取失败', details: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { getTMDBTrendingContent } from '@/lib/tmdb.client';
import { getConfig } from '@/lib/config';
// 缓存配置 - 服务器内存缓存3小时
const CACHE_DURATION = 3 * 60 * 60 * 1000; // 3小时
let cachedData: { data: any; timestamp: number } | null = null;
export const dynamic = 'force-dynamic';
export async function GET() {
try {
// 检查缓存
if (cachedData && Date.now() - cachedData.timestamp < CACHE_DURATION) {
return NextResponse.json(cachedData.data);
}
// 获取配置
const config = await getConfig();
const apiKey = config.SiteConfig?.TMDBApiKey;
const proxy = config.SiteConfig?.TMDBProxy;
if (!apiKey) {
return NextResponse.json(
{ code: 400, message: 'TMDB API Key 未配置' },
{ status: 400 }
);
}
// 获取热门内容
const result = await getTMDBTrendingContent(apiKey, proxy);
// 更新缓存
cachedData = {
data: result,
timestamp: Date.now(),
};
return NextResponse.json(result);
} catch (error) {
console.error('获取 TMDB 热门内容失败:', error);
return NextResponse.json(
{ code: 500, message: '获取热门内容失败' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,30 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
export const runtime = 'nodejs';
/**
* GET /api/watch-room-auth
*
* 需要登录才能访问的接口,返回观影室外部服务器的认证信息
* 这样可以避免将敏感的 externalServerAuth 暴露给未登录用户
*/
export async function GET(request: NextRequest) {
console.log('watch-room-auth called: ', request.url);
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 返回外部服务器认证信息
const externalServerAuth = process.env.WATCH_ROOM_EXTERNAL_SERVER_AUTH;
return NextResponse.json({
externalServerAuth: externalServerAuth || null,
});
}

View File

@@ -103,6 +103,12 @@ function OIDCRegisterPageClient() {
{oidcInfo.name && (
<>
: <strong>{oidcInfo.name}</strong>
<br />
</>
)}
{oidcInfo.trust_level !== undefined && (
<>
: <strong>{oidcInfo.trust_level}</strong>
</>
)}
</p>

View File

@@ -10,27 +10,20 @@ import {
BangumiCalendarData,
GetBangumiCalendarData,
} from '@/lib/bangumi.client';
// 客户端收藏 API
import {
clearAllFavorites,
getAllFavorites,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { getDoubanCategories } from '@/lib/douban.client';
import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client';
import { DoubanItem } from '@/lib/types';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import ContinueWatching from '@/components/ContinueWatching';
import PageLayout from '@/components/PageLayout';
import ScrollableRow from '@/components/ScrollableRow';
import { useSite } from '@/components/SiteProvider';
import VideoCard from '@/components/VideoCard';
import HttpWarningDialog from '@/components/HttpWarningDialog';
import BannerCarousel from '@/components/BannerCarousel';
function HomeClient() {
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
// 移除了 activeTab 状态,收藏夹功能已移到 UserMenu
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
@@ -57,22 +50,6 @@ function HomeClient() {
}
}, [announcement]);
// 收藏夹数据
type FavoriteItem = {
id: string;
source: string;
title: string;
poster: string;
episodes: number;
source_name: string;
currentEpisode?: number;
search_title?: string;
origin?: 'vod' | 'live';
};
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
const favoritesFetchedRef = useRef(false);
useEffect(() => {
const fetchRecommendData = async () => {
try {
@@ -146,74 +123,7 @@ function HomeClient() {
fetchRecommendData();
}, []);
// 处理收藏数据更新的函数
const updateFavoriteItems = useCallback(
async (allFavorites: Record<string, any>) => {
const allPlayRecords = await getAllPlayRecords();
// 根据保存时间排序(从近到远)
const sorted = Object.entries(allFavorites)
.sort(([, a], [, b]) => b.save_time - a.save_time)
.map(([key, fav]) => {
const plusIndex = key.indexOf('+');
const source = key.slice(0, plusIndex);
const id = key.slice(plusIndex + 1);
// 查找对应的播放记录,获取当前集数
const playRecord = allPlayRecords[key];
const currentEpisode = playRecord?.index;
return {
id,
source,
title: fav.title,
year: fav.year,
poster: fav.cover,
episodes: fav.total_episodes,
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
origin: fav?.origin,
} as FavoriteItem;
});
setFavoriteItems(sorted);
},
[]
);
// 当切换到收藏夹时加载收藏数据(使用 ref 防止重复加载)
useEffect(() => {
if (activeTab !== 'favorites') {
favoritesFetchedRef.current = false;
return;
}
// 已经加载过就不再加载
if (favoritesFetchedRef.current) return;
favoritesFetchedRef.current = true;
const loadFavorites = async () => {
const allFavorites = await getAllFavorites();
await updateFavoriteItems(allFavorites);
};
loadFavorites();
}, [activeTab, updateFavoriteItems]);
// 监听收藏更新事件(独立的 useEffect
useEffect(() => {
if (activeTab !== 'favorites') return;
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(newFavorites: Record<string, any>) => {
updateFavoriteItems(newFavorites);
}
);
return unsubscribe;
}, [activeTab, updateFavoriteItems]);
const handleCloseAnnouncement = (announcement: string) => {
setShowAnnouncement(false);
@@ -222,60 +132,15 @@ function HomeClient() {
return (
<PageLayout>
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
{/* 顶部 Tab 切换 */}
<div className='mb-8 flex justify-center'>
<CapsuleSwitch
options={[
{ label: '首页', value: 'home' },
{ label: '收藏夹', value: 'favorites' },
]}
active={activeTab}
onChange={(value) => setActiveTab(value as 'home' | 'favorites')}
/>
</div>
{/* TMDB 热门轮播图 */}
<div className='w-full mb-6 sm:mb-8'>
<BannerCarousel />
</div>
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
<div className='max-w-[95%] mx-auto'>
{activeTab === 'favorites' ? (
// 收藏夹视图
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
{favoriteItems.length > 0 && (
<button
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
onClick={async () => {
await clearAllFavorites();
setFavoriteItems([]);
}}
>
</button>
)}
</div>
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
{favoriteItems.map((item) => (
<div key={item.id + item.source} className='w-full'>
<VideoCard
query={item.search_title}
{...item}
from='favorite'
type={item.episodes > 1 ? 'tv' : ''}
/>
</div>
))}
{favoriteItems.length === 0 && (
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
</div>
)}
</div>
</section>
) : (
// 首页视图
<>
{/* 首页内容 */}
<>
{/* 继续观看 */}
<ContinueWatching />
@@ -315,6 +180,7 @@ function HomeClient() {
poster={movie.poster}
title={movie.title}
year={movie.year}
rate={movie.rate}
type='movie'
from='douban'
/>
@@ -471,6 +337,7 @@ function HomeClient() {
poster={tvShow.poster}
title={tvShow.title}
year={tvShow.year}
rate={tvShow.rate}
type='tv'
from='douban'
/>
@@ -514,6 +381,7 @@ function HomeClient() {
poster={varietyShow.poster}
title={varietyShow.title}
year={varietyShow.year}
rate={varietyShow.rate}
type='tv'
from='douban'
/>
@@ -555,8 +423,7 @@ function HomeClient() {
</ScrollableRow>
</section>
)}
</>
)}
</>
</div>
</div>

View File

@@ -45,6 +45,7 @@ import EpisodeSelector from '@/components/EpisodeSelector';
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
import PageLayout from '@/components/PageLayout';
import DoubanComments from '@/components/DoubanComments';
import DoubanRecommendations from '@/components/DoubanRecommendations';
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
import Toast, { ToastProps } from '@/components/Toast';
import { useEnableComments } from '@/hooks/useEnableComments';
@@ -434,6 +435,20 @@ function PlayPageClient() {
}
}, [searchParams, currentEpisodeIndex]);
// 监听 URL 参数变化,当切换到不同视频时重新加载页面
useEffect(() => {
const urlTitle = searchParams.get('title') || '';
const urlSource = searchParams.get('source') || '';
const urlId = searchParams.get('id') || '';
// 只在切换到不同视频时重新加载页面title变化
// 换源source/id变化由播放器自己处理不需要刷新页面
if (urlTitle && urlTitle !== videoTitle) {
console.log('[PlayPage] Title changed, reloading page');
window.location.href = window.location.href;
}
}, [searchParams, videoTitle]);
const currentSourceRef = useRef(currentSource);
const currentIdRef = useRef(currentId);
const videoTitleRef = useRef(videoTitle);
@@ -904,6 +919,10 @@ function PlayPageClient() {
!detailData.episodes ||
episodeIndex >= detailData.episodes.length
) {
// openlist 源的剧集是懒加载的,如果 episodes 为空则跳过
if (detailData?.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) {
return;
}
setVideoUrl('');
return;
}
@@ -2073,7 +2092,7 @@ function PlayPageClient() {
}
}
const newDetail = availableSources.find(
let newDetail: SearchResult | undefined = availableSources.find(
(source) => source.source === newSource && source.id === newId
);
if (!newDetail) {
@@ -2081,6 +2100,33 @@ function PlayPageClient() {
return;
}
// 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息
if (newDetail.source === 'openlist' && (!newDetail.episodes || newDetail.episodes.length === 0)) {
try {
const detailResponse = await fetch(`/api/detail?source=${newSource}&id=${newId}`);
if (detailResponse.ok) {
const detailData = await detailResponse.json();
if (!detailData) {
throw new Error('获取的详情数据为空');
}
newDetail = detailData;
} else {
throw new Error('获取 openlist 详情失败');
}
} catch (err) {
console.error('获取 openlist 详情失败:', err);
setIsVideoLoading(false);
setError('获取视频详情失败,请重试');
return;
}
}
// 再次确认 newDetail 不为空(类型守卫)
if (!newDetail) {
setError('视频详情数据无效');
return;
}
// 尝试跳转到当前正在播放的集数
let targetIndex = currentEpisodeIndex;
@@ -2922,6 +2968,11 @@ function PlayPageClient() {
return;
}
// openlist 源的剧集是懒加载的,如果 episodes 为空则跳过检查
if ((currentSource === 'openlist' || detail?.source === 'openlist') && (!detail || !detail.episodes || detail.episodes.length === 0)) {
return;
}
// 确保选集索引有效
if (
!detail ||
@@ -5161,6 +5212,28 @@ function PlayPageClient() {
</div>
</div>
{/* 豆瓣推荐区域 */}
{videoDoubanId !== 0 && enableComments && (
<div className='mt-6 px-4'>
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50 overflow-hidden'>
{/* 标题 */}
<div className='px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'>
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/>
</svg>
</h3>
</div>
{/* 推荐内容 */}
<div className='p-6'>
<DoubanRecommendations doubanId={videoDoubanId} />
</div>
</div>
</div>
)}
{/* 豆瓣评论区域 */}
{videoDoubanId !== 0 && enableComments && (
<div className='mt-6 px-4'>

View File

@@ -63,8 +63,8 @@ export default function PrivateLibraryPage() {
};
const handleVideoClick = (video: Video) => {
// 跳转到播放页面
router.push(`/play?source=openlist&id=${encodeURIComponent(video.folder)}`);
// 跳转到播放页面,使用 idkey而不是 folder
router.push(`/play?source=openlist&id=${encodeURIComponent(video.id)}`);
};
return (
@@ -106,11 +106,16 @@ export default function PrivateLibraryPage() {
{videos.map((video) => (
<VideoCard
key={video.id}
id={video.folder}
id={video.id}
source='openlist'
title={video.title}
poster={video.poster}
year={video.releaseDate.split('-')[0]}
rate={
video.voteAverage && video.voteAverage > 0
? video.voteAverage.toFixed(1)
: ''
}
from='search'
/>
))}

View File

@@ -0,0 +1,296 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { getTMDBImageUrl, getGenreNames, type TMDBItem } from '@/lib/tmdb.client';
import { ChevronLeft, ChevronRight, Play } from 'lucide-react';
interface BannerCarouselProps {
autoPlayInterval?: number; // 自动播放间隔(毫秒)
}
export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarouselProps) {
const router = useRouter();
const [items, setItems] = useState<TMDBItem[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isPaused, setIsPaused] = useState(false);
const [skipNextAutoPlay, setSkipNextAutoPlay] = useState(false); // 跳过下一次自动播放
const touchStartX = useRef(0);
const touchEndX = useRef(0);
const isManualChange = useRef(false); // 标记是否为手动切换
// LocalStorage 缓存配置
const LOCALSTORAGE_KEY = 'tmdb_trending_cache';
const LOCALSTORAGE_DURATION = 24 * 60 * 60 * 1000; // 1天
// 跳转到播放页面
const handlePlay = (title: string) => {
router.push(`/play?title=${encodeURIComponent(title)}`);
};
// 获取热门内容
useEffect(() => {
const fetchTrending = async () => {
try {
// 先检查 localStorage 缓存
const cached = localStorage.getItem(LOCALSTORAGE_KEY);
if (cached) {
try {
const { data, timestamp } = JSON.parse(cached);
const now = Date.now();
// 如果缓存未过期,直接使用
if (now - timestamp < LOCALSTORAGE_DURATION) {
setItems(data);
setIsLoading(false);
return;
}
} catch (e) {
// 缓存解析失败,继续请求 API
console.error('解析缓存数据失败:', e);
}
}
// 从 API 获取数据
const response = await fetch('/api/tmdb/trending');
const result = await response.json();
if (result.code === 200 && result.list.length > 0) {
setItems(result.list);
// 保存到 localStorage
try {
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify({
data: result.list,
timestamp: Date.now()
}));
} catch (e) {
// localStorage 可能已满,忽略错误
console.error('保存到 localStorage 失败:', e);
}
}
} catch (error) {
console.error('获取热门内容失败:', error);
} finally {
setIsLoading(false);
}
};
fetchTrending();
}, [LOCALSTORAGE_KEY, LOCALSTORAGE_DURATION]);
// 自动播放
useEffect(() => {
if (!items.length || isPaused) return;
const timer = setInterval(() => {
// 如果设置了跳过标志,跳过这一次自动播放
if (skipNextAutoPlay) {
setSkipNextAutoPlay(false);
return;
}
setCurrentIndex((prev) => (prev + 1) % items.length);
}, autoPlayInterval);
return () => clearInterval(timer);
}, [items.length, isPaused, autoPlayInterval, skipNextAutoPlay]);
const goToPrevious = useCallback(() => {
isManualChange.current = true;
setSkipNextAutoPlay(true);
setCurrentIndex((prev) => (prev - 1 + items.length) % items.length);
setTimeout(() => {
isManualChange.current = false;
}, 100);
}, [items.length]);
const goToNext = useCallback(() => {
isManualChange.current = true;
setSkipNextAutoPlay(true);
setCurrentIndex((prev) => (prev + 1) % items.length);
setTimeout(() => {
isManualChange.current = false;
}, 100);
}, [items.length]);
const goToSlide = useCallback((index: number) => {
isManualChange.current = true;
setSkipNextAutoPlay(true);
setCurrentIndex(index);
setTimeout(() => {
isManualChange.current = false;
}, 100);
}, []);
// 触摸事件处理
const handleTouchStart = (e: React.TouchEvent) => {
// 防止在手动切换过程中触发
if (isManualChange.current) return;
touchStartX.current = e.touches[0].clientX;
touchEndX.current = 0; // 重置结束位置
};
const handleTouchMove = (e: React.TouchEvent) => {
// 防止在手动切换过程中触发
if (isManualChange.current) return;
touchEndX.current = e.touches[0].clientX;
};
const handleTouchEnd = () => {
// 防止在手动切换过程中触发
if (isManualChange.current) return;
if (!touchStartX.current) return;
// 如果有滑动,则执行滑动逻辑
if (touchEndX.current !== 0) {
const distance = touchStartX.current - touchEndX.current;
const minSwipeDistance = 50; // 最小滑动距离
if (Math.abs(distance) > minSwipeDistance) {
if (distance > 0) {
// 向左滑动,显示下一张
goToNext();
} else {
// 向右滑动,显示上一张
goToPrevious();
}
}
}
// 重置
touchStartX.current = 0;
touchEndX.current = 0;
};
if (isLoading) {
return (
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] bg-gradient-to-b from-gray-800 to-gray-900 overflow-hidden animate-pulse">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-16 h-16 border-4 border-gray-600 border-t-gray-400 rounded-full animate-spin"></div>
</div>
</div>
);
}
if (!items.length) {
return null;
}
const currentItem = items[currentIndex];
return (
<div
className="relative w-full h-[200px] sm:h-[300px] md:h-[400px] lg:h-[500px] overflow-hidden group"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={() => {
// 移动端点击整个轮播图跳转
if (window.innerWidth < 768) {
handlePlay(currentItem.title);
}
}}
>
{/* 背景图片 */}
<div className="absolute inset-0">
{items.map((item, index) => (
<div
key={item.id}
className={`absolute inset-0 transition-opacity duration-1000 ${
index === currentIndex ? 'opacity-100' : 'opacity-0'
}`}
>
<Image
src={getTMDBImageUrl(item.backdrop_path || item.poster_path, 'original')}
alt={item.title}
fill
className="object-cover"
priority={index === 0}
sizes="100vw"
/>
{/* 渐变遮罩 */}
<div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/50 to-transparent"></div>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
</div>
))}
</div>
{/* 内容信息 */}
<div className="absolute inset-0 flex items-end p-8 md:p-12 pointer-events-none">
<div className="max-w-2xl space-y-4">
<h2 className="text-3xl md:text-5xl font-bold text-white drop-shadow-lg">
{currentItem.title}
</h2>
<div className="flex items-center gap-2 md:gap-3 text-sm md:text-base text-white/90 flex-wrap">
<span className="px-2 py-1 bg-yellow-500 text-black font-semibold rounded">
{currentItem.vote_average.toFixed(1)}
</span>
{getGenreNames(currentItem.genre_ids, 3).map(genre => (
<span key={genre} className="px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm">
{genre}
</span>
))}
{currentItem.release_date && (
<span>{currentItem.release_date}</span>
)}
</div>
{/* PC端播放按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
handlePlay(currentItem.title);
}}
className="hidden md:flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors pointer-events-auto shadow-lg"
>
<Play className="w-5 h-5 fill-white" />
</button>
{currentItem.overview && (
<p className="text-sm md:text-base text-white/80 line-clamp-3 drop-shadow-md">
{currentItem.overview}
</p>
)}
</div>
</div>
{/* 左右切换按钮 - 只在桌面端显示 */}
<button
onClick={goToPrevious}
className="hidden md:flex absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label="上一张"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={goToNext}
className="hidden md:flex absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/60 text-white rounded-full items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label="下一张"
>
<ChevronRight className="w-8 h-8" />
</button>
{/* 指示器 */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{items.map((_, index) => (
<button
key={index}
onClick={() => goToSlide(index)}
className={`h-1.5 rounded-full transition-all duration-300 ${
index === currentIndex
? 'w-8 bg-white'
: 'w-1.5 bg-white/50 hover:bg-white/80'
}`}
aria-label={`跳转到第 ${index + 1}`}
/>
))}
</div>
</div>
);
}

View File

@@ -22,10 +22,20 @@ interface TMDBResult {
media_type: 'movie' | 'tv';
}
interface TMDBSeason {
id: number;
name: string;
season_number: number;
episode_count: number;
air_date: string | null;
poster_path: string | null;
overview: string;
}
interface CorrectDialogProps {
isOpen: boolean;
onClose: () => void;
folder: string;
videoKey: string;
currentTitle: string;
onCorrect: () => void;
}
@@ -33,7 +43,7 @@ interface CorrectDialogProps {
export default function CorrectDialog({
isOpen,
onClose,
folder,
videoKey,
currentTitle,
onCorrect,
}: CorrectDialogProps) {
@@ -43,11 +53,20 @@ export default function CorrectDialog({
const [error, setError] = useState('');
const [correcting, setCorrecting] = useState(false);
// 季度选择相关状态
const [selectedResult, setSelectedResult] = useState<TMDBResult | null>(null);
const [seasons, setSeasons] = useState<TMDBSeason[]>([]);
const [loadingSeasons, setLoadingSeasons] = useState(false);
const [showSeasonSelection, setShowSeasonSelection] = useState(false);
useEffect(() => {
if (isOpen) {
setSearchQuery(currentTitle);
setResults([]);
setError('');
setSelectedResult(null);
setSeasons([]);
setShowSeasonSelection(false);
}
}, [isOpen, currentTitle]);
@@ -60,6 +79,8 @@ export default function CorrectDialog({
setSearching(true);
setError('');
setResults([]);
setShowSeasonSelection(false);
setSelectedResult(null);
try {
const response = await fetch(
@@ -88,22 +109,98 @@ export default function CorrectDialog({
}
};
const handleCorrect = async (result: TMDBResult) => {
// 获取电视剧的季度列表
const fetchSeasons = async (tvId: number) => {
setLoadingSeasons(true);
setError('');
try {
const response = await fetch(`/api/tmdb/seasons?tvId=${tvId}`);
if (!response.ok) {
throw new Error('获取季度列表失败');
}
const data = await response.json();
if (data.success && data.seasons) {
return data.seasons as TMDBSeason[];
} else {
setError('获取季度列表失败');
return [];
}
} catch (err) {
console.error('获取季度列表失败:', err);
setError('获取季度列表失败,请重试');
return [];
} finally {
setLoadingSeasons(false);
}
};
// 处理选择结果(电影直接纠错,电视剧显示季度选择)
const handleSelectResult = async (result: TMDBResult) => {
if (result.media_type === 'tv') {
// 电视剧:先获取季度列表
setSelectedResult(result);
const seasonsList = await fetchSeasons(result.id);
if (seasonsList.length === 1) {
// 只有一季,直接使用该季度进行纠错
await handleCorrect(result, seasonsList[0]);
} else if (seasonsList.length > 1) {
// 多季,显示选择界面
setSeasons(seasonsList);
setShowSeasonSelection(true);
} else {
// 没有季度信息,直接使用剧集信息
await handleCorrect(result);
}
} else {
// 电影:直接纠错
await handleCorrect(result);
}
};
// 处理选择季度
const handleSelectSeason = async (season: TMDBSeason) => {
if (!selectedResult) return;
await handleCorrect(selectedResult, season);
};
// 执行纠错
const handleCorrect = async (result: TMDBResult, season?: TMDBSeason) => {
setCorrecting(true);
try {
// 构建标题和ID如果是第二季及以后在标题后加上季度名称并使用季度ID
let finalTitle = result.title || result.name;
let finalTmdbId = result.id;
if (season && season.season_number > 1) {
finalTitle = `${finalTitle} ${season.name}`;
}
const body: any = {
key: videoKey,
tmdbId: finalTmdbId,
title: finalTitle,
posterPath: season?.poster_path || result.poster_path,
releaseDate: season?.air_date || result.release_date || result.first_air_date,
overview: season?.overview || result.overview,
voteAverage: result.vote_average,
mediaType: result.media_type,
};
// 如果有季度信息,添加到请求中
if (season) {
body.seasonNumber = season.season_number;
body.seasonName = season.name;
}
const response = await fetch('/api/openlist/correct', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
folder,
tmdbId: result.id,
title: result.title || result.name,
posterPath: result.poster_path,
releaseDate: result.release_date || result.first_air_date,
overview: result.overview,
voteAverage: result.vote_average,
mediaType: result.media_type,
}),
body: JSON.stringify(body),
});
if (!response.ok) {
@@ -120,6 +217,13 @@ export default function CorrectDialog({
}
};
// 返回搜索结果列表
const handleBackToResults = () => {
setShowSeasonSelection(false);
setSelectedResult(null);
setSeasons([]);
};
if (!isOpen) return null;
return createPortal(
@@ -169,11 +273,98 @@ export default function CorrectDialog({
{/* 结果列表 */}
<div className='flex-1 overflow-y-auto p-4'>
{results.length === 0 ? (
{showSeasonSelection ? (
// 季度选择界面
<div>
<div className='mb-4 flex items-center gap-2'>
<button
onClick={handleBackToResults}
className='text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 flex items-center gap-1'
>
<span></span>
<span></span>
</button>
</div>
{selectedResult && (
<div className='mb-4 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg'>
<h3 className='font-semibold text-gray-900 dark:text-gray-100'>
{selectedResult.title || selectedResult.name}
</h3>
<p className='text-sm text-gray-600 dark:text-gray-400 mt-1'>
</p>
</div>
)}
{loadingSeasons ? (
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
...
</div>
) : seasons.length === 0 ? (
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
</div>
) : (
<div className='space-y-3'>
{seasons.map((season) => (
<div
key={season.id}
className='flex gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors'
>
{/* 海报 */}
<div className='flex-shrink-0 w-16 h-24 relative rounded overflow-hidden bg-gray-200 dark:bg-gray-700'>
{season.poster_path ? (
<Image
src={processImageUrl(getTMDBImageUrl(season.poster_path))}
alt={season.name}
fill
className='object-cover'
referrerPolicy='no-referrer'
/>
) : (
<div className='w-full h-full flex items-center justify-center text-gray-400 text-xs'>
</div>
)}
</div>
{/* 信息 */}
<div className='flex-1 min-w-0'>
<h3 className='font-semibold text-gray-900 dark:text-gray-100'>
{season.name}
</h3>
<p className='text-sm text-gray-600 dark:text-gray-400 mt-1'>
{season.episode_count}
{season.air_date && `${season.air_date.split('-')[0]}`}
</p>
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1 line-clamp-2'>
{season.overview || '暂无简介'}
</p>
</div>
{/* 选择按钮 */}
<div className='flex-shrink-0 flex items-center'>
<button
onClick={() => handleSelectSeason(season)}
disabled={correcting}
className='px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed'
>
{correcting ? '处理中...' : '选择'}
</button>
</div>
</div>
))}
</div>
)}
</div>
) : results.length === 0 ? (
// 空状态
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
{searching ? '搜索中...' : '请输入关键词搜索'}
</div>
) : (
// 搜索结果列表
<div className='space-y-3'>
{results.map((result) => (
<div
@@ -217,11 +408,11 @@ export default function CorrectDialog({
{/* 选择按钮 */}
<div className='flex-shrink-0 flex items-center'>
<button
onClick={() => handleCorrect(result)}
disabled={correcting}
onClick={() => handleSelectResult(result)}
disabled={correcting || loadingSeasons}
className='px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed'
>
{correcting ? '处理中...' : '选择'}
{correcting || loadingSeasons ? '处理中...' : '选择'}
</button>
</div>
</div>

View File

@@ -0,0 +1,132 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useEnableComments } from '@/hooks/useEnableComments';
import VideoCard from '@/components/VideoCard';
import ScrollableRow from '@/components/ScrollableRow';
interface DoubanRecommendation {
doubanId: string;
title: string;
poster: string;
rating: string;
}
interface DoubanRecommendationsProps {
doubanId: number;
}
export default function DoubanRecommendations({ doubanId }: DoubanRecommendationsProps) {
const [recommendations, setRecommendations] = useState<DoubanRecommendation[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const enableComments = useEnableComments();
const fetchRecommendations = useCallback(async () => {
try {
console.log('正在获取推荐');
setLoading(true);
setError(null);
// 检查localStorage缓存
const cacheKey = `douban_recommendations_${doubanId}`;
const cached = localStorage.getItem(cacheKey);
if (cached) {
try {
const { data, timestamp } = JSON.parse(cached);
const cacheAge = Date.now() - timestamp;
const cacheMaxAge = 7 * 24 * 60 * 60 * 1000; // 7天
if (cacheAge < cacheMaxAge) {
console.log('使用缓存的推荐数据');
setRecommendations(data);
setLoading(false);
return;
}
} catch (e) {
console.error('解析缓存失败:', e);
}
}
const response = await fetch(
`/api/douban-recommendations?id=${doubanId}`
);
if (!response.ok) {
throw new Error('获取推荐失败');
}
const result = await response.json();
console.log('获取到推荐:', result.recommendations);
const recommendationsData = result.recommendations || [];
setRecommendations(recommendationsData);
// 保存到localStorage
try {
localStorage.setItem(cacheKey, JSON.stringify({
data: recommendationsData,
timestamp: Date.now()
}));
} catch (e) {
console.error('保存缓存失败:', e);
}
} catch (err) {
console.error('获取推荐失败:', err);
setError(err instanceof Error ? err.message : '获取推荐失败');
} finally {
setLoading(false);
}
}, [doubanId]);
useEffect(() => {
if (enableComments && doubanId) {
fetchRecommendations();
}
}, [enableComments, doubanId, fetchRecommendations]);
if (!enableComments) {
return null;
}
if (loading) {
return (
<div className='flex justify-center items-center py-8'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
</div>
);
}
if (error) {
return (
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
{error}
</div>
);
}
if (recommendations.length === 0) {
return null;
}
return (
<ScrollableRow scrollDistance={600}>
{recommendations.map((rec) => (
<div
key={rec.doubanId}
className='min-w-[96px] w-24 sm:min-w-[140px] sm:w-[140px]'
>
<VideoCard
title={rec.title}
poster={rec.poster}
rate={rec.rating}
douban_id={parseInt(rec.doubanId)}
from='douban'
/>
</div>
))}
</ScrollableRow>
);
}

View File

@@ -738,7 +738,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
>
{/* 封面 */}
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
{source.episodes && source.episodes.length > 0 && (
{source.poster && (
<img
src={processImageUrl(source.poster)}
alt={source.title}

View File

@@ -0,0 +1,192 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { Star, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
clearAllFavorites,
getAllFavorites,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import VideoCard from '@/components/VideoCard';
interface FavoriteItem {
id: string;
source: string;
title: string;
year: string;
poster: string;
episodes?: number;
source_name?: string;
currentEpisode?: number;
search_title?: string;
origin?: string;
}
interface FavoritesPanelProps {
isOpen: boolean;
onClose: () => void;
}
export const FavoritesPanel: React.FC<FavoritesPanelProps> = ({
isOpen,
onClose,
}) => {
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
const [loading, setLoading] = useState(false);
// 加载收藏数据
const loadFavorites = async () => {
setLoading(true);
try {
const allFavorites = await getAllFavorites();
const allPlayRecords = await getAllPlayRecords();
// 根据保存时间排序(从近到远)
const sorted = Object.entries(allFavorites)
.sort(([, a], [, b]) => b.save_time - a.save_time)
.map(([key, fav]) => {
const plusIndex = key.indexOf('+');
const source = key.slice(0, plusIndex);
const id = key.slice(plusIndex + 1);
// 查找对应的播放记录,获取当前集数
const playRecord = allPlayRecords[key];
const currentEpisode = playRecord?.index;
return {
id,
source,
title: fav.title,
year: fav.year,
poster: fav.cover,
episodes: fav.total_episodes,
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
origin: fav?.origin,
} as FavoriteItem;
});
setFavoriteItems(sorted);
} catch (error) {
console.error('加载收藏失败:', error);
} finally {
setLoading(false);
}
};
// 清空所有收藏
const handleClearAll = async () => {
try {
await clearAllFavorites();
setFavoriteItems([]);
} catch (error) {
console.error('清空收藏失败:', error);
}
};
// 打开面板时加载收藏
useEffect(() => {
if (isOpen) {
loadFavorites();
}
}, [isOpen]);
// 监听收藏变化,实时移除已取消收藏的项目
useEffect(() => {
const unsubscribe = subscribeToDataUpdates(async (event) => {
if (event === 'favoritesUpdated' && isOpen) {
// 获取最新的收藏列表
const allFavorites = await getAllFavorites();
const currentKeys = Object.keys(allFavorites);
// 过滤掉已经不在收藏中的项目
setFavoriteItems((prevItems) =>
prevItems.filter((item) => {
const key = `${item.source}+${item.id}`;
return currentKeys.includes(key);
})
);
}
});
return () => {
unsubscribe();
};
}, [isOpen]);
return (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={onClose}
/>
{/* 收藏面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-4xl max-h-[85vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] flex flex-col overflow-hidden'>
{/* 标题栏 */}
<div className='flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
<div className='flex items-center gap-2'>
<Star className='w-5 h-5 text-yellow-500' />
<h3 className='text-lg font-bold text-gray-800 dark:text-gray-200'>
</h3>
{favoriteItems.length > 0 && (
<span className='px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full'>
{favoriteItems.length}
</span>
)}
</div>
<div className='flex items-center gap-2'>
{favoriteItems.length > 0 && (
<button
onClick={handleClearAll}
className='text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors'
>
</button>
)}
<button
onClick={onClose}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
</div>
{/* 收藏列表 */}
<div className='flex-1 overflow-y-auto p-6'>
{loading ? (
<div className='flex items-center justify-center py-12'>
<div className='w-8 h-8 border-4 border-yellow-500 border-t-transparent rounded-full animate-spin'></div>
</div>
) : favoriteItems.length === 0 ? (
<div className='flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400'>
<Star className='w-12 h-12 mb-3 opacity-30' />
<p className='text-sm'></p>
</div>
) : (
<div className='grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
{favoriteItems.map((item) => (
<div key={item.id + item.source} className='w-full'>
<VideoCard
query={item.search_title}
{...item}
from='favorite'
type={item.episodes && item.episodes > 1 ? 'tv' : ''}
/>
</div>
))}
</div>
)}
</div>
</div>
</>
);
};

View File

@@ -14,6 +14,7 @@ import {
Rss,
Settings,
Shield,
Star,
User,
X,
} from 'lucide-react';
@@ -30,6 +31,7 @@ import { useVersionCheck } from './VersionCheckProvider';
import { VersionPanel } from './VersionPanel';
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
import { NotificationPanel } from './NotificationPanel';
import { FavoritesPanel } from './FavoritesPanel';
interface AuthInfo {
username?: string;
@@ -46,6 +48,7 @@ export const UserMenu: React.FC = () => {
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false);
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false);
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
const [storageType, setStorageType] = useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
@@ -700,6 +703,18 @@ export const UserMenu: React.FC = () => {
)}
</button>
{/* 我的收藏按钮 */}
<button
onClick={() => {
setIsOpen(false);
setIsFavoritesPanelOpen(true);
}}
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm relative'
>
<Star className='w-4 h-4 text-gray-500 dark:text-gray-400' />
<span className='font-medium'></span>
</button>
{/* 设置按钮 */}
<button
onClick={handleSettings}
@@ -1533,6 +1548,17 @@ export const UserMenu: React.FC = () => {
/>,
document.body
)}
{/* 使用 Portal 将收藏面板渲染到 document.body */}
{isFavoritesPanelOpen &&
mounted &&
createPortal(
<FavoritesPanel
isOpen={isFavoritesPanelOpen}
onClose={() => setIsFavoritesPanelOpen(false)}
/>,
document.body
)}
</>
);
};

View File

@@ -49,6 +49,8 @@ export interface VideoCardProps {
origin?: 'vod' | 'live';
releaseDate?: string; // 上映日期格式YYYY-MM-DD
isUpcoming?: boolean; // 是否为即将上映
seasonNumber?: number; // 季度编号
seasonName?: string; // 季度名称
}
export type VideoCardHandle = {
@@ -80,6 +82,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
origin = 'vod',
releaseDate,
isUpcoming = false,
seasonNumber,
seasonName,
}: VideoCardProps,
ref
) {
@@ -385,7 +389,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
showHeart: true, // 移动端菜单中需要显示收藏选项
showCheckCircle: false,
showDoubanLink: true, // 移动端菜单中显示豆瓣链接
showRating: false,
showRating: !!rate,
showYear: true,
},
douban: {
@@ -394,7 +398,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
showHeart: false,
showCheckCircle: false,
showDoubanLink: true,
showDoubanLink: false,
showRating: !!rate,
showYear: false,
},
@@ -775,6 +779,25 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
</div>
)}
{/* 季度徽章 */}
{seasonNumber && (
<div
className="absolute top-2 left-2 bg-blue-500/80 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90"
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
title={seasonName || `${seasonNumber}`}
>
S{seasonNumber}
</div>
)}
{/* 徽章 */}
{config.showRating && rate && (
<div

View File

@@ -157,8 +157,27 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
enabled: data.WatchRoom?.enabled ?? false, // 默认不启用
serverType: data.WatchRoom?.serverType ?? 'internal',
externalServerUrl: data.WatchRoom?.externalServerUrl,
externalServerAuth: data.WatchRoom?.externalServerAuth,
};
// 如果使用外部服务器,需要获取认证信息(需要登录)
if (watchRoomConfig.serverType === 'external' && watchRoomConfig.enabled) {
try {
const authResponse = await fetch('/api/watch-room-auth');
if (authResponse.ok) {
const authData = await authResponse.json();
watchRoomConfig.externalServerAuth = authData.externalServerAuth;
} else {
console.error('[WatchRoom] Failed to load auth info:', authResponse.status);
// 如果无法获取认证信息,禁用观影室
watchRoomConfig.enabled = false;
}
} catch (error) {
console.error('[WatchRoom] Error loading auth info:', error);
// 如果无法获取认证信息,禁用观影室
watchRoomConfig.enabled = false;
}
}
setConfig(watchRoomConfig);
setIsEnabled(watchRoomConfig.enabled);

View File

@@ -48,6 +48,7 @@ export interface AdminConfig {
OIDCClientId?: string; // OIDC Client ID
OIDCClientSecret?: string; // OIDC Client Secret
OIDCButtonText?: string; // OIDC登录按钮文字
OIDCMinTrustLevel?: number; // 最低信任等级仅LinuxDo网站有效为0时不判断
};
UserConfig: {
Users: {

View File

@@ -1,5 +1,33 @@
import CryptoJS from 'crypto-js';
/**
* 生成 SHA256 哈希值
* @param data 要哈希的数据
* @returns SHA256 哈希值(十六进制字符串)
*/
export function sha256(data: string): string {
return CryptoJS.SHA256(data).toString(CryptoJS.enc.Hex);
}
/**
* 生成文件夹的唯一 key
* @param folderName 文件夹名称
* @param existingKeys 已存在的 key 集合,用于检测冲突
* @returns 唯一的 keySHA256 的前10位
*/
export function generateFolderKey(folderName: string, existingKeys: Set<string> = new Set()): string {
let hash = sha256(folderName);
let key = hash.substring(0, 10);
// 如果遇到冲突,继续 sha256 直到不冲突
while (existingKeys.has(key)) {
hash = sha256(hash);
key = hash.substring(0, 10);
}
return key;
}
/**
* 简单的对称加密工具
* 使用 AES 加密算法

View File

@@ -18,7 +18,8 @@ const VIDEOINFO_CACHE: Map<string, VideoInfoCacheEntry> = new Map();
export interface MetaInfo {
folders: {
[folderName: string]: {
[key: string]: {
folderName: string; // 原始文件夹名称
tmdb_id: number;
title: string;
poster_path: string | null;
@@ -28,6 +29,8 @@ export interface MetaInfo {
media_type: 'movie' | 'tv';
last_updated: number;
failed?: boolean; // 标记是否搜索失败
season_number?: number; // 季度编号(仅电视剧)
season_name?: string; // 季度名称(仅电视剧)
};
};
last_refresh: number;

198
src/lib/season-parser.ts Normal file
View File

@@ -0,0 +1,198 @@
/**
* 季度标识解析工具
* 用于从文件夹名称中识别和提取季度信息
*/
export interface SeasonInfo {
/** 清理后的标题(移除季度标识和年份) */
cleanTitle: string;
/** 季度编号,如果未识别则为 null */
seasonNumber: number | null;
/** 年份,如果未识别则为 null */
year: number | null;
/** 原始标题 */
originalTitle: string;
}
/**
* 从文件夹名称中提取季度信息
* 支持多种格式:
* - S01, S1, s01, s1
* - [S01], [S1]
* - Season 1, Season 01
* - 第一季, 第1季, 第01季
* - [第一季], [第1季]
* - 第一部, 第1部
* - 年份: 2023, [2023], (2023)
*/
export function parseSeasonFromTitle(title: string): SeasonInfo {
const originalTitle = title;
// 先将下划线替换成空格,方便后续解析和搜索
let cleanTitle = title.replace(/_/g, ' ');
let seasonNumber: number | null = null;
let year: number | null = null;
// 定义季度匹配模式(按优先级排序)
const patterns = [
// [S01], [S1], [s01], [s1] 格式(方括号包裹)
{
regex: /\[([Ss]\d{1,2})\]/,
extract: (match: RegExpMatchArray) => {
const seasonMatch = match[1].match(/[Ss](\d{1,2})/);
return seasonMatch ? parseInt(seasonMatch[1], 10) : null;
},
},
// S01, S1, s01, s1 格式
{
regex: /\b[Ss](\d{1,2})\b/,
extract: (match: RegExpMatchArray) => parseInt(match[1], 10),
},
// [Season 1], [Season 01] 格式(方括号包裹)
{
regex: /\[Season\s+(\d{1,2})\]/i,
extract: (match: RegExpMatchArray) => parseInt(match[1], 10),
},
// Season 1, Season 01 格式
{
regex: /\bSeason\s+(\d{1,2})\b/i,
extract: (match: RegExpMatchArray) => parseInt(match[1], 10),
},
// [第一季], [第1季], [第01季] 格式(方括号包裹)
{
regex: /\[第([一二三四五六七八九十\d]{1,2})季\]/,
extract: (match: RegExpMatchArray) => chineseNumberToInt(match[1]),
},
// 第一季, 第1季, 第01季 格式
{
regex: /第([一二三四五六七八九十\d]{1,2})季/,
extract: (match: RegExpMatchArray) => chineseNumberToInt(match[1]),
},
// [第一部], [第1部] 格式(方括号包裹)
{
regex: /\[第([一二三四五六七八九十\d]{1,2})部\]/,
extract: (match: RegExpMatchArray) => chineseNumberToInt(match[1]),
},
// 第一部, 第1部, 第01部 格式
{
regex: /第([一二三四五六七八九十\d]{1,2})部/,
extract: (match: RegExpMatchArray) => chineseNumberToInt(match[1]),
},
];
// 尝试匹配每个模式
for (const pattern of patterns) {
const match = cleanTitle.match(pattern.regex);
if (match) {
const extracted = pattern.extract(match);
if (extracted !== null) {
seasonNumber = extracted;
// 移除匹配到的季度标识
cleanTitle = cleanTitle.replace(pattern.regex, '').trim();
break;
}
}
}
// 提取年份(支持多种格式)
const yearPatterns = [
/\[(\d{4})\]/, // [2023]
/\((\d{4})\)/, // (2023)
/\b(\d{4})\b/, // 2023
];
for (const yearPattern of yearPatterns) {
const yearMatch = cleanTitle.match(yearPattern);
if (yearMatch) {
const extractedYear = parseInt(yearMatch[1], 10);
// 验证年份合理性1900-2100
if (extractedYear >= 1900 && extractedYear <= 2100) {
year = extractedYear;
cleanTitle = cleanTitle.replace(yearPattern, '').trim();
break;
}
}
}
// 清理标题:移除空的方括号和多余的空格
cleanTitle = cleanTitle
.replace(/\[\s*\]/g, '') // 移除空方括号
.replace(/\(\s*\)/g, '') // 移除空圆括号
.replace(/\s+/g, ' ') // 合并多个空格
.replace(/[·\-_\s]+$/, '') // 移除末尾的特殊字符
.trim();
return {
cleanTitle,
seasonNumber,
year,
originalTitle,
};
}
/**
* 将中文数字转换为阿拉伯数字
*/
function chineseNumberToInt(str: string): number {
// 如果已经是数字,直接返回
if (/^\d+$/.test(str)) {
return parseInt(str, 10);
}
const chineseNumbers: Record<string, number> = {
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
};
// 处理"十"的特殊情况
if (str === '十') {
return 10;
}
// 处理"十X"的情况(如"十一"
if (str.startsWith('十')) {
const unit = str.substring(1);
return 10 + (chineseNumbers[unit] || 0);
}
// 处理"X十"的情况(如"二十"
if (str.endsWith('十')) {
const tens = str.substring(0, str.length - 1);
return (chineseNumbers[tens] || 0) * 10;
}
// 处理"X十Y"的情况(如"二十一"
const tenIndex = str.indexOf('十');
if (tenIndex !== -1) {
const tens = str.substring(0, tenIndex);
const units = str.substring(tenIndex + 1);
return (chineseNumbers[tens] || 0) * 10 + (chineseNumbers[units] || 0);
}
// 单个中文数字
return chineseNumbers[str] || parseInt(str, 10) || 1;
}
/**
* 测试示例
*/
export function testSeasonParser() {
const testCases = [
'权力的游戏 第一季',
'Breaking Bad S01',
'Game of Thrones Season 1',
'绝命毒师 第1季',
'权力的游戏 S1',
'权力的游戏',
'绝命毒师 第二部',
'Stranger Things S03',
];
console.log('Season Parser Test Results:');
testCases.forEach((title) => {
const result = parseSeasonFromTitle(title);
console.log(`Input: "${title}"`);
console.log(` Clean Title: "${result.cleanTitle}"`);
console.log(` Season: ${result.seasonNumber}`);
console.log('');
});
}

View File

@@ -26,10 +26,12 @@ export interface TMDBItem {
id: number;
title: string;
poster_path: string | null;
backdrop_path?: string | null; // 背景图,用于轮播图
release_date: string;
overview: string;
vote_average: number;
media_type: 'movie' | 'tv';
genre_ids?: number[]; // 类型ID列表
}
interface TMDBUpcomingResponse {
@@ -229,6 +231,70 @@ export async function getTMDBUpcomingContent(
}
}
/**
* 获取热门内容(电影+电视剧)
* @param apiKey - TMDB API Key
* @param proxy - 代理服务器地址
* @returns 热门内容列表
*/
export async function getTMDBTrendingContent(
apiKey: string,
proxy?: string
): Promise<{ code: number; list: TMDBItem[] }> {
try {
if (!apiKey) {
return { code: 400, list: [] };
}
// 获取本周热门内容(电影+电视剧)
const url = `https://api.themoviedb.org/3/trending/all/week?api_key=${apiKey}&language=zh-CN`;
const fetchOptions: any = proxy
? {
agent: new HttpsProxyAgent(proxy, {
timeout: 30000,
keepAlive: false,
}),
signal: AbortSignal.timeout(30000),
}
: {
signal: AbortSignal.timeout(15000),
};
const response = await nodeFetch(url, fetchOptions);
if (!response.ok) {
console.error('TMDB Trending API 请求失败:', response.status, response.statusText);
return { code: response.status, list: [] };
}
const data: any = await response.json();
// 转换为统一格式只保留有backdrop_path的项目用于轮播图
const items: TMDBItem[] = data.results
.filter((item: any) => item.backdrop_path) // 只保留有背景图的
.slice(0, 10) // 只取前10个
.map((item: any) => ({
id: item.id,
title: item.title || item.name,
poster_path: item.poster_path,
backdrop_path: item.backdrop_path, // 添加背景图
release_date: item.release_date || item.first_air_date || '',
overview: item.overview,
vote_average: item.vote_average,
media_type: item.media_type as 'movie' | 'tv',
genre_ids: item.genre_ids || [], // 保存类型ID
}));
return {
code: 200,
list: items,
};
} catch (error) {
console.error('获取 TMDB 热门内容失败:', error);
return { code: 500, list: [] };
}
}
/**
* 获取 TMDB 图片完整 URL
* @param path - 图片路径
@@ -242,3 +308,51 @@ export function getTMDBImageUrl(
if (!path) return '';
return `https://image.tmdb.org/t/p/${size}${path}`;
}
/**
* TMDB 类型映射(中文)
*/
export const TMDB_GENRES: Record<number, string> = {
// 电影类型
28: '动作',
12: '冒险',
16: '动画',
35: '喜剧',
80: '犯罪',
99: '纪录',
18: '剧情',
10751: '家庭',
14: '奇幻',
36: '历史',
27: '恐怖',
10402: '音乐',
9648: '悬疑',
10749: '爱情',
878: '科幻',
10770: '电视电影',
53: '惊悚',
10752: '战争',
37: '西部',
// 电视剧类型
10759: '动作冒险',
10762: '儿童',
10763: '新闻',
10764: '真人秀',
10765: '科幻奇幻',
10766: '肥皂剧',
10767: '脱口秀',
10768: '战争政治',
};
/**
* 根据类型ID获取类型名称列表
* @param genreIds - 类型ID数组
* @param limit - 最多返回几个类型默认2个
* @returns 类型名称数组
*/
export function getGenreNames(genreIds: number[] = [], limit: number = 2): string[] {
return genreIds
.map(id => TMDB_GENRES[id])
.filter(Boolean)
.slice(0, limit);
}

View File

@@ -28,7 +28,8 @@ interface TMDBSearchResponse {
export async function searchTMDB(
apiKey: string,
query: string,
proxy?: string
proxy?: string,
year?: number
): Promise<{ code: number; result: TMDBSearchResult | null }> {
try {
if (!apiKey) {
@@ -36,7 +37,12 @@ export async function searchTMDB(
}
// 使用 multi search 同时搜索电影和电视剧
const url = `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`;
let url = `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`;
// 如果提供了年份,添加到搜索参数中
if (year) {
url += `&year=${year}`;
}
const fetchOptions: any = proxy
? {
@@ -79,6 +85,129 @@ export async function searchTMDB(
}
}
/**
* TMDB 季度信息
*/
export interface TMDBSeasonInfo {
id: number;
name: string;
season_number: number;
episode_count: number;
air_date: string | null;
poster_path: string | null;
overview: string;
}
/**
* TMDB 电视剧详情(包含季度列表)
*/
interface TMDBTVDetails {
id: number;
name: string;
seasons: TMDBSeasonInfo[];
number_of_seasons: number;
poster_path: string | null;
first_air_date: string;
overview: string;
vote_average: number;
}
/**
* 获取电视剧的季度列表
*/
export async function getTVSeasons(
apiKey: string,
tvId: number,
proxy?: string
): Promise<{ code: number; seasons: TMDBSeasonInfo[] | null }> {
try {
if (!apiKey) {
return { code: 400, seasons: null };
}
const url = `https://api.themoviedb.org/3/tv/${tvId}?api_key=${apiKey}&language=zh-CN`;
const fetchOptions: any = proxy
? {
agent: new HttpsProxyAgent(proxy, {
timeout: 30000,
keepAlive: false,
}),
signal: AbortSignal.timeout(30000),
}
: {
signal: AbortSignal.timeout(15000),
};
const response = await nodeFetch(url, fetchOptions);
if (!response.ok) {
console.error('TMDB 获取电视剧详情失败:', response.status, response.statusText);
return { code: response.status, seasons: null };
}
const data: TMDBTVDetails = await response.json() as TMDBTVDetails;
// 过滤掉特殊季度(如 Season 0 通常是特别篇)
const validSeasons = data.seasons.filter((season) => season.season_number > 0);
return {
code: 200,
seasons: validSeasons,
};
} catch (error) {
console.error('TMDB 获取季度列表异常:', error);
return { code: 500, seasons: null };
}
}
/**
* 获取电视剧特定季度的详细信息
*/
export async function getTVSeasonDetails(
apiKey: string,
tvId: number,
seasonNumber: number,
proxy?: string
): Promise<{ code: number; season: TMDBSeasonInfo | null }> {
try {
if (!apiKey) {
return { code: 400, season: null };
}
const url = `https://api.themoviedb.org/3/tv/${tvId}/season/${seasonNumber}?api_key=${apiKey}&language=zh-CN`;
const fetchOptions: any = proxy
? {
agent: new HttpsProxyAgent(proxy, {
timeout: 30000,
keepAlive: false,
}),
signal: AbortSignal.timeout(30000),
}
: {
signal: AbortSignal.timeout(15000),
};
const response = await nodeFetch(url, fetchOptions);
if (!response.ok) {
console.error('TMDB 获取季度详情失败:', response.status, response.statusText);
return { code: response.status, season: null };
}
const data: TMDBSeasonInfo = await response.json() as TMDBSeasonInfo;
return {
code: 200,
season: data,
};
} catch (error) {
console.error('TMDB 获取季度详情异常:', error);
return { code: 500, season: null };
}
}
/**
* 获取 TMDB 图片完整 URL
*/

View File

@@ -32,6 +32,11 @@ function getDoubanImageProxyConfig(): {
export function processImageUrl(originalUrl: string): string {
if (!originalUrl) return originalUrl;
// 如果已经是代理URL直接返回
if (originalUrl.startsWith('/api/image-proxy')) {
return originalUrl;
}
// 仅处理豆瓣图片代理
if (!originalUrl.includes('doubanio.com')) {
return originalUrl;

View File

@@ -129,7 +129,7 @@ export interface WatchRoomConfig {
enabled: boolean;
serverType: 'internal' | 'external';
externalServerUrl?: string;
externalServerAuth?: string;
externalServerAuth?: string; // 通过 /api/watch-room-auth 接口获取(需要登录)
}
// LocalStorage 存储的房间信息