添加即将上映

This commit is contained in:
mtvpls
2025-12-14 22:15:38 +08:00
parent 1fd6968238
commit ec5271dca0
9 changed files with 401 additions and 12 deletions

View File

@@ -296,6 +296,7 @@ interface SiteConfig {
FluidSearch: boolean;
DanmakuApiBase: string;
DanmakuApiToken: string;
TMDBApiKey?: string;
EnableComments: boolean;
EnableRegistration?: boolean;
RegistrationRequireTurnstile?: boolean;
@@ -4592,6 +4593,7 @@ const SiteConfigComponent = ({
FluidSearch: true,
DanmakuApiBase: 'http://localhost:9321',
DanmakuApiToken: '87654321',
TMDBApiKey: '',
EnableComments: false,
EnableRegistration: false,
RegistrationRequireTurnstile: false,
@@ -4674,6 +4676,7 @@ const SiteConfigComponent = ({
DanmakuApiBase:
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
TMDBApiKey: config.SiteConfig.TMDBApiKey || '',
EnableComments: config.SiteConfig.EnableComments || false,
EnableRegistration: config.SiteConfig.EnableRegistration || false,
RegistrationRequireTurnstile: config.SiteConfig.RegistrationRequireTurnstile || false,
@@ -5235,6 +5238,43 @@ const SiteConfigComponent = ({
</div>
</div>
{/* TMDB 配置 */}
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
TMDB
</h3>
{/* TMDB API Key */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
TMDB API Key
</label>
<input
type='text'
placeholder='请输入 TMDB API Key'
value={siteSettings.TMDBApiKey}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
TMDBApiKey: e.target.value,
}))
}
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
TMDB API Key 访{' '}
<a
href='https://www.themoviedb.org/settings/api'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300'
>
TMDB API
</a>
</p>
</div>
</div>
{/* 评论功能配置 */}
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>

View File

@@ -41,6 +41,7 @@ export async function POST(request: NextRequest) {
FluidSearch,
DanmakuApiBase,
DanmakuApiToken,
TMDBApiKey,
EnableComments,
CustomAdFilterCode,
CustomAdFilterVersion,
@@ -72,6 +73,7 @@ export async function POST(request: NextRequest) {
FluidSearch: boolean;
DanmakuApiBase: string;
DanmakuApiToken: string;
TMDBApiKey?: string;
EnableComments: boolean;
CustomAdFilterCode?: string;
CustomAdFilterVersion?: number;
@@ -106,6 +108,7 @@ export async function POST(request: NextRequest) {
typeof FluidSearch !== 'boolean' ||
typeof DanmakuApiBase !== 'string' ||
typeof DanmakuApiToken !== 'string' ||
(TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') ||
typeof EnableComments !== 'boolean' ||
(CustomAdFilterCode !== undefined && typeof CustomAdFilterCode !== 'string') ||
(CustomAdFilterVersion !== undefined && typeof CustomAdFilterVersion !== 'number') ||
@@ -155,6 +158,7 @@ export async function POST(request: NextRequest) {
FluidSearch,
DanmakuApiBase,
DanmakuApiToken,
TMDBApiKey,
EnableComments,
CustomAdFilterCode,
CustomAdFilterVersion,

View File

@@ -61,6 +61,7 @@ export default async function RootLayout({
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
let enableComments = false;
let tmdbApiKey = '';
let customCategories = [] as {
name: string;
type: 'movie' | 'tv';
@@ -85,6 +86,7 @@ export default async function RootLayout({
}));
fluidSearch = config.SiteConfig.FluidSearch;
enableComments = config.SiteConfig.EnableComments;
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
}
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
@@ -128,7 +130,7 @@ export default async function RootLayout({
enableSystem
disableTransitionOnChange
>
<SiteProvider siteName={siteName} announcement={announcement}>
<SiteProvider siteName={siteName} announcement={announcement} tmdbApiKey={tmdbApiKey}>
<WatchRoomProvider>
<DownloadProvider>
<DanmakuCacheCleanup />

View File

@@ -18,6 +18,11 @@ import {
subscribeToDataUpdates,
} from '@/lib/db.client';
import { getDoubanCategories } from '@/lib/douban.client';
import {
getTMDBUpcomingContent,
getTMDBImageUrl,
TMDBItem,
} from '@/lib/tmdb.client';
import { DoubanItem } from '@/lib/types';
import CapsuleSwitch from '@/components/CapsuleSwitch';
@@ -32,11 +37,12 @@ function HomeClient() {
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
const [upcomingContent, setUpcomingContent] = useState<TMDBItem[]>([]);
const [bangumiCalendarData, setBangumiCalendarData] = useState<
BangumiCalendarData[]
>([]);
const [loading, setLoading] = useState(true);
const { announcement } = useSite();
const { announcement, tmdbApiKey } = useSite();
const [showAnnouncement, setShowAnnouncement] = useState(false);
@@ -73,7 +79,7 @@ function HomeClient() {
try {
setLoading(true);
// 并行获取热门电影、热门剧集热门综艺
// 并行获取热门电影、热门剧集热门综艺和番剧日历
const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] =
await Promise.all([
getDoubanCategories({
@@ -99,6 +105,20 @@ function HomeClient() {
}
setBangumiCalendarData(bangumiCalendarData);
// 如果配置了 TMDB API Key则获取即将上映/播出内容
if (tmdbApiKey) {
const tmdbData = await getTMDBUpcomingContent(tmdbApiKey);
if (tmdbData.code === 200) {
// 按上映/播出日期升序排序(最近的排在前面)
const sortedContent = [...tmdbData.list].sort((a, b) => {
const dateA = new Date(a.release_date || '9999-12-31').getTime();
const dateB = new Date(b.release_date || '9999-12-31').getTime();
return dateA - dateB;
});
setUpcomingContent(sortedContent);
}
}
} catch (error) {
console.error('获取推荐数据失败:', error);
} finally {
@@ -107,7 +127,7 @@ function HomeClient() {
};
fetchRecommendData();
}, []);
}, [tmdbApiKey]);
// 处理收藏数据更新的函数
const updateFavoriteItems = useCallback(
@@ -447,6 +467,40 @@ function HomeClient() {
))}
</ScrollableRow>
</section>
{/* 即将上映/播出 (TMDB) */}
{tmdbApiKey && upcomingContent.length > 0 && (
<section className='mb-8'>
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
</div>
<ScrollableRow>
{upcomingContent.map((item) => (
<div
key={`${item.media_type}-${item.id}`}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
title={item.title}
poster={getTMDBImageUrl(item.poster_path)}
year={item.release_date?.split('-')?.[0] || ''}
rate={
item.vote_average && item.vote_average > 0
? item.vote_average.toFixed(1)
: ''
}
type={item.media_type === 'tv' ? 'tv' : 'movie'}
from='douban'
releaseDate={item.release_date}
isUpcoming={true}
/>
</div>
))}
</ScrollableRow>
</section>
)}
</>
)}
</div>

View File

@@ -2,11 +2,16 @@
import { createContext, ReactNode, useContext } from 'react';
const SiteContext = createContext<{ siteName: string; announcement?: string }>({
const SiteContext = createContext<{
siteName: string;
announcement?: string;
tmdbApiKey?: string;
}>({
// 默认值
siteName: 'MoonTV',
announcement:
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
tmdbApiKey: '',
});
export const useSite = () => useContext(SiteContext);
@@ -15,13 +20,15 @@ export function SiteProvider({
children,
siteName,
announcement,
tmdbApiKey,
}: {
children: ReactNode;
siteName: string;
announcement?: string;
tmdbApiKey?: string;
}) {
return (
<SiteContext.Provider value={{ siteName, announcement }}>
<SiteContext.Provider value={{ siteName, announcement, tmdbApiKey }}>
{children}
</SiteContext.Provider>
);

View File

@@ -47,6 +47,8 @@ export interface VideoCardProps {
isBangumi?: boolean;
isAggregate?: boolean;
origin?: 'vod' | 'live';
releaseDate?: string; // 上映日期格式YYYY-MM-DD
isUpcoming?: boolean; // 是否为即将上映
}
export type VideoCardHandle = {
@@ -76,6 +78,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
isBangumi = false,
isAggregate = false,
origin = 'vod',
releaseDate,
isUpcoming = false,
}: VideoCardProps,
ref
) {
@@ -223,6 +227,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
);
const handleClick = useCallback(() => {
// 即将上映的电影不跳转
if (isUpcoming) {
return;
}
if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
@@ -240,6 +249,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
router.push(url);
}
}, [
isUpcoming,
origin,
from,
actualSource,
@@ -254,6 +264,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
// 新标签页播放处理函数
const handlePlayInNewTab = useCallback(() => {
// 即将上映的电影不跳转
if (isUpcoming) {
return;
}
if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
@@ -270,6 +285,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
window.open(url, '_blank');
}
}, [
isUpcoming,
origin,
from,
actualSource,
@@ -295,6 +311,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
// 长按操作
const handleLongPress = useCallback(() => {
// 即将上映的电影不显示操作菜单
if (isUpcoming) {
return;
}
if (!showMobileActions) { // 防止重复触发
// 立即显示菜单,避免等待数据加载导致动画卡顿
setShowMobileActions(true);
@@ -304,7 +325,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
checkSearchFavoriteStatus();
}
}
}, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]);
}, [isUpcoming, showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]);
// 长按手势hook
const longPressProps = useLongPress({
@@ -313,6 +334,28 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
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: {
@@ -348,7 +391,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
douban: {
showSourceName: false,
showProgress: false,
showPlayButton: true,
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
showHeart: false,
showCheckCircle: false,
showDoubanLink: true,
@@ -357,7 +400,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
},
};
return configs[from] || configs.search;
}, [from, isAggregate, douban_id, rate]);
}, [from, isAggregate, douban_id, rate, isUpcoming]);
// 移动端操作菜单配置
const mobileActions = useMemo(() => {
@@ -495,7 +538,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return (
<>
<div
className='group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500]'
className={`group relative w-full rounded-lg bg-transparent transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500] ${isUpcoming ? 'cursor-default' : 'cursor-pointer'}`}
onClick={handleClick}
{...longPressProps}
style={{
@@ -513,6 +556,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
e.preventDefault();
e.stopPropagation();
// 即将上映的电影不显示操作菜单
if (isUpcoming) {
return false;
}
// 右键弹出操作菜单
setShowMobileActions(true);
@@ -595,8 +643,37 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}}
/>
{/* 播放按钮 */}
{config.showPlayButton && (
{/* 播放按钮或上映倒计时 */}
{isUpcoming && daysUntilRelease !== null ? (
<div
data-button="true"
className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
>
<div
className='bg-black/70 backdrop-blur-sm text-white px-4 py-2 rounded-lg text-sm font-medium shadow-lg'
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties}
>
{daysUntilRelease > 0
? `${daysUntilRelease}天后上映`
: daysUntilRelease === 0
? '今日上映'
: '已上映'}
</div>
</div>
) : config.showPlayButton && (
<div
data-button="true"
className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'

View File

@@ -19,6 +19,8 @@ export interface AdminConfig {
// 弹幕配置
DanmakuApiBase: string;
DanmakuApiToken: string;
// TMDB配置
TMDBApiKey?: string;
// 评论功能开关
EnableComments: boolean;
// 自定义去广告代码

View File

@@ -221,6 +221,8 @@ async function getInitConfig(configFile: string, subConfig: {
// 弹幕配置
DanmakuApiBase: process.env.DANMAKU_API_BASE || 'http://localhost:9321',
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
// TMDB配置
TMDBApiKey: '',
// 评论功能开关
EnableComments: false,
},

201
src/lib/tmdb.client.ts Normal file
View File

@@ -0,0 +1,201 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
export interface TMDBMovie {
id: number;
title: string;
poster_path: string | null;
release_date: string;
overview: string;
vote_average: number;
}
export interface TMDBTVShow {
id: number;
name: string;
poster_path: string | null;
first_air_date: string;
overview: string;
vote_average: number;
}
// 统一的类型,用于显示
export interface TMDBItem {
id: number;
title: string;
poster_path: string | null;
release_date: string;
overview: string;
vote_average: number;
media_type: 'movie' | 'tv';
}
interface TMDBUpcomingResponse {
results: TMDBMovie[];
page: number;
total_pages: number;
total_results: number;
}
interface TMDBTVAiringTodayResponse {
results: TMDBTVShow[];
page: number;
total_pages: number;
total_results: number;
}
/**
* 获取即将上映的电影
* @param apiKey - TMDB API Key
* @param page - 页码
* @param region - 地区代码,默认 CN (中国)
* @returns 即将上映的电影列表
*/
export async function getTMDBUpcomingMovies(
apiKey: string,
page: number = 1,
region: string = 'CN'
): Promise<{ code: number; list: TMDBMovie[] }> {
try {
if (!apiKey) {
return { code: 400, list: [] };
}
const response = await fetch(
`https://api.themoviedb.org/3/movie/upcoming?api_key=${apiKey}&language=zh-CN&page=${page}&region=${region}`
);
if (!response.ok) {
console.error('TMDB API 请求失败:', response.status, response.statusText);
return { code: response.status, list: [] };
}
const data: TMDBUpcomingResponse = await response.json();
return {
code: 200,
list: data.results,
};
} catch (error) {
console.error('获取 TMDB 即将上映电影失败:', error);
return { code: 500, list: [] };
}
}
/**
* 获取正在播出的电视剧
* @param apiKey - TMDB API Key
* @param page - 页码
* @returns 正在播出的电视剧列表
*/
export async function getTMDBUpcomingTVShows(
apiKey: string,
page: number = 1
): Promise<{ code: number; list: TMDBTVShow[] }> {
try {
if (!apiKey) {
return { code: 400, list: [] };
}
// 使用 on_the_air 接口获取正在播出的电视剧
const response = await fetch(
`https://api.themoviedb.org/3/tv/on_the_air?api_key=${apiKey}&language=zh-CN&page=${page}`
);
if (!response.ok) {
console.error('TMDB TV API 请求失败:', response.status, response.statusText);
return { code: response.status, list: [] };
}
const data: TMDBTVAiringTodayResponse = await response.json();
return {
code: 200,
list: data.results,
};
} catch (error) {
console.error('获取 TMDB 正在播出电视剧失败:', error);
return { code: 500, list: [] };
}
}
/**
* 获取即将上映/播出的内容(电影+电视剧)
* @param apiKey - TMDB API Key
* @returns 统一格式的即将上映/播出列表
*/
export async function getTMDBUpcomingContent(
apiKey: string
): Promise<{ code: number; list: TMDBItem[] }> {
try {
if (!apiKey) {
return { code: 400, list: [] };
}
// 并行获取电影和电视剧数据
const [moviesResult, tvShowsResult] = await Promise.all([
getTMDBUpcomingMovies(apiKey),
getTMDBUpcomingTVShows(apiKey),
]);
// 获取今天的日期(本地时区)
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// 转换电影数据为统一格式,并过滤掉已上映的
const movies: TMDBItem[] = moviesResult.list
.filter((movie) => {
// 只保留未来上映的电影
return movie.release_date && movie.release_date >= todayStr;
})
.map((movie) => ({
id: movie.id,
title: movie.title,
poster_path: movie.poster_path,
release_date: movie.release_date,
overview: movie.overview,
vote_average: movie.vote_average,
media_type: 'movie' as const,
}));
// 转换电视剧数据为统一格式,并过滤掉已播出的
const tvShows: TMDBItem[] = tvShowsResult.list
.filter((tv) => {
// 只保留未来播出的电视剧
return tv.first_air_date && tv.first_air_date >= todayStr;
})
.map((tv) => ({
id: tv.id,
title: tv.name,
poster_path: tv.poster_path,
release_date: tv.first_air_date,
overview: tv.overview,
vote_average: tv.vote_average,
media_type: 'tv' as const,
}));
// 合并并返回
const allContent = [...movies, ...tvShows];
return {
code: 200,
list: allContent,
};
} catch (error) {
console.error('获取 TMDB 即将上映内容失败:', error);
return { code: 500, list: [] };
}
}
/**
* 获取 TMDB 图片完整 URL
* @param path - 图片路径
* @param size - 图片尺寸,默认 w500
* @returns 完整的图片 URL
*/
export function getTMDBImageUrl(
path: string | null,
size: string = 'w500'
): string {
if (!path) return '';
return `https://image.tmdb.org/t/p/${size}${path}`;
}