Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91cdb7a1d2 | ||
|
|
c961d0999c | ||
|
|
e071d6fff2 | ||
|
|
83bbf78545 | ||
|
|
fb9f55136d | ||
|
|
3050cd48a9 | ||
|
|
df78abaf11 | ||
|
|
0b37e663fe | ||
|
|
d66538e992 | ||
|
|
712fc7489e | ||
|
|
5d593bafb2 | ||
|
|
76b3349aa2 |
@@ -1,7 +1,7 @@
|
|||||||
## [204.0.0] - 2025-12-25
|
## [204.0.0] - 2025-12-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- ⚠️⚠️⚠️更新此版本前前务必进行备份!!!⚠️⚠️⚠️
|
- ⚠️⚠️⚠️更新此版本前务必进行备份!!!⚠️⚠️⚠️
|
||||||
- 新增私人影视库功能(实验性)
|
- 新增私人影视库功能(实验性)
|
||||||
- 增加弹幕热力图
|
- 增加弹幕热力图
|
||||||
- 增加盘搜搜索资源
|
- 增加盘搜搜索资源
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -58,6 +58,7 @@
|
|||||||
- [配置文件](#配置文件)
|
- [配置文件](#配置文件)
|
||||||
- [自动更新](#自动更新)
|
- [自动更新](#自动更新)
|
||||||
- [环境变量](#环境变量)
|
- [环境变量](#环境变量)
|
||||||
|
- [外部观影室服务器部署](#外部观影室服务器部署)
|
||||||
- [弹幕后端部署](#弹幕后端部署)
|
- [弹幕后端部署](#弹幕后端部署)
|
||||||
- [超分功能说明](#超分功能说明)
|
- [超分功能说明](#超分功能说明)
|
||||||
- [AndroidTV 使用](#androidtv-使用)
|
- [AndroidTV 使用](#androidtv-使用)
|
||||||
@@ -251,7 +252,10 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
|||||||
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) |
|
||||||
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false |
|
||||||
| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) |
|
| 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_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback |
|
||||||
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false |
|
||||||
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data |
|
||||||
@@ -279,6 +283,24 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释:
|
|||||||
- webrtc-fallback:使用 WebRTC P2P 连接,失败时自动回退到服务器中转(推荐)
|
- webrtc-fallback:使用 WebRTC P2P 连接,失败时自动回退到服务器中转(推荐)
|
||||||
- server-only:仅使用服务器中转(适用于无法建立 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. 重启应用即可使用外部观影室服务器
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 弹幕后端部署
|
## 弹幕后端部署
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@upstash/redis": "^1.25.0",
|
"@upstash/redis": "^1.25.0",
|
||||||
"@vidstack/react": "^1.12.13",
|
"@vidstack/react": "^1.12.13",
|
||||||
"anime4k-webgpu": "^1.0.0",
|
"anime4k-webgpu": "^1.0.0",
|
||||||
"artplayer": "^5.2.5",
|
"artplayer": "^5.3.0",
|
||||||
"artplayer-plugin-danmuku": "^5.2.0",
|
"artplayer-plugin-danmuku": "^5.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
|
|||||||
@@ -2959,7 +2959,7 @@ const OpenListConfigComponent = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteVideo = async (folder: string, title: string) => {
|
const handleDeleteVideo = async (key: string, title: string) => {
|
||||||
// 显示确认对话框,直接在 onConfirm 中执行删除操作
|
// 显示确认对话框,直接在 onConfirm 中执行删除操作
|
||||||
showAlert({
|
showAlert({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
@@ -2971,7 +2971,7 @@ const OpenListConfigComponent = ({
|
|||||||
const response = await fetch('/api/openlist/delete', {
|
const response = await fetch('/api/openlist/delete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ folder }),
|
body: JSON.stringify({ key }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
类型
|
类型
|
||||||
</th>
|
</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 className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
年份
|
年份
|
||||||
</th>
|
</th>
|
||||||
@@ -3257,6 +3260,15 @@ const OpenListConfigComponent = ({
|
|||||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
|
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
|
||||||
{video.mediaType === 'movie' ? '电影' : '剧集'}
|
{video.mediaType === 'movie' ? '电影' : '剧集'}
|
||||||
</td>
|
</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'>
|
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
|
||||||
{video.releaseDate ? video.releaseDate.split('-')[0] : '-'}
|
{video.releaseDate ? video.releaseDate.split('-')[0] : '-'}
|
||||||
</td>
|
</td>
|
||||||
@@ -3283,7 +3295,7 @@ const OpenListConfigComponent = ({
|
|||||||
{video.failed ? '立即纠错' : '纠错'}
|
{video.failed ? '立即纠错' : '纠错'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteVideo(video.folder, video.title)}
|
onClick={() => handleDeleteVideo(video.id, video.title)}
|
||||||
className={buttonStyles.dangerSmall}
|
className={buttonStyles.dangerSmall}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -3319,7 +3331,7 @@ const OpenListConfigComponent = ({
|
|||||||
<CorrectDialog
|
<CorrectDialog
|
||||||
isOpen={correctDialogOpen}
|
isOpen={correctDialogOpen}
|
||||||
onClose={() => setCorrectDialogOpen(false)}
|
onClose={() => setCorrectDialogOpen(false)}
|
||||||
folder={selectedVideo.folder}
|
videoKey={selectedVideo.id}
|
||||||
currentTitle={selectedVideo.title}
|
currentTitle={selectedVideo.title}
|
||||||
onCorrect={handleCorrectSuccess}
|
onCorrect={handleCorrectSuccess}
|
||||||
/>
|
/>
|
||||||
@@ -6169,11 +6181,11 @@ const SiteConfigComponent = ({
|
|||||||
评论配置
|
评论配置
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* 开启评论 */}
|
{/* 开启评论与相似推荐 */}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
开启评论
|
开启评论与相似推荐
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
@@ -6196,7 +6208,7 @@ const SiteConfigComponent = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
开启后将显示豆瓣评论。评论为逆向抓取,请自行承担责任。
|
开启后将显示豆瓣评论与相似推荐。评论为逆向抓取,请自行承担责任。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6241,7 +6253,7 @@ const SiteConfigComponent = ({
|
|||||||
<div className='p-6'>
|
<div className='p-6'>
|
||||||
<div className='flex items-center justify-between mb-6'>
|
<div className='flex items-center justify-between mb-6'>
|
||||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
开启评论功能
|
开启评论与相似推荐功能
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEnableCommentsModal(false)}
|
onClick={() => setShowEnableCommentsModal(false)}
|
||||||
@@ -6328,6 +6340,7 @@ const RegistrationConfigComponent = ({
|
|||||||
OIDCClientId: string;
|
OIDCClientId: string;
|
||||||
OIDCClientSecret: string;
|
OIDCClientSecret: string;
|
||||||
OIDCButtonText: string;
|
OIDCButtonText: string;
|
||||||
|
OIDCMinTrustLevel: number;
|
||||||
}>({
|
}>({
|
||||||
EnableRegistration: false,
|
EnableRegistration: false,
|
||||||
RegistrationRequireTurnstile: false,
|
RegistrationRequireTurnstile: false,
|
||||||
@@ -6344,6 +6357,7 @@ const RegistrationConfigComponent = ({
|
|||||||
OIDCClientId: '',
|
OIDCClientId: '',
|
||||||
OIDCClientSecret: '',
|
OIDCClientSecret: '',
|
||||||
OIDCButtonText: '',
|
OIDCButtonText: '',
|
||||||
|
OIDCMinTrustLevel: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -6364,6 +6378,7 @@ const RegistrationConfigComponent = ({
|
|||||||
OIDCClientId: config.SiteConfig.OIDCClientId || '',
|
OIDCClientId: config.SiteConfig.OIDCClientId || '',
|
||||||
OIDCClientSecret: config.SiteConfig.OIDCClientSecret || '',
|
OIDCClientSecret: config.SiteConfig.OIDCClientSecret || '',
|
||||||
OIDCButtonText: config.SiteConfig.OIDCButtonText || '',
|
OIDCButtonText: config.SiteConfig.OIDCButtonText || '',
|
||||||
|
OIDCMinTrustLevel: config.SiteConfig.OIDCMinTrustLevel ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [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'
|
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'>
|
<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网站有效。设置为0时不判断,1-4表示最低信任等级要求
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export async function POST(request: NextRequest) {
|
|||||||
OIDCClientId,
|
OIDCClientId,
|
||||||
OIDCClientSecret,
|
OIDCClientSecret,
|
||||||
OIDCButtonText,
|
OIDCButtonText,
|
||||||
|
OIDCMinTrustLevel,
|
||||||
} = body as {
|
} = body as {
|
||||||
SiteName: string;
|
SiteName: string;
|
||||||
Announcement: string;
|
Announcement: string;
|
||||||
@@ -100,6 +101,7 @@ export async function POST(request: NextRequest) {
|
|||||||
OIDCClientId?: string;
|
OIDCClientId?: string;
|
||||||
OIDCClientSecret?: string;
|
OIDCClientSecret?: string;
|
||||||
OIDCButtonText?: string;
|
OIDCButtonText?: string;
|
||||||
|
OIDCMinTrustLevel?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 参数校验
|
// 参数校验
|
||||||
@@ -135,7 +137,8 @@ export async function POST(request: NextRequest) {
|
|||||||
(OIDCUserInfoEndpoint !== undefined && typeof OIDCUserInfoEndpoint !== 'string') ||
|
(OIDCUserInfoEndpoint !== undefined && typeof OIDCUserInfoEndpoint !== 'string') ||
|
||||||
(OIDCClientId !== undefined && typeof OIDCClientId !== 'string') ||
|
(OIDCClientId !== undefined && typeof OIDCClientId !== 'string') ||
|
||||||
(OIDCClientSecret !== undefined && typeof OIDCClientSecret !== '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 });
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -190,6 +193,7 @@ export async function POST(request: NextRequest) {
|
|||||||
OIDCClientId,
|
OIDCClientId,
|
||||||
OIDCClientSecret,
|
OIDCClientSecret,
|
||||||
OIDCButtonText,
|
OIDCButtonText,
|
||||||
|
OIDCMinTrustLevel,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 写入数据库
|
// 写入数据库
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export async function GET(request: NextRequest) {
|
|||||||
sub: oidcSub,
|
sub: oidcSub,
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
name: userInfo.name,
|
name: userInfo.name,
|
||||||
|
trust_level: userInfo.trust_level, // 提取trust_level字段
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
if (username === process.env.USERNAME) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
email: oidcSession.email,
|
email: oidcSession.email,
|
||||||
name: oidcSession.name,
|
name: oidcSession.name,
|
||||||
|
trust_level: oidcSession.trust_level,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -307,12 +307,12 @@ async function handleOpenListProxy(request: NextRequest) {
|
|||||||
if (wd) {
|
if (wd) {
|
||||||
const results = Object.entries(metaInfo.folders)
|
const results = Object.entries(metaInfo.folders)
|
||||||
.filter(
|
.filter(
|
||||||
([folderName, info]) =>
|
([key, info]) =>
|
||||||
folderName.toLowerCase().includes(wd.toLowerCase()) ||
|
info.folderName.toLowerCase().includes(wd.toLowerCase()) ||
|
||||||
info.title.toLowerCase().includes(wd.toLowerCase())
|
info.title.toLowerCase().includes(wd.toLowerCase())
|
||||||
)
|
)
|
||||||
.map(([folderName, info]) => ({
|
.map(([key, info]) => ({
|
||||||
vod_id: folderName,
|
vod_id: key,
|
||||||
vod_name: info.title,
|
vod_name: info.title,
|
||||||
vod_pic: getTMDBImageUrl(info.poster_path),
|
vod_pic: getTMDBImageUrl(info.poster_path),
|
||||||
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||||||
@@ -333,8 +333,8 @@ async function handleOpenListProxy(request: NextRequest) {
|
|||||||
|
|
||||||
// 详情模式
|
// 详情模式
|
||||||
if (ids) {
|
if (ids) {
|
||||||
const folderName = ids;
|
const key = ids;
|
||||||
const info = metaInfo.folders[folderName];
|
const info = metaInfo.folders[key];
|
||||||
|
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -343,6 +343,8 @@ async function handleOpenListProxy(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const folderName = info.folderName;
|
||||||
|
|
||||||
// 获取视频详情
|
// 获取视频详情
|
||||||
try {
|
try {
|
||||||
const detailResponse = await fetch(
|
const detailResponse = await fetch(
|
||||||
@@ -376,7 +378,7 @@ async function handleOpenListProxy(request: NextRequest) {
|
|||||||
total: 1,
|
total: 1,
|
||||||
list: [
|
list: [
|
||||||
{
|
{
|
||||||
vod_id: folderName,
|
vod_id: key,
|
||||||
vod_name: info.title,
|
vod_name: info.title,
|
||||||
vod_pic: getTMDBImageUrl(info.poster_path),
|
vod_pic: getTMDBImageUrl(info.poster_path),
|
||||||
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||||||
@@ -399,8 +401,8 @@ async function handleOpenListProxy(request: NextRequest) {
|
|||||||
|
|
||||||
// 默认返回所有视频
|
// 默认返回所有视频
|
||||||
const results = Object.entries(metaInfo.folders).map(
|
const results = Object.entries(metaInfo.folders).map(
|
||||||
([folderName, info]) => ({
|
([key, info]) => ({
|
||||||
vod_id: folderName,
|
vod_id: key,
|
||||||
vod_name: info.title,
|
vod_name: info.title,
|
||||||
vod_pic: getTMDBImageUrl(info.poster_path),
|
vod_pic: getTMDBImageUrl(info.poster_path),
|
||||||
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
vod_remarks: info.media_type === 'movie' ? '电影' : '剧集',
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = openListConfig.RootPath || '/';
|
const rootPath = openListConfig.RootPath || '/';
|
||||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${id}`;
|
|
||||||
|
|
||||||
// 1. 读取 metainfo 获取元数据
|
// 1. 读取 metainfo 获取元数据
|
||||||
let metaInfo: any = null;
|
let metaInfo: any = null;
|
||||||
|
let folderMeta: any = null;
|
||||||
try {
|
try {
|
||||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
@@ -54,10 +54,20 @@ export async function GET(request: NextRequest) {
|
|||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(rootPath, metaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用 key 查找文件夹信息
|
||||||
|
folderMeta = metaInfo?.folders?.[id];
|
||||||
|
if (!folderMeta) {
|
||||||
|
throw new Error('未找到该视频信息');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 忽略错误
|
throw new Error('读取视频信息失败: ' + (error as Error).message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用 folderName 构建实际路径
|
||||||
|
const folderName = folderMeta.folderName;
|
||||||
|
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
||||||
|
|
||||||
// 2. 直接调用 OpenList 客户端获取视频列表
|
// 2. 直接调用 OpenList 客户端获取视频列表
|
||||||
const { OpenListClient } = await import('@/lib/openlist.client');
|
const { OpenListClient } = await import('@/lib/openlist.client');
|
||||||
const { getCachedVideoInfo, setCachedVideoInfo } = await import('@/lib/openlist-cache');
|
const { getCachedVideoInfo, setCachedVideoInfo } = await import('@/lib/openlist-cache');
|
||||||
@@ -71,24 +81,6 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
let videoInfo = getCachedVideoInfo(folderPath);
|
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);
|
const listResponse = await client.listDirectory(folderPath);
|
||||||
|
|
||||||
if (listResponse.code !== 200) {
|
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));
|
.sort((a, b) => a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName));
|
||||||
|
|
||||||
// 3. 从 metainfo 中获取元数据
|
// 3. 从 metainfo 中获取元数据
|
||||||
const folderMeta = metaInfo?.folders?.[id];
|
|
||||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
source: 'openlist',
|
source: 'openlist',
|
||||||
source_name: '私人影库',
|
source_name: '私人影库',
|
||||||
id: id,
|
id: id,
|
||||||
title: folderMeta?.title || id,
|
title: folderMeta?.title || folderName,
|
||||||
poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '',
|
poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '',
|
||||||
year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '',
|
year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '',
|
||||||
douban_id: 0,
|
douban_id: 0,
|
||||||
desc: folderMeta?.overview || '',
|
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),
|
episodes_titles: episodes.map((ep) => ep.title),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
104
src/app/api/douban-recommendations/route.ts
Normal file
104
src/app/api/douban-recommendations/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ export async function GET(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const imageResponse = await fetch(imageUrl, {
|
const imageResponse = await fetch(imageUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
Referer: 'https://movie.douban.com/',
|
|
||||||
'User-Agent':
|
'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',
|
'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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,20 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
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(
|
return NextResponse.json(
|
||||||
{ error: '缺少必要参数' },
|
{ error: '缺少必要参数' },
|
||||||
{ status: 400 }
|
{ 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,
|
tmdb_id: tmdbId,
|
||||||
title: title,
|
title: title,
|
||||||
poster_path: posterPath,
|
poster_path: posterPath,
|
||||||
@@ -97,6 +120,8 @@ export async function POST(request: NextRequest) {
|
|||||||
media_type: mediaType,
|
media_type: mediaType,
|
||||||
last_updated: Date.now(),
|
last_updated: Date.now(),
|
||||||
failed: false, // 纠错后标记为成功
|
failed: false, // 纠错后标记为成功
|
||||||
|
season_number: seasonNumber, // 季度编号(可选)
|
||||||
|
season_name: seasonName, // 季度名称(可选)
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存 metainfo 到数据库
|
// 保存 metainfo 到数据库
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
// 获取请求参数
|
// 获取请求参数
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { folder } = body;
|
const { key } = body;
|
||||||
|
|
||||||
if (!folder) {
|
if (!key) {
|
||||||
return NextResponse.json({ error: '缺少 folder 参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置
|
// 获取配置
|
||||||
@@ -62,16 +62,16 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const metaInfo: MetaInfo = JSON.parse(metainfoContent);
|
const metaInfo: MetaInfo = JSON.parse(metainfoContent);
|
||||||
|
|
||||||
// 检查文件夹是否存在
|
// 检查 key 是否存在
|
||||||
if (!metaInfo.folders[folder]) {
|
if (!metaInfo.folders[key]) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '未找到该视频记录' },
|
{ error: '未找到该视频记录' },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除文件夹记录
|
// 删除记录
|
||||||
delete metaInfo.folders[folder];
|
delete metaInfo.folders[key];
|
||||||
|
|
||||||
// 保存到数据库
|
// 保存到数据库
|
||||||
const updatedMetainfoContent = JSON.stringify(metaInfo);
|
const updatedMetainfoContent = JSON.stringify(metaInfo);
|
||||||
|
|||||||
@@ -125,19 +125,23 @@ export async function GET(request: NextRequest) {
|
|||||||
const allVideos = Object.entries(metaInfo.folders)
|
const allVideos = Object.entries(metaInfo.folders)
|
||||||
.filter(([, info]) => includeFailed || !info.failed) // 根据参数过滤失败的视频
|
.filter(([, info]) => includeFailed || !info.failed) // 根据参数过滤失败的视频
|
||||||
.map(
|
.map(
|
||||||
([folderName, info]) => ({
|
([key, info]) => {
|
||||||
id: folderName,
|
return {
|
||||||
folder: folderName,
|
id: key,
|
||||||
tmdbId: info.tmdb_id,
|
folder: info.folderName,
|
||||||
title: info.title,
|
tmdbId: info.tmdb_id,
|
||||||
poster: getTMDBImageUrl(info.poster_path),
|
title: info.title,
|
||||||
releaseDate: info.release_date,
|
poster: getTMDBImageUrl(info.poster_path),
|
||||||
overview: info.overview,
|
releaseDate: info.release_date,
|
||||||
voteAverage: info.vote_average,
|
overview: info.overview,
|
||||||
mediaType: info.media_type,
|
voteAverage: info.vote_average,
|
||||||
lastUpdated: info.last_updated,
|
mediaType: info.media_type,
|
||||||
failed: info.failed || false,
|
lastUpdated: info.last_updated,
|
||||||
})
|
failed: info.failed || false,
|
||||||
|
seasonNumber: info.season_number,
|
||||||
|
seasonName: info.season_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 按更新时间倒序排序
|
// 按更新时间倒序排序
|
||||||
|
|||||||
@@ -48,15 +48,6 @@ export async function POST(request: NextRequest) {
|
|||||||
openListConfig.Password
|
openListConfig.Password
|
||||||
);
|
);
|
||||||
|
|
||||||
// 删除 videoinfo.json
|
|
||||||
const videoinfoPath = `${folderPath}/videoinfo.json`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.deleteFile(videoinfoPath);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('videoinfo.json 不存在或删除失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除缓存
|
// 清除缓存
|
||||||
invalidateVideoInfoCache(folderPath);
|
invalidateVideoInfoCache(folderPath);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { generateFolderKey } from '@/lib/crypto';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { OpenListClient } from '@/lib/openlist.client';
|
import { OpenListClient } from '@/lib/openlist.client';
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +20,8 @@ import {
|
|||||||
failScanTask,
|
failScanTask,
|
||||||
updateScanTaskProgress,
|
updateScanTaskProgress,
|
||||||
} from '@/lib/scan-task';
|
} 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';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -187,6 +189,15 @@ async function performScan(
|
|||||||
let existingCount = 0;
|
let existingCount = 0;
|
||||||
let errorCount = 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++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
const folder = folders[i];
|
const folder = folders[i];
|
||||||
|
|
||||||
@@ -194,23 +205,37 @@ async function performScan(
|
|||||||
updateScanTaskProgress(taskId, i + 1, folders.length, folder.name);
|
updateScanTaskProgress(taskId, i + 1, folders.length, folder.name);
|
||||||
|
|
||||||
// 如果是立即扫描(不清空 metainfo),且文件夹已存在,跳过
|
// 如果是立即扫描(不清空 metainfo),且文件夹已存在,跳过
|
||||||
if (!clearMetaInfo && metaInfo.folders[folder.name]) {
|
if (!clearMetaInfo && folderNameToKey.has(folder.name)) {
|
||||||
existingCount++;
|
existingCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成文件夹的 key
|
||||||
|
const folderKey = generateFolderKey(folder.name, existingKeys);
|
||||||
|
existingKeys.add(folderKey);
|
||||||
|
|
||||||
try {
|
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(
|
const searchResult = await searchTMDB(
|
||||||
tmdbApiKey,
|
tmdbApiKey,
|
||||||
folder.name,
|
searchQuery,
|
||||||
tmdbProxy
|
tmdbProxy,
|
||||||
|
seasonInfo.year || undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
if (searchResult.code === 200 && searchResult.result) {
|
if (searchResult.code === 200 && searchResult.result) {
|
||||||
const result = searchResult.result;
|
const result = searchResult.result;
|
||||||
|
|
||||||
metaInfo.folders[folder.name] = {
|
// 基础信息
|
||||||
|
const folderInfo: any = {
|
||||||
|
folderName: folder.name, // 保存原始文件夹名称
|
||||||
tmdb_id: result.id,
|
tmdb_id: result.id,
|
||||||
title: result.title || result.name || folder.name,
|
title: result.title || result.name || folder.name,
|
||||||
poster_path: result.poster_path,
|
poster_path: result.poster_path,
|
||||||
@@ -222,10 +247,55 @@ async function performScan(
|
|||||||
failed: false,
|
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++;
|
newCount++;
|
||||||
} else {
|
} else {
|
||||||
// 记录失败的文件夹
|
// 记录失败的文件夹
|
||||||
metaInfo.folders[folder.name] = {
|
metaInfo.folders[folderKey] = {
|
||||||
|
folderName: folder.name, // 保存原始文件夹名称
|
||||||
tmdb_id: 0,
|
tmdb_id: 0,
|
||||||
title: folder.name,
|
title: folder.name,
|
||||||
poster_path: null,
|
poster_path: null,
|
||||||
@@ -244,7 +314,8 @@ async function performScan(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error);
|
console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error);
|
||||||
// 记录失败的文件夹
|
// 记录失败的文件夹
|
||||||
metaInfo.folders[folder.name] = {
|
metaInfo.folders[folderKey] = {
|
||||||
|
folderName: folder.name, // 保存原始文件夹名称
|
||||||
tmdb_id: 0,
|
tmdb_id: 0,
|
||||||
title: folder.name,
|
title: folder.name,
|
||||||
poster_path: null,
|
poster_path: null,
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (metaInfo && metaInfo.folders) {
|
if (metaInfo && metaInfo.folders) {
|
||||||
openlistResults = Object.entries(metaInfo.folders)
|
openlistResults = Object.entries(metaInfo.folders)
|
||||||
.filter(([folderName, info]: [string, any]) => {
|
.filter(([key, info]: [string, any]) => {
|
||||||
const matchFolder = folderName.toLowerCase().includes(query.toLowerCase());
|
const matchFolder = info.folderName.toLowerCase().includes(query.toLowerCase());
|
||||||
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
||||||
return matchFolder || matchTitle;
|
return matchFolder || matchTitle;
|
||||||
})
|
})
|
||||||
.map(([folderName, info]: [string, any]) => ({
|
.map(([key, info]: [string, any]) => ({
|
||||||
id: folderName,
|
id: key,
|
||||||
source: 'openlist',
|
source: 'openlist',
|
||||||
source_name: '私人影库',
|
source_name: '私人影库',
|
||||||
title: info.title,
|
title: info.title,
|
||||||
|
|||||||
@@ -109,13 +109,13 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
if (metaInfo && metaInfo.folders) {
|
if (metaInfo && metaInfo.folders) {
|
||||||
const openlistResults = Object.entries(metaInfo.folders)
|
const openlistResults = Object.entries(metaInfo.folders)
|
||||||
.filter(([folderName, info]: [string, any]) => {
|
.filter(([key, info]: [string, any]) => {
|
||||||
const matchFolder = folderName.toLowerCase().includes(query.toLowerCase());
|
const matchFolder = info.folderName.toLowerCase().includes(query.toLowerCase());
|
||||||
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
const matchTitle = info.title.toLowerCase().includes(query.toLowerCase());
|
||||||
return matchFolder || matchTitle;
|
return matchFolder || matchTitle;
|
||||||
})
|
})
|
||||||
.map(([folderName, info]: [string, any]) => ({
|
.map(([key, info]: [string, any]) => ({
|
||||||
id: folderName,
|
id: key,
|
||||||
source: 'openlist',
|
source: 'openlist',
|
||||||
source_name: '私人影库',
|
source_name: '私人影库',
|
||||||
title: info.title,
|
title: info.title,
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ export async function GET(request: NextRequest) {
|
|||||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
|
||||||
// 观影室配置从环境变量读取
|
// 观影室配置从环境变量读取
|
||||||
|
// 注意:不要暴露 externalServerAuth 到前端,这是敏感凭据
|
||||||
const watchRoomConfig = {
|
const watchRoomConfig = {
|
||||||
enabled: process.env.WATCH_ROOM_ENABLED === 'true',
|
enabled: process.env.WATCH_ROOM_ENABLED === 'true',
|
||||||
serverType: (process.env.WATCH_ROOM_SERVER_TYPE as 'internal' | 'external') || 'internal',
|
serverType: (process.env.WATCH_ROOM_SERVER_TYPE as 'internal' | 'external') || 'internal',
|
||||||
externalServerUrl: process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
|
externalServerUrl: process.env.WATCH_ROOM_EXTERNAL_SERVER_URL,
|
||||||
externalServerAuth: process.env.WATCH_ROOM_EXTERNAL_SERVER_AUTH,
|
// externalServerAuth 不应该暴露给前端
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果使用 localStorage,返回默认配置
|
// 如果使用 localStorage,返回默认配置
|
||||||
|
|||||||
65
src/app/api/tmdb/seasons/route.ts
Normal file
65
src/app/api/tmdb/seasons/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/tmdb/trending/route.ts
Normal file
47
src/app/api/tmdb/trending/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/api/watch-room-auth/route.ts
Normal file
30
src/app/api/watch-room-auth/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -103,6 +103,12 @@ function OIDCRegisterPageClient() {
|
|||||||
{oidcInfo.name && (
|
{oidcInfo.name && (
|
||||||
<>
|
<>
|
||||||
名称: <strong>{oidcInfo.name}</strong>
|
名称: <strong>{oidcInfo.name}</strong>
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{oidcInfo.trust_level !== undefined && (
|
||||||
|
<>
|
||||||
|
信任等级: <strong>{oidcInfo.trust_level}</strong>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
159
src/app/page.tsx
159
src/app/page.tsx
@@ -10,27 +10,20 @@ import {
|
|||||||
BangumiCalendarData,
|
BangumiCalendarData,
|
||||||
GetBangumiCalendarData,
|
GetBangumiCalendarData,
|
||||||
} from '@/lib/bangumi.client';
|
} from '@/lib/bangumi.client';
|
||||||
// 客户端收藏 API
|
|
||||||
import {
|
|
||||||
clearAllFavorites,
|
|
||||||
getAllFavorites,
|
|
||||||
getAllPlayRecords,
|
|
||||||
subscribeToDataUpdates,
|
|
||||||
} from '@/lib/db.client';
|
|
||||||
import { getDoubanCategories } from '@/lib/douban.client';
|
import { getDoubanCategories } from '@/lib/douban.client';
|
||||||
import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client';
|
import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client';
|
||||||
import { DoubanItem } from '@/lib/types';
|
import { DoubanItem } from '@/lib/types';
|
||||||
|
|
||||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
|
||||||
import ContinueWatching from '@/components/ContinueWatching';
|
import ContinueWatching from '@/components/ContinueWatching';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import ScrollableRow from '@/components/ScrollableRow';
|
import ScrollableRow from '@/components/ScrollableRow';
|
||||||
import { useSite } from '@/components/SiteProvider';
|
import { useSite } from '@/components/SiteProvider';
|
||||||
import VideoCard from '@/components/VideoCard';
|
import VideoCard from '@/components/VideoCard';
|
||||||
import HttpWarningDialog from '@/components/HttpWarningDialog';
|
import HttpWarningDialog from '@/components/HttpWarningDialog';
|
||||||
|
import BannerCarousel from '@/components/BannerCarousel';
|
||||||
|
|
||||||
function HomeClient() {
|
function HomeClient() {
|
||||||
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
|
// 移除了 activeTab 状态,收藏夹功能已移到 UserMenu
|
||||||
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
||||||
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
||||||
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
|
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
|
||||||
@@ -57,22 +50,6 @@ function HomeClient() {
|
|||||||
}
|
}
|
||||||
}, [announcement]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const fetchRecommendData = async () => {
|
const fetchRecommendData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -146,74 +123,7 @@ function HomeClient() {
|
|||||||
fetchRecommendData();
|
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) => {
|
const handleCloseAnnouncement = (announcement: string) => {
|
||||||
setShowAnnouncement(false);
|
setShowAnnouncement(false);
|
||||||
@@ -222,60 +132,15 @@ function HomeClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
{/* TMDB 热门轮播图 */}
|
||||||
{/* 顶部 Tab 切换 */}
|
<div className='w-full mb-6 sm:mb-8'>
|
||||||
<div className='mb-8 flex justify-center'>
|
<BannerCarousel />
|
||||||
<CapsuleSwitch
|
</div>
|
||||||
options={[
|
|
||||||
{ label: '首页', value: 'home' },
|
|
||||||
{ label: '收藏夹', value: 'favorites' },
|
|
||||||
]}
|
|
||||||
active={activeTab}
|
|
||||||
onChange={(value) => setActiveTab(value as 'home' | 'favorites')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
||||||
<div className='max-w-[95%] mx-auto'>
|
<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 />
|
<ContinueWatching />
|
||||||
|
|
||||||
@@ -315,6 +180,7 @@ function HomeClient() {
|
|||||||
poster={movie.poster}
|
poster={movie.poster}
|
||||||
title={movie.title}
|
title={movie.title}
|
||||||
year={movie.year}
|
year={movie.year}
|
||||||
|
rate={movie.rate}
|
||||||
type='movie'
|
type='movie'
|
||||||
from='douban'
|
from='douban'
|
||||||
/>
|
/>
|
||||||
@@ -471,6 +337,7 @@ function HomeClient() {
|
|||||||
poster={tvShow.poster}
|
poster={tvShow.poster}
|
||||||
title={tvShow.title}
|
title={tvShow.title}
|
||||||
year={tvShow.year}
|
year={tvShow.year}
|
||||||
|
rate={tvShow.rate}
|
||||||
type='tv'
|
type='tv'
|
||||||
from='douban'
|
from='douban'
|
||||||
/>
|
/>
|
||||||
@@ -514,6 +381,7 @@ function HomeClient() {
|
|||||||
poster={varietyShow.poster}
|
poster={varietyShow.poster}
|
||||||
title={varietyShow.title}
|
title={varietyShow.title}
|
||||||
year={varietyShow.year}
|
year={varietyShow.year}
|
||||||
|
rate={varietyShow.rate}
|
||||||
type='tv'
|
type='tv'
|
||||||
from='douban'
|
from='douban'
|
||||||
/>
|
/>
|
||||||
@@ -555,8 +423,7 @@ function HomeClient() {
|
|||||||
</ScrollableRow>
|
</ScrollableRow>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import EpisodeSelector from '@/components/EpisodeSelector';
|
|||||||
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
|
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import DoubanComments from '@/components/DoubanComments';
|
import DoubanComments from '@/components/DoubanComments';
|
||||||
|
import DoubanRecommendations from '@/components/DoubanRecommendations';
|
||||||
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
||||||
import Toast, { ToastProps } from '@/components/Toast';
|
import Toast, { ToastProps } from '@/components/Toast';
|
||||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||||
@@ -434,6 +435,20 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
}, [searchParams, currentEpisodeIndex]);
|
}, [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 currentSourceRef = useRef(currentSource);
|
||||||
const currentIdRef = useRef(currentId);
|
const currentIdRef = useRef(currentId);
|
||||||
const videoTitleRef = useRef(videoTitle);
|
const videoTitleRef = useRef(videoTitle);
|
||||||
@@ -904,6 +919,10 @@ function PlayPageClient() {
|
|||||||
!detailData.episodes ||
|
!detailData.episodes ||
|
||||||
episodeIndex >= detailData.episodes.length
|
episodeIndex >= detailData.episodes.length
|
||||||
) {
|
) {
|
||||||
|
// openlist 源的剧集是懒加载的,如果 episodes 为空则跳过
|
||||||
|
if (detailData?.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setVideoUrl('');
|
setVideoUrl('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2073,7 +2092,7 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDetail = availableSources.find(
|
let newDetail: SearchResult | undefined = availableSources.find(
|
||||||
(source) => source.source === newSource && source.id === newId
|
(source) => source.source === newSource && source.id === newId
|
||||||
);
|
);
|
||||||
if (!newDetail) {
|
if (!newDetail) {
|
||||||
@@ -2081,6 +2100,33 @@ function PlayPageClient() {
|
|||||||
return;
|
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;
|
let targetIndex = currentEpisodeIndex;
|
||||||
|
|
||||||
@@ -2922,6 +2968,11 @@ function PlayPageClient() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openlist 源的剧集是懒加载的,如果 episodes 为空则跳过检查
|
||||||
|
if ((currentSource === 'openlist' || detail?.source === 'openlist') && (!detail || !detail.episodes || detail.episodes.length === 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 确保选集索引有效
|
// 确保选集索引有效
|
||||||
if (
|
if (
|
||||||
!detail ||
|
!detail ||
|
||||||
@@ -5161,6 +5212,28 @@ function PlayPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{videoDoubanId !== 0 && enableComments && (
|
||||||
<div className='mt-6 px-4'>
|
<div className='mt-6 px-4'>
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export default function PrivateLibraryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleVideoClick = (video: Video) => {
|
const handleVideoClick = (video: Video) => {
|
||||||
// 跳转到播放页面
|
// 跳转到播放页面,使用 id(key)而不是 folder
|
||||||
router.push(`/play?source=openlist&id=${encodeURIComponent(video.folder)}`);
|
router.push(`/play?source=openlist&id=${encodeURIComponent(video.id)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,11 +106,16 @@ export default function PrivateLibraryPage() {
|
|||||||
{videos.map((video) => (
|
{videos.map((video) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={video.id}
|
key={video.id}
|
||||||
id={video.folder}
|
id={video.id}
|
||||||
source='openlist'
|
source='openlist'
|
||||||
title={video.title}
|
title={video.title}
|
||||||
poster={video.poster}
|
poster={video.poster}
|
||||||
year={video.releaseDate.split('-')[0]}
|
year={video.releaseDate.split('-')[0]}
|
||||||
|
rate={
|
||||||
|
video.voteAverage && video.voteAverage > 0
|
||||||
|
? video.voteAverage.toFixed(1)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
from='search'
|
from='search'
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
296
src/components/BannerCarousel.tsx
Normal file
296
src/components/BannerCarousel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,10 +22,20 @@ interface TMDBResult {
|
|||||||
media_type: 'movie' | 'tv';
|
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 {
|
interface CorrectDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
folder: string;
|
videoKey: string;
|
||||||
currentTitle: string;
|
currentTitle: string;
|
||||||
onCorrect: () => void;
|
onCorrect: () => void;
|
||||||
}
|
}
|
||||||
@@ -33,7 +43,7 @@ interface CorrectDialogProps {
|
|||||||
export default function CorrectDialog({
|
export default function CorrectDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
folder,
|
videoKey,
|
||||||
currentTitle,
|
currentTitle,
|
||||||
onCorrect,
|
onCorrect,
|
||||||
}: CorrectDialogProps) {
|
}: CorrectDialogProps) {
|
||||||
@@ -43,11 +53,20 @@ export default function CorrectDialog({
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [correcting, setCorrecting] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setSearchQuery(currentTitle);
|
setSearchQuery(currentTitle);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setError('');
|
setError('');
|
||||||
|
setSelectedResult(null);
|
||||||
|
setSeasons([]);
|
||||||
|
setShowSeasonSelection(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, currentTitle]);
|
}, [isOpen, currentTitle]);
|
||||||
|
|
||||||
@@ -60,6 +79,8 @@ export default function CorrectDialog({
|
|||||||
setSearching(true);
|
setSearching(true);
|
||||||
setError('');
|
setError('');
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setShowSeasonSelection(false);
|
||||||
|
setSelectedResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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);
|
setCorrecting(true);
|
||||||
try {
|
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', {
|
const response = await fetch('/api/openlist/correct', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -120,6 +217,13 @@ export default function CorrectDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 返回搜索结果列表
|
||||||
|
const handleBackToResults = () => {
|
||||||
|
setShowSeasonSelection(false);
|
||||||
|
setSelectedResult(null);
|
||||||
|
setSeasons([]);
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -169,11 +273,98 @@ export default function CorrectDialog({
|
|||||||
|
|
||||||
{/* 结果列表 */}
|
{/* 结果列表 */}
|
||||||
<div className='flex-1 overflow-y-auto p-4'>
|
<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'>
|
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||||
{searching ? '搜索中...' : '请输入关键词搜索'}
|
{searching ? '搜索中...' : '请输入关键词搜索'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
// 搜索结果列表
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{results.map((result) => (
|
{results.map((result) => (
|
||||||
<div
|
<div
|
||||||
@@ -217,11 +408,11 @@ export default function CorrectDialog({
|
|||||||
{/* 选择按钮 */}
|
{/* 选择按钮 */}
|
||||||
<div className='flex-shrink-0 flex items-center'>
|
<div className='flex-shrink-0 flex items-center'>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCorrect(result)}
|
onClick={() => handleSelectResult(result)}
|
||||||
disabled={correcting}
|
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'
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
132
src/components/DoubanRecommendations.tsx
Normal file
132
src/components/DoubanRecommendations.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'>
|
<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
|
<img
|
||||||
src={processImageUrl(source.poster)}
|
src={processImageUrl(source.poster)}
|
||||||
alt={source.title}
|
alt={source.title}
|
||||||
|
|||||||
192
src/components/FavoritesPanel.tsx
Normal file
192
src/components/FavoritesPanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Rss,
|
Rss,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
|
Star,
|
||||||
User,
|
User,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -30,6 +31,7 @@ import { useVersionCheck } from './VersionCheckProvider';
|
|||||||
import { VersionPanel } from './VersionPanel';
|
import { VersionPanel } from './VersionPanel';
|
||||||
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
|
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
|
||||||
import { NotificationPanel } from './NotificationPanel';
|
import { NotificationPanel } from './NotificationPanel';
|
||||||
|
import { FavoritesPanel } from './FavoritesPanel';
|
||||||
|
|
||||||
interface AuthInfo {
|
interface AuthInfo {
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -46,6 +48,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||||
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false);
|
const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false);
|
||||||
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
|
||||||
|
const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false);
|
||||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -700,6 +703,18 @@ export const UserMenu: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleSettings}
|
onClick={handleSettings}
|
||||||
@@ -1533,6 +1548,17 @@ export const UserMenu: React.FC = () => {
|
|||||||
/>,
|
/>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 使用 Portal 将收藏面板渲染到 document.body */}
|
||||||
|
{isFavoritesPanelOpen &&
|
||||||
|
mounted &&
|
||||||
|
createPortal(
|
||||||
|
<FavoritesPanel
|
||||||
|
isOpen={isFavoritesPanelOpen}
|
||||||
|
onClose={() => setIsFavoritesPanelOpen(false)}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface VideoCardProps {
|
|||||||
origin?: 'vod' | 'live';
|
origin?: 'vod' | 'live';
|
||||||
releaseDate?: string; // 上映日期,格式:YYYY-MM-DD
|
releaseDate?: string; // 上映日期,格式:YYYY-MM-DD
|
||||||
isUpcoming?: boolean; // 是否为即将上映
|
isUpcoming?: boolean; // 是否为即将上映
|
||||||
|
seasonNumber?: number; // 季度编号
|
||||||
|
seasonName?: string; // 季度名称
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VideoCardHandle = {
|
export type VideoCardHandle = {
|
||||||
@@ -80,6 +82,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
origin = 'vod',
|
origin = 'vod',
|
||||||
releaseDate,
|
releaseDate,
|
||||||
isUpcoming = false,
|
isUpcoming = false,
|
||||||
|
seasonNumber,
|
||||||
|
seasonName,
|
||||||
}: VideoCardProps,
|
}: VideoCardProps,
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -385,7 +389,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
showHeart: true, // 移动端菜单中需要显示收藏选项
|
showHeart: true, // 移动端菜单中需要显示收藏选项
|
||||||
showCheckCircle: false,
|
showCheckCircle: false,
|
||||||
showDoubanLink: true, // 移动端菜单中显示豆瓣链接
|
showDoubanLink: true, // 移动端菜单中显示豆瓣链接
|
||||||
showRating: false,
|
showRating: !!rate,
|
||||||
showYear: true,
|
showYear: true,
|
||||||
},
|
},
|
||||||
douban: {
|
douban: {
|
||||||
@@ -394,7 +398,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
||||||
showHeart: false,
|
showHeart: false,
|
||||||
showCheckCircle: false,
|
showCheckCircle: false,
|
||||||
showDoubanLink: true,
|
showDoubanLink: false,
|
||||||
showRating: !!rate,
|
showRating: !!rate,
|
||||||
showYear: false,
|
showYear: false,
|
||||||
},
|
},
|
||||||
@@ -775,6 +779,25 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
</div>
|
</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 && (
|
{config.showRating && rate && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -157,8 +157,27 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
enabled: data.WatchRoom?.enabled ?? false, // 默认不启用
|
enabled: data.WatchRoom?.enabled ?? false, // 默认不启用
|
||||||
serverType: data.WatchRoom?.serverType ?? 'internal',
|
serverType: data.WatchRoom?.serverType ?? 'internal',
|
||||||
externalServerUrl: data.WatchRoom?.externalServerUrl,
|
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);
|
setConfig(watchRoomConfig);
|
||||||
setIsEnabled(watchRoomConfig.enabled);
|
setIsEnabled(watchRoomConfig.enabled);
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface AdminConfig {
|
|||||||
OIDCClientId?: string; // OIDC Client ID
|
OIDCClientId?: string; // OIDC Client ID
|
||||||
OIDCClientSecret?: string; // OIDC Client Secret
|
OIDCClientSecret?: string; // OIDC Client Secret
|
||||||
OIDCButtonText?: string; // OIDC登录按钮文字
|
OIDCButtonText?: string; // OIDC登录按钮文字
|
||||||
|
OIDCMinTrustLevel?: number; // 最低信任等级(仅LinuxDo网站有效,为0时不判断)
|
||||||
};
|
};
|
||||||
UserConfig: {
|
UserConfig: {
|
||||||
Users: {
|
Users: {
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
import CryptoJS from 'crypto-js';
|
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 唯一的 key(SHA256 的前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 加密算法
|
* 使用 AES 加密算法
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ const VIDEOINFO_CACHE: Map<string, VideoInfoCacheEntry> = new Map();
|
|||||||
|
|
||||||
export interface MetaInfo {
|
export interface MetaInfo {
|
||||||
folders: {
|
folders: {
|
||||||
[folderName: string]: {
|
[key: string]: {
|
||||||
|
folderName: string; // 原始文件夹名称
|
||||||
tmdb_id: number;
|
tmdb_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
poster_path: string | null;
|
poster_path: string | null;
|
||||||
@@ -28,6 +29,8 @@ export interface MetaInfo {
|
|||||||
media_type: 'movie' | 'tv';
|
media_type: 'movie' | 'tv';
|
||||||
last_updated: number;
|
last_updated: number;
|
||||||
failed?: boolean; // 标记是否搜索失败
|
failed?: boolean; // 标记是否搜索失败
|
||||||
|
season_number?: number; // 季度编号(仅电视剧)
|
||||||
|
season_name?: string; // 季度名称(仅电视剧)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
last_refresh: number;
|
last_refresh: number;
|
||||||
|
|||||||
198
src/lib/season-parser.ts
Normal file
198
src/lib/season-parser.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,10 +26,12 @@ export interface TMDBItem {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
poster_path: string | null;
|
poster_path: string | null;
|
||||||
|
backdrop_path?: string | null; // 背景图,用于轮播图
|
||||||
release_date: string;
|
release_date: string;
|
||||||
overview: string;
|
overview: string;
|
||||||
vote_average: number;
|
vote_average: number;
|
||||||
media_type: 'movie' | 'tv';
|
media_type: 'movie' | 'tv';
|
||||||
|
genre_ids?: number[]; // 类型ID列表
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TMDBUpcomingResponse {
|
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
|
* 获取 TMDB 图片完整 URL
|
||||||
* @param path - 图片路径
|
* @param path - 图片路径
|
||||||
@@ -242,3 +308,51 @@ export function getTMDBImageUrl(
|
|||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
return `https://image.tmdb.org/t/p/${size}${path}`;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ interface TMDBSearchResponse {
|
|||||||
export async function searchTMDB(
|
export async function searchTMDB(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
query: string,
|
query: string,
|
||||||
proxy?: string
|
proxy?: string,
|
||||||
|
year?: number
|
||||||
): Promise<{ code: number; result: TMDBSearchResult | null }> {
|
): Promise<{ code: number; result: TMDBSearchResult | null }> {
|
||||||
try {
|
try {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -36,8 +37,13 @@ export async function searchTMDB(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用 multi search 同时搜索电影和电视剧
|
// 使用 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
|
const fetchOptions: any = proxy
|
||||||
? {
|
? {
|
||||||
agent: new HttpsProxyAgent(proxy, {
|
agent: new HttpsProxyAgent(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
|
* 获取 TMDB 图片完整 URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ function getDoubanImageProxyConfig(): {
|
|||||||
export function processImageUrl(originalUrl: string): string {
|
export function processImageUrl(originalUrl: string): string {
|
||||||
if (!originalUrl) return originalUrl;
|
if (!originalUrl) return originalUrl;
|
||||||
|
|
||||||
|
// 如果已经是代理URL,直接返回
|
||||||
|
if (originalUrl.startsWith('/api/image-proxy')) {
|
||||||
|
return originalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
// 仅处理豆瓣图片代理
|
// 仅处理豆瓣图片代理
|
||||||
if (!originalUrl.includes('doubanio.com')) {
|
if (!originalUrl.includes('doubanio.com')) {
|
||||||
return originalUrl;
|
return originalUrl;
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export interface WatchRoomConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
serverType: 'internal' | 'external';
|
serverType: 'internal' | 'external';
|
||||||
externalServerUrl?: string;
|
externalServerUrl?: string;
|
||||||
externalServerAuth?: string;
|
externalServerAuth?: string; // 通过 /api/watch-room-auth 接口获取(需要登录)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocalStorage 存储的房间信息
|
// LocalStorage 存储的房间信息
|
||||||
|
|||||||
Reference in New Issue
Block a user