diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 39de0e5..72c54f9 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -367,6 +367,10 @@ interface SiteConfig { TMDBApiKey?: string; TMDBProxy?: string; TMDBReverseProxy?: string; + BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl'; + BangumiApiBaseUrl?: string; + BangumiImageBaseUrl?: string; + BangumiProxy?: string; BannerDataSource?: string; RecommendationDataSource?: string; PansouApiUrl?: string; @@ -894,12 +898,14 @@ const UserConfig = ({ if (checked) { // 只选择自己有权限操作的用户 const selectableUsernames = - displayUsers?.filter( - (user) => - role === 'owner' || - (role === 'admin' && - (user.role === 'user' || user.username === currentUsername)) - ).map((u) => u.username) || []; + displayUsers + ?.filter( + (user) => + role === 'owner' || + (role === 'admin' && + (user.role === 'user' || user.username === currentUsername)) + ) + .map((u) => u.username) || []; setSelectedUsers(new Set(selectableUsernames)); } else { setSelectedUsers(new Set()); @@ -1261,9 +1267,7 @@ const UserConfig = ({ } }} className={ - showAddUserForm - ? buttonStyles.secondary - : buttonStyles.success + showAddUserForm ? buttonStyles.secondary : buttonStyles.success } > {showAddUserForm ? '取消' : '添加用户'} @@ -1900,7 +1904,9 @@ const UserConfig = ({ 下一页
- - setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000)} className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> + + + setCacheTTL(parseInt(e.target.value) || 10 * 60 * 1000) + } + className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' + />
-

Legado 订阅

-

目前处于实验性阶段,仅支持部分简单订阅。

+

+ Legado 订阅 +

+

+ 目前处于实验性阶段,仅支持部分简单订阅。 +

- +
- setLegadoSubscriptionName(e.target.value)} placeholder='订阅名称(可选)' className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' /> - setLegadoSubscriptionUrl(e.target.value)} placeholder='https://example.com/bookSource.json' className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' /> + setLegadoSubscriptionName(e.target.value)} + placeholder='订阅名称(可选)' + className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' + /> + setLegadoSubscriptionUrl(e.target.value)} + placeholder='https://example.com/bookSource.json' + className='rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm text-gray-900 dark:border-amber-800 dark:bg-gray-900 dark:text-gray-100' + />
- {(legadoSubscriptions || []).length === 0 ?
暂无 Legado 订阅。
: (legadoSubscriptions || []).map((sub) => ( -
-
-
-
{sub.name}
-
{sub.url}
-
源数量:{sub.sourceCount || 0} · 上次同步:{sub.lastSuccessAt ? new Date(sub.lastSuccessAt).toLocaleString() : '-'}
- {sub.lastError ?
{sub.lastError}
: null} -
-
- - - + {(legadoSubscriptions || []).length === 0 ? ( +
+ 暂无 Legado 订阅。 +
+ ) : ( + (legadoSubscriptions || []).map((sub) => ( +
+
+
+
+ {sub.name} +
+
+ {sub.url} +
+
+ 源数量:{sub.sourceCount || 0} · 上次同步: + {sub.lastSuccessAt + ? new Date(sub.lastSuccessAt).toLocaleString() + : '-'} +
+ {sub.lastError ? ( +
+ {sub.lastError} +
+ ) : null} +
+
+ + + +
-
- ))} + )) + )}
-

OPDS 书源列表

- +

+ OPDS 书源列表 +

+
- {sources.length === 0 ?
暂无 OPDS 书源。
: null} + {sources.length === 0 ? ( +
+ 暂无 OPDS 书源。 +
+ ) : null}
{sources.map((source, index) => { const isEditing = editingIndex === index; return ( -
+
-
{source.name || `书源 ${index + 1}`}
-
{source.url || '-'}
+
+ {source.name || `书源 ${index + 1}`} +
+
+ {source.url || '-'} +
- - - - + + + +
{isEditing ? (
- updateSource(index, { id: e.target.value })} placeholder='书源 ID' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> - updateSource(index, { name: e.target.value })} placeholder='书源名称' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> - updateSource(index, { url: e.target.value })} placeholder='https://example.com/opds' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2' /> - + updateSource(index, { id: e.target.value }) + } + placeholder='书源 ID' + className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' + /> + + updateSource(index, { name: e.target.value }) + } + placeholder='书源名称' + className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' + /> + + updateSource(index, { url: e.target.value }) + } + placeholder='https://example.com/opds' + className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2' + /> + - updateSource(index, { language: e.target.value })} placeholder='语言 zh / en' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> - updateSource(index, { searchTemplate: e.target.value })} placeholder='搜索模板 https://...{searchTerms}' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2' /> - {source.authMode === 'basic' ? <> updateSource(index, { username: e.target.value })} placeholder='用户名' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> updateSource(index, { password: e.target.value })} placeholder='密码' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> : null} - {source.authMode === 'header' ? <> updateSource(index, { headerName: e.target.value })} placeholder='Header 名称' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> updateSource(index, { headerValue: e.target.value })} placeholder='Header 值' className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' /> : null} + + updateSource(index, { language: e.target.value }) + } + placeholder='语言 zh / en' + className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' + /> + + updateSource(index, { searchTemplate: e.target.value }) + } + placeholder='搜索模板 https://...{searchTerms}' + className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 md:col-span-2' + /> + {source.authMode === 'basic' ? ( + <> + + updateSource(index, { username: e.target.value }) + } + placeholder='用户名' + className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' + /> + + updateSource(index, { password: e.target.value }) + } + placeholder='密码' + className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' + /> + + ) : null} + {source.authMode === 'header' ? ( + <> + + updateSource(index, { headerName: e.target.value }) + } + placeholder='Header 名称' + className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' + /> + + updateSource(index, { headerValue: e.target.value }) + } + placeholder='Header 值' + className='rounded-lg border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100' + /> + + ) : null}
) : null}
@@ -12597,10 +13063,24 @@ const OPDSConfigComponent = ({
- +
- +
); }; @@ -15717,33 +16197,34 @@ function AdminPageClient() { const userLimit = 10; // 获取新版本用户列表 - const fetchUsersV2 = useCallback(async (page = 1, search = userSearch) => { - try { - setUserListLoading(true); - const params = new URLSearchParams({ - page: String(page), - limit: String(userLimit), - }); - const trimmedSearch = search.trim(); - if (trimmedSearch) { - params.set('search', trimmedSearch); + const fetchUsersV2 = useCallback( + async (page = 1, search = userSearch) => { + try { + setUserListLoading(true); + const params = new URLSearchParams({ + page: String(page), + limit: String(userLimit), + }); + const trimmedSearch = search.trim(); + if (trimmedSearch) { + params.set('search', trimmedSearch); + } + const response = await fetch(`/api/admin/users?${params.toString()}`); + if (response.ok) { + const data = await response.json(); + setUsersV2(data.users); + setUserTotalPages(data.totalPages || 1); + setUserTotal(data.total || 0); + setUserPage(page); + } + } catch (err) { + console.error('获取新版本用户列表失败:', err); + } finally { + setUserListLoading(false); } - const response = await fetch( - `/api/admin/users?${params.toString()}` - ); - if (response.ok) { - const data = await response.json(); - setUsersV2(data.users); - setUserTotalPages(data.totalPages || 1); - setUserTotal(data.total || 0); - setUserPage(page); - } - } catch (err) { - console.error('获取新版本用户列表失败:', err); - } finally { - setUserListLoading(false); - } - }, [userSearch]); + }, + [userSearch] + ); // 刷新配置和用户列表 const refreshConfigAndUsers = useCallback(async () => { diff --git a/src/app/api/admin/site/route.ts b/src/app/api/admin/site/route.ts index f1c6fe1..6ec50d3 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -46,6 +46,10 @@ export async function POST(request: NextRequest) { TMDBApiKey, TMDBProxy, TMDBReverseProxy, + BangumiDataSource, + BangumiApiBaseUrl, + BangumiImageBaseUrl, + BangumiProxy, BannerDataSource, RecommendationDataSource, PansouApiUrl, @@ -95,6 +99,10 @@ export async function POST(request: NextRequest) { TMDBApiKey?: string; TMDBProxy?: string; TMDBReverseProxy?: string; + BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl'; + BangumiApiBaseUrl?: string; + BangumiImageBaseUrl?: string; + BangumiProxy?: string; BannerDataSource?: string; RecommendationDataSource?: string; PansouApiUrl?: string; @@ -149,33 +157,63 @@ export async function POST(request: NextRequest) { typeof DanmakuAutoLoadDefault !== 'boolean') || (TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') || (TMDBProxy !== undefined && typeof TMDBProxy !== 'string') || - (TMDBReverseProxy !== undefined && typeof TMDBReverseProxy !== 'string') || - (BannerDataSource !== undefined && typeof BannerDataSource !== 'string') || - (RecommendationDataSource !== undefined && typeof RecommendationDataSource !== 'string') || - (PansouKeywordBlocklist !== undefined && typeof PansouKeywordBlocklist !== 'string') || + (TMDBReverseProxy !== undefined && + typeof TMDBReverseProxy !== 'string') || + (BangumiDataSource !== undefined && + BangumiDataSource !== 'direct' && + BangumiDataSource !== 'server-proxy' && + BangumiDataSource !== 'custom-baseurl') || + (BangumiApiBaseUrl !== undefined && + typeof BangumiApiBaseUrl !== 'string') || + (BangumiImageBaseUrl !== undefined && + typeof BangumiImageBaseUrl !== 'string') || + (BangumiProxy !== undefined && typeof BangumiProxy !== 'string') || + (BannerDataSource !== undefined && + typeof BannerDataSource !== 'string') || + (RecommendationDataSource !== undefined && + typeof RecommendationDataSource !== 'string') || + (PansouKeywordBlocklist !== undefined && + typeof PansouKeywordBlocklist !== 'string') || (MagnetProxy !== undefined && typeof MagnetProxy !== 'string') || - (MagnetMikanReverseProxy !== undefined && typeof MagnetMikanReverseProxy !== 'string') || - (MagnetDmhyReverseProxy !== undefined && typeof MagnetDmhyReverseProxy !== 'string') || - (MagnetAcgripReverseProxy !== undefined && typeof MagnetAcgripReverseProxy !== 'string') || + (MagnetMikanReverseProxy !== undefined && + typeof MagnetMikanReverseProxy !== 'string') || + (MagnetDmhyReverseProxy !== undefined && + typeof MagnetDmhyReverseProxy !== 'string') || + (MagnetAcgripReverseProxy !== undefined && + typeof MagnetAcgripReverseProxy !== 'string') || typeof EnableComments !== 'boolean' || - (CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') || - (CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') || - (EnableRegistration !== undefined && typeof EnableRegistration !== 'boolean') || - (RequireRegistrationInviteCode !== undefined && typeof RequireRegistrationInviteCode !== 'boolean') || - (RegistrationInviteCode !== undefined && typeof RegistrationInviteCode !== 'string') || - (RegistrationRequireTurnstile !== undefined && typeof RegistrationRequireTurnstile !== 'boolean') || - (LoginRequireTurnstile !== undefined && typeof LoginRequireTurnstile !== 'boolean') || - (TurnstileSiteKey !== undefined && typeof TurnstileSiteKey !== 'string') || - (TurnstileSecretKey !== undefined && typeof TurnstileSecretKey !== 'string') || + (CustomAdFilterCode !== undefined && + typeof CustomAdFilterCode !== 'string') || + (CustomAdFilterVersion !== undefined && + typeof CustomAdFilterVersion !== 'number') || + (EnableRegistration !== undefined && + typeof EnableRegistration !== 'boolean') || + (RequireRegistrationInviteCode !== undefined && + typeof RequireRegistrationInviteCode !== 'boolean') || + (RegistrationInviteCode !== undefined && + typeof RegistrationInviteCode !== 'string') || + (RegistrationRequireTurnstile !== undefined && + typeof RegistrationRequireTurnstile !== 'boolean') || + (LoginRequireTurnstile !== undefined && + typeof LoginRequireTurnstile !== 'boolean') || + (TurnstileSiteKey !== undefined && + typeof TurnstileSiteKey !== 'string') || + (TurnstileSecretKey !== undefined && + typeof TurnstileSecretKey !== 'string') || (DefaultUserTags !== undefined && !Array.isArray(DefaultUserTags)) || (EnableOIDCLogin !== undefined && typeof EnableOIDCLogin !== 'boolean') || - (EnableOIDCRegistration !== undefined && typeof EnableOIDCRegistration !== 'boolean') || + (EnableOIDCRegistration !== undefined && + typeof EnableOIDCRegistration !== 'boolean') || (OIDCIssuer !== undefined && typeof OIDCIssuer !== 'string') || - (OIDCAuthorizationEndpoint !== undefined && typeof OIDCAuthorizationEndpoint !== 'string') || - (OIDCTokenEndpoint !== undefined && typeof OIDCTokenEndpoint !== 'string') || - (OIDCUserInfoEndpoint !== undefined && typeof OIDCUserInfoEndpoint !== 'string') || + (OIDCAuthorizationEndpoint !== undefined && + typeof OIDCAuthorizationEndpoint !== 'string') || + (OIDCTokenEndpoint !== undefined && + typeof OIDCTokenEndpoint !== 'string') || + (OIDCUserInfoEndpoint !== undefined && + typeof OIDCUserInfoEndpoint !== 'string') || (OIDCClientId !== undefined && typeof OIDCClientId !== 'string') || - (OIDCClientSecret !== undefined && typeof OIDCClientSecret !== 'string') || + (OIDCClientSecret !== undefined && + typeof OIDCClientSecret !== 'string') || (OIDCButtonText !== undefined && typeof OIDCButtonText !== 'string') || (OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number') ) { @@ -211,6 +249,10 @@ export async function POST(request: NextRequest) { TMDBApiKey, TMDBProxy, TMDBReverseProxy, + BangumiDataSource, + BangumiApiBaseUrl, + BangumiImageBaseUrl, + BangumiProxy, BannerDataSource, RecommendationDataSource, PansouApiUrl, diff --git a/src/app/api/bangumi/calendar/route.ts b/src/app/api/bangumi/calendar/route.ts new file mode 100644 index 0000000..324cfa7 --- /dev/null +++ b/src/app/api/bangumi/calendar/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; + +import { fetchBangumiFromServer } from '@/lib/bangumi.server'; +import { getConfig } from '@/lib/config'; + +export async function GET() { + try { + const config = await getConfig(); + const response = await fetchBangumiFromServer('/calendar', { + baseUrl: config.SiteConfig.BangumiApiBaseUrl, + proxy: config.SiteConfig.BangumiProxy, + }); + + if (!response.ok) { + return NextResponse.json( + { error: `Bangumi calendar 请求失败: ${response.status}` }, + { status: response.status || 502 } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { + headers: { + 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=21600', + }, + }); + } catch (error) { + console.error('获取 Bangumi calendar 失败:', error); + return NextResponse.json( + { error: '获取 Bangumi calendar 失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bangumi/subject/route.ts b/src/app/api/bangumi/subject/route.ts new file mode 100644 index 0000000..80e926f --- /dev/null +++ b/src/app/api/bangumi/subject/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { fetchBangumiFromServer } from '@/lib/bangumi.server'; +import { getConfig } from '@/lib/config'; + +export async function GET(request: NextRequest) { + try { + const id = request.nextUrl.searchParams.get('id') || ''; + if (!/^\d+$/.test(id)) { + return NextResponse.json( + { error: 'Bangumi ID 格式错误' }, + { status: 400 } + ); + } + + const config = await getConfig(); + const response = await fetchBangumiFromServer(`/v0/subjects/${id}`, { + baseUrl: config.SiteConfig.BangumiApiBaseUrl, + proxy: config.SiteConfig.BangumiProxy, + }); + + if (!response.ok) { + return NextResponse.json( + { error: `Bangumi subject 请求失败: ${response.status}` }, + { status: response.status || 502 } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { + headers: { + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800', + }, + }); + } catch (error) { + console.error('获取 Bangumi subject 失败:', error); + return NextResponse.json( + { error: '获取 Bangumi subject 失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/bangumi/v0/subjects/[id]/route.ts b/src/app/api/bangumi/v0/subjects/[id]/route.ts new file mode 100644 index 0000000..016ecc8 --- /dev/null +++ b/src/app/api/bangumi/v0/subjects/[id]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { fetchBangumiFromServer } from '@/lib/bangumi.server'; +import { getConfig } from '@/lib/config'; + +export async function GET( + _request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const id = params.id; + if (!/^\d+$/.test(id)) { + return NextResponse.json( + { error: 'Bangumi ID 格式错误' }, + { status: 400 } + ); + } + + const config = await getConfig(); + const response = await fetchBangumiFromServer(`/v0/subjects/${id}`, { + baseUrl: config.SiteConfig.BangumiApiBaseUrl, + proxy: config.SiteConfig.BangumiProxy, + }); + + if (!response.ok) { + return NextResponse.json( + { error: `Bangumi subject 请求失败: ${response.status}` }, + { status: response.status || 502 } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { + headers: { + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800', + }, + }); + } catch (error) { + console.error('获取 Bangumi subject 失败:', error); + return NextResponse.json( + { error: '获取 Bangumi subject 失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/image-proxy/route.ts b/src/app/api/image-proxy/route.ts index f3cb928..ffc82f2 100644 --- a/src/app/api/image-proxy/route.ts +++ b/src/app/api/image-proxy/route.ts @@ -1,25 +1,103 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { HttpsProxyAgent } from 'https-proxy-agent'; +import nodeFetch from 'node-fetch'; import { NextResponse } from 'next/server'; +import { getConfig } from '@/lib/config'; + export const runtime = 'nodejs'; +function isCloudflareEnvironment(): boolean { + return ( + process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare' + ); +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.trim().replace(/\/+$/, ''); +} + +function applyBangumiImageBaseUrl( + imageUrl: string, + imageBaseUrl?: string +): string { + const normalizedBaseUrl = normalizeBaseUrl(imageBaseUrl || ''); + if (!normalizedBaseUrl) { + return imageUrl; + } + + if (imageUrl.startsWith(`${normalizedBaseUrl}/`)) { + return imageUrl; + } + + return `${normalizedBaseUrl}/${imageUrl}`; +} + +function isBangumiImageUrl(url: string): boolean { + try { + const hostname = new URL(url).hostname.toLowerCase(); + return ( + hostname === 'lain.bgm.tv' || + hostname === 'r.bgm.tv' || + hostname.endsWith('.bgm.tv') || + hostname.endsWith('.bangumi.tv') + ); + } catch { + return false; + } +} + +async function fetchImage( + imageUrl: string, + options?: { source?: string } +): Promise { + const isBangumiImage = + options?.source === 'bangumi' || isBangumiImageUrl(imageUrl); + const 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', + Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + Referer: isBangumiImage ? 'https://bgm.tv/' : 'https://movie.douban.com/', + }; + + const config = isBangumiImage ? await getConfig() : null; + const targetUrl = isBangumiImage + ? applyBangumiImageBaseUrl(imageUrl, config?.SiteConfig.BangumiImageBaseUrl) + : imageUrl; + + if (!isBangumiImage || isCloudflareEnvironment()) { + return fetch(targetUrl, { headers, signal: AbortSignal.timeout(15000) }); + } + + const proxy = config?.SiteConfig.BangumiProxy?.trim(); + const fetchOptions: any = { + headers, + signal: AbortSignal.timeout(proxy ? 30000 : 15000), + }; + + if (proxy) { + fetchOptions.agent = new HttpsProxyAgent(proxy, { + timeout: 30000, + keepAlive: false, + }); + } + + return nodeFetch(targetUrl, fetchOptions) as unknown as Promise; +} + // OrionTV 兼容接口 export async function GET(request: Request) { const { searchParams } = new URL(request.url); const imageUrl = searchParams.get('url'); + const source = searchParams.get('source') || undefined; if (!imageUrl) { return NextResponse.json({ error: 'Missing image URL' }, { status: 400 }); } try { - const imageResponse = await fetch(imageUrl, { - 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', - Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8', - Referer: 'https://movie.douban.com/', - }, - }); + const imageResponse = await fetchImage(imageUrl, { source }); if (!imageResponse.ok) { return NextResponse.json( @@ -55,6 +133,7 @@ export async function GET(request: Request) { headers, }); } catch (error) { + console.error('图片代理请求失败:', error); return NextResponse.json( { error: 'Error fetching image' }, { status: 500 } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 52b0948..7b4a74f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -59,7 +59,8 @@ export default async function RootLayout({ process.env.ANNOUNCEMENT || '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。'; - let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent'; + let doubanProxyType = + process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent'; let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || ''; let doubanImageProxyType = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent'; @@ -71,6 +72,16 @@ export default async function RootLayout({ let danmakuAutoLoadDefault = true; let recommendationDataSource = 'Mixed'; let tmdbApiKey = ''; + let bangumiDataSource = + (process.env.NEXT_PUBLIC_BANGUMI_DATA_SOURCE as any) || 'direct'; + let bangumiApiBaseUrl = + process.env.NEXT_PUBLIC_BANGUMI_API_BASE_URL || + process.env.BANGUMI_API_BASE_URL || + 'https://api.bgm.tv'; + let bangumiImageBaseUrl = + process.env.NEXT_PUBLIC_BANGUMI_IMAGE_BASE_URL || + process.env.BANGUMI_IMAGE_BASE_URL || + ''; let openListEnabled = false; let embyEnabled = false; let xiaoyaEnabled = false; @@ -101,7 +112,13 @@ export default async function RootLayout({ let customAdFilterVersion = 0; let musicFeatureEnabled = false; let suwayomiEnabled = false; - let booksEnabled = process.env.OPDS_ENABLED === 'true' && !!(process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL || process.env.OPDS_SOURCES_JSON); + let booksEnabled = + process.env.OPDS_ENABLED === 'true' && + !!( + process.env.OPDS_URL || + process.env.NEXT_PUBLIC_OPDS_URL || + process.env.OPDS_SOURCES_JSON + ); let musicProxyEnabled = true; let advancedRecommendationEnabled = false; let userFeatureAccess = @@ -137,8 +154,13 @@ export default async function RootLayout({ fluidSearch = config.SiteConfig.FluidSearch; enableComments = config.SiteConfig.EnableComments; danmakuAutoLoadDefault = config.SiteConfig.DanmakuAutoLoadDefault !== false; - recommendationDataSource = config.SiteConfig.RecommendationDataSource || 'Mixed'; + recommendationDataSource = + config.SiteConfig.RecommendationDataSource || 'Mixed'; tmdbApiKey = config.SiteConfig.TMDBApiKey || ''; + bangumiDataSource = config.SiteConfig.BangumiDataSource || 'direct'; + bangumiApiBaseUrl = + config.SiteConfig.BangumiApiBaseUrl || 'https://api.bgm.tv'; + bangumiImageBaseUrl = config.SiteConfig.BangumiImageBaseUrl || ''; loginBackgroundImage = config.ThemeConfig?.loginBackgroundImage || ''; registerBackgroundImage = config.ThemeConfig?.registerBackgroundImage || ''; homeBackgroundImage = config.ThemeConfig?.homeBackgroundImage || ''; @@ -146,9 +168,11 @@ export default async function RootLayout({ progressThumbPresetId = config.ThemeConfig?.progressThumbPresetId || ''; progressThumbCustomUrl = config.ThemeConfig?.progressThumbCustomUrl || ''; enableRegistration = config.SiteConfig.EnableRegistration || false; - requireRegistrationInviteCode = config.SiteConfig.RequireRegistrationInviteCode || false; + requireRegistrationInviteCode = + config.SiteConfig.RequireRegistrationInviteCode || false; loginRequireTurnstile = config.SiteConfig.LoginRequireTurnstile || false; - registrationRequireTurnstile = config.SiteConfig.RegistrationRequireTurnstile || false; + registrationRequireTurnstile = + config.SiteConfig.RegistrationRequireTurnstile || false; turnstileSiteKey = config.SiteConfig.TurnstileSiteKey || ''; enableOIDCLogin = config.SiteConfig.EnableOIDCLogin || false; enableOIDCRegistration = config.SiteConfig.EnableOIDCRegistration || false; @@ -173,8 +197,7 @@ export default async function RootLayout({ musicProxyEnabled = config.MusicConfig?.ProxyEnabled ?? true; // 漫画功能配置 suwayomiEnabled = !!( - config.SuwayomiConfig?.Enabled && - config.SuwayomiConfig?.ServerURL + config.SuwayomiConfig?.Enabled && config.SuwayomiConfig?.ServerURL ); // 电子书功能配置 const opdsConfig = config.OPDSConfig; @@ -198,19 +221,23 @@ export default async function RootLayout({ embyEnabled = !!( config.EmbyConfig?.Sources && config.EmbyConfig.Sources.length > 0 && - config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL) + config.EmbyConfig.Sources.some((s) => s.enabled && s.ServerURL) ); // 检查是否启用了小雅功能 xiaoyaEnabled = !!( - config.XiaoyaConfig?.Enabled && - config.XiaoyaConfig?.ServerURL + config.XiaoyaConfig?.Enabled && config.XiaoyaConfig?.ServerURL ); } // 将运行时配置注入到全局 window 对象,供客户端在运行时读取 - const runtimeStorageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; - const isCloudflare = process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; - const displayStorageType = runtimeStorageType === 'd1' && !isCloudflare ? 'sqlite' : runtimeStorageType; + const runtimeStorageType = + process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + const isCloudflare = + process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; + const displayStorageType = + runtimeStorageType === 'd1' && !isCloudflare + ? 'sqlite' + : runtimeStorageType; const runtimeConfig = { STORAGE_TYPE: runtimeStorageType, @@ -225,9 +252,14 @@ export default async function RootLayout({ EnableComments: enableComments, DANMAKU_AUTO_LOAD_DEFAULT: danmakuAutoLoadDefault, RecommendationDataSource: recommendationDataSource, + BANGUMI_DATA_SOURCE: bangumiDataSource, + BANGUMI_API_BASE_URL: bangumiApiBaseUrl, + BANGUMI_IMAGE_BASE_URL: bangumiImageBaseUrl, ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true', - ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true', - VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback', + ENABLE_OFFLINE_DOWNLOAD: + process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true', + VOICE_CHAT_STRATEGY: + process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback', OPENLIST_ENABLED: openListEnabled && userFeatureAccess.private_library, EMBY_ENABLED: embyEnabled && userFeatureAccess.emby, XIAOYA_ENABLED: xiaoyaEnabled && userFeatureAccess.xiaoya, @@ -273,8 +305,7 @@ export default async function RootLayout({ userFeatureAccess.magnet_save_private_library, NETDISK_TRANSFER_ENABLED: userFeatureAccess.netdisk_transfer, NETDISK_TEMP_PLAY_ENABLED: userFeatureAccess.netdisk_temp_play, - FESTIVE_EFFECT_ENABLED: - process.env.FESTIVE_EFFECT_ENABLED === 'true', + FESTIVE_EFFECT_ENABLED: process.env.FESTIVE_EFFECT_ENABLED === 'true', }; return ( @@ -307,7 +338,11 @@ export default async function RootLayout({ - + diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index c36a1ae..2479489 100644 --- a/src/components/DetailPanel.tsx +++ b/src/components/DetailPanel.tsx @@ -1,10 +1,22 @@ 'use client'; -import { Calendar, Clock, ExternalLink, Film, Globe, Images, Star, Tag, Users, X } from 'lucide-react'; +import { + Calendar, + Clock, + ExternalLink, + Film, + Globe, + Images, + Star, + Tag, + Users, + X, +} from 'lucide-react'; import Image from 'next/image'; import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; +import { getBangumiSubject } from '@/lib/bangumi.client'; import { getTMDBImageUrl } from '@/lib/tmdb.client'; import { processImageUrl } from '@/lib/utils'; @@ -105,9 +117,14 @@ const DetailPanel: React.FC = ({ const [detailData, setDetailData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [seasonData, setSeasonData] = useState<{ seasons: any[]; episodes: Episode[] } | null>(null); + const [seasonData, setSeasonData] = useState<{ + seasons: any[]; + episodes: Episode[]; + } | null>(null); const [loadingSeasons, setLoadingSeasons] = useState(false); - const [expandedEpisodes, setExpandedEpisodes] = useState>(new Set()); + const [expandedEpisodes, setExpandedEpisodes] = useState>( + new Set() + ); const [selectedSeason, setSelectedSeason] = useState(1); const [seasonsLoaded, setSeasonsLoaded] = useState(false); const [showImageViewer, setShowImageViewer] = useState(false); @@ -122,12 +139,16 @@ const DetailPanel: React.FC = ({ const [galleryViewportWidth, setGalleryViewportWidth] = useState(0); const galleryScrollRef = React.useRef(null); - // 数据源状态管理 - const [currentSource, setCurrentSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb'); - const [originalSource, setOriginalSource] = useState<'douban' | 'bangumi' | 'cms' | 'tmdb'>('tmdb'); + const [currentSource, setCurrentSource] = useState< + 'douban' | 'bangumi' | 'cms' | 'tmdb' + >('tmdb'); + const [originalSource, setOriginalSource] = useState< + 'douban' | 'bangumi' | 'cms' | 'tmdb' + >('tmdb'); const [isUsingTmdb, setIsUsingTmdb] = useState(false); - const [originalDetailData, setOriginalDetailData] = useState(null); + const [originalDetailData, setOriginalDetailData] = + useState(null); const getExternalUrl = () => { if (currentSource === 'douban' && doubanId) { @@ -387,14 +408,19 @@ const DetailPanel: React.FC = ({ if (sourceId && source) { try { const response = await fetch( - `/api/source-detail?id=${encodeURIComponent(sourceId)}&source=${encodeURIComponent(source)}&title=${encodeURIComponent(title)}` + `/api/source-detail?id=${encodeURIComponent( + sourceId + )}&source=${encodeURIComponent( + source + )}&title=${encodeURIComponent(title)}` ); if (response.ok) { const data = await response.json(); const detailData = { title: data.title || title, intro: data.desc || '', - episodesCount: data.episodes?.length || cmsData.episodes?.length, + episodesCount: + data.episodes?.length || cmsData.episodes?.length, poster: data.poster || poster, year: data.year, }; @@ -415,11 +441,10 @@ const DetailPanel: React.FC = ({ setCurrentSource('bangumi'); setOriginalSource('bangumi'); const actualBangumiId = bangumiId || doubanId; - const response = await fetch(`https://api.bgm.tv/v0/subjects/${actualBangumiId}`); - if (!response.ok) { - throw new Error('获取Bangumi详情失败'); + if (!actualBangumiId) { + throw new Error('Bangumi ID 缺失'); } - const data = await response.json(); + const data = await getBangumiSubject(actualBangumiId); const detailData = { title: data.name_cn || data.name, @@ -517,10 +542,19 @@ const DetailPanel: React.FC = ({ const seasonStr = match[1]; // 中文数字转数字 const chineseNumbers: Record = { - '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, - '六': 6, '七': 7, '八': 8, '九': 9, '十': 10, + 一: 1, + 二: 2, + 三: 3, + 四: 4, + 五: 5, + 六: 6, + 七: 7, + 八: 8, + 九: 9, + 十: 10, }; - extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined; + extractedSeasonNumber = + chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined; } break; } @@ -540,7 +574,9 @@ const DetailPanel: React.FC = ({ const mediaType = result.media_type || type; // 获取详情 - const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`); + const detailResponse = await fetch( + `/api/tmdb/detail?id=${detailId}&type=${mediaType}` + ); if (!detailResponse.ok) { throw new Error('获取TMDB详情失败'); } @@ -566,17 +602,26 @@ const DetailPanel: React.FC = ({ mediaType === 'movie' ? detailResult.title : seasonData?.name - ? `${detailResult.name} ${seasonData.name}` - : detailResult.name, + ? `${detailResult.name} ${seasonData.name}` + : detailResult.name, originalTitle: - mediaType === 'movie' ? detailResult.original_title : detailResult.original_name, + mediaType === 'movie' + ? detailResult.original_title + : detailResult.original_name, year: mediaType === 'movie' ? detailResult.release_date?.substring(0, 4) - : seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4), - poster: (seasonData?.poster_path || detailResult.poster_path) - ? processImageUrl(getTMDBImageUrl(seasonData?.poster_path || detailResult.poster_path, 'w500')) - : poster, + : seasonData?.air_date?.substring(0, 4) || + detailResult.first_air_date?.substring(0, 4), + poster: + seasonData?.poster_path || detailResult.poster_path + ? processImageUrl( + getTMDBImageUrl( + seasonData?.poster_path || detailResult.poster_path, + 'w500' + ) + ) + : poster, rating: detailResult.vote_average ? { value: detailResult.vote_average, @@ -587,8 +632,11 @@ const DetailPanel: React.FC = ({ genres: detailResult.genres?.map((g: any) => g.name), countries: detailResult.production_countries?.map((c: any) => c.name), languages: detailResult.spoken_languages?.map((l: any) => l.name), - duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined, - episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes, + duration: detailResult.runtime + ? `${detailResult.runtime}分钟` + : undefined, + episodesCount: + seasonData?.episodes?.length || detailResult.number_of_episodes, releaseDate: mediaType === 'movie' ? detailResult.release_date @@ -609,7 +657,21 @@ const DetailPanel: React.FC = ({ }; fetchDetail(); - }, [isOpen, doubanId, bangumiId, isBangumi, tmdbId, title, type, seasonNumber, poster, cmsData, sourceId, source, isUsingTmdb]); + }, [ + isOpen, + doubanId, + bangumiId, + isBangumi, + tmdbId, + title, + type, + seasonNumber, + poster, + cmsData, + sourceId, + source, + isUsingTmdb, + ]); // 切换数据源的函数 const handleToggleSource = async () => { @@ -664,10 +726,19 @@ const DetailPanel: React.FC = ({ const seasonStr = match[1]; // 中文数字转数字 const chineseNumbers: Record = { - '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, - '六': 6, '七': 7, '八': 8, '九': 9, '十': 10, + 一: 1, + 二: 2, + 三: 3, + 四: 4, + 五: 5, + 六: 6, + 七: 7, + 八: 8, + 九: 9, + 十: 10, }; - extractedSeasonNumber = chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined; + extractedSeasonNumber = + chineseNumbers[seasonStr] || parseInt(seasonStr) || undefined; } break; } @@ -687,7 +758,9 @@ const DetailPanel: React.FC = ({ const mediaType = result.media_type || type; // 获取详情 - const detailResponse = await fetch(`/api/tmdb/detail?id=${detailId}&type=${mediaType}`); + const detailResponse = await fetch( + `/api/tmdb/detail?id=${detailId}&type=${mediaType}` + ); if (!detailResponse.ok) { throw new Error('获取TMDB详情失败'); } @@ -713,17 +786,26 @@ const DetailPanel: React.FC = ({ mediaType === 'movie' ? detailResult.title : seasonData?.name - ? `${detailResult.name} ${seasonData.name}` - : detailResult.name, + ? `${detailResult.name} ${seasonData.name}` + : detailResult.name, originalTitle: - mediaType === 'movie' ? detailResult.original_title : detailResult.original_name, + mediaType === 'movie' + ? detailResult.original_title + : detailResult.original_name, year: mediaType === 'movie' ? detailResult.release_date?.substring(0, 4) - : seasonData?.air_date?.substring(0, 4) || detailResult.first_air_date?.substring(0, 4), - poster: (seasonData?.poster_path || detailResult.poster_path) - ? processImageUrl(getTMDBImageUrl(seasonData?.poster_path || detailResult.poster_path, 'w500')) - : poster, + : seasonData?.air_date?.substring(0, 4) || + detailResult.first_air_date?.substring(0, 4), + poster: + seasonData?.poster_path || detailResult.poster_path + ? processImageUrl( + getTMDBImageUrl( + seasonData?.poster_path || detailResult.poster_path, + 'w500' + ) + ) + : poster, rating: detailResult.vote_average ? { value: detailResult.vote_average, @@ -734,8 +816,11 @@ const DetailPanel: React.FC = ({ genres: detailResult.genres?.map((g: any) => g.name), countries: detailResult.production_countries?.map((c: any) => c.name), languages: detailResult.spoken_languages?.map((l: any) => l.name), - duration: detailResult.runtime ? `${detailResult.runtime}分钟` : undefined, - episodesCount: seasonData?.episodes?.length || detailResult.number_of_episodes, + duration: detailResult.runtime + ? `${detailResult.runtime}分钟` + : undefined, + episodesCount: + seasonData?.episodes?.length || detailResult.number_of_episodes, releaseDate: mediaType === 'movie' ? detailResult.release_date @@ -758,7 +843,12 @@ const DetailPanel: React.FC = ({ // 异步获取季度和集数详情(仅TMDB) useEffect(() => { - if (!detailData?.tmdbId || !detailData?.mediaType || detailData.mediaType !== 'tv' || seasonsLoaded) { + if ( + !detailData?.tmdbId || + !detailData?.mediaType || + detailData.mediaType !== 'tv' || + seasonsLoaded + ) { return; } @@ -766,7 +856,9 @@ const DetailPanel: React.FC = ({ setLoadingSeasons(true); try { // 获取所有季度 - const seasonsResponse = await fetch(`/api/tmdb/seasons?tvId=${detailData.tmdbId}`); + const seasonsResponse = await fetch( + `/api/tmdb/seasons?tvId=${detailData.tmdbId}` + ); if (!seasonsResponse.ok) return; const seasonsData = await seasonsResponse.json(); @@ -794,24 +886,36 @@ const DetailPanel: React.FC = ({ }; fetchSeasonData(); - }, [detailData?.tmdbId, detailData?.mediaType, detailData?.seasonNumber, seasonsLoaded]); + }, [ + detailData?.tmdbId, + detailData?.mediaType, + detailData?.seasonNumber, + seasonsLoaded, + ]); // 自动滚动到当前集数 useEffect(() => { - if (!currentEpisode || !seasonData?.episodes || !episodesScrollRef.current || currentSource !== 'tmdb') { + if ( + !currentEpisode || + !seasonData?.episodes || + !episodesScrollRef.current || + currentSource !== 'tmdb' + ) { return; } // 等待 DOM 更新后再滚动 const timer = setTimeout(() => { - const episodeElement = document.getElementById(`episode-${currentEpisode}`); + const episodeElement = document.getElementById( + `episode-${currentEpisode}` + ); if (episodeElement && episodesScrollRef.current) { // 计算滚动位置,使当前集数居中显示 const container = episodesScrollRef.current; const elementLeft = episodeElement.offsetLeft; const elementWidth = episodeElement.offsetWidth; const containerWidth = container.offsetWidth; - const scrollLeft = elementLeft - (containerWidth / 2) + (elementWidth / 2); + const scrollLeft = elementLeft - containerWidth / 2 + elementWidth / 2; container.scrollLeft = scrollLeft; } @@ -822,7 +926,11 @@ const DetailPanel: React.FC = ({ // 异步获取演职人员信息(仅TMDB) useEffect(() => { - if (!detailData?.tmdbId || !detailData?.mediaType || currentSource !== 'tmdb') { + if ( + !detailData?.tmdbId || + !detailData?.mediaType || + currentSource !== 'tmdb' + ) { return; } @@ -840,30 +948,39 @@ const DetailPanel: React.FC = ({ const creditsData = await creditsResponse.json(); // 更新演员和导演信息 - setDetailData(prev => prev ? { - ...prev, - directors: creditsData.crew - ?.filter((person: any) => person.job === 'Director') - .slice(0, 5) - .map((person: any) => ({ - name: person.name, - profile_path: person.profile_path, - })) || prev.directors, - actors: creditsData.cast - ?.slice(0, 15) - .map((person: any) => ({ - name: person.name, - character: person.character, - profile_path: person.profile_path, - })) || prev.actors, - } : null); + setDetailData((prev) => + prev + ? { + ...prev, + directors: + creditsData.crew + ?.filter((person: any) => person.job === 'Director') + .slice(0, 5) + .map((person: any) => ({ + name: person.name, + profile_path: person.profile_path, + })) || prev.directors, + actors: + creditsData.cast?.slice(0, 15).map((person: any) => ({ + name: person.name, + character: person.character, + profile_path: person.profile_path, + })) || prev.actors, + } + : null + ); } catch (err) { console.error('获取演职人员信息失败:', err); } }; fetchCredits(); - }, [detailData?.tmdbId, detailData?.mediaType, currentSource, detailData?.actors]); + }, [ + detailData?.tmdbId, + detailData?.mediaType, + currentSource, + detailData?.actors, + ]); // 切换季度时获取集数 const handleSeasonChange = async (seasonNumber: number) => { @@ -879,25 +996,43 @@ const DetailPanel: React.FC = ({ const episodesData = await episodesResponse.json(); // 从当前 seasonData 中查找季度信息 - const season = seasonData?.seasons.find((s: any) => s.season_number === seasonNumber); + const season = seasonData?.seasons.find( + (s: any) => s.season_number === seasonNumber + ); - setSeasonData(prev => ({ + setSeasonData((prev) => ({ seasons: prev?.seasons || [], episodes: episodesData.episodes || [], })); // 更新季度元信息 - setDetailData(prev => prev ? { - ...prev, - title: (episodesData.name || season?.name) - ? `${prev.seriesTitle || prev.title} ${episodesData.name || season?.name}` - : prev.title, - intro: episodesData.overview || season?.overview || prev.overview, - poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster, - releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate, - year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year, - episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount, - } : null); + setDetailData((prev) => + prev + ? { + ...prev, + title: + episodesData.name || season?.name + ? `${prev.seriesTitle || prev.title} ${ + episodesData.name || season?.name + }` + : prev.title, + intro: episodesData.overview || season?.overview || prev.overview, + poster: season?.poster_path + ? getTMDBImageUrl(season.poster_path, 'w500') + : prev.poster, + releaseDate: + episodesData.air_date || season?.air_date || prev.releaseDate, + year: + episodesData.air_date?.substring(0, 4) || + season?.air_date?.substring(0, 4) || + prev.year, + episodesCount: + episodesData.episodes?.length || + season?.episode_count || + prev.episodesCount, + } + : null + ); setExpandedEpisodes(new Set()); } catch (err) { @@ -1006,7 +1141,7 @@ const DetailPanel: React.FC = ({ const galleryEntryButton = canShowGalleryEntry ? (
); - const galleryModal = showGallery ? (useDrawer ? ( -
-
- {galleryHeader} - {galleryBody} + const galleryModal = showGallery ? ( + useDrawer ? ( +
+
+ {galleryHeader} + {galleryBody} +
-
- ) : ( -
-
setShowGallery(false)} - /> -
- {galleryHeader} - {galleryBody} + ) : ( +
+
setShowGallery(false)} + /> +
+ {galleryHeader} + {galleryBody} +
-
- )) : null; + ) + ) : null; if (!isVisible || !mounted) return null; const content = useDrawer ? ( -
+
{/* 详情面板 - 抽屉模式 */}
= ({ }`} > {/* 头部 */} -
-

详情

-
+
+

+ 详情 +

+
{externalUrl && ( )}
{/* 内容区域 */} -
+
{loading && ( -
-
+
+
)} {error && ( -
-
-

{error}

+
+
+

{error}

{/* 数据源显示和切换 - 错误时也显示 */} -
-
-
- 数据来源: - +
+
+
+ + 数据来源: + + {currentSource === 'douban' && 'Douban'} {currentSource === 'bangumi' && 'Bangumi'} {currentSource === 'cms' && 'CMS'} {currentSource === 'tmdb' && 'TMDB'}
-
+
{galleryEntryButton} {currentSource !== 'tmdb' && ( )} - {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( - - )} + {currentSource === 'tmdb' && + originalSource !== 'tmdb' && + originalDetailData && ( + + )}
@@ -1256,47 +1451,48 @@ const DetailPanel: React.FC = ({ )} {!loading && !error && detailData && ( -
+
{/* 海报和基本信息 */} -
+
{detailData.poster && ( -
+
handleImageClick(detailData.poster!)} >
{galleryEntryButton}
)} -
-

+
+

{detailData.title}

- {detailData.originalTitle && detailData.originalTitle !== detailData.title && ( -

- {detailData.originalTitle} -

- )} + {detailData.originalTitle && + detailData.originalTitle !== detailData.title && ( +

+ {detailData.originalTitle} +

+ )} {/* 评分 */} {detailData.rating && ( -
+
- + {detailData.rating.value.toFixed(1)} {detailData.rating.count > 0 && ( - + ({detailData.rating.count} 评价) )} @@ -1305,11 +1501,11 @@ const DetailPanel: React.FC = ({ {/* 类型标签 */} {detailData.genres && detailData.genres.length > 0 && ( -
+
{detailData.genres.map((genre, index) => ( {genre} @@ -1318,21 +1514,21 @@ const DetailPanel: React.FC = ({ )} {/* 年份和时长 */} -
+
{detailData.year && ( -
+
{detailData.year}
)} {detailData.duration && ( -
+
{detailData.duration}
)} {detailData.episodesCount && ( -
+
{detailData.episodesCount} 集
@@ -1343,11 +1539,11 @@ const DetailPanel: React.FC = ({ {/* 简介 */} {(detailData.intro || detailData.overview) && ( -
-

+
+

简介

-

+

{detailData.intro || detailData.overview}

@@ -1355,20 +1551,20 @@ const DetailPanel: React.FC = ({ {/* 导演和演员 */} {detailData.directors && detailData.directors.length > 0 && ( -
-

+
+

导演

-

+

{detailData.directors.map((d) => d.name).join(', ')}

)} {detailData.actors && detailData.actors.length > 0 && ( -
-

+
+

演员

@@ -1379,47 +1575,61 @@ const DetailPanel: React.FC = ({ onMouseMove={handleActorsMouseMove} onMouseUp={handleActorsMouseUp} onMouseLeave={handleActorsMouseLeave} - className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing" + className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing' style={{ scrollbarWidth: 'thin', - scrollBehavior: isActorsDragging ? 'auto' : 'smooth' + scrollBehavior: isActorsDragging ? 'auto' : 'smooth', }} > -
+
{detailData.actors.map((actor, index) => (
{actor.profile_path ? (
handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))} + className='relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity' + onClick={() => + handleImageClick( + getTMDBImageUrl( + actor.profile_path || null, + 'w185' + ) + ) + } >
) : ( -
- +
+
)} e.stopPropagation()} > {actor.name} {actor.character && ( -

+

{actor.character}

)} @@ -1428,22 +1638,25 @@ const DetailPanel: React.FC = ({
) : ( -

- {detailData.actors.slice(0, 10).map((a) => a.name).join(', ')} +

+ {detailData.actors + .slice(0, 10) + .map((a) => a.name) + .join(', ')}

)}
)} {/* 制作信息 */} -
+
{detailData.countries && detailData.countries.length > 0 && (
-

+

国家/地区

-

+

{detailData.countries.join(', ')}

@@ -1451,11 +1664,11 @@ const DetailPanel: React.FC = ({ {detailData.languages && detailData.languages.length > 0 && (
-

+

语言

-

+

{detailData.languages.join(', ')}

@@ -1463,28 +1676,34 @@ const DetailPanel: React.FC = ({ {detailData.releaseDate && (
-

+

上映日期

-

{detailData.releaseDate}

+

+ {detailData.releaseDate} +

)} {detailData.status && (
-

状态

-

{detailData.status}

+

+ 状态 +

+

+ {detailData.status} +

)}
{/* 季度和集数信息(仅TMDB电视剧) */} {detailData.mediaType === 'tv' && ( -
+
{loadingSeasons && ( -
-
+
+
)} @@ -1492,15 +1711,17 @@ const DetailPanel: React.FC = ({ <> {/* 季度列表 */} {seasonData.seasons.length > 0 && ( -
-

+
+

季度

-
+
{seasonData.seasons.map((season: any) => (
handleSeasonChange(season.season_number)} + onClick={() => + handleSeasonChange(season.season_number) + } className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${ selectedSeason === season.season_number ? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500' @@ -1509,25 +1730,33 @@ const DetailPanel: React.FC = ({ > {season.poster_path && (
{ e.stopPropagation(); - handleImageClick(getTMDBImageUrl(season.poster_path, 'w500')); + handleImageClick( + getTMDBImageUrl( + season.poster_path, + 'w500' + ) + ); }} >
)} -
-

+

+

{season.name}

-

+

{season.episode_count} 集

@@ -1540,8 +1769,10 @@ const DetailPanel: React.FC = ({ {/* 集数列表 */} {seasonData.episodes.length > 0 && (
-

- {seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `第${selectedSeason}季`} +

+ {seasonData.seasons.find( + (s: any) => s.season_number === selectedSeason + )?.name || `第${selectedSeason}季`}

= ({ onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} - className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing" + className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing' style={{ scrollbarWidth: 'thin', - scrollBehavior: isDragging ? 'auto' : 'smooth' + scrollBehavior: isDragging ? 'auto' : 'smooth', }} > -
+
{seasonData.episodes.map((episode: Episode) => { - const isExpanded = expandedEpisodes.has(episode.id); - const isCurrentEpisode = currentEpisode === episode.episode_number; + const isExpanded = expandedEpisodes.has( + episode.id + ); + const isCurrentEpisode = + currentEpisode === episode.episode_number; return (
= ({ ? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500' : 'bg-gray-50 dark:bg-gray-800' }`} - style={{ pointerEvents: isDragging ? 'none' : 'auto' }} + style={{ + pointerEvents: isDragging + ? 'none' + : 'auto', + }} > {episode.still_path && (
handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))} + className='relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity' + onClick={() => + handleImageClick( + getTMDBImageUrl( + episode.still_path, + 'w500' + ) + ) + } >
)} -

- 第{episode.episode_number}集: {episode.name} +

+ 第{episode.episode_number}集:{' '} + {episode.name}

{episode.overview && (

{ - const newExpanded = new Set(expandedEpisodes); + const newExpanded = new Set( + expandedEpisodes + ); if (isExpanded) { newExpanded.delete(episode.id); } else { @@ -1597,13 +1848,15 @@ const DetailPanel: React.FC = ({ } setExpandedEpisodes(newExpanded); }} - className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`} + className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${ + isExpanded ? '' : 'line-clamp-3' + }`} > {episode.overview}

)} {episode.air_date && ( -

+

{episode.air_date}

)} @@ -1620,37 +1873,46 @@ const DetailPanel: React.FC = ({ )} {/* 数据源显示和切换 */} -
-
-
- 数据来源: - +
+
+
+ + 数据来源: + + {currentSource === 'douban' && 'Douban'} {currentSource === 'bangumi' && 'Bangumi'} {currentSource === 'cms' && 'CMS'} {currentSource === 'tmdb' && 'TMDB'}
-
+
{galleryEntryButton} {currentSource !== 'tmdb' && ( )} - {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( - - )} + {currentSource === 'tmdb' && + originalSource !== 'tmdb' && + originalDetailData && ( + + )}
@@ -1671,7 +1933,7 @@ const DetailPanel: React.FC = ({ )}
) : ( -
+
{/* 背景遮罩 */}
= ({ {/* 详情面板 - 居中模式 */}
{/* 头部 */} -
-

详情

-
+
+

+ 详情 +

+
{externalUrl && ( )}
{/* 内容区域 */} -
+
{loading && ( -
-
+
+
)} {error && ( -
-
-

{error}

+
+
+

{error}

{/* 数据源显示和切换 - 错误时也显示 */} -
-
-
- 数据来源: - +
+
+
+ + 数据来源: + + {currentSource === 'douban' && 'Douban'} {currentSource === 'bangumi' && 'Bangumi'} {currentSource === 'cms' && 'CMS'} @@ -1749,67 +2022,75 @@ const DetailPanel: React.FC = ({ )} - {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( - - )} + {currentSource === 'tmdb' && + originalSource !== 'tmdb' && + originalDetailData && ( + + )}
)} {!loading && !error && detailData && ( -
+
{/* 海报和基本信息 */} -
+
{detailData.poster && ( -
+
handleImageClick(detailData.poster!)} >
{galleryEntryButton}
)} -
-

+
+

{detailData.title}

- {detailData.originalTitle && detailData.originalTitle !== detailData.title && ( -

- {detailData.originalTitle} -

- )} + {detailData.originalTitle && + detailData.originalTitle !== detailData.title && ( +

+ {detailData.originalTitle} +

+ )} {/* 评分 */} {detailData.rating && ( -
+
- + {detailData.rating.value.toFixed(1)} {detailData.rating.count > 0 && ( - + ({detailData.rating.count} 评价) )} @@ -1818,11 +2099,11 @@ const DetailPanel: React.FC = ({ {/* 类型标签 */} {detailData.genres && detailData.genres.length > 0 && ( -
+
{detailData.genres.map((genre, index) => ( {genre} @@ -1831,21 +2112,21 @@ const DetailPanel: React.FC = ({ )} {/* 年份和时长 */} -
+
{detailData.year && ( -
+
{detailData.year}
)} {detailData.duration && ( -
+
{detailData.duration}
)} {detailData.episodesCount && ( -
+
{detailData.episodesCount} 集
@@ -1856,11 +2137,11 @@ const DetailPanel: React.FC = ({ {/* 简介 */} {(detailData.intro || detailData.overview) && ( -
-

+
+

简介

-

+

{detailData.intro || detailData.overview}

@@ -1868,20 +2149,20 @@ const DetailPanel: React.FC = ({ {/* 导演和演员 */} {detailData.directors && detailData.directors.length > 0 && ( -
-

+
+

导演

-

+

{detailData.directors.map((d) => d.name).join(', ')}

)} {detailData.actors && detailData.actors.length > 0 && ( -
-

+
+

演员

@@ -1892,47 +2173,61 @@ const DetailPanel: React.FC = ({ onMouseMove={handleActorsMouseMove} onMouseUp={handleActorsMouseUp} onMouseLeave={handleActorsMouseLeave} - className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing" + className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing' style={{ scrollbarWidth: 'thin', - scrollBehavior: isActorsDragging ? 'auto' : 'smooth' + scrollBehavior: isActorsDragging ? 'auto' : 'smooth', }} > -
+
{detailData.actors.map((actor, index) => (
{actor.profile_path ? (
handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))} + className='relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity' + onClick={() => + handleImageClick( + getTMDBImageUrl( + actor.profile_path || null, + 'w185' + ) + ) + } >
) : ( -
- +
+
)} e.stopPropagation()} > {actor.name} {actor.character && ( -

+

{actor.character}

)} @@ -1941,22 +2236,25 @@ const DetailPanel: React.FC = ({
) : ( -

- {detailData.actors.slice(0, 10).map((a) => a.name).join(', ')} +

+ {detailData.actors + .slice(0, 10) + .map((a) => a.name) + .join(', ')}

)}
)} {/* 制作信息 */} -
+
{detailData.countries && detailData.countries.length > 0 && (
-

+

国家/地区

-

+

{detailData.countries.join(', ')}

@@ -1964,11 +2262,11 @@ const DetailPanel: React.FC = ({ {detailData.languages && detailData.languages.length > 0 && (
-

+

语言

-

+

{detailData.languages.join(', ')}

@@ -1976,28 +2274,34 @@ const DetailPanel: React.FC = ({ {detailData.releaseDate && (
-

+

上映日期

-

{detailData.releaseDate}

+

+ {detailData.releaseDate} +

)} {detailData.status && (
-

状态

-

{detailData.status}

+

+ 状态 +

+

+ {detailData.status} +

)}
{/* 季度和集数信息(仅TMDB电视剧) */} {detailData.mediaType === 'tv' && ( -
+
{loadingSeasons && ( -
-
+
+
)} @@ -2005,15 +2309,17 @@ const DetailPanel: React.FC = ({ <> {/* 季度列表 */} {seasonData.seasons.length > 0 && ( -
-

+
+

季度

-
+
{seasonData.seasons.map((season: any) => (
handleSeasonChange(season.season_number)} + onClick={() => + handleSeasonChange(season.season_number) + } className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${ selectedSeason === season.season_number ? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500' @@ -2022,25 +2328,33 @@ const DetailPanel: React.FC = ({ > {season.poster_path && (
{ e.stopPropagation(); - handleImageClick(getTMDBImageUrl(season.poster_path, 'w500')); + handleImageClick( + getTMDBImageUrl( + season.poster_path, + 'w500' + ) + ); }} >
)} -
-

+

+

{season.name}

-

+

{season.episode_count} 集

@@ -2053,8 +2367,10 @@ const DetailPanel: React.FC = ({ {/* 集数列表 */} {seasonData.episodes.length > 0 && (
-

- {seasonData.seasons.find((s: any) => s.season_number === selectedSeason)?.name || `第${selectedSeason}季`} +

+ {seasonData.seasons.find( + (s: any) => s.season_number === selectedSeason + )?.name || `第${selectedSeason}季`}

= ({ onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} - className="overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing" + className='overflow-x-auto -mx-6 px-6 cursor-grab active:cursor-grabbing' style={{ scrollbarWidth: 'thin', - scrollBehavior: isDragging ? 'auto' : 'smooth' + scrollBehavior: isDragging ? 'auto' : 'smooth', }} > -
+
{seasonData.episodes.map((episode: Episode) => { - const isExpanded = expandedEpisodes.has(episode.id); - const isCurrentEpisode = currentEpisode === episode.episode_number; + const isExpanded = expandedEpisodes.has( + episode.id + ); + const isCurrentEpisode = + currentEpisode === episode.episode_number; return (
= ({ ? 'bg-green-100 dark:bg-green-900/30 ring-2 ring-green-500' : 'bg-gray-50 dark:bg-gray-800' }`} - style={{ pointerEvents: isDragging ? 'none' : 'auto' }} + style={{ + pointerEvents: isDragging + ? 'none' + : 'auto', + }} > {episode.still_path && (
handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))} + className='relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity' + onClick={() => + handleImageClick( + getTMDBImageUrl( + episode.still_path, + 'w500' + ) + ) + } >
)} -

- 第{episode.episode_number}集: {episode.name} +

+ 第{episode.episode_number}集:{' '} + {episode.name}

{episode.overview && (

{ - const newExpanded = new Set(expandedEpisodes); + const newExpanded = new Set( + expandedEpisodes + ); if (isExpanded) { newExpanded.delete(episode.id); } else { @@ -2110,13 +2446,15 @@ const DetailPanel: React.FC = ({ } setExpandedEpisodes(newExpanded); }} - className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${isExpanded ? '' : 'line-clamp-3'}`} + className={`text-xs text-gray-600 dark:text-gray-400 cursor-pointer ${ + isExpanded ? '' : 'line-clamp-3' + }`} > {episode.overview}

)} {episode.air_date && ( -

+

{episode.air_date}

)} @@ -2133,11 +2471,13 @@ const DetailPanel: React.FC = ({ )} {/* 数据源显示和切换 */} -
-
-
- 数据来源: - +
+
+
+ + 数据来源: + + {currentSource === 'douban' && 'Douban'} {currentSource === 'bangumi' && 'Bangumi'} {currentSource === 'cms' && 'CMS'} @@ -2148,20 +2488,27 @@ const DetailPanel: React.FC = ({ )} - {currentSource === 'tmdb' && originalSource !== 'tmdb' && originalDetailData && ( - - )} + {currentSource === 'tmdb' && + originalSource !== 'tmdb' && + originalDetailData && ( + + )}
diff --git a/src/components/ProxyImage.tsx b/src/components/ProxyImage.tsx index 251c766..7f9e2fa 100644 --- a/src/components/ProxyImage.tsx +++ b/src/components/ProxyImage.tsx @@ -1,8 +1,12 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { processImageUrl, tryApplyDoubanImageFallback } from '@/lib/utils'; +import { + processImageUrl, + tryApplyBangumiImageFallback, + tryApplyDoubanImageFallback, +} from '@/lib/utils'; interface ProxyImageProps extends React.ImgHTMLAttributes { originalSrc: string; @@ -22,17 +26,49 @@ const ProxyImage: React.FC = ({ src: _src, ...props }) => { + const initialSrc = useMemo( + () => displaySrc || processImageUrl(originalSrc), + [displaySrc, originalSrc] + ); + const [currentSrc, setCurrentSrc] = useState(initialSrc); + const imgRef = useRef(null); + + useEffect(() => { + setCurrentSrc(initialSrc); + }, [initialSrc]); + + useEffect(() => { + if (displaySrc) return; + + const timer = window.setTimeout(() => { + const img = imgRef.current; + if (!img || img.complete || img.dataset.bangumiBackupTried === 'true') { + return; + } + + if (tryApplyBangumiImageFallback(img, originalSrc)) { + setCurrentSrc(img.src); + } + }, 5000); + + return () => window.clearTimeout(timer); + }, [currentSrc, displaySrc, originalSrc]); + const handleError = (e: React.SyntheticEvent) => { const img = e.currentTarget; - if (tryApplyDoubanImageFallback(img, originalSrc)) { + if ( + tryApplyDoubanImageFallback(img, originalSrc) || + tryApplyBangumiImageFallback(img, originalSrc) + ) { + setCurrentSrc(img.src); return; } if (retryOnError && !img.dataset.retried) { img.dataset.retried = 'true'; window.setTimeout(() => { - img.src = displaySrc || processImageUrl(originalSrc); + setCurrentSrc(initialSrc); }, retryDelay); } @@ -42,7 +78,8 @@ const ProxyImage: React.FC = ({ return ( { ); const [doubanDataSourceBackup, setDoubanDataSourceBackup] = useState('direct'); + const [animeDataSource, setAnimeDataSource] = useState('direct'); + const [animeDataSourceBackup, setAnimeDataSourceBackup] = + useState('server-proxy'); + const [animeCustomBaseUrl, setAnimeCustomBaseUrl] = useState(''); + const [animeImageBaseUrl, setAnimeImageBaseUrl] = useState(''); const [doubanImageProxyType, setDoubanImageProxyType] = useState( 'cmliussss-cdn-tencent' ); @@ -164,6 +170,9 @@ export const UserMenu: React.FC = () => { const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false); const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] = useState(false); + const [isAnimeDropdownOpen, setIsAnimeDropdownOpen] = useState(false); + const [isAnimeBackupDropdownOpen, setIsAnimeBackupDropdownOpen] = + useState(false); const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] = useState(false); const [ @@ -277,6 +286,12 @@ export const UserMenu: React.FC = () => { { value: 'custom', label: '自定义代理' }, ]; + const animeDataSourceOptions = [ + { value: 'direct', label: '直连(浏览器直连 Bangumi)' }, + { value: 'server-proxy', label: '服务器代理(由服务器访问 Bangumi)' }, + { value: 'custom-baseurl', label: '自定义 Base URL' }, + ]; + // 豆瓣图片代理选项 const doubanImageProxyTypeOptions = [ { value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' }, @@ -584,6 +599,23 @@ export const UserMenu: React.FC = () => { ); setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || ''); + const savedAnimeDataSource = localStorage.getItem('animeDataSource'); + const defaultAnimeDataSource = + (window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE || 'direct'; + setAnimeDataSource(savedAnimeDataSource || defaultAnimeDataSource); + + const savedAnimeDataSourceBackup = localStorage.getItem( + 'animeDataSourceBackup' + ); + setAnimeDataSourceBackup(savedAnimeDataSourceBackup || 'server-proxy'); + + const savedAnimeCustomBaseUrl = + localStorage.getItem('animeCustomBaseUrl'); + setAnimeCustomBaseUrl(savedAnimeCustomBaseUrl || ''); + + const savedAnimeImageBaseUrl = localStorage.getItem('animeImageBaseUrl'); + setAnimeImageBaseUrl(savedAnimeImageBaseUrl || ''); + const savedDoubanImageProxyType = localStorage.getItem( 'doubanImageProxyType' ); @@ -973,6 +1005,40 @@ export const UserMenu: React.FC = () => { } }, [isDoubanBackupDropdownOpen]); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isAnimeDropdownOpen) { + const target = event.target as Element; + if (!target.closest('[data-dropdown="anime-datasource"]')) { + setIsAnimeDropdownOpen(false); + } + } + }; + + if (isAnimeDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => + document.removeEventListener('mousedown', handleClickOutside); + } + }, [isAnimeDropdownOpen]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isAnimeBackupDropdownOpen) { + const target = event.target as Element; + if (!target.closest('[data-dropdown="anime-datasource-backup"]')) { + setIsAnimeBackupDropdownOpen(false); + } + } + }; + + if (isAnimeBackupDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => + document.removeEventListener('mousedown', handleClickOutside); + } + }, [isAnimeBackupDropdownOpen]); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (isDoubanImageProxyDropdownOpen) { @@ -1325,6 +1391,38 @@ export const UserMenu: React.FC = () => { } }; + const handleAnimeDataSourceChange = (value: string) => { + clearBangumiImageFallbackCache(); + setAnimeDataSource(value); + if (typeof window !== 'undefined') { + localStorage.setItem('animeDataSource', value); + } + }; + + const handleAnimeDataSourceBackupChange = (value: string) => { + clearBangumiImageFallbackCache(); + setAnimeDataSourceBackup(value); + if (typeof window !== 'undefined') { + localStorage.setItem('animeDataSourceBackup', value); + } + }; + + const handleAnimeCustomBaseUrlChange = (value: string) => { + clearBangumiImageFallbackCache(); + setAnimeCustomBaseUrl(value); + if (typeof window !== 'undefined') { + localStorage.setItem('animeCustomBaseUrl', value); + } + }; + + const handleAnimeImageBaseUrlChange = (value: string) => { + clearBangumiImageFallbackCache(); + setAnimeImageBaseUrl(value); + if (typeof window !== 'undefined') { + localStorage.setItem('animeImageBaseUrl', value); + } + }; + const handleDoubanImageProxyTypeChange = (value: string) => { setDoubanImageProxyType(value); if (typeof window !== 'undefined') { @@ -1539,6 +1637,10 @@ export const UserMenu: React.FC = () => { (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || ''; const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false; + const defaultAnimeDataSource = + (window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE || 'direct'; + const defaultAnimeBaseUrl = ''; + const defaultAnimeImageBaseUrl = ''; setDefaultAggregateSearch(true); setEnableOptimization(true); @@ -1550,6 +1652,10 @@ export const UserMenu: React.FC = () => { setDoubanDataSource(defaultDoubanProxyType); setDoubanDataSourceBackup('direct'); setDoubanProxyUrlBackup(''); + setAnimeDataSource(defaultAnimeDataSource); + setAnimeDataSourceBackup('server-proxy'); + setAnimeCustomBaseUrl(defaultAnimeBaseUrl); + setAnimeImageBaseUrl(defaultAnimeImageBaseUrl); setDoubanImageProxyType(defaultDoubanImageProxyType); setDoubanImageProxyUrl(defaultDoubanImageProxyUrl); setDoubanImageProxyTypeBackup('server'); @@ -1581,6 +1687,10 @@ export const UserMenu: React.FC = () => { localStorage.setItem('doubanDataSource', defaultDoubanProxyType); localStorage.setItem('doubanDataSourceBackup', 'direct'); localStorage.setItem('doubanProxyUrlBackup', ''); + localStorage.setItem('animeDataSource', defaultAnimeDataSource); + localStorage.setItem('animeDataSourceBackup', 'server-proxy'); + localStorage.setItem('animeCustomBaseUrl', defaultAnimeBaseUrl); + localStorage.setItem('animeImageBaseUrl', defaultAnimeImageBaseUrl); localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType); localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl); localStorage.setItem('doubanImageProxyTypeBackup', 'server'); @@ -2142,6 +2252,9 @@ export const UserMenu: React.FC = () => { {/* 分割线 */}
+ {/* 分割线 */} +
+ {/* 豆瓣图片代理设置 */}
@@ -2376,6 +2489,185 @@ export const UserMenu: React.FC = () => { } />
+ + {/* 分割线 */} +
+ + {/* 动漫数据源设置 */} +
+
+

+ 动漫数据源 +

+

+ 用于 Bangumi + 新番放送和番剧详情;默认主源直连,备用源服务器代理。 +

+
+ +
+
+ +
+ +
+ +
+ {isAnimeDropdownOpen && ( +
+ {animeDataSourceOptions.map((option) => ( + + ))} +
+ )} +
+
+ +
+ +
+ +
+ +
+ {isAnimeBackupDropdownOpen && ( +
+ {animeDataSourceOptions.map((option) => ( + + ))} +
+ )} +
+
+
+ + {(animeDataSource === 'custom-baseurl' || + animeDataSourceBackup === 'custom-baseurl') && ( +
+ + + handleAnimeCustomBaseUrlChange(e.target.value) + } + /> + {!animeCustomBaseUrl.trim() && ( +

+ 未填写时自定义 Base URL 会自动按 Bangumi + 官方直连处理。 +

+ )} +
+ )} +
+ + + handleAnimeImageBaseUrlChange(e.target.value) + } + /> +

+ 用于替换 Bangumi + 图片域名。只需填写基础部分,不需要填写完整图片路径。 +

+
+
)}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index d26919b..f55b7dd 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1,6 +1,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */ -import { Cloud, ExternalLink, Heart, Info, Link, PlayCircleIcon, Radio, Sparkles, Trash2 } from 'lucide-react'; +import { + Cloud, + ExternalLink, + Heart, + Info, + Link, + PlayCircleIcon, + Radio, + Sparkles, + Trash2, +} from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import React, { @@ -10,6 +20,7 @@ import React, { useEffect, useImperativeHandle, useMemo, + useRef, useState, } from 'react'; @@ -24,8 +35,11 @@ import { import { isNetdiskSource } from '@/lib/netdisk/source'; import { base58Decode, + getBangumiImageFallbackUrl, getDoubanImageFallbackUrl, + markBangumiImageFallbackActive, processImageUrl, + tryApplyBangumiImageFallback, tryApplyDoubanImageFallback, } from '@/lib/utils'; import { useLongPress } from '@/hooks/useLongPress'; @@ -47,7 +61,13 @@ export interface VideoCardProps { source_names?: string[]; progress?: number; year?: string; - from: 'playrecord' | 'favorite' | 'search' | 'douban' | 'tmdb' | 'source-search'; + from: + | 'playrecord' + | 'favorite' + | 'search' + | 'douban' + | 'tmdb' + | 'source-search'; currentEpisode?: number; douban_id?: number; tmdb_id?: number; @@ -78,45 +98,46 @@ export type VideoCardHandle = { setDoubanId: (id?: number) => void; }; -const VideoCard = forwardRef(function VideoCard( - { - id, - title = '', - query = '', - poster = '', - episodes, - source, - source_name, - source_names, - progress = 0, - year, - from, - currentEpisode, - douban_id, - tmdb_id, - onDelete, - rate, - type = '', - isBangumi = false, - isAggregate = false, - origin = 'vod', - releaseDate, - isUpcoming = false, - seasonNumber, - seasonName, - orientation = 'vertical', - playTime, - totalTime, - cmsData, - onBeforeNavigate, - }: VideoCardProps, - ref -) { - const router = useRouter(); - const actualTitle = title; - const actualPoster = poster; - const netdiskPosterPlaceholder = useMemo(() => { - return `data:image/svg+xml;utf8,${encodeURIComponent(` +const VideoCard = forwardRef( + function VideoCard( + { + id, + title = '', + query = '', + poster = '', + episodes, + source, + source_name, + source_names, + progress = 0, + year, + from, + currentEpisode, + douban_id, + tmdb_id, + onDelete, + rate, + type = '', + isBangumi = false, + isAggregate = false, + origin = 'vod', + releaseDate, + isUpcoming = false, + seasonNumber, + seasonName, + orientation = 'vertical', + playTime, + totalTime, + cmsData, + onBeforeNavigate, + }: VideoCardProps, + ref + ) { + const router = useRouter(); + const actualTitle = title; + const actualPoster = poster; + const netdiskPosterPlaceholder = useMemo(() => { + return `data:image/svg+xml;utf8,${encodeURIComponent(` @@ -124,1232 +145,1092 @@ const VideoCard = forwardRef(function VideoCard `)}`; - }, []); - const processedPoster = useMemo( - () => - actualPoster - ? processImageUrl(actualPoster) - : isNetdiskSource(source) + }, []); + const processedPoster = useMemo( + () => + actualPoster + ? processImageUrl(actualPoster) + : isNetdiskSource(source) ? netdiskPosterPlaceholder : '', - [actualPoster, source, netdiskPosterPlaceholder] - ); - const [favorited, setFavorited] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [showMobileActions, setShowMobileActions] = useState(false); - const [searchFavorited, setSearchFavorited] = useState(null); // 搜索结果的收藏状态 - const [showAIChat, setShowAIChat] = useState(false); - const [isAIStreaming, setIsAIStreaming] = useState(false); - const [aiEnabled, setAiEnabled] = useState(false); - const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState(''); - const [showDetailPanel, setShowDetailPanel] = useState(false); - const [showImageViewer, setShowImageViewer] = useState(false); - const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示 - const [displayPoster, setDisplayPoster] = useState(processedPoster); + [actualPoster, source, netdiskPosterPlaceholder] + ); + const [favorited, setFavorited] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [showMobileActions, setShowMobileActions] = useState(false); + const [searchFavorited, setSearchFavorited] = useState( + null + ); // 搜索结果的收藏状态 + const [showAIChat, setShowAIChat] = useState(false); + const [isAIStreaming, setIsAIStreaming] = useState(false); + const [aiEnabled, setAiEnabled] = useState(false); + const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = + useState(''); + const [showDetailPanel, setShowDetailPanel] = useState(false); + const [showImageViewer, setShowImageViewer] = useState(false); + const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示 + const [displayPoster, setDisplayPoster] = useState(processedPoster); - // 检查AI功能是否启用 - useEffect(() => { - if (typeof window !== 'undefined') { - const enabled = - (window as any).RUNTIME_CONFIG?.AI_ENABLED && - (window as any).RUNTIME_CONFIG?.AI_ENABLE_VIDEOCARD_ENTRY; - setAiEnabled(enabled); + // 检查AI功能是否启用 + useEffect(() => { + if (typeof window !== 'undefined') { + const enabled = + (window as any).RUNTIME_CONFIG?.AI_ENABLED && + (window as any).RUNTIME_CONFIG?.AI_ENABLE_VIDEOCARD_ENTRY; + setAiEnabled(enabled); - // 加载AI默认消息配置 - const defaultMsg = (window as any).RUNTIME_CONFIG?.AI_DEFAULT_MESSAGE_WITH_VIDEO; - if (defaultMsg) { - setAiDefaultMessageWithVideo(defaultMsg); + // 加载AI默认消息配置 + const defaultMsg = (window as any).RUNTIME_CONFIG + ?.AI_DEFAULT_MESSAGE_WITH_VIDEO; + if (defaultMsg) { + setAiDefaultMessageWithVideo(defaultMsg); + } } - } - }, []); + }, []); - // 可外部修改的可控字段 - const [dynamicEpisodes, setDynamicEpisodes] = useState( - episodes - ); - const [dynamicSourceNames, setDynamicSourceNames] = useState( - source_names - ); - const [dynamicDoubanId, setDynamicDoubanId] = useState( - douban_id - ); - - useEffect(() => { - setDynamicEpisodes(episodes); - }, [episodes]); - - useEffect(() => { - setDynamicSourceNames(source_names); - }, [source_names]); - - useEffect(() => { - setDynamicDoubanId(douban_id); - }, [douban_id]); - - useEffect(() => { - setDisplayPoster(processedPoster); - }, [processedPoster]); - - useImperativeHandle(ref, () => ({ - setEpisodes: (eps?: number) => setDynamicEpisodes(eps), - setSourceNames: (names?: string[]) => setDynamicSourceNames(names), - setDoubanId: (id?: number) => setDynamicDoubanId(id), - })); - - const actualSource = source; - const actualId = id; - const actualDoubanId = dynamicDoubanId; - const actualEpisodes = dynamicEpisodes; - const actualYear = year; - const actualQuery = query || ''; - const actualSearchType = type; - const isDirectPlaySource = actualSource === 'directplay'; - const directLinkUrl = useMemo(() => { - if (!isDirectPlaySource || !actualId) return ''; - try { - return base58Decode(actualId); - } catch { - return ''; - } - }, [isDirectPlaySource, actualId]); - const displayYear = useMemo(() => { - if (!actualYear) return ''; - const normalized = actualYear.trim(); - if (!normalized || normalized === 'unknown') return ''; - const digits = normalized.replace(/\D/g, ''); - if (!digits) return normalized; - return digits.slice(-2).padStart(2, '0'); - }, [actualYear]); - - // 获取收藏状态(搜索结果页面不检查) - useEffect(() => { - if (from === 'douban' || from === 'search' || !actualSource || !actualId) return; - - const fetchFavoriteStatus = async () => { - try { - const fav = await isFavorited(actualSource, actualId); - setFavorited(fav); - } catch (err) { - throw new Error('检查收藏状态失败'); - } - }; - - fetchFavoriteStatus(); - - // 监听收藏状态更新事件 - const storageKey = generateStorageKey(actualSource, actualId); - const unsubscribe = subscribeToDataUpdates( - 'favoritesUpdated', - (newFavorites: Record) => { - // 检查当前项目是否在新的收藏列表中 - const isNowFavorited = !!newFavorites[storageKey]; - setFavorited(isNowFavorited); - } + // 可外部修改的可控字段 + const [dynamicEpisodes, setDynamicEpisodes] = useState( + episodes + ); + const [dynamicSourceNames, setDynamicSourceNames] = useState< + string[] | undefined + >(source_names); + const [dynamicDoubanId, setDynamicDoubanId] = useState( + douban_id ); - return unsubscribe; - }, [from, actualSource, actualId]); + useEffect(() => { + setDynamicEpisodes(episodes); + }, [episodes]); - const handleToggleFavorite = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (from === 'douban' || !actualSource || !actualId) return; + useEffect(() => { + setDynamicSourceNames(source_names); + }, [source_names]); - try { - // 确定当前收藏状态 - const currentFavorited = from === 'search' ? searchFavorited : favorited; + useEffect(() => { + setDynamicDoubanId(douban_id); + }, [douban_id]); - if (currentFavorited) { - // 如果已收藏,删除收藏 - await deleteFavorite(actualSource, actualId); - if (from === 'search') { - setSearchFavorited(false); - } else { - setFavorited(false); - } - } else { - // 如果未收藏,添加收藏 - await saveFavorite(actualSource, actualId, { - title: actualTitle, - source_name: source_name || '', - year: actualYear || '', - cover: actualPoster, - total_episodes: actualEpisodes ?? 1, - save_time: Date.now(), - }); - if (from === 'search') { - setSearchFavorited(true); - } else { - setFavorited(true); - } - } - } catch (err) { - throw new Error('切换收藏状态失败'); + useEffect(() => { + setDisplayPoster(processedPoster); + }, [processedPoster]); + + const bangumiImageTimeoutRef = useRef | null>( + null + ); + + useEffect(() => { + if (bangumiImageTimeoutRef.current) { + clearTimeout(bangumiImageTimeoutRef.current); + bangumiImageTimeoutRef.current = null; } - }, - [ + + if (!actualPoster) return; + + const bangumiFallbackPoster = getBangumiImageFallbackUrl(actualPoster); + if (!bangumiFallbackPoster || displayPoster === bangumiFallbackPoster) { + return; + } + + bangumiImageTimeoutRef.current = setTimeout(() => { + markBangumiImageFallbackActive(); + setDisplayPoster((current) => + current === bangumiFallbackPoster ? current : bangumiFallbackPoster + ); + }, 5000); + + return () => { + if (bangumiImageTimeoutRef.current) { + clearTimeout(bangumiImageTimeoutRef.current); + bangumiImageTimeoutRef.current = null; + } + }; + }, [actualPoster, displayPoster]); + + const clearBangumiImageTimeout = useCallback(() => { + if (bangumiImageTimeoutRef.current) { + clearTimeout(bangumiImageTimeoutRef.current); + bangumiImageTimeoutRef.current = null; + } + }, []); + + useImperativeHandle(ref, () => ({ + setEpisodes: (eps?: number) => setDynamicEpisodes(eps), + setSourceNames: (names?: string[]) => setDynamicSourceNames(names), + setDoubanId: (id?: number) => setDynamicDoubanId(id), + })); + + const actualSource = source; + const actualId = id; + const actualDoubanId = dynamicDoubanId; + const actualEpisodes = dynamicEpisodes; + const actualYear = year; + const actualQuery = query || ''; + const actualSearchType = type; + const isDirectPlaySource = actualSource === 'directplay'; + const directLinkUrl = useMemo(() => { + if (!isDirectPlaySource || !actualId) return ''; + try { + return base58Decode(actualId); + } catch { + return ''; + } + }, [isDirectPlaySource, actualId]); + const displayYear = useMemo(() => { + if (!actualYear) return ''; + const normalized = actualYear.trim(); + if (!normalized || normalized === 'unknown') return ''; + const digits = normalized.replace(/\D/g, ''); + if (!digits) return normalized; + return digits.slice(-2).padStart(2, '0'); + }, [actualYear]); + + // 获取收藏状态(搜索结果页面不检查) + useEffect(() => { + if (from === 'douban' || from === 'search' || !actualSource || !actualId) + return; + + const fetchFavoriteStatus = async () => { + try { + const fav = await isFavorited(actualSource, actualId); + setFavorited(fav); + } catch (err) { + throw new Error('检查收藏状态失败'); + } + }; + + fetchFavoriteStatus(); + + // 监听收藏状态更新事件 + const storageKey = generateStorageKey(actualSource, actualId); + const unsubscribe = subscribeToDataUpdates( + 'favoritesUpdated', + (newFavorites: Record) => { + // 检查当前项目是否在新的收藏列表中 + const isNowFavorited = !!newFavorites[storageKey]; + setFavorited(isNowFavorited); + } + ); + + return unsubscribe; + }, [from, actualSource, actualId]); + + const handleToggleFavorite = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (from === 'douban' || !actualSource || !actualId) return; + + try { + // 确定当前收藏状态 + const currentFavorited = + from === 'search' ? searchFavorited : favorited; + + if (currentFavorited) { + // 如果已收藏,删除收藏 + await deleteFavorite(actualSource, actualId); + if (from === 'search') { + setSearchFavorited(false); + } else { + setFavorited(false); + } + } else { + // 如果未收藏,添加收藏 + await saveFavorite(actualSource, actualId, { + title: actualTitle, + source_name: source_name || '', + year: actualYear || '', + cover: actualPoster, + total_episodes: actualEpisodes ?? 1, + save_time: Date.now(), + }); + if (from === 'search') { + setSearchFavorited(true); + } else { + setFavorited(true); + } + } + } catch (err) { + throw new Error('切换收藏状态失败'); + } + }, + [ + from, + actualSource, + actualId, + actualTitle, + source_name, + actualYear, + actualPoster, + actualEpisodes, + favorited, + searchFavorited, + ] + ); + + const handleDeleteRecord = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (from !== 'playrecord' || !actualSource || !actualId) return; + try { + await deletePlayRecord(actualSource, actualId); + onDelete?.(); + } catch (err) { + throw new Error('删除播放记录失败'); + } + }, + [from, actualSource, actualId, onDelete] + ); + + const handleClick = useCallback(() => { + // 即将上映的电影:单击显示上映倒计时提示,不跳转 + if (isUpcoming) { + setShowUpcomingInfo(true); + // 2秒后自动隐藏 + setTimeout(() => { + setShowUpcomingInfo(false); + }, 2000); + return; + } + + onBeforeNavigate?.(); + + if (origin === 'live' && actualSource && actualId) { + // 直播内容跳转到直播页面 + const url = `/live?source=${actualSource.replace( + 'live_', + '' + )}&id=${actualId.replace('live_', '')}`; + router.push(url); + } else if ( + from === 'douban' || + from === 'tmdb' || + (isAggregate && !actualSource && !actualId) + ) { + // 检测当前是否在 play 页面 + const isCurrentlyOnPlayPage = + typeof window !== 'undefined' && window.location.pathname === '/play'; + + let url = `/play?title=${encodeURIComponent(actualTitle.trim())}${ + actualYear ? `&year=${actualYear}` : '' + }${actualSearchType ? `&stype=${actualSearchType}` : ''}${ + isAggregate ? '&prefer=true' : '' + }${ + actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' + }`; + + if (isCurrentlyOnPlayPage) { + // 在 play 页面内,添加 _reload 参数强制刷新 + url += `&_reload=${Date.now()}`; + window.location.href = url; + } else { + // 不在 play 页面,正常跳转 + router.push(url); + } + } else if (actualSource && actualId) { + // 检测当前是否在 play 页面 + const isCurrentlyOnPlayPage = + typeof window !== 'undefined' && window.location.pathname === '/play'; + + let url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( + actualTitle + )}${actualYear ? `&year=${actualYear}` : ''}${ + isAggregate ? '&prefer=true' : '' + }${ + actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' + }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; + + if (isCurrentlyOnPlayPage) { + // 在 play 页面内,添加 _reload 参数强制刷新 + url += `&_reload=${Date.now()}`; + window.location.href = url; + } else { + // 不在 play 页面,正常跳转 + router.push(url); + } + } + }, [ + isUpcoming, + origin, + from, + actualSource, + actualId, + router, + actualTitle, + actualYear, + isAggregate, + actualQuery, + actualSearchType, + onBeforeNavigate, + ]); + + // 新标签页播放处理函数 + const handlePlayInNewTab = useCallback(() => { + // 即将上映的电影不跳转 + if (isUpcoming) { + return; + } + + onBeforeNavigate?.(); + + if (origin === 'live' && actualSource && actualId) { + // 直播内容跳转到直播页面 + const url = `/live?source=${actualSource.replace( + 'live_', + '' + )}&id=${actualId.replace('live_', '')}`; + window.open(url, '_blank'); + } else if ( + from === 'douban' || + from === 'tmdb' || + (isAggregate && !actualSource && !actualId) + ) { + const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${ + actualYear ? `&year=${actualYear}` : '' + }${actualSearchType ? `&stype=${actualSearchType}` : ''}${ + isAggregate ? '&prefer=true' : '' + }${ + actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' + }`; + window.open(url, '_blank'); + } else if (actualSource && actualId) { + const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( + actualTitle + )}${actualYear ? `&year=${actualYear}` : ''}${ + isAggregate ? '&prefer=true' : '' + }${ + actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' + }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; + window.open(url, '_blank'); + } + }, [ + isUpcoming, + origin, from, actualSource, actualId, actualTitle, - source_name, actualYear, - actualPoster, - actualEpisodes, - favorited, + isAggregate, + actualQuery, + actualSearchType, + onBeforeNavigate, + ]); + + // 检查搜索结果的收藏状态 + const checkSearchFavoriteStatus = useCallback(async () => { + if ( + from === 'search' && + !isAggregate && + actualSource && + actualId && + searchFavorited === null + ) { + try { + const fav = await isFavorited(actualSource, actualId); + setSearchFavorited(fav); + } catch (err) { + setSearchFavorited(false); + } + } + }, [from, isAggregate, actualSource, actualId, searchFavorited]); + + // 长按操作 + const handleLongPress = useCallback(() => { + if (!showMobileActions) { + // 防止重复触发 + // 立即显示菜单,避免等待数据加载导致动画卡顿 + setShowMobileActions(true); + + // 异步检查收藏状态,不阻塞菜单显示 + if ( + from === 'search' && + !isAggregate && + actualSource && + actualId && + searchFavorited === null + ) { + checkSearchFavoriteStatus(); + } + } + }, [ + showMobileActions, + from, + isAggregate, + actualSource, + actualId, searchFavorited, - ] - ); + checkSearchFavoriteStatus, + ]); - const handleDeleteRecord = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (from !== 'playrecord' || !actualSource || !actualId) return; - try { - await deletePlayRecord(actualSource, actualId); - onDelete?.(); - } catch (err) { - throw new Error('删除播放记录失败'); + // 长按手势hook + const longPressProps = useLongPress({ + onLongPress: handleLongPress, + onClick: handleClick, // 保持点击播放功能 + longPressDelay: 500, + }); + + // 计算距离上映的天数(使用本地时区) + const daysUntilRelease = useMemo(() => { + if (!isUpcoming || !releaseDate) return null; + + // 获取今天的本地日期(午夜) + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String( + today.getMonth() + 1 + ).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + // 将日期字符串解析为本地时区的日期对象 + // 使用 'YYYY-MM-DD' 格式直接构造,避免 UTC 解析问题 + const [releaseYear, releaseMonth, releaseDay] = releaseDate + .split('-') + .map(Number); + const release = new Date(releaseYear, releaseMonth - 1, releaseDay); + + const [todayYear, todayMonth, todayDay] = todayStr.split('-').map(Number); + const todayDate = new Date(todayYear, todayMonth - 1, todayDay); + + const diffTime = release.getTime() - todayDate.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + return diffDays; + }, [isUpcoming, releaseDate]); + + const config = useMemo(() => { + const configs = { + playrecord: { + showSourceName: true, + showProgress: true, + showPlayButton: true, + showHeart: true, + showCheckCircle: true, + showDoubanLink: false, + showRating: false, + showYear: false, + }, + favorite: { + showSourceName: true, + showProgress: false, + showPlayButton: true, + showHeart: true, + showCheckCircle: false, + showDoubanLink: false, + showRating: false, + showYear: false, + }, + search: { + showSourceName: true, + showProgress: false, + showPlayButton: true, + showHeart: true, // 移动端菜单中需要显示收藏选项 + showCheckCircle: false, + showDoubanLink: true, // 移动端菜单中显示豆瓣链接 + showRating: !!rate, + showYear: true, + }, + douban: { + showSourceName: false, + showProgress: false, + showPlayButton: !isUpcoming, // 即将上映不显示播放按钮 + showHeart: false, + showCheckCircle: false, + showDoubanLink: false, + showRating: !!rate, + showYear: false, + }, + tmdb: { + showSourceName: false, + showProgress: false, + showPlayButton: !isUpcoming, // 即将上映不显示播放按钮 + showHeart: false, + showCheckCircle: false, + showDoubanLink: false, + showRating: !!rate, + showYear: false, + }, + 'source-search': { + showSourceName: false, + showProgress: false, + showPlayButton: true, + showHeart: true, + showCheckCircle: false, + showDoubanLink: true, + showRating: !!rate, + showYear: true, + }, + }; + return configs[from] || configs.search; + }, [from, isAggregate, douban_id, rate, isUpcoming]); + + // 移动端操作菜单配置 + const mobileActions = useMemo(() => { + const actions = []; + + // 播放操作 + if (config.showPlayButton) { + actions.push({ + id: 'play', + label: origin === 'live' ? '观看直播' : '播放', + icon: , + onClick: handleClick, + color: 'primary' as const, + }); + + // 新标签页播放 + actions.push({ + id: 'play-new-tab', + label: origin === 'live' ? '新标签页观看' : '新标签页播放', + icon: , + onClick: handlePlayInNewTab, + color: 'default' as const, + }); } - }, - [from, actualSource, actualId, onDelete] - ); - const handleClick = useCallback(() => { - // 即将上映的电影:单击显示上映倒计时提示,不跳转 - if (isUpcoming) { - setShowUpcomingInfo(true); - // 2秒后自动隐藏 - setTimeout(() => { - setShowUpcomingInfo(false); - }, 2000); - return; - } + // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项 - onBeforeNavigate?.(); + // 收藏/取消收藏操作 + if ( + config.showHeart && + from !== 'douban' && + from !== 'tmdb' && + actualSource && + actualId + ) { + const currentFavorited = + from === 'search' ? searchFavorited : favorited; - if (origin === 'live' && actualSource && actualId) { - // 直播内容跳转到直播页面 - const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; - router.push(url); - } else if (from === 'douban' || from === 'tmdb' || (isAggregate && !actualSource && !actualId)) { - // 检测当前是否在 play 页面 - const isCurrentlyOnPlayPage = typeof window !== 'undefined' && window.location.pathname === '/play'; - - let url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : '' - }${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; - - if (isCurrentlyOnPlayPage) { - // 在 play 页面内,添加 _reload 参数强制刷新 - url += `&_reload=${Date.now()}`; - window.location.href = url; - } else { - // 不在 play 页面,正常跳转 - router.push(url); - } - } else if (actualSource && actualId) { - // 检测当前是否在 play 页面 - const isCurrentlyOnPlayPage = typeof window !== 'undefined' && window.location.pathname === '/play'; - - let url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( - actualTitle - )}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' - }${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' - }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; - - if (isCurrentlyOnPlayPage) { - // 在 play 页面内,添加 _reload 参数强制刷新 - url += `&_reload=${Date.now()}`; - window.location.href = url; - } else { - // 不在 play 页面,正常跳转 - router.push(url); - } - } - }, [ - isUpcoming, - origin, - from, - actualSource, - actualId, - router, - actualTitle, - actualYear, - isAggregate, - actualQuery, - actualSearchType, - onBeforeNavigate, - ]); - - // 新标签页播放处理函数 - const handlePlayInNewTab = useCallback(() => { - // 即将上映的电影不跳转 - if (isUpcoming) { - return; - } - - onBeforeNavigate?.(); - - if (origin === 'live' && actualSource && actualId) { - // 直播内容跳转到直播页面 - const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; - window.open(url, '_blank'); - } else if (from === 'douban' || from === 'tmdb' || (isAggregate && !actualSource && !actualId)) { - const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; - window.open(url, '_blank'); - } else if (actualSource && actualId) { - const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( - actualTitle - )}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' - }${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' - }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; - window.open(url, '_blank'); - } - }, [ - isUpcoming, - origin, - from, - actualSource, - actualId, - actualTitle, - actualYear, - isAggregate, - actualQuery, - actualSearchType, - onBeforeNavigate, - ]); - - // 检查搜索结果的收藏状态 - const checkSearchFavoriteStatus = useCallback(async () => { - if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { - try { - const fav = await isFavorited(actualSource, actualId); - setSearchFavorited(fav); - } catch (err) { - setSearchFavorited(false); - } - } - }, [from, isAggregate, actualSource, actualId, searchFavorited]); - - // 长按操作 - const handleLongPress = useCallback(() => { - if (!showMobileActions) { // 防止重复触发 - // 立即显示菜单,避免等待数据加载导致动画卡顿 - setShowMobileActions(true); - - // 异步检查收藏状态,不阻塞菜单显示 - if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { - checkSearchFavoriteStatus(); - } - } - }, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]); - - // 长按手势hook - const longPressProps = useLongPress({ - onLongPress: handleLongPress, - onClick: handleClick, // 保持点击播放功能 - longPressDelay: 500, - }); - - // 计算距离上映的天数(使用本地时区) - const daysUntilRelease = useMemo(() => { - if (!isUpcoming || !releaseDate) return null; - - // 获取今天的本地日期(午夜) - const today = new Date(); - const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; - - // 将日期字符串解析为本地时区的日期对象 - // 使用 'YYYY-MM-DD' 格式直接构造,避免 UTC 解析问题 - const [releaseYear, releaseMonth, releaseDay] = releaseDate.split('-').map(Number); - const release = new Date(releaseYear, releaseMonth - 1, releaseDay); - - const [todayYear, todayMonth, todayDay] = todayStr.split('-').map(Number); - const todayDate = new Date(todayYear, todayMonth - 1, todayDay); - - const diffTime = release.getTime() - todayDate.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - return diffDays; - }, [isUpcoming, releaseDate]); - - const config = useMemo(() => { - const configs = { - playrecord: { - showSourceName: true, - showProgress: true, - showPlayButton: true, - showHeart: true, - showCheckCircle: true, - showDoubanLink: false, - showRating: false, - showYear: false, - }, - favorite: { - showSourceName: true, - showProgress: false, - showPlayButton: true, - showHeart: true, - showCheckCircle: false, - showDoubanLink: false, - showRating: false, - showYear: false, - }, - search: { - showSourceName: true, - showProgress: false, - showPlayButton: true, - showHeart: true, // 移动端菜单中需要显示收藏选项 - showCheckCircle: false, - showDoubanLink: true, // 移动端菜单中显示豆瓣链接 - showRating: !!rate, - showYear: true, - }, - douban: { - showSourceName: false, - showProgress: false, - showPlayButton: !isUpcoming, // 即将上映不显示播放按钮 - showHeart: false, - showCheckCircle: false, - showDoubanLink: false, - showRating: !!rate, - showYear: false, - }, - tmdb: { - showSourceName: false, - showProgress: false, - showPlayButton: !isUpcoming, // 即将上映不显示播放按钮 - showHeart: false, - showCheckCircle: false, - showDoubanLink: false, - showRating: !!rate, - showYear: false, - }, - 'source-search': { - showSourceName: false, - showProgress: false, - showPlayButton: true, - showHeart: true, - showCheckCircle: false, - showDoubanLink: true, - showRating: !!rate, - showYear: true, - }, - }; - return configs[from] || configs.search; - }, [from, isAggregate, douban_id, rate, isUpcoming]); - - // 移动端操作菜单配置 - const mobileActions = useMemo(() => { - const actions = []; - - // 播放操作 - if (config.showPlayButton) { - actions.push({ - id: 'play', - label: origin === 'live' ? '观看直播' : '播放', - icon: , - onClick: handleClick, - color: 'primary' as const, - }); - - // 新标签页播放 - actions.push({ - id: 'play-new-tab', - label: origin === 'live' ? '新标签页观看' : '新标签页播放', - icon: , - onClick: handlePlayInNewTab, - color: 'default' as const, - }); - } - - // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项 - - // 收藏/取消收藏操作 - if (config.showHeart && from !== 'douban' && from !== 'tmdb' && actualSource && actualId) { - const currentFavorited = from === 'search' ? searchFavorited : favorited; - - if (from === 'search') { - // 搜索结果:根据加载状态显示不同的选项 - if (searchFavorited !== null) { - // 已加载完成,显示实际的收藏状态 + if (from === 'search') { + // 搜索结果:根据加载状态显示不同的选项 + if (searchFavorited !== null) { + // 已加载完成,显示实际的收藏状态 + actions.push({ + id: 'favorite', + label: currentFavorited ? '取消收藏' : '添加收藏', + icon: currentFavorited ? ( + + ) : ( + + ), + onClick: () => { + const mockEvent = { + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.MouseEvent; + handleToggleFavorite(mockEvent); + }, + color: currentFavorited + ? ('danger' as const) + : ('default' as const), + }); + } else { + // 正在加载中,显示占位项 + actions.push({ + id: 'favorite-loading', + label: '收藏加载中...', + icon: , + onClick: () => {}, // 加载中时不响应点击 + disabled: true, + }); + } + } else { + // 非搜索结果:直接显示收藏选项 actions.push({ id: 'favorite', label: currentFavorited ? '取消收藏' : '添加收藏', icon: currentFavorited ? ( - + ) : ( - + ), onClick: () => { const mockEvent = { - preventDefault: () => { }, - stopPropagation: () => { }, + preventDefault: () => {}, + stopPropagation: () => {}, } as React.MouseEvent; handleToggleFavorite(mockEvent); }, - color: currentFavorited ? ('danger' as const) : ('default' as const), - }); - } else { - // 正在加载中,显示占位项 - actions.push({ - id: 'favorite-loading', - label: '收藏加载中...', - icon: , - onClick: () => { }, // 加载中时不响应点击 - disabled: true, + color: currentFavorited + ? ('danger' as const) + : ('default' as const), }); } - } else { - // 非搜索结果:直接显示收藏选项 + } + + // 删除播放记录操作 + if ( + config.showCheckCircle && + from === 'playrecord' && + actualSource && + actualId + ) { actions.push({ - id: 'favorite', - label: currentFavorited ? '取消收藏' : '添加收藏', - icon: currentFavorited ? ( - - ) : ( - - ), + id: 'delete', + label: '删除记录', + icon: , onClick: () => { const mockEvent = { - preventDefault: () => { }, - stopPropagation: () => { }, + preventDefault: () => {}, + stopPropagation: () => {}, } as React.MouseEvent; - handleToggleFavorite(mockEvent); + handleDeleteRecord(mockEvent); }, - color: currentFavorited ? ('danger' as const) : ('default' as const), + color: 'danger' as const, }); } - } - // 删除播放记录操作 - if (config.showCheckCircle && from === 'playrecord' && actualSource && actualId) { - actions.push({ - id: 'delete', - label: '删除记录', - icon: , - onClick: () => { - const mockEvent = { - preventDefault: () => { }, - stopPropagation: () => { }, - } as React.MouseEvent; - handleDeleteRecord(mockEvent); - }, - color: 'danger' as const, - }); - } + // 豆瓣链接操作 + if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) { + actions.push({ + id: 'douban', + label: isBangumi ? 'Bangumi 详情' : '豆瓣详情', + icon: , + onClick: () => { + const url = isBangumi + ? `https://bgm.tv/subject/${actualDoubanId.toString()}` + : `https://movie.douban.com/subject/${actualDoubanId.toString()}`; + window.open(url, '_blank', 'noopener,noreferrer'); + }, + color: 'default' as const, + }); + } - // 豆瓣链接操作 - if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) { - actions.push({ - id: 'douban', - label: isBangumi ? 'Bangumi 详情' : '豆瓣详情', - icon: , - onClick: () => { - const url = isBangumi - ? `https://bgm.tv/subject/${actualDoubanId.toString()}` - : `https://movie.douban.com/subject/${actualDoubanId.toString()}`; - window.open(url, '_blank', 'noopener,noreferrer'); - }, - color: 'default' as const, - }); - } + // 详情页面按钮(直播源不显示详情) + if (origin !== 'live') { + actions.push({ + id: 'detail', + label: '详情', + icon: , + onClick: () => { + setShowMobileActions(false); + // 延迟打开 DetailPanel,确保 MobileActionSheet 完全清理完成 + setTimeout(() => { + setShowDetailPanel(true); + }, 250); + }, + color: 'default' as const, + }); + } - // 详情页面按钮(直播源不显示详情) - if (origin !== 'live') { - actions.push({ - id: 'detail', - label: '详情', - icon: , - onClick: () => { - setShowMobileActions(false); - // 延迟打开 DetailPanel,确保 MobileActionSheet 完全清理完成 - setTimeout(() => { - setShowDetailPanel(true); - }, 250); - }, - color: 'default' as const, - }); - } + // AI问片功能 + if (aiEnabled && actualTitle) { + actions.push({ + id: 'ai-chat', + label: 'AI问片', + icon: , + onClick: () => { + setShowMobileActions(false); + // 延迟打开 AIChatPanel,确保 MobileActionSheet 完全清理完成 + setTimeout(() => { + setShowAIChat(true); + }, 250); + }, + color: 'default' as const, + }); + } - // AI问片功能 - if (aiEnabled && actualTitle) { - actions.push({ - id: 'ai-chat', - label: 'AI问片', - icon: , - onClick: () => { - setShowMobileActions(false); - // 延迟打开 AIChatPanel,确保 MobileActionSheet 完全清理完成 - setTimeout(() => { - setShowAIChat(true); - }, 250); - }, - color: 'default' as const, - }); - } + return actions; + }, [ + config, + from, + actualSource, + actualId, + favorited, + searchFavorited, + actualDoubanId, + isBangumi, + isAggregate, + dynamicSourceNames, + handleClick, + handleToggleFavorite, + handleDeleteRecord, + handlePlayInNewTab, + aiEnabled, + actualTitle, + ]); - return actions; - }, [ - config, - from, - actualSource, - actualId, - favorited, - searchFavorited, - actualDoubanId, - isBangumi, - isAggregate, - dynamicSourceNames, - handleClick, - handleToggleFavorite, - handleDeleteRecord, - handlePlayInNewTab, - aiEnabled, - actualTitle, - ]); - - return ( - <> -
{ - // 阻止默认右键菜单 - e.preventDefault(); - e.stopPropagation(); - - // 右键弹出操作菜单 - setShowMobileActions(true); - - // 异步检查收藏状态,不阻塞菜单显示 - if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { - checkSearchFavoriteStatus(); - } - - return false; - }} - - onDragStart={(e) => { - // 阻止拖拽 - e.preventDefault(); - return false; - }} - > - {/* 海报容器 */} + return ( + <>
{ + // 阻止默认右键菜单 + e.preventDefault(); + e.stopPropagation(); + + // 右键弹出操作菜单 + setShowMobileActions(true); + + // 异步检查收藏状态,不阻塞菜单显示 + if ( + from === 'search' && + !isAggregate && + actualSource && + actualId && + searchFavorited === null + ) { + checkSearchFavoriteStatus(); + } + + return false; + }} + onDragStart={(e) => { + // 阻止拖拽 e.preventDefault(); return false; }} > - {/* 骨架屏 */} - {!isLoading && !isDirectPlaySource && } - {isDirectPlaySource ? ( -
- -
- ) : (isNetdiskSource(actualSource) && !actualPoster && displayPoster === netdiskPosterPlaceholder) ? ( -
- -
- ) : ( - {actualTitle} setIsLoading(true)} - onClick={(e) => { - e.stopPropagation(); - setShowImageViewer(true); - }} - onError={(e) => { - const img = e.currentTarget as HTMLImageElement; - const fallbackPoster = getDoubanImageFallbackUrl(actualPoster); - if (fallbackPoster && tryApplyDoubanImageFallback(img, actualPoster)) { - setDisplayPoster(fallbackPoster); - return; - } - - // 图片加载失败时的重试机制 - if (!img.dataset.retried) { - img.dataset.retried = 'true'; - setTimeout(() => { - setDisplayPoster(processedPoster); - img.src = processedPoster; - }, 2000); - } - }} - style={{ - // 禁用图片的默认长按效果 + {/* 海报容器 */} +
{ - e.preventDefault(); - return false; - }} - onDragStart={(e) => { - e.preventDefault(); - return false; - }} - /> - )} - - {/* 悬浮遮罩 */} -
{ e.preventDefault(); return false; }} - /> - - {/* 播放按钮或上映倒计时 */} - {isUpcoming && daysUntilRelease !== null ? ( -
{ - e.preventDefault(); - return false; - }} - > -
- {daysUntilRelease > 0 - ? `${daysUntilRelease}天后上映` - : daysUntilRelease === 0 - ? '今日上映' - : '已上映'} + > + {/* 骨架屏 */} + {!isLoading && !isDirectPlaySource && ( + + )} + {isDirectPlaySource ? ( +
+
-
- ) : config.showPlayButton && ( -
{ - e.preventDefault(); - return false; - }} - > - + +
+ ) : ( + {actualTitle} { + setIsLoading(true); + clearBangumiImageTimeout(); + }} + onClick={(e) => { + e.stopPropagation(); + setShowImageViewer(true); + }} + onError={(e) => { + const img = e.currentTarget as HTMLImageElement; + const doubanFallbackPoster = + getDoubanImageFallbackUrl(actualPoster); + if ( + doubanFallbackPoster && + tryApplyDoubanImageFallback(img, actualPoster) + ) { + clearBangumiImageTimeout(); + setDisplayPoster(doubanFallbackPoster); + return; + } + + const bangumiFallbackPoster = + getBangumiImageFallbackUrl(actualPoster); + if ( + bangumiFallbackPoster && + tryApplyBangumiImageFallback(img, actualPoster) + ) { + clearBangumiImageTimeout(); + setDisplayPoster(bangumiFallbackPoster); + return; + } + + // 图片加载失败时的重试机制 + if (!img.dataset.retried) { + img.dataset.retried = 'true'; + setTimeout(() => { + setDisplayPoster(processedPoster); + img.src = processedPoster; + }, 2000); + } + }} + style={ + { + // 禁用图片的默认长按效果 + WebkitUserSelect: 'none', + userSelect: 'none', + WebkitTouchCallout: 'none', + pointerEvents: 'auto', // 改为auto以响应点击事件 + cursor: 'pointer', // 添加指针样式 + } as React.CSSProperties + } onContextMenu={(e) => { e.preventDefault(); return false; }} + onDragStart={(e) => { + e.preventDefault(); + return false; + }} /> -
- )} + )} - {/* 操作按钮 - 继续观看不显示桌面端悬停按钮 */} - {(config.showHeart || config.showCheckCircle) && from !== 'playrecord' && ( + {/* 悬浮遮罩 */}
{ - e.preventDefault(); - return false; - }} - > - {config.showCheckCircle && ( - { - e.preventDefault(); - return false; - }} - /> - )} - {config.showHeart && from !== 'search' && ( - { - e.preventDefault(); - return false; - }} - /> - )} -
- )} - - - {/* 季度徽章 */} - {seasonNumber && ( -
{ - e.preventDefault(); - return false; - }} - title={seasonName || `第${seasonNumber}季`} - > - S{seasonNumber} -
- )} - - {/* 徽章 */} - {config.showRating && rate && ( -
{ - e.preventDefault(); - return false; - }} - > - {rate} -
- )} - - {/* 竖向模式:顶部直链地址显示 */} - {orientation === 'vertical' && isDirectPlaySource && directLinkUrl && ( -
{ - e.preventDefault(); - return false; - }} - > -
{ - e.preventDefault(); - return false; - }} - title={directLinkUrl} - > - {directLinkUrl} -
-
- )} - - {actualEpisodes && actualEpisodes > 1 && orientation === 'vertical' && ( -
{ - e.preventDefault(); - return false; - }} - > - {/* 集数显示 */} -
{ - e.preventDefault(); - return false; - }} - > - {currentEpisode !== undefined && currentEpisode !== null - ? `${currentEpisode}/${actualEpisodes}` - : `共${actualEpisodes}集`} -
- - {/* 年份显示 */} - {displayYear && ( -
{ - e.preventDefault(); - return false; - }} - > - {displayYear}年 -
- )} -
- )} - - {/* 竖向模式:来源名称显示在海报右下角 */} - {orientation === 'vertical' && config.showSourceName && source_name && !cmsData && ( -
{ - e.preventDefault(); - return false; - }} - > - { - e.preventDefault(); - return false; - }} - > - {origin === 'live' && ( - - )} - {source_name} - -
- )} - - {/* 豆瓣链接 */} - {config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && ( - e.stopPropagation()} - className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0' - style={{ - WebkitUserSelect: 'none', - userSelect: 'none', - WebkitTouchCallout: 'none', - } as React.CSSProperties} onContextMenu={(e) => { e.preventDefault(); return false; }} - > + /> + + {/* 播放按钮或上映倒计时 */} + {isUpcoming && daysUntilRelease !== null ? (
{ - e.preventDefault(); - return false; - }} - > - -
-
- )} - - {/* 聚合播放源指示器 */} - {isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => { - const uniqueSources = Array.from(new Set(dynamicSourceNames)); - const sourceCount = uniqueSources.length; - - return ( -
{ e.preventDefault(); return false; }} >
-
+ {daysUntilRelease > 0 + ? `${daysUntilRelease}天后上映` + : daysUntilRelease === 0 + ? '今日上映' + : '已上映'} +
+
+ ) : ( + config.showPlayButton && ( +
{ + e.preventDefault(); + return false; + }} + > + { e.preventDefault(); return false; }} - > - {sourceCount} -
+ /> +
+ ) + )} - {/* 播放源详情悬浮框 */} - {(() => { - // 优先显示的播放源(常见的主流平台) - const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+']; - - // 按优先级排序播放源 - const sortedSources = uniqueSources.sort((a, b) => { - const aIndex = prioritySources.indexOf(a); - const bIndex = prioritySources.indexOf(b); - if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; - if (aIndex !== -1) return -1; - if (bIndex !== -1) return 1; - return a.localeCompare(b); - }); - - const maxDisplayCount = 6; // 最多显示6个 - const displaySources = sortedSources.slice(0, maxDisplayCount); - const hasMore = sortedSources.length > maxDisplayCount; - const remainingCount = sortedSources.length - maxDisplayCount; - - return ( -
{ + e.preventDefault(); + return false; + }} + > + {config.showCheckCircle && ( + { - e.preventDefault(); - return false; - }} - > -
{ - e.preventDefault(); - return false; - }} - > - {/* 单列布局 */} -
- {displaySources.map((sourceName, index) => ( -
-
- - {sourceName} - -
- ))} -
- - {/* 显示更多提示 */} - {hasMore && ( -
-
- +{remainingCount} 播放源 -
-
- )} - - {/* 小箭头 */} -
-
-
- ); - })()} + } as React.CSSProperties + } + onContextMenu={(e) => { + e.preventDefault(); + return false; + }} + /> + )} + {config.showHeart && from !== 'search' && ( + { + e.preventDefault(); + return false; + }} + /> + )}
-
- ); - })()} + )} - {/* 横向模式:标题和进度条在海报上 */} - {orientation === 'horizontal' && ( - <> - {/* 顶部渐变遮罩 - 用于标题背景 */} + {/* 季度徽章 */} + {seasonNumber && (
{ + e.preventDefault(); + return false; + }} + title={seasonName || `第${seasonNumber}季`} + > + S{seasonNumber} +
+ )} + + {/* 徽章 */} + {config.showRating && rate && ( +
{ e.preventDefault(); return false; }} > - {/* 标题 */} + {rate} +
+ )} + + {/* 竖向模式:顶部直链地址显示 */} + {orientation === 'vertical' && + isDirectPlaySource && + directLinkUrl && (
{ + e.preventDefault(); + return false; + }} > - { - e.preventDefault(); - return false; - }} - title={actualTitle} - > - {actualTitle} - -
- - {/* 集数信息 - 只有超过1集时才显示 */} - {currentEpisode && actualEpisodes && actualEpisodes > 1 && (
{ - e.preventDefault(); - return false; - }} - > - 第{currentEpisode}集 · 共{actualEpisodes}集 -
- )} - - {/* 直链地址 */} - {isDirectPlaySource && directLinkUrl && ( -
{ e.preventDefault(); return false; @@ -1358,305 +1239,783 @@ const VideoCard = forwardRef(function VideoCard > {directLinkUrl}
- )} -
+
+ )} - {/* 底部渐变遮罩 - 用于进度条背景 */} + {actualEpisodes && + actualEpisodes > 1 && + orientation === 'vertical' && ( +
{ + e.preventDefault(); + return false; + }} + > + {/* 集数显示 */} +
{ + e.preventDefault(); + return false; + }} + > + {currentEpisode !== undefined && currentEpisode !== null + ? `${currentEpisode}/${actualEpisodes}` + : `共${actualEpisodes}集`} +
+ + {/* 年份显示 */} + {displayYear && ( +
{ + e.preventDefault(); + return false; + }} + > + {displayYear}年 +
+ )} +
+ )} + + {/* 竖向模式:来源名称显示在海报右下角 */} + {orientation === 'vertical' && + config.showSourceName && + source_name && + !cmsData && ( +
{ + e.preventDefault(); + return false; + }} + > + { + e.preventDefault(); + return false; + }} + > + {origin === 'live' && ( + + )} + {source_name} + +
+ )} + + {/* 豆瓣链接 */} + {config.showDoubanLink && + actualDoubanId && + actualDoubanId !== 0 && ( + e.stopPropagation()} + className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0' + style={ + { + WebkitUserSelect: 'none', + userSelect: 'none', + WebkitTouchCallout: 'none', + } as React.CSSProperties + } + onContextMenu={(e) => { + e.preventDefault(); + return false; + }} + > +
{ + e.preventDefault(); + return false; + }} + > + +
+
+ )} + + {/* 聚合播放源指示器 */} + {isAggregate && + dynamicSourceNames && + dynamicSourceNames.length > 0 && + (() => { + const uniqueSources = Array.from(new Set(dynamicSourceNames)); + const sourceCount = uniqueSources.length; + + return ( +
{ + e.preventDefault(); + return false; + }} + > +
+
{ + e.preventDefault(); + return false; + }} + > + {sourceCount} +
+ + {/* 播放源详情悬浮框 */} + {(() => { + // 优先显示的播放源(常见的主流平台) + const prioritySources = [ + '爱奇艺', + '腾讯视频', + '优酷', + '芒果TV', + '哔哩哔哩', + 'Netflix', + 'Disney+', + ]; + + // 按优先级排序播放源 + const sortedSources = uniqueSources.sort((a, b) => { + const aIndex = prioritySources.indexOf(a); + const bIndex = prioritySources.indexOf(b); + if (aIndex !== -1 && bIndex !== -1) + return aIndex - bIndex; + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + return a.localeCompare(b); + }); + + const maxDisplayCount = 6; // 最多显示6个 + const displaySources = sortedSources.slice( + 0, + maxDisplayCount + ); + const hasMore = sortedSources.length > maxDisplayCount; + const remainingCount = + sortedSources.length - maxDisplayCount; + + return ( +
{ + e.preventDefault(); + return false; + }} + > +
{ + e.preventDefault(); + return false; + }} + > + {/* 单列布局 */} +
+ {displaySources.map((sourceName, index) => ( +
+
+ + {sourceName} + +
+ ))} +
+ + {/* 显示更多提示 */} + {hasMore && ( +
+
+ + +{remainingCount} 播放源 + +
+
+ )} + + {/* 小箭头 */} +
+
+
+ ); + })()} +
+
+ ); + })()} + + {/* 横向模式:标题和进度条在海报上 */} + {orientation === 'horizontal' && ( + <> + {/* 顶部渐变遮罩 - 用于标题背景 */} +
{ + e.preventDefault(); + return false; + }} + > + {/* 标题 */} +
+ { + e.preventDefault(); + return false; + }} + title={actualTitle} + > + {actualTitle} + +
+ + {/* 集数信息 - 只有超过1集时才显示 */} + {currentEpisode && actualEpisodes && actualEpisodes > 1 && ( +
{ + e.preventDefault(); + return false; + }} + > + 第{currentEpisode}集 · 共{actualEpisodes}集 +
+ )} + + {/* 直链地址 */} + {isDirectPlaySource && directLinkUrl && ( +
{ + e.preventDefault(); + return false; + }} + title={directLinkUrl} + > + {directLinkUrl} +
+ )} +
+ + {/* 底部渐变遮罩 - 用于进度条背景 */} +
{ + e.preventDefault(); + return false; + }} + > + {/* 进度条 */} + {config.showProgress && + progress !== undefined && + origin !== 'live' && ( +
+ {/* 来源和时长显示 - 在进度条上方 */} +
+ {/* 时长显示 - 左侧 */} + {from === 'playrecord' && + playTime !== undefined && + totalTime !== undefined && ( +
{ + e.preventDefault(); + return false; + }} + > + {(() => { + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + // 0分钟时不显示分钟 + if (mins === 0) { + return `${secs}秒`; + } + return `${mins}分${secs}秒`; + }; + return formatTime(playTime); + })()} +
+ )} + + {/* 来源 - 右侧 */} + {config.showSourceName && source_name && !cmsData && ( + { + e.preventDefault(); + return false; + }} + > + {source_name} + + )} +
+
{ + e.preventDefault(); + return false; + }} + > +
{ + e.preventDefault(); + return false; + }} + /> +
+
+ )} + + {/* 直播时只显示来源 */} + {origin === 'live' && + config.showSourceName && + source_name && + !cmsData && ( +
+ { + e.preventDefault(); + return false; + }} + > + + {source_name} + +
+ )} +
+ + )} +
+ + {/* 竖向模式:进度条和标题在海报下方 */} + {orientation === 'vertical' && ( + <> + {/* 进度条 */} + {config.showProgress && progress !== undefined && ( +
{ + e.preventDefault(); + return false; + }} + > +
{ + e.preventDefault(); + return false; + }} + /> +
+ )} + + {/* 标题 */}
{ e.preventDefault(); return false; }} > - {/* 进度条 */} - {config.showProgress && progress !== undefined && origin !== 'live' && ( -
- {/* 来源和时长显示 - 在进度条上方 */} -
- {/* 时长显示 - 左侧 */} - {from === 'playrecord' && playTime !== undefined && totalTime !== undefined && ( -
{ - e.preventDefault(); - return false; - }} - > - {(() => { - const formatTime = (seconds: number) => { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - // 0分钟时不显示分钟 - if (mins === 0) { - return `${secs}秒`; - } - return `${mins}分${secs}秒`; - }; - return formatTime(playTime); - })()} -
- )} - - {/* 来源 - 右侧 */} - {config.showSourceName && source_name && !cmsData && ( - { - e.preventDefault(); - return false; - }} - > - {source_name} - - )} -
-
+ { - e.preventDefault(); - return false; - }} - > -
{ + e.preventDefault(); + return false; + }} + > + {actualTitle} + + {/* 自定义 tooltip */} +
{ + e.preventDefault(); + return false; + }} + > + {actualTitle} +
{ - e.preventDefault(); - return false; - }} - /> -
+ } as React.CSSProperties + } + >
- )} - - {/* 直播时只显示来源 */} - {origin === 'live' && config.showSourceName && source_name && !cmsData && ( -
- { - e.preventDefault(); - return false; - }} - > - - {source_name} - -
- )} +
)}
- {/* 竖向模式:进度条和标题在海报下方 */} - {orientation === 'vertical' && ( - <> - {/* 进度条 */} - {config.showProgress && progress !== undefined && ( -
{ - e.preventDefault(); - return false; - }} - > -
{ - e.preventDefault(); - return false; - }} - /> -
- )} - - {/* 标题 */} -
{ - e.preventDefault(); - return false; - }} - > -
- { - e.preventDefault(); - return false; - }} - > - {actualTitle} - - {/* 自定义 tooltip */} -
{ - e.preventDefault(); - return false; - }} - > - {actualTitle} -
-
-
-
- - )} -
- - {/* 操作菜单 - 支持右键和长按触发 */} - setShowMobileActions(false)} - title={actualTitle} - poster={displayPoster} - actions={mobileActions} - sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined} - isAggregate={isAggregate} - sourceName={cmsData ? undefined : source_name} - directLinkUrl={directLinkUrl || undefined} - currentEpisode={currentEpisode} - totalEpisodes={actualEpisodes} - origin={origin} - onPosterClick={() => { - setShowImageViewer(true); - }} - /> - - {/* AI问片面板 - 只在打开或正在流式响应时渲染 */} - {aiEnabled && (showAIChat || isAIStreaming) && ( - setShowAIChat(false)} - onStreamingChange={setIsAIStreaming} - context={{ - title: actualTitle, - year: actualYear, - douban_id: actualDoubanId, - tmdb_id, - type: actualSearchType as 'movie' | 'tv', - currentEpisode, - }} - welcomeMessage={aiDefaultMessageWithVideo ? aiDefaultMessageWithVideo.replace('{title}', actualTitle || '') : `想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`} - /> - )} - - {/* 详情面板 */} - {showDetailPanel && ( - setShowDetailPanel(false)} + {/* 操作菜单 - 支持右键和长按触发 */} + setShowMobileActions(false)} title={actualTitle} poster={displayPoster} - doubanId={actualDoubanId} - bangumiId={isBangumi ? actualDoubanId : undefined} - isBangumi={isBangumi} - tmdbId={tmdb_id} - type={actualSearchType as 'movie' | 'tv'} - seasonNumber={seasonNumber} + actions={mobileActions} + sources={ + isAggregate && dynamicSourceNames + ? Array.from(new Set(dynamicSourceNames)) + : undefined + } + isAggregate={isAggregate} + sourceName={cmsData ? undefined : source_name} + directLinkUrl={directLinkUrl || undefined} currentEpisode={currentEpisode} - cmsData={cmsData} - sourceId={id} - source={source} + totalEpisodes={actualEpisodes} + origin={origin} + onPosterClick={() => { + setShowImageViewer(true); + }} /> - )} - {/* 图片查看器 */} - {showImageViewer && ( - setShowImageViewer(false)} - imageUrl={actualPoster} - alt={actualTitle} - /> - )} - - ); -} + {/* AI问片面板 - 只在打开或正在流式响应时渲染 */} + {aiEnabled && (showAIChat || isAIStreaming) && ( + setShowAIChat(false)} + onStreamingChange={setIsAIStreaming} + context={{ + title: actualTitle, + year: actualYear, + douban_id: actualDoubanId, + tmdb_id, + type: actualSearchType as 'movie' | 'tv', + currentEpisode, + }} + welcomeMessage={ + aiDefaultMessageWithVideo + ? aiDefaultMessageWithVideo.replace( + '{title}', + actualTitle || '' + ) + : `想了解《${actualTitle}》的更多信息吗?我可以帮你查询剧情、演员、评价等。` + } + /> + )} + {/* 详情面板 */} + {showDetailPanel && ( + setShowDetailPanel(false)} + title={actualTitle} + poster={displayPoster} + doubanId={actualDoubanId} + bangumiId={isBangumi ? actualDoubanId : undefined} + isBangumi={isBangumi} + tmdbId={tmdb_id} + type={actualSearchType as 'movie' | 'tv'} + seasonNumber={seasonNumber} + currentEpisode={currentEpisode} + cmsData={cmsData} + sourceId={id} + source={source} + /> + )} + + {/* 图片查看器 */} + {showImageViewer && ( + setShowImageViewer(false)} + imageUrl={actualPoster} + alt={actualTitle} + /> + )} + + ); + } ); export default memo(VideoCard); diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 09f8021..21d7b6d 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -25,6 +25,11 @@ export interface AdminConfig { TMDBApiKey?: string; TMDBProxy?: string; TMDBReverseProxy?: string; + // 动漫/Bangumi配置 + BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl'; + BangumiApiBaseUrl?: string; + BangumiImageBaseUrl?: string; + BangumiProxy?: string; BannerDataSource?: string; // 轮播图数据源:TMDB、TX 或 Douban RecommendationDataSource?: string; // 更多推荐数据源:Douban、TMDB、Mixed、MixedSmart // Pansou配置 @@ -101,7 +106,7 @@ export interface AdminConfig { LiveConfig?: { key: string; name: string; - url: string; // m3u 地址 + url: string; // m3u 地址 ua?: string; epg?: string; // 节目单 from: 'config' | 'custom'; diff --git a/src/lib/bangumi.client.ts b/src/lib/bangumi.client.ts index 678ebae..a814d15 100644 --- a/src/lib/bangumi.client.ts +++ b/src/lib/bangumi.client.ts @@ -1,5 +1,7 @@ 'use client'; +export type AnimeDataSource = 'direct' | 'server-proxy' | 'custom-baseurl'; + export interface BangumiCalendarData { weekday: { en: string; @@ -22,8 +24,154 @@ export interface BangumiCalendarData { }[]; } +export interface BangumiSubjectData { + id?: number; + name: string; + name_cn?: string; + date?: string; + images?: { + large?: string; + common?: string; + medium?: string; + small?: string; + grid?: string; + }; + rating?: { + score: number; + total: number; + }; + summary?: string; + tags?: { name: string }[]; + eps?: number; +} + +const BANGUMI_OFFICIAL_BASE_URL = 'https://api.bgm.tv'; +const SERVER_PROXY_BASE_URL = '/api/bangumi'; + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.trim().replace(/\/+$/, ''); +} + +function getRuntimeConfig() { + if (typeof window === 'undefined') return {} as any; + return (window as any).RUNTIME_CONFIG || {}; +} + +function getPrimaryAnimeDataSource(): AnimeDataSource { + if (typeof window === 'undefined') return 'direct'; + + const saved = localStorage.getItem( + 'animeDataSource' + ) as AnimeDataSource | null; + if ( + saved === 'direct' || + saved === 'server-proxy' || + saved === 'custom-baseurl' + ) { + return saved; + } + + const runtimeValue = getRuntimeConfig().BANGUMI_DATA_SOURCE as + | AnimeDataSource + | undefined; + if ( + runtimeValue === 'direct' || + runtimeValue === 'server-proxy' || + runtimeValue === 'custom-baseurl' + ) { + return runtimeValue; + } + + return 'direct'; +} + +function getBackupAnimeDataSource( + primary: AnimeDataSource +): AnimeDataSource | null { + if (typeof window === 'undefined') + return primary === 'server-proxy' ? null : 'server-proxy'; + + const saved = localStorage.getItem( + 'animeDataSourceBackup' + ) as AnimeDataSource | null; + const backup = + saved === 'direct' || saved === 'server-proxy' || saved === 'custom-baseurl' + ? saved + : 'server-proxy'; + + return backup === primary ? null : backup; +} + +function getCustomAnimeBaseUrl(): string { + if (typeof window === 'undefined') return ''; + return localStorage.getItem('animeCustomBaseUrl') || ''; +} + +function buildBangumiUrl(source: AnimeDataSource, path: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + switch (source) { + case 'server-proxy': + return `${SERVER_PROXY_BASE_URL}${normalizedPath}`; + case 'custom-baseurl': { + const customBaseUrl = normalizeBaseUrl(getCustomAnimeBaseUrl()); + if (!customBaseUrl) { + return `${BANGUMI_OFFICIAL_BASE_URL}${normalizedPath}`; + } + return `${customBaseUrl}${normalizedPath}`; + } + case 'direct': + default: + return `${BANGUMI_OFFICIAL_BASE_URL}${normalizedPath}`; + } +} + +async function fetchBangumiJson( + source: AnimeDataSource, + path: string +): Promise { + const response = await fetch(buildBangumiUrl(source, path), { + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + throw new Error(`Bangumi 请求失败: ${response.status}`); + } + + return response.json() as Promise; +} + +async function requestWithFallback(path: string): Promise { + const primary = getPrimaryAnimeDataSource(); + const backup = getBackupAnimeDataSource(primary); + + try { + return await fetchBangumiJson(primary, path); + } catch (primaryError) { + if (!backup) throw primaryError; + + try { + return await fetchBangumiJson(backup, path); + } catch (backupError) { + console.error('Bangumi 主源与备用源均请求失败:', { + primary, + backup, + primaryError, + backupError, + }); + throw backupError; + } + } +} + export async function GetBangumiCalendarData(): Promise { - const response = await fetch('https://api.bgm.tv/calendar'); - const data = await response.json(); - return data; + return requestWithFallback('/calendar'); +} + +export async function getBangumiSubject( + id: number | string +): Promise { + return requestWithFallback( + `/v0/subjects/${encodeURIComponent(String(id))}` + ); } diff --git a/src/lib/bangumi.server.ts b/src/lib/bangumi.server.ts new file mode 100644 index 0000000..7f4c907 --- /dev/null +++ b/src/lib/bangumi.server.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { HttpsProxyAgent } from 'https-proxy-agent'; +import nodeFetch from 'node-fetch'; + +export type AnimeDataSource = 'direct' | 'server-proxy' | 'custom-baseurl'; + +export const DEFAULT_BANGUMI_BASE_URL = 'https://api.bgm.tv'; + +function isCloudflareEnvironment(): boolean { + return ( + process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare' + ); +} + +export function normalizeBangumiBaseUrl(baseUrl?: string): string { + const normalized = (baseUrl || DEFAULT_BANGUMI_BASE_URL) + .trim() + .replace(/\/+$/, ''); + return normalized || DEFAULT_BANGUMI_BASE_URL; +} + +export async function fetchBangumiFromServer( + path: string, + options?: { baseUrl?: string; proxy?: string } +): Promise { + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const url = `${normalizeBangumiBaseUrl(options?.baseUrl)}${normalizedPath}`; + const proxy = options?.proxy?.trim(); + + if (isCloudflareEnvironment()) { + return fetch(url, { + headers: { + Accept: 'application/json', + 'User-Agent': 'MoonTVPlus/1.0 (https://github.com)', + }, + signal: AbortSignal.timeout(15000), + }) as Promise; + } + + const fetchOptions: any = { + headers: { + Accept: 'application/json', + 'User-Agent': 'MoonTVPlus/1.0 (https://github.com)', + }, + signal: AbortSignal.timeout(proxy ? 30000 : 15000), + }; + + if (proxy) { + fetchOptions.agent = new HttpsProxyAgent(proxy, { + timeout: 30000, + keepAlive: false, + }); + } + + return nodeFetch(url, fetchOptions) as unknown as Promise; +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 70ae39a..4a0eaa9 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -7,7 +7,9 @@ import { AdminConfig } from './admin.types'; const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321'; const DEFAULT_LIVE_REFRESH_INTERVAL_HOURS = 12; -function normalizeLiveRefreshIntervalHours(refreshIntervalHours?: number): number { +function normalizeLiveRefreshIntervalHours( + refreshIntervalHours?: number +): number { const normalizedInterval = Number(refreshIntervalHours); if (!Number.isFinite(normalizedInterval) || normalizedInterval <= 0) { @@ -44,7 +46,7 @@ interface ConfigFileStruct { }[]; lives?: { [key: string]: LiveCfg; - } + }; } export const API_CONFIG = { @@ -71,7 +73,6 @@ export const API_CONFIG = { let cachedConfig: AdminConfig; let configInitPromise: Promise | null = null; - // 从配置文件补充管理员配置 export function refineConfig(adminConfig: AdminConfig): AdminConfig { let fileConfig: ConfigFileStruct; @@ -197,19 +198,22 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig { return adminConfig; } -async function getInitConfig(configFile: string, subConfig: { - URL: string; - AutoUpdate: boolean; - LastCheck: string; -} = { - URL: "", +async function getInitConfig( + configFile: string, + subConfig: { + URL: string; + AutoUpdate: boolean; + LastCheck: string; + } = { + URL: '', AutoUpdate: false, - LastCheck: "", - }): Promise { + LastCheck: '', + } +): Promise { let cfgFile: ConfigFileStruct; // 优先从环境变量读取订阅 URL - const envSubUrl = process.env.CONFIG_SUBSCRIPTION_URL || ""; + const envSubUrl = process.env.CONFIG_SUBSCRIPTION_URL || ''; if (envSubUrl) { try { @@ -228,7 +232,7 @@ async function getInitConfig(configFile: string, subConfig: { } // 优先从环境变量读取配置 - const envConfig = process.env.INIT_CONFIG || ""; + const envConfig = process.env.INIT_CONFIG || ''; const configSource = envConfig || configFile; try { @@ -254,23 +258,37 @@ async function getInitConfig(configFile: string, subConfig: { process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent', DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '', DoubanImageProxyType: - process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent', + process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || + 'cmliussss-cdn-tencent', DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '', DisableYellowFilter: process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true', - FluidSearch: - process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false', + FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false', // 弹幕配置 DanmakuSourceType: hasCustomDanmakuEnv ? 'custom' : 'builtin', DanmakuApiBase: process.env.DANMAKU_API_BASE || - (hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE), + (hasCustomDanmakuEnv + ? 'http://localhost:9321' + : BUILTIN_DANMAKU_API_BASE), DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321', DanmakuAutoLoadDefault: true, // TMDB配置 TMDBApiKey: process.env.TMDB_API_KEY || '', TMDBProxy: process.env.TMDB_PROXY || '', TMDBReverseProxy: process.env.TMDB_REVERSE_PROXY || '', + // 动漫/Bangumi配置 + BangumiDataSource: + (process.env.NEXT_PUBLIC_BANGUMI_DATA_SOURCE as any) || 'direct', + BangumiApiBaseUrl: + process.env.BANGUMI_API_BASE_URL || + process.env.NEXT_PUBLIC_BANGUMI_API_BASE_URL || + 'https://api.bgm.tv', + BangumiImageBaseUrl: + process.env.BANGUMI_IMAGE_BASE_URL || + process.env.NEXT_PUBLIC_BANGUMI_IMAGE_BASE_URL || + '', + BangumiProxy: process.env.BANGUMI_PROXY || '', // Pansou配置 PansouApiUrl: '', PansouUsername: '', @@ -365,7 +383,7 @@ export async function getConfig(): Promise { // localStorage 模式下直接从环境变量初始化 if (storageType === 'localstorage') { console.log('localStorage 模式:从环境变量初始化配置'); - const adminConfig = await getInitConfig(""); + const adminConfig = await getInitConfig(''); cachedConfig = configSelfCheck(adminConfig); configInitPromise = null; return cachedConfig; @@ -386,19 +404,20 @@ export async function getConfig(): Promise { if (dbReadFailed) { // 数据库读取失败,使用默认配置但不保存,避免覆盖数据库 console.warn('数据库读取失败,使用临时默认配置(不会保存到数据库)'); - adminConfig = await getInitConfig(""); + adminConfig = await getInitConfig(''); } else { // 数据库中确实没有配置,首次初始化并保存 console.log('首次初始化配置'); - adminConfig = await getInitConfig(""); + adminConfig = await getInitConfig(''); await db.saveAdminConfig(adminConfig); } } // 检查是否有旧格式Emby配置需要迁移 - const needsEmbyMigration = adminConfig.EmbyConfig && - adminConfig.EmbyConfig.ServerURL && - !adminConfig.EmbyConfig.Sources; + const needsEmbyMigration = + adminConfig.EmbyConfig && + adminConfig.EmbyConfig.ServerURL && + !adminConfig.EmbyConfig.Sources; adminConfig = configSelfCheck(adminConfig); cachedConfig = adminConfig; @@ -544,19 +563,27 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { if (!adminConfig.UserConfig) { adminConfig.UserConfig = { Users: [] }; } - if (!adminConfig.UserConfig.Users || !Array.isArray(adminConfig.UserConfig.Users)) { + if ( + !adminConfig.UserConfig.Users || + !Array.isArray(adminConfig.UserConfig.Users) + ) { adminConfig.UserConfig.Users = []; } if (!adminConfig.SourceConfig || !Array.isArray(adminConfig.SourceConfig)) { adminConfig.SourceConfig = []; } - if (!adminConfig.CustomCategories || !Array.isArray(adminConfig.CustomCategories)) { + if ( + !adminConfig.CustomCategories || + !Array.isArray(adminConfig.CustomCategories) + ) { adminConfig.CustomCategories = []; } if (!adminConfig.LiveConfig || !Array.isArray(adminConfig.LiveConfig)) { adminConfig.LiveConfig = []; } - adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours(adminConfig.LiveRefreshIntervalHours); + adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours( + adminConfig.LiveRefreshIntervalHours + ); if (adminConfig.OpenListConfig) { if (!adminConfig.OpenListConfig.RootPaths) { @@ -567,7 +594,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { if (!adminConfig.OpenListConfig.OfflineDownloadPath) { adminConfig.OpenListConfig.OfflineDownloadPath = '/'; } - if (adminConfig.OpenListConfig.OfflineDownloadUseCustomSource === undefined) { + if ( + adminConfig.OpenListConfig.OfflineDownloadUseCustomSource === undefined + ) { adminConfig.OpenListConfig.OfflineDownloadUseCustomSource = false; } if (adminConfig.OpenListConfig.OfflineDownloadURL === undefined) { @@ -584,11 +613,13 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { // 用户信息已迁移到新版数据库 // 这里只保留站长用户用于兼容性,其他用户从数据库读取 const ownerUser = process.env.USERNAME; - adminConfig.UserConfig.Users = [{ - username: ownerUser!, - role: 'owner', - banned: false, - }]; + adminConfig.UserConfig.Users = [ + { + username: ownerUser!, + role: 'owner', + banned: false, + }, + ]; // 采集源去重 const seenSourceKeys = new Set(); @@ -602,13 +633,15 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { // 自定义分类去重 const seenCustomCategoryKeys = new Set(); - adminConfig.CustomCategories = adminConfig.CustomCategories.filter((category) => { - if (seenCustomCategoryKeys.has(category.query + category.type)) { - return false; + adminConfig.CustomCategories = adminConfig.CustomCategories.filter( + (category) => { + if (seenCustomCategoryKeys.has(category.query + category.type)) { + return false; + } + seenCustomCategoryKeys.add(category.query + category.type); + return true; } - seenCustomCategoryKeys.add(category.query + category.type); - return true; - }); + ); // 直播源去重 const seenLiveKeys = new Set(); @@ -627,42 +660,52 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { console.log('[Config] 检测到旧格式Emby配置,自动迁移到新格式'); const oldConfig = adminConfig.EmbyConfig; adminConfig.EmbyConfig = { - Sources: [{ - key: 'default', - name: 'Emby', - enabled: oldConfig.Enabled ?? false, - ServerURL: oldConfig.ServerURL || '', - ApiKey: oldConfig.ApiKey, - Username: oldConfig.Username, - Password: oldConfig.Password, - UserId: oldConfig.UserId, - AuthToken: oldConfig.AuthToken, - Libraries: oldConfig.Libraries, - LastSyncTime: oldConfig.LastSyncTime, - ItemCount: oldConfig.ItemCount, - isDefault: true, - }], + Sources: [ + { + key: 'default', + name: 'Emby', + enabled: oldConfig.Enabled ?? false, + ServerURL: oldConfig.ServerURL || '', + ApiKey: oldConfig.ApiKey, + Username: oldConfig.Username, + Password: oldConfig.Password, + UserId: oldConfig.UserId, + AuthToken: oldConfig.AuthToken, + Libraries: oldConfig.Libraries, + LastSyncTime: oldConfig.LastSyncTime, + ItemCount: oldConfig.ItemCount, + isDefault: true, + }, + ], }; } // Emby源去重 if (adminConfig.EmbyConfig?.Sources) { const seenEmbyKeys = new Set(); - adminConfig.EmbyConfig.Sources = adminConfig.EmbyConfig.Sources.filter((source) => { - if (seenEmbyKeys.has(source.key)) { - return false; + adminConfig.EmbyConfig.Sources = adminConfig.EmbyConfig.Sources.filter( + (source) => { + if (seenEmbyKeys.has(source.key)) { + return false; + } + seenEmbyKeys.add(source.key); + return true; } - seenEmbyKeys.add(source.key); - return true; - }); + ); } } if (!adminConfig.SuwayomiConfig) { adminConfig.SuwayomiConfig = { Enabled: process.env.SUWAYOMI_ENABLED === 'true', - ServerURL: process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '', - AuthMode: (process.env.SUWAYOMI_AUTH_MODE as 'none' | 'basic_auth' | 'simple_login' | undefined) || 'none', + ServerURL: + process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '', + AuthMode: + (process.env.SUWAYOMI_AUTH_MODE as + | 'none' + | 'basic_auth' + | 'simple_login' + | undefined) || 'none', Username: process.env.SUWAYOMI_USERNAME || '', Password: process.env.SUWAYOMI_PASSWORD || '', DefaultLang: process.env.SUWAYOMI_DEFAULT_LANG || 'zh', @@ -694,7 +737,10 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { if (!Array.isArray(adminConfig.SuwayomiConfig.SourceIds)) { adminConfig.SuwayomiConfig.SourceIds = []; } - if (adminConfig.SuwayomiConfig.MaxSources === undefined || Number.isNaN(adminConfig.SuwayomiConfig.MaxSources)) { + if ( + adminConfig.SuwayomiConfig.MaxSources === undefined || + Number.isNaN(adminConfig.SuwayomiConfig.MaxSources) + ) { adminConfig.SuwayomiConfig.MaxSources = 10; } @@ -715,19 +761,26 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { const envUrl = process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL; if (!envUrl) return []; - return [{ - id: 'default', - name: process.env.OPDS_NAME || '默认书源', - type: 'opds', - url: envUrl, - enabled: true, - authMode: (process.env.OPDS_AUTH_MODE as 'none' | 'basic' | 'header' | undefined) || 'none', - username: process.env.OPDS_USERNAME || '', - password: process.env.OPDS_PASSWORD || '', - headerName: process.env.OPDS_HEADER_NAME || '', - headerValue: process.env.OPDS_HEADER_VALUE || '', - searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '', - }]; + return [ + { + id: 'default', + name: process.env.OPDS_NAME || '默认书源', + type: 'opds', + url: envUrl, + enabled: true, + authMode: + (process.env.OPDS_AUTH_MODE as + | 'none' + | 'basic' + | 'header' + | undefined) || 'none', + username: process.env.OPDS_USERNAME || '', + password: process.env.OPDS_PASSWORD || '', + headerName: process.env.OPDS_HEADER_NAME || '', + headerValue: process.env.OPDS_HEADER_VALUE || '', + searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '', + }, + ]; })(), CacheTTL: Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000), }; @@ -738,15 +791,22 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { if (!Array.isArray(adminConfig.OPDSConfig.Sources)) { adminConfig.OPDSConfig.Sources = []; } - adminConfig.OPDSConfig.Sources = adminConfig.OPDSConfig.Sources.filter((source: any) => (source?.type || 'opds') === 'opds').map((source: any) => { + adminConfig.OPDSConfig.Sources = adminConfig.OPDSConfig.Sources.filter( + (source: any) => (source?.type || 'opds') === 'opds' + ).map((source: any) => { const { legado: _legado, ...rest } = source || {}; return { ...rest, type: 'opds' }; }); if (!Array.isArray(adminConfig.OPDSConfig.LegadoSubscriptions)) { adminConfig.OPDSConfig.LegadoSubscriptions = []; } - if (adminConfig.OPDSConfig.CacheTTL === undefined || Number.isNaN(adminConfig.OPDSConfig.CacheTTL)) { - adminConfig.OPDSConfig.CacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000); + if ( + adminConfig.OPDSConfig.CacheTTL === undefined || + Number.isNaN(adminConfig.OPDSConfig.CacheTTL) + ) { + adminConfig.OPDSConfig.CacheTTL = Number( + process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000 + ); } if (!adminConfig.NetDiskConfig) { @@ -888,7 +948,10 @@ export async function resetConfig() { if (!originConfig) { originConfig = {} as AdminConfig; } - const adminConfig = await getInitConfig(originConfig.ConfigFile, originConfig.ConfigSubscribtion); + const adminConfig = await getInitConfig( + originConfig.ConfigFile, + originConfig.ConfigSubscribtion + ); cachedConfig = adminConfig; await db.saveAdminConfig(adminConfig); @@ -923,13 +986,15 @@ export async function getAvailableApiSites(user?: string): Promise { // 优先根据用户自己的 enabledApis 配置查找 if (userInfoV2.enabledApis && userInfoV2.enabledApis.length > 0) { const userApiSitesSet = new Set(userInfoV2.enabledApis); - return allApiSites.filter((s) => userApiSitesSet.has(s.key)).map((s) => ({ - key: s.key, - name: s.name, - api: s.api, - detail: s.detail, - proxyMode: s.proxyMode, - })); + return allApiSites + .filter((s) => userApiSitesSet.has(s.key)) + .map((s) => ({ + key: s.key, + name: s.name, + api: s.api, + detail: s.detail, + proxyMode: s.proxyMode, + })); } // 如果没有 enabledApis 配置,则根据 tags 查找 @@ -937,21 +1002,25 @@ export async function getAvailableApiSites(user?: string): Promise { const enabledApisFromTags = new Set(); // 遍历用户的所有 tags,收集对应的 enabledApis - userInfoV2.tags.forEach(tagName => { - const tagConfig = config.UserConfig.Tags?.find(t => t.name === tagName); + userInfoV2.tags.forEach((tagName) => { + const tagConfig = config.UserConfig.Tags?.find((t) => t.name === tagName); if (tagConfig && tagConfig.enabledApis) { - tagConfig.enabledApis.forEach(apiKey => enabledApisFromTags.add(apiKey)); + tagConfig.enabledApis.forEach((apiKey) => + enabledApisFromTags.add(apiKey) + ); } }); if (enabledApisFromTags.size > 0) { - return allApiSites.filter((s) => enabledApisFromTags.has(s.key)).map((s) => ({ - key: s.key, - name: s.name, - api: s.api, - detail: s.detail, - proxyMode: s.proxyMode, - })); + return allApiSites + .filter((s) => enabledApisFromTags.has(s.key)) + .map((s) => ({ + key: s.key, + name: s.name, + api: s.api, + detail: s.detail, + proxyMode: s.proxyMode, + })); } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 1b3164e..85db5d2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -55,9 +55,13 @@ function buildDoubanImageUrl( 'img.doubanio.cmliussss.com' ); case 'baidu': - return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`; + return `https://image.baidu.com/search/down?url=${encodeURIComponent( + originalUrl + )}`; case 'custom': - return proxyUrl ? `${proxyUrl}${encodeURIComponent(originalUrl)}` : originalUrl; + return proxyUrl + ? `${proxyUrl}${encodeURIComponent(originalUrl)}` + : originalUrl; case 'direct': default: return originalUrl; @@ -89,8 +93,9 @@ function getDoubanImageProxyConfig(): { (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || ''; const doubanImageProxyBackupType = - (localStorage.getItem('doubanImageProxyTypeBackup') as DoubanImageProxyType | null) || - 'server'; + (localStorage.getItem( + 'doubanImageProxyTypeBackup' + ) as DoubanImageProxyType | null) || 'server'; const doubanImageProxyBackupUrl = localStorage.getItem('doubanImageProxyUrlBackup') || ''; const primaryConfig = normalizeDoubanImageProxyConfig( @@ -130,6 +135,162 @@ export function getDoubanImageFallbackUrl(originalUrl: string): string | null { return backupUrl; } +function isBangumiImageUrl(url: string): boolean { + try { + const hostname = new URL(url).hostname.toLowerCase(); + return ( + hostname === 'lain.bgm.tv' || + hostname === 'r.bgm.tv' || + hostname.endsWith('.bgm.tv') || + hostname.endsWith('.bangumi.tv') + ); + } catch { + return false; + } +} + +type AnimeImageSource = 'direct' | 'server-proxy' | 'custom-baseurl'; + +const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil'; +const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature'; +const BANGUMI_IMAGE_FALLBACK_DURATION = 60 * 60 * 1000; + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.trim().replace(/\/+$/, ''); +} + +function normalizeAnimeImageSource( + value: string | null | undefined +): AnimeImageSource { + return value === 'server-proxy' || + value === 'custom-baseurl' || + value === 'direct' + ? value + : 'direct'; +} + +function getPrimaryBangumiImageSource(): AnimeImageSource { + if (typeof window === 'undefined') { + return 'direct'; + } + + return normalizeAnimeImageSource( + localStorage.getItem('animeDataSource') || + (window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE || + 'direct' + ); +} + +function getBackupBangumiImageSource( + primary: AnimeImageSource +): AnimeImageSource | null { + if (typeof window === 'undefined') { + return primary === 'server-proxy' ? null : 'server-proxy'; + } + + const backup = normalizeAnimeImageSource( + localStorage.getItem('animeDataSourceBackup') || 'server-proxy' + ); + + return backup === primary ? null : backup; +} + +function getBangumiImageBaseUrl(): string { + if (typeof window === 'undefined') { + return ''; + } + + return normalizeBaseUrl(localStorage.getItem('animeImageBaseUrl') || ''); +} + +function getBangumiImageFallbackSignature(): string { + if (typeof window === 'undefined') return ''; + + return JSON.stringify({ + primary: getPrimaryBangumiImageSource(), + backup: normalizeAnimeImageSource( + localStorage.getItem('animeDataSourceBackup') || 'server-proxy' + ), + imageBaseUrl: getBangumiImageBaseUrl(), + }); +} + +export function clearBangumiImageFallbackCache(): void { + if (typeof window === 'undefined') return; + localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY); + localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY); +} + +export function markBangumiImageFallbackActive(): void { + if (typeof window === 'undefined') return; + localStorage.setItem( + BANGUMI_IMAGE_FALLBACK_UNTIL_KEY, + String(Date.now() + BANGUMI_IMAGE_FALLBACK_DURATION) + ); + localStorage.setItem( + BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY, + getBangumiImageFallbackSignature() + ); +} + +function isBangumiImageFallbackActive(): boolean { + if (typeof window === 'undefined') return false; + + const until = Number(localStorage.getItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY)); + if (!until || Date.now() >= until) { + clearBangumiImageFallbackCache(); + return false; + } + + const signature = localStorage.getItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY); + if (signature !== getBangumiImageFallbackSignature()) { + clearBangumiImageFallbackCache(); + return false; + } + + return true; +} + +function buildBangumiImageUrl( + originalUrl: string, + source: AnimeImageSource +): string { + switch (source) { + case 'server-proxy': + return `/api/image-proxy?url=${encodeURIComponent( + originalUrl + )}&source=bangumi`; + case 'custom-baseurl': { + const imageBaseUrl = getBangumiImageBaseUrl(); + return imageBaseUrl ? `${imageBaseUrl}/${originalUrl}` : originalUrl; + } + case 'direct': + default: + return originalUrl; + } +} + +export function getBangumiImageFallbackUrl(originalUrl: string): string | null { + if (!originalUrl || !isBangumiImageUrl(originalUrl)) { + return null; + } + + const primary = getPrimaryBangumiImageSource(); + const backup = getBackupBangumiImageSource(primary); + if (!backup) { + return null; + } + + const primaryUrl = buildBangumiImageUrl(originalUrl, primary); + const backupUrl = buildBangumiImageUrl(originalUrl, backup); + + if (backupUrl === primaryUrl) { + return null; + } + + return backupUrl; +} + export function tryApplyDoubanImageFallback( target: HTMLImageElement, originalUrl: string @@ -143,7 +304,11 @@ export function tryApplyDoubanImageFallback( } const fallbackUrl = getDoubanImageFallbackUrl(originalUrl); - if (!fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src) { + if ( + !fallbackUrl || + fallbackUrl === target.currentSrc || + fallbackUrl === target.src + ) { return false; } @@ -152,6 +317,33 @@ export function tryApplyDoubanImageFallback( return true; } +export function tryApplyBangumiImageFallback( + target: HTMLImageElement, + originalUrl: string +): boolean { + if (!originalUrl || !isBangumiImageUrl(originalUrl)) { + return false; + } + + if (target.dataset.bangumiBackupTried === 'true') { + return false; + } + + const fallbackUrl = getBangumiImageFallbackUrl(originalUrl); + if ( + !fallbackUrl || + fallbackUrl === target.currentSrc || + fallbackUrl === target.src + ) { + return false; + } + + target.dataset.bangumiBackupTried = 'true'; + markBangumiImageFallbackActive(); + target.src = fallbackUrl; + return true; +} + /** * 处理图片 URL,根据用户设置使用相应的代理 */ @@ -166,7 +358,8 @@ export function processImageUrl(originalUrl: string): string { // 处理 TMDB 图片 URL 替换 if (originalUrl.includes('image.tmdb.org')) { if (typeof window !== 'undefined') { - const tmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl') || 'https://image.tmdb.org'; + const tmdbImageBaseUrl = + localStorage.getItem('tmdbImageBaseUrl') || 'https://image.tmdb.org'; // 只有当用户设置了不同的 baseUrl 时才进行替换 if (tmdbImageBaseUrl !== 'https://image.tmdb.org') { return originalUrl.replace('https://image.tmdb.org', tmdbImageBaseUrl); @@ -175,6 +368,17 @@ export function processImageUrl(originalUrl: string): string { return originalUrl; } + // 处理 Bangumi 图片代理。直连模式尊重用户选择,不代理图片; + // 仅在服务器代理 / 自定义 Base URL 模式下使用本站图片代理。 + if (isBangumiImageUrl(originalUrl)) { + const primary = getPrimaryBangumiImageSource(); + const backup = getBackupBangumiImageSource(primary); + if (backup && isBangumiImageFallbackActive()) { + return buildBangumiImageUrl(originalUrl, backup); + } + return buildBangumiImageUrl(originalUrl, primary); + } + // 处理豆瓣图片代理 if (!originalUrl.includes('doubanio.com')) { return originalUrl; @@ -229,7 +433,10 @@ export function processVideoUrl(originalUrl: string): string { case 'custom': // 使用自定义代理 if (proxyUrl) { - return originalUrl.replace(/https?:\/\/img\d\.doubanio\.com/g, proxyUrl); + return originalUrl.replace( + /https?:\/\/img\d\.doubanio\.com/g, + proxyUrl + ); } return originalUrl; @@ -291,22 +498,23 @@ export async function getVideoResolutionFromM3u8( width >= 3840 ? '4K' : width >= 2560 - ? '2K' - : width >= 1920 - ? '1080p' - : width >= 1280 - ? '720p' - : width >= 854 - ? '480p' - : width > 0 - ? 'SD' - : '未知'; + ? '2K' + : width >= 1920 + ? '1080p' + : width >= 1280 + ? '720p' + : width >= 854 + ? '480p' + : width > 0 + ? 'SD' + : '未知'; - const bitrateStr = estimatedBitrate > 0 - ? estimatedBitrate >= 1000000 - ? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps` - : `${Math.round(estimatedBitrate / 1000)} Kbps` - : '未知'; + const bitrateStr = + estimatedBitrate > 0 + ? estimatedBitrate >= 1000000 + ? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps` + : `${Math.round(estimatedBitrate / 1000)} Kbps` + : '未知'; hls.destroy(); video.remove(); @@ -385,9 +593,17 @@ export async function getVideoResolutionFromM3u8( const fragmentSize = size; // 分片大小(字节) // 码率 = (分片大小 × 8 bits) / 分片时长 - estimatedBitrate = Math.round((fragmentSize * 8) / fragmentDuration); + estimatedBitrate = Math.round( + (fragmentSize * 8) / fragmentDuration + ); - console.log(`[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed(2)} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed(2)} MB, 时长: ${fragmentDuration.toFixed(1)}s)`); + console.log( + `[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed( + 2 + )} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed( + 2 + )} MB, 时长: ${fragmentDuration.toFixed(1)}s)` + ); } checkAndResolve(); // 尝试返回结果 @@ -412,8 +628,14 @@ export async function getVideoResolutionFromM3u8( if (data.fatal) { const statusCode = data.response?.code || data.response?.status; // 防止 415 代理兜底熔断导致正常的二进制源在优选逻辑中被剔除 - if (statusCode === 415 && (m3u8Url.includes('/api/proxy-m3u8') || m3u8Url.includes('/api/proxy/vod/m3u8'))) { - console.log('[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过'); + if ( + statusCode === 415 && + (m3u8Url.includes('/api/proxy-m3u8') || + m3u8Url.includes('/api/proxy/vod/m3u8')) + ) { + console.log( + '[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过' + ); clearTimeout(timeout); hls.destroy(); video.remove(); @@ -441,7 +663,8 @@ export async function getVideoResolutionFromM3u8( }); } catch (error) { throw new Error( - `Error getting video resolution: ${error instanceof Error ? error.message : String(error) + `Error getting video resolution: ${ + error instanceof Error ? error.message : String(error) }` ); }