diff --git a/src/app/api/tmdb/videos/route.ts b/src/app/api/tmdb/videos/route.ts new file mode 100644 index 0000000..4f552e6 --- /dev/null +++ b/src/app/api/tmdb/videos/route.ts @@ -0,0 +1,117 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { getTMDBVideoList, searchTMDBMulti } from '@/lib/tmdb.client'; + +export const runtime = 'nodejs'; + +function normalizeType(type: string | null): 'movie' | 'tv' | null { + if (type === 'movie' || type === 'tv') return type; + return null; +} + +/** + * GET /api/tmdb/videos?id=xxx&type=movie|tv + * GET /api/tmdb/videos?title=xxx&type=movie|tv&year=2026 + * 获取 TMDB YouTube 视频列表,用于选择预告片 + */ +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const idParam = searchParams.get('id'); + const title = searchParams.get('title') || ''; + const typeParam = normalizeType(searchParams.get('type')) || 'movie'; + const year = searchParams.get('year') || ''; + + const config = await getConfig(); + const tmdbApiKey = config.SiteConfig.TMDBApiKey; + const tmdbProxy = config.SiteConfig.TMDBProxy; + const tmdbReverseProxy = config.SiteConfig.TMDBReverseProxy; + + if (!tmdbApiKey) { + return NextResponse.json( + { error: 'TMDB API Key 未配置' }, + { status: 400 } + ); + } + + let mediaId = idParam ? parseInt(idParam, 10) : 0; + let mediaType: 'movie' | 'tv' = typeParam; + + if (!mediaId) { + if (!title.trim()) { + return NextResponse.json({ error: '缺少 id 或 title 参数' }, { status: 400 }); + } + + const searchResponse = await searchTMDBMulti( + tmdbApiKey, + title.trim(), + tmdbProxy, + tmdbReverseProxy + ); + + if (searchResponse.code !== 200) { + return NextResponse.json( + { error: 'TMDB 搜索失败', code: searchResponse.code }, + { status: searchResponse.code } + ); + } + + const validResults = (searchResponse.results || []).filter( + (item: any) => item.media_type === 'movie' || item.media_type === 'tv' + ); + const matched = + validResults.find((item: any) => { + if (item.media_type !== mediaType) return false; + if (!year) return true; + const date = item.release_date || item.first_air_date || ''; + return date.startsWith(year); + }) || + validResults.find((item: any) => item.media_type === mediaType) || + validResults[0]; + + if (!matched?.id) { + return NextResponse.json({ error: '未找到 TMDB 条目' }, { status: 404 }); + } + + mediaId = matched.id; + mediaType = matched.media_type; + } + + const response = await getTMDBVideoList( + tmdbApiKey, + mediaType, + mediaId, + tmdbProxy, + tmdbReverseProxy + ); + + if (response.code !== 200) { + return NextResponse.json( + { error: 'TMDB 视频获取失败', code: response.code }, + { status: response.code } + ); + } + + return NextResponse.json({ + success: true, + mediaId, + mediaType, + videos: response.videos, + }); + } catch (error) { + console.error('TMDB视频获取失败:', error); + return NextResponse.json( + { error: '获取预告片失败', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c67c9b6..cb8668b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -852,6 +852,7 @@ function HomeClient() { } type={item.media_type === 'tv' ? 'tv' : 'movie'} from='douban' + tmdb_id={item.id} releaseDate={item.release_date} isUpcoming={true} /> diff --git a/src/components/MobileActionSheet.tsx b/src/components/MobileActionSheet.tsx index 9216116..6127b1d 100644 --- a/src/components/MobileActionSheet.tsx +++ b/src/components/MobileActionSheet.tsx @@ -26,6 +26,7 @@ interface MobileActionSheetProps { totalEpisodes?: number; // 总集数 origin?: 'vod' | 'live'; onPosterClick?: () => void; // 海报点击回调 + description?: string; // 标题下方描述文案 } const MobileActionSheet: React.FC = ({ @@ -42,6 +43,7 @@ const MobileActionSheet: React.FC = ({ totalEpisodes, origin = 'vod', onPosterClick, + description = '选择操作', }) => { const [isVisible, setIsVisible] = useState(false); const [isAnimating, setIsAnimating] = useState(false); @@ -322,7 +324,7 @@ const MobileActionSheet: React.FC = ({

)}

- 选择操作 + {description}

diff --git a/src/components/TrailerPickerDialog.tsx b/src/components/TrailerPickerDialog.tsx new file mode 100644 index 0000000..ddf8bde --- /dev/null +++ b/src/components/TrailerPickerDialog.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { ExternalLink, Film, Loader2, PlayCircle, Youtube, X } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import type { TMDBVideoItem } from '@/lib/tmdb.client'; + +interface TrailerPickerDialogProps { + isOpen: boolean; + title: string; + videos: TMDBVideoItem[]; + loading?: boolean; + error?: string | null; + onClose: () => void; + onRetry?: () => void; + onSelect: (video: TMDBVideoItem) => void; +} + +export default function TrailerPickerDialog({ + isOpen, + title, + videos, + loading = false, + error = null, + onClose, + onRetry, + onSelect, +}: TrailerPickerDialogProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (!isOpen) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isOpen, onClose]); + + const sortedVideos = useMemo(() => { + return [...videos].sort((a, b) => { + const aOfficial = a.official ? 1 : 0; + const bOfficial = b.official ? 1 : 0; + if (aOfficial !== bOfficial) return bOfficial - aOfficial; + const aTrailer = a.type === 'Trailer' ? 1 : 0; + const bTrailer = b.type === 'Trailer' ? 1 : 0; + if (aTrailer !== bTrailer) return bTrailer - aTrailer; + return (b.published_at || '').localeCompare(a.published_at || ''); + }); + }, [videos]); + + if (!mounted || !isOpen) return null; + + return createPortal( +
+
+
+
+
+
+ + 选择预告片 +
+

+ {title} +

+
+ +
+ +
+ {loading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : error ? ( +
+
{error}
+ {onRetry && ( + + )} +
+ ) : sortedVideos.length === 0 ? ( +
+ +

+ 未找到预告片 +

+

+ 该影片在 TMDB 中没有可用的 YouTube 视频。 +

+
+ ) : ( +
+ {sortedVideos.map((video) => ( + + ))} +
+ )} +
+
+
, + document.body + ); +} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 2bf72f8..de7b594 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -10,6 +10,7 @@ import { Radio, Sparkles, Trash2, + Youtube, } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; @@ -50,6 +51,8 @@ import DetailPanel from '@/components/DetailPanel'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; import ImageViewer from '@/components/ImageViewer'; import MobileActionSheet from '@/components/MobileActionSheet'; +import TrailerPickerDialog from '@/components/TrailerPickerDialog'; +import type { TMDBVideoItem } from '@/lib/tmdb.client'; export interface VideoCardProps { id?: string; @@ -170,6 +173,10 @@ const VideoCard = forwardRef( const [showDetailPanel, setShowDetailPanel] = useState(false); const [showImageViewer, setShowImageViewer] = useState(false); const [showUpcomingInfo, setShowUpcomingInfo] = useState(false); // 控制即将上映倒计时的显示 + const [showTrailerPicker, setShowTrailerPicker] = useState(false); + const [trailerLoading, setTrailerLoading] = useState(false); + const [trailerError, setTrailerError] = useState(null); + const [trailerVideos, setTrailerVideos] = useState([]); const [displayPoster, setDisplayPoster] = useState(processedPoster); // 检查AI功能是否启用 @@ -664,6 +671,43 @@ const VideoCard = forwardRef( return configs[from] || configs.search; }, [from, isAggregate, douban_id, rate, isUpcoming]); + const upcomingReleaseText = useMemo(() => { + if (!isUpcoming || daysUntilRelease === null) return undefined; + if (daysUntilRelease > 0) return `${daysUntilRelease}天后上映`; + if (daysUntilRelease === 0) return '今日上映'; + return '已上映'; + }, [isUpcoming, daysUntilRelease]); + + + const openTrailerPicker = useCallback(async () => { + if (!actualTitle) return; + setShowMobileActions(false); + setTrailerError(null); + setTrailerLoading(true); + setShowTrailerPicker(true); + + try { + const params = new URLSearchParams(); + if (tmdb_id) params.set('id', String(tmdb_id)); + if (actualSearchType) params.set('type', actualSearchType); + if (actualTitle) params.set('title', actualTitle); + if (actualYear) params.set('year', actualYear); + + const res = await fetch(`/api/tmdb/videos?${params.toString()}`); + const data = await res.json(); + if (!res.ok) { + throw new Error(data?.error || '获取预告片失败'); + } + + setTrailerVideos(data.videos || []); + } catch (err) { + setTrailerVideos([]); + setTrailerError(err instanceof Error ? err.message : '获取预告片失败'); + } finally { + setTrailerLoading(false); + } + }, [actualSearchType, actualTitle, actualYear, tmdb_id]); + // 移动端操作菜单配置 const mobileActions = useMemo(() => { const actions = []; @@ -796,6 +840,17 @@ const VideoCard = forwardRef( }); } + // 预告片操作:仅即将上映卡片显示 + if (origin !== 'live' && isUpcoming && actualTitle) { + actions.push({ + id: 'trailer', + label: '预告片', + icon: , + onClick: openTrailerPicker, + color: 'default' as const, + }); + } + // 详情页面按钮(直播源不显示详情) if (origin !== 'live') { actions.push({ @@ -848,6 +903,11 @@ const VideoCard = forwardRef( handlePlayInNewTab, aiEnabled, actualTitle, + actualSearchType, + isUpcoming, + origin, + tmdb_id, + openTrailerPicker, ]); return ( @@ -1039,7 +1099,6 @@ const VideoCard = forwardRef( {/* 播放按钮或上映倒计时 */} {isUpcoming && daysUntilRelease !== null ? (
( } as React.CSSProperties } > - {daysUntilRelease > 0 - ? `${daysUntilRelease}天后上映` - : daysUntilRelease === 0 - ? '今日上映' - : '已上映'} + {upcomingReleaseText}
) : ( @@ -1963,11 +2018,31 @@ const VideoCard = forwardRef( currentEpisode={currentEpisode} totalEpisodes={actualEpisodes} origin={origin} + description={upcomingReleaseText} onPosterClick={() => { setShowImageViewer(true); }} /> + + setShowTrailerPicker(false)} + onRetry={openTrailerPicker} + onSelect={(video) => { + window.open( + `https://www.youtube.com/watch?v=${video.key}`, + '_blank', + 'noopener,noreferrer' + ); + setShowTrailerPicker(false); + }} + /> + {/* AI问片面板 - 只在打开或正在流式响应时渲染 */} {aiEnabled && (showAIChat || isAIStreaming) && ( { + try { + const actualKey = getNextApiKey(apiKey); + if (!actualKey) { + return { code: 400, videos: [] }; + } + + const baseUrl = reverseProxyBaseUrl || DEFAULT_TMDB_BASE_URL; + // 一次性请求中文、英文和未标语言视频,避免按语言多次请求。 + // TMDB videos 接口支持 include_video_language 传逗号分隔的多个 ISO-639-1 值。 + const url = `${baseUrl}/3/${mediaType}/${mediaId}/videos?api_key=${actualKey}&include_video_language=zh,en,null`; + const response = await universalFetch(url, proxy); + + if (!response.ok) { + return { code: response.status, videos: [] }; + } + + const data: any = await response.json(); + const videoMap = new Map(); + (data.results || []) + .filter((video: any) => video?.site === 'YouTube' && video?.key) + .forEach((video: any) => { + if (videoMap.has(video.key)) return; + videoMap.set(video.key, { + id: String(video.id ?? video.key), + key: video.key, + name: video.name || '未命名视频', + site: video.site || 'YouTube', + type: video.type || '', + official: video.official, + published_at: video.published_at, + iso_639_1: video.iso_639_1, + }); + }); + + const typePriority: Record = { + Trailer: 0, + Teaser: 1, + Clip: 2, + Featurette: 3, + }; + + const videos = Array.from(videoMap.values()).sort((a, b) => { + const officialDiff = (b.official ? 1 : 0) - (a.official ? 1 : 0); + if (officialDiff !== 0) return officialDiff; + const typeDiff = (typePriority[a.type] ?? 99) - (typePriority[b.type] ?? 99); + if (typeDiff !== 0) return typeDiff; + return (b.published_at || '').localeCompare(a.published_at || ''); + }); + + return { + code: 200, + videos, + }; + } catch (error) { + console.error('获取 TMDB 视频列表失败:', error); + return { code: 500, videos: [] }; + } +} + /** * 获取热门内容(电影+电视剧) * @param apiKey - TMDB API Key