From 24a411c1043f447b760594ae2c72130a834d2563 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 26 Jan 2026 10:29:13 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E8=BD=AE=E6=92=AD?= =?UTF-8?q?=E5=9B=BE=E5=92=8C=E7=BB=A7=E7=BB=AD=E8=A7=82=E7=9C=8B=E5=8F=AF?= =?UTF-8?q?=E9=9A=90=E8=97=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 59 ++++++++++++++----------- src/components/UserMenu.tsx | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 25 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index f04e6a2..8c5b233 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -55,6 +55,8 @@ function HomeClient() { { id: 'hotVarietyShows', name: '热门综艺', enabled: true, order: 4 }, { id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 }, ]); + const [homeBannerEnabled, setHomeBannerEnabled] = useState(true); + const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true); const [showAnnouncement, setShowAnnouncement] = useState(false); const [showHttpWarning, setShowHttpWarning] = useState(true); @@ -63,33 +65,38 @@ function HomeClient() { const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?'); const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true); - // 加载首页模块配置 - useEffect(() => { - if (typeof window !== 'undefined') { - const savedHomeModules = localStorage.getItem('homeModules'); - if (savedHomeModules) { - try { - setHomeModules(JSON.parse(savedHomeModules)); - } catch (error) { - console.error('解析首页模块配置失败:', error); - } + const loadHomeLayoutSettings = () => { + if (typeof window === 'undefined') return; + + const savedHomeModules = localStorage.getItem('homeModules'); + if (savedHomeModules) { + try { + setHomeModules(JSON.parse(savedHomeModules)); + } catch (error) { + console.error('解析首页模块配置失败:', error); } } + + const savedHomeBannerEnabled = localStorage.getItem('homeBannerEnabled'); + if (savedHomeBannerEnabled !== null) { + setHomeBannerEnabled(savedHomeBannerEnabled === 'true'); + } + + const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled'); + if (savedHomeContinueWatchingEnabled !== null) { + setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true'); + } + }; + + // 加载首页模块配置 + useEffect(() => { + loadHomeLayoutSettings(); }, []); // 监听首页模块配置更新事件 useEffect(() => { const handleHomeModulesUpdated = () => { - if (typeof window !== 'undefined') { - const savedHomeModules = localStorage.getItem('homeModules'); - if (savedHomeModules) { - try { - setHomeModules(JSON.parse(savedHomeModules)); - } catch (error) { - console.error('解析首页模块配置失败:', error); - } - } - } + loadHomeLayoutSettings(); }; window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated); @@ -547,16 +554,18 @@ function HomeClient() { {/* TMDB 热门轮播图 */} -
- -
+ {homeBannerEnabled && ( +
+ +
+ )}
{/* 首页内容 */} <> {/* 源站寻片和AI问片入口 */} -
+
{/* 源站寻片入口 */} {sourceSearchEnabled && ( @@ -582,7 +591,7 @@ function HomeClient() {
{/* 继续观看 */} - + {homeContinueWatchingEnabled && } {/* 根据配置动态渲染首页模块 */} {homeModules diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index e096f48..81a5ee1 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -174,6 +174,8 @@ export const UserMenu: React.FC = () => { ]; const [homeModules, setHomeModules] = useState(defaultHomeModules); + const [homeBannerEnabled, setHomeBannerEnabled] = useState(true); + const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true); // 豆瓣数据源选项 const doubanDataSourceOptions = [ @@ -443,6 +445,16 @@ export const UserMenu: React.FC = () => { setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true'); } + const savedHomeBannerEnabled = localStorage.getItem('homeBannerEnabled'); + if (savedHomeBannerEnabled !== null) { + setHomeBannerEnabled(savedHomeBannerEnabled === 'true'); + } + + const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled'); + if (savedHomeContinueWatchingEnabled !== null) { + setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true'); + } + // 加载首页模块配置 const savedHomeModules = localStorage.getItem('homeModules'); if (savedHomeModules !== null) { @@ -917,6 +929,22 @@ export const UserMenu: React.FC = () => { } }; + const handleHomeBannerToggle = (value: boolean) => { + setHomeBannerEnabled(value); + if (typeof window !== 'undefined') { + localStorage.setItem('homeBannerEnabled', String(value)); + window.dispatchEvent(new CustomEvent('homeModulesUpdated')); + } + }; + + const handleHomeContinueWatchingToggle = (value: boolean) => { + setHomeContinueWatchingEnabled(value); + if (typeof window !== 'undefined') { + localStorage.setItem('homeContinueWatchingEnabled', String(value)); + window.dispatchEvent(new CustomEvent('homeModulesUpdated')); + } + }; + // 首页模块配置处理函数 const handleHomeModuleToggle = (id: string, enabled: boolean) => { const updatedModules = homeModules.map(module => @@ -1010,6 +1038,8 @@ export const UserMenu: React.FC = () => { setNextEpisodePreCache(true); setNextEpisodeDanmakuPreload(true); setDisableAutoLoadDanmaku(false); + setHomeBannerEnabled(true); + setHomeContinueWatchingEnabled(true); setHomeModules(defaultHomeModules); setSearchTraditionalToSimplified(false); @@ -1031,6 +1061,8 @@ export const UserMenu: React.FC = () => { localStorage.setItem('disableAutoLoadDanmaku', 'false'); localStorage.setItem('danmakuMaxCount', '0'); localStorage.setItem('danmaku_heatmap_disabled', 'false'); + localStorage.setItem('homeBannerEnabled', 'true'); + localStorage.setItem('homeContinueWatchingEnabled', 'true'); localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules)); localStorage.setItem('searchTraditionalToSimplified', 'false'); window.dispatchEvent(new CustomEvent('homeModulesUpdated')); @@ -2189,6 +2221,55 @@ export const UserMenu: React.FC = () => {

+ {/* 首页顶部组件显示 */} +
+
+ +
+ + 首页轮播图 + +
+
+ +
+ +
+ + 继续观看 + +
+
+
+ {/* 模块列表 */}
{homeModules.map((module, index) => ( @@ -2247,8 +2328,12 @@ export const UserMenu: React.FC = () => { + {/* 源站寻片入口 */} {sourceSearchEnabled && ( @@ -635,6 +662,62 @@ function HomeClient() {
)} + + {showDirectPlayDialog && ( +
setShowDirectPlayDialog(false)} + > +
event.stopPropagation()} + > +
+

+ 直链播放 +

+ +
+
+
+ 请输入可直接播放的视频链接。 +
+ setDirectPlayUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + submitDirectPlay(); + } + }} + placeholder='https://example.com/video.m3u8' + className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500' + /> +
+ + +
+
+
+
+ )} ); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index f2e1055..d081a7d 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -48,7 +48,7 @@ import { import { getDoubanDetail } from '@/lib/douban.client'; import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { DanmakuFilterConfig, EpisodeFilterConfig,SearchResult } from '@/lib/types'; -import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; +import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { useEnableComments } from '@/hooks/useEnableComments'; import { usePlaySync } from '@/hooks/usePlaySync'; @@ -488,6 +488,7 @@ function PlayPageClient() { const [currentSource, setCurrentSource] = useState(searchParams.get('source') || ''); const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名 + const isDirectPlay = currentSource === 'directplay'; // 解析 source 参数以获取 embyKey(仅用于 API 调用) const parseSourceForApi = (source: string): { source: string; embyKey?: string } => { @@ -601,6 +602,10 @@ function PlayPageClient() { return; } + if (isDirectPlay) { + return; + } + // 检查是否禁用了自动装填弹幕 const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (disableAutoLoad) { @@ -938,11 +943,19 @@ function PlayPageClient() { }; loadDanmakuForCurrentEpisode(); - }, [currentEpisodeIndex, videoTitle, loading]); + }, [currentEpisodeIndex, videoTitle, loading, isDirectPlay]); // 获取豆瓣评分数据 useEffect(() => { const fetchDoubanRating = async () => { + if (isDirectPlay) { + setDoubanRating(null); + setDoubanCardSubtitle(''); + setDoubanAka([]); + setDoubanYear(''); + return; + } + if (!videoDoubanId || videoDoubanId === 0) { setDoubanRating(null); setDoubanCardSubtitle(''); @@ -994,11 +1007,16 @@ function PlayPageClient() { }; fetchDoubanRating(); - }, [videoDoubanId]); + }, [videoDoubanId, isDirectPlay]); // 获取TMDB背景图 useEffect(() => { const fetchTMDBBackdrop = async () => { + if (isDirectPlay) { + setTmdbBackdrop(null); + return; + } + // 检查是否禁用背景图 if (typeof window !== 'undefined') { const disabled = localStorage.getItem('tmdb_backdrop_disabled'); @@ -1146,7 +1164,7 @@ function PlayPageClient() { }; fetchTMDBBackdrop(); - }, [videoTitle, videoDoubanId]); + }, [videoTitle, videoDoubanId, isDirectPlay]); // 视频播放地址 @@ -1168,6 +1186,14 @@ function PlayPageClient() { // 总集数 const totalEpisodes = detail?.episodes?.length || 0; + const directEpisodeLabel = detail?.episodes_titles?.[currentEpisodeIndex] || '直链'; + const shouldShowEpisodeLabel = totalEpisodes > 1 || isDirectPlay; + const episodeLabel = isDirectPlay + ? directEpisodeLabel + : detail?.episodes_titles?.[currentEpisodeIndex] || `第 ${currentEpisodeIndex + 1} 集`; + const playerEpisodeLabel = isDirectPlay + ? directEpisodeLabel + : `第${currentEpisodeIndex + 1}集`; // 用于记录是否需要在播放器 ready 后跳转到指定进度 const resumeTimeRef = useRef(null); @@ -2808,6 +2834,73 @@ function PlayPageClient() { }; const initAll = async () => { + if (currentSource === 'directplay') { + if (!currentId) { + setError('缺少直链地址'); + setLoading(false); + return; + } + + setLoading(true); + setLoadingStage('fetching'); + setLoadingMessage('🎬 正在准备直链播放...'); + + let directUrl = ''; + try { + directUrl = base58Decode(currentId); + } catch (decodeError) { + console.error('直链地址解析失败:', decodeError); + setError('直链地址解析失败'); + setLoading(false); + return; + } + + const directDetail: SearchResult = { + id: currentId, + title: '直链播放', + poster: '', + episodes: [directUrl], + episodes_titles: ['直链'], + source: 'directplay', + source_name: '直链', + class: '', + year: '', + desc: '', + type_name: '', + douban_id: 0, + }; + + setNeedPrefer(false); + setCurrentSource('directplay'); + setCurrentId(currentId); + setVideoTitle('直链播放'); + setVideoYear(''); + setVideoCover(''); + setVideoDoubanId(0); + setCorrectedDesc(''); + setDetail(directDetail); + setSourceProxyMode(false); + setAvailableSources([directDetail]); + setCurrentEpisodeIndex(0); + setSourceSearchError(null); + setSourceSearchLoading(false); + setBackgroundSourcesLoading(false); + + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('source', 'directplay'); + newUrl.searchParams.set('id', currentId); + newUrl.searchParams.delete('prefer'); + newUrl.searchParams.delete('fileName'); + window.history.replaceState({}, '', newUrl.toString()); + + setLoadingStage('ready'); + setLoadingMessage('✨ 准备就绪,即将开始播放...'); + setTimeout(() => { + setLoading(false); + }, 500); + return; + } + if (!currentSource && !currentId && !videoTitle && !searchTitle) { setError('缺少必要参数'); setLoading(false); @@ -3698,6 +3791,7 @@ function PlayPageClient() { // 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑) const preloadNextEpisodeDanmaku = async () => { try { + if (isDirectPlay) return; const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (disableAutoLoad) return; @@ -4043,6 +4137,7 @@ function PlayPageClient() { // 自动搜索并加载弹幕 const autoSearchDanmaku = async () => { + if (isDirectPlay) return; const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (disableAutoLoad) return; @@ -4616,9 +4711,7 @@ function PlayPageClient() { // 非WebKit浏览器且播放器已存在,使用switch方法切换 if (!isWebkit && artPlayerRef.current) { artPlayerRef.current.switch = videoUrl; - artPlayerRef.current.title = `${videoTitle} - 第${ - currentEpisodeIndex + 1 - }集`; + artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`; artPlayerRef.current.poster = videoCover; if (artPlayerRef.current?.video) { ensureVideoSource( @@ -6993,12 +7086,9 @@ function PlayPageClient() {

{videoTitle || '影片标题'} - {totalEpisodes > 1 && ( + {shouldShowEpisodeLabel && ( - {` > ${ - detail?.episodes_titles?.[currentEpisodeIndex] || - `第 ${currentEpisodeIndex + 1} 集` - }`} + {` > ${episodeLabel}`} )} @@ -7568,254 +7658,258 @@ function PlayPageClient() {

- {/* 详情展示 */} -
- {/* 文字区 */} -
-
- {/* 标题 */} -

- 0 ? 'relative group cursor-help' : ''}> - {videoTitle || '影片标题'} - {/* aka 悬浮提示 */} - {doubanAka.length > 0 && ( -
-
又名:
- {doubanAka.map((name, index) => ( -
- {name} + {!isDirectPlay && ( + <> + {/* 详情展示 */} +
+ {/* 文字区 */} +
+
+ {/* 标题 */} +

+ 0 ? 'relative group cursor-help' : ''}> + {videoTitle || '影片标题'} + {/* aka 悬浮提示 */} + {doubanAka.length > 0 && ( +
+
又名:
+ {doubanAka.map((name, index) => ( +
+ {name} +
+ ))} +
- ))} -
-

- )} - - - {/* 网盘搜索按钮 */} - - {/* AI问片按钮 */} - {aiEnabled && detail && ( - - )} - {/* 纠错按钮 - 仅小雅源显示 */} - {detail && detail.source === 'xiaoya' && ( - - )} - {/* 豆瓣评分显示 */} - {doubanRating && doubanRating.value > 0 && ( -
- {/* 星级显示 */} -
- {[1, 2, 3, 4, 5].map((star) => { - const starValue = doubanRating.value / 2; // 转换为5星制 - const isFullStar = star <= Math.floor(starValue); - const isHalfStar = !isFullStar && star <= Math.ceil(starValue) && starValue % 1 >= 0.25; - - return ( -
- {isFullStar ? ( - // 全星 - - - - ) : isHalfStar ? ( - // 半星 - <> - {/* 空星背景 */} - - - - {/* 半星遮罩 */} - - - - - ) : ( - // 空星 - - - - )} -
- ); - })} -
- {/* 评分数值 */} - - {doubanRating.value.toFixed(1)} + )} - {/* 评分人数 */} - - ({doubanRating.count.toLocaleString()}人评价) - -
- )} -

- - {/* 关键信息行 */} -
- {detail?.class && ( - - {detail.class} - - )} - {/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */} - {(doubanYear || detail?.year || videoYear) && ( - {doubanYear || detail?.year || videoYear} - )} - {detail?.source_name && ( - - {detail.source_name} - - )} - {detail?.type_name && {detail.type_name}} -
- {/* 剧情简介 */} - {(doubanCardSubtitle || correctedDesc || detail?.desc) && ( -
- {/* card_subtitle 在前,desc 在后 */} - {doubanCardSubtitle && ( -
- {doubanCardSubtitle} -
- )} - {correctedDesc || detail?.desc} -
- )} -
-
- - {/* 封面展示 */} -
-
-
- {videoCover ? ( - <> - {videoTitle} - - {/* 豆瓣链接按钮 */} - {videoDoubanId !== 0 && ( - { + e.stopPropagation(); + handleToggleFavorite(); + }} + className='flex-shrink-0 hover:opacity-80 transition-opacity' + > + + + {/* 网盘搜索按钮 */} + + {/* AI问片按钮 */} + {aiEnabled && detail && ( + )} - - ) : ( - - 封面图片 - - )} + {/* 纠错按钮 - 仅小雅源显示 */} + {detail && detail.source === 'xiaoya' && ( + + )} + {/* 豆瓣评分显示 */} + {doubanRating && doubanRating.value > 0 && ( +
+ {/* 星级显示 */} +
+ {[1, 2, 3, 4, 5].map((star) => { + const starValue = doubanRating.value / 2; // 转换为5星制 + const isFullStar = star <= Math.floor(starValue); + const isHalfStar = !isFullStar && star <= Math.ceil(starValue) && starValue % 1 >= 0.25; + + return ( +
+ {isFullStar ? ( + // 全星 + + + + ) : isHalfStar ? ( + // 半星 + <> + {/* 空星背景 */} + + + + {/* 半星遮罩 */} + + + + + ) : ( + // 空星 + + + + )} +
+ ); + })} +
+ {/* 评分数值 */} + + {doubanRating.value.toFixed(1)} + + {/* 评分人数 */} + + ({doubanRating.count.toLocaleString()}人评价) + +
+ )} + + + {/* 关键信息行 */} +
+ {detail?.class && ( + + {detail.class} + + )} + {/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */} + {(doubanYear || detail?.year || videoYear) && ( + {doubanYear || detail?.year || videoYear} + )} + {detail?.source_name && ( + + {detail.source_name} + + )} + {detail?.type_name && {detail.type_name}} +
+ {/* 剧情简介 */} + {(doubanCardSubtitle || correctedDesc || detail?.desc) && ( +
+ {/* card_subtitle 在前,desc 在后 */} + {doubanCardSubtitle && ( +
+ {doubanCardSubtitle} +
+ )} + {correctedDesc || detail?.desc} +
+ )} +
+
+ + {/* 封面展示 */} +
+
+
+ {videoCover ? ( + <> + {videoTitle} + + {/* 豆瓣链接按钮 */} + {videoDoubanId !== 0 && ( + +
+ + + + +
+
+ )} + + ) : ( + + 封面图片 + + )} +
+
-
- - {/* 推荐区域 */} - + {/* 推荐区域 */} + - {/* 豆瓣评论区域 */} - {videoDoubanId !== 0 && enableComments && ( -
-
- {/* 标题 */} -
-

- - - - 豆瓣评论 -

+ {/* 豆瓣评论区域 */} + {videoDoubanId !== 0 && enableComments && ( +
+
+ {/* 标题 */} +
+

+ + + + 豆瓣评论 +

+
+ + {/* 评论内容 */} +
+ +
+
- - {/* 评论内容 */} -
- -
-
-
+ )} + )}
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index ee82b8b..73ef15a 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ -import { Settings } from 'lucide-react'; +import { Link as LinkIcon, Settings } from 'lucide-react'; import { useRouter } from 'next/navigation'; import React, { useCallback, @@ -811,8 +811,10 @@ const EpisodeSelector: React.FC = ({ }`.trim()} > {/* 封面 */} -
- {source.poster && ( +
+ {source.source === 'directplay' ? ( + + ) : source.poster ? ( {source.title} = ({ target.style.display = 'none'; }} /> - )} + ) : null}
{/* 信息区域 */} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 5badba6..a170b05 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -167,6 +167,7 @@ const VideoCard = forwardRef(function VideoCard const actualYear = year; const actualQuery = query || ''; const actualSearchType = type; + const isDirectPlaySource = actualSource === 'directplay'; const displayYear = useMemo(() => { if (!actualYear) return ''; const normalized = actualYear.trim(); @@ -719,42 +720,47 @@ const VideoCard = forwardRef(function VideoCard }} > {/* 骨架屏 */} - {!isLoading && } - {/* 图片 */} - {actualTitle} setIsLoading(true)} - onError={(e) => { - // 图片加载失败时的重试机制 - const img = e.target as HTMLImageElement; - if (!img.dataset.retried) { - img.dataset.retried = 'true'; - setTimeout(() => { - img.src = processImageUrl(actualPoster); - }, 2000); - } - }} - style={{ - // 禁用图片的默认长按效果 - WebkitUserSelect: 'none', - userSelect: 'none', - WebkitTouchCallout: 'none', - pointerEvents: 'none', // 图片不响应任何指针事件 - } as React.CSSProperties} - onContextMenu={(e) => { - e.preventDefault(); - return false; - }} - onDragStart={(e) => { - e.preventDefault(); - return false; - }} - /> + {!isLoading && !isDirectPlaySource && } + {isDirectPlaySource ? ( +
+ +
+ ) : ( + {actualTitle} setIsLoading(true)} + onError={(e) => { + // 图片加载失败时的重试机制 + const img = e.target as HTMLImageElement; + if (!img.dataset.retried) { + img.dataset.retried = 'true'; + setTimeout(() => { + img.src = processImageUrl(actualPoster); + }, 2000); + } + }} + style={{ + // 禁用图片的默认长按效果 + WebkitUserSelect: 'none', + userSelect: 'none', + WebkitTouchCallout: 'none', + pointerEvents: 'none', // 图片不响应任何指针事件 + } as React.CSSProperties} + onContextMenu={(e) => { + e.preventDefault(); + return false; + }} + onDragStart={(e) => { + e.preventDefault(); + return false; + }} + /> + )} {/* 悬浮遮罩 */}
Date: Mon, 26 Jan 2026 18:48:15 +0800 Subject: [PATCH 05/11] fix cf --- .github/workflows/cloudflare-deploy.yml | 47 +++++++++++++++++++++++++ wrangler.toml | 5 +-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cloudflare-deploy.yml b/.github/workflows/cloudflare-deploy.yml index 1d5210b..697a172 100644 --- a/.github/workflows/cloudflare-deploy.yml +++ b/.github/workflows/cloudflare-deploy.yml @@ -67,6 +67,52 @@ jobs: echo "INIT_CONFIG=${{ secrets.INIT_CONFIG }}" >> $GITHUB_ENV echo "CONFIG_SUBSCRIPTION_URL=${{ secrets.CONFIG_SUBSCRIPTION_URL }}" >> $GITHUB_ENV + - name: Update wrangler.toml with environment variables + run: | + # Function to append variable to wrangler.toml if it has a value + append_var() { + local var_name=$1 + local var_value=$2 + if [ -n "$var_value" ]; then + echo "$var_name = \"$var_value\"" >> wrangler.toml + fi + } + + # Append all environment variables to wrangler.toml + append_var "NEXT_PUBLIC_STORAGE_TYPE" "${{ secrets.NEXT_PUBLIC_STORAGE_TYPE }}" + append_var "NEXT_PUBLIC_SITE_NAME" "${{ secrets.NEXT_PUBLIC_SITE_NAME }}" + append_var "NEXT_PUBLIC_SEARCH_MAX_PAGE" "${{ secrets.NEXT_PUBLIC_SEARCH_MAX_PAGE }}" + append_var "NEXT_PUBLIC_DOUBAN_PROXY_TYPE" "${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY_TYPE }}" + append_var "NEXT_PUBLIC_DOUBAN_PROXY" "${{ secrets.NEXT_PUBLIC_DOUBAN_PROXY }}" + append_var "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE" "${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE }}" + append_var "NEXT_PUBLIC_DOUBAN_IMAGE_PROXY" "${{ secrets.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY }}" + append_var "NEXT_PUBLIC_DISABLE_YELLOW_FILTER" "${{ secrets.NEXT_PUBLIC_DISABLE_YELLOW_FILTER }}" + append_var "NEXT_PUBLIC_FLUID_SEARCH" "${{ secrets.NEXT_PUBLIC_FLUID_SEARCH }}" + append_var "NEXT_PUBLIC_PROXY_M3U8_TOKEN" "${{ secrets.NEXT_PUBLIC_PROXY_M3U8_TOKEN }}" + append_var "NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES" "${{ secrets.NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES }}" + append_var "NEXT_PUBLIC_VOICE_CHAT_STRATEGY" "${{ secrets.NEXT_PUBLIC_VOICE_CHAT_STRATEGY }}" + append_var "NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD" "${{ secrets.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD }}" + append_var "NEXT_PUBLIC_ENABLE_SOURCE_SEARCH" "${{ secrets.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH }}" + append_var "USERNAME" "${{ secrets.USERNAME }}" + append_var "PASSWORD" "${{ secrets.PASSWORD }}" + append_var "UPSTASH_URL" "${{ secrets.UPSTASH_URL }}" + append_var "UPSTASH_TOKEN" "${{ secrets.UPSTASH_TOKEN }}" + append_var "TMDB_API_KEY" "${{ secrets.TMDB_API_KEY }}" + append_var "TMDB_PROXY" "${{ secrets.TMDB_PROXY }}" + append_var "TMDB_REVERSE_PROXY" "${{ secrets.TMDB_REVERSE_PROXY }}" + append_var "DANMAKU_API_BASE" "${{ secrets.DANMAKU_API_BASE }}" + append_var "DANMAKU_API_TOKEN" "${{ secrets.DANMAKU_API_TOKEN }}" + append_var "ANNOUNCEMENT" "${{ secrets.ANNOUNCEMENT }}" + append_var "CRON_PASSWORD" "${{ secrets.CRON_PASSWORD }}" + append_var "SITE_BASE" "${{ secrets.SITE_BASE }}" + append_var "REDIS_URL" "${{ secrets.REDIS_URL }}" + append_var "KVROCKS_URL" "${{ secrets.KVROCKS_URL }}" + append_var "INIT_CONFIG" "${{ secrets.INIT_CONFIG }}" + append_var "CONFIG_SUBSCRIPTION_URL" "${{ secrets.CONFIG_SUBSCRIPTION_URL }}" + + echo "Updated wrangler.toml:" + cat wrangler.toml + - name: Build for Cloudflare run: pnpm run build:cloudflare @@ -77,3 +123,4 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: deploy packageManager: pnpm + wranglerVersion: "4.60.0" diff --git a/wrangler.toml b/wrangler.toml index 489df3c..71dfc64 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -9,5 +9,6 @@ main = ".open-next/worker.js" directory = ".open-next/assets" binding = "ASSETS" -[env.production] -# 生产环境变量可以在 Cloudflare Dashboard 配置 +[vars] +NODE_ENV = "production" +BUILD_TARGET = "cloudflare" From 2a336ac15baf18b6025fc221761df21c6704dd6c Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 26 Jan 2026 21:12:22 +0800 Subject: [PATCH 06/11] =?UTF-8?q?cloudflare=E9=83=A8=E7=BD=B2=E6=95=99?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 199 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 146 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 8d414ec..54b36fa 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,17 @@ MoonTVPlus Logo
+ > 🎬 **MoonTVPlus** 是基于 [MoonTV v100](https://github.com/MoonTechLab/LunaTV) 二次开发的增强版影视聚合播放器。它在原版基础上新增了外部播放器支持、视频超分、弹幕系统、评论抓取等实用功能,提供更强大的观影体验。
- ![Next.js](https://img.shields.io/badge/Next.js-14-000?logo=nextdotjs) ![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3-38bdf8?logo=tailwindcss) ![TypeScript](https://img.shields.io/badge/TypeScript-4.x-3178c6?logo=typescript) ![License](https://img.shields.io/badge/License-MIT-green) ![Docker Ready](https://img.shields.io/badge/Docker-ready-blue?logo=docker) +
@@ -50,6 +51,7 @@ 项目截图 + ### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目,不授权任何“科技周刊/月刊”类项目或站点收录本项目。 ## 🗺 目录 @@ -72,18 +74,18 @@ ## 技术栈 -| 分类 | 主要依赖 | -| --------- | ----------------------------------------------------------------------------------------------------- | -| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router | -| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) | -| 语言 | TypeScript 4 | +| 分类 | 主要依赖 | +| --------- | ------------------------------------------------------------ | +| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router | +| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) | +| 语言 | TypeScript 4 | | 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) | -| 代码质量 | ESLint · Prettier · Jest | -| 部署 | Docker | +| 代码质量 | ESLint · Prettier · Jest | +| 部署 | Docker | ## 部署 -本项目**支持 Docker 和Vercel平台** 部署。 +本项目**支持 Docker、Vercel 和 Cloudflare Workers 平台** 部署。 [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus) @@ -91,7 +93,92 @@ [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/SCHCAY/deploy) -### Kvrocks 存储(推荐) + + +### Cloudflare Workers 部署(通过 GitHub Actions) + +Cloudflare Workers 提供免费的边缘计算服务,通过 GitHub Actions 可以实现自动化部署。 + +#### 前置要求 + +1. 一个 Cloudflare 账号 +2. Fork 本项目到你的 GitHub 账号 +3. 准备一个 Upstash Redis 实例(推荐) + +#### 配置步骤 + +**1. 获取 Cloudflare API Token 和 Account ID** + +- 访问 [Cloudflare Dashboard](https://dash.cloudflare.com/) +- 点击右上角头像 > My Profile > API Tokens +- 点击 "Create Token",选择 "Edit Cloudflare Workers" 模板 +- 或使用自定义 Token,需要以下权限: + - Account - Cloudflare Workers Scripts - Edit + - Account - Cloudflare Workers KV Storage - Edit +- 创建后复制生成的 API Token +- 在 Dashboard 首页右侧可以看到你的 Account ID + +**2. 配置 GitHub Secrets** + +进入你 Fork 的仓库,点击 Settings > Secrets and variables > Actions > New repository secret,添加以下必需的 Secrets: + +**必需配置:** + +| Secret 名称 | 说明 | 示例值 | +| -------------------------- | --------------------- | ------------------------ | +| `CLOUDFLARE_API_TOKEN` | Cloudflare API Token | `your_api_token_here` | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Account ID | `abc123def456` | +| `USERNAME` | 站长账号 | `admin` | +| `PASSWORD` | 站长密码 | `your_secure_password` | +| `NEXT_PUBLIC_STORAGE_TYPE` | 存储类型 | `upstash` | +| `UPSTASH_URL` | Upstash Redis URL | `https://xxx.upstash.io` | +| `UPSTASH_TOKEN` | Upstash Redis Token | `your_upstash_token` | + +**3. 触发部署** + +配置完成后,有两种方式触发部署: + +**方式一:手动触发** + +- 进入仓库的 Actions 页面 +- 选择 "Deploy to Cloudflare" workflow +- 点击 "Run workflow" 按钮 +- 选择分支(通常是 main 或 dev) +- 点击 "Run workflow" 开始部署 + +**方式二:自动触发(可选)** + +如果想要在推送代码时自动部署,可以修改 `.github/workflows/cloudflare-deploy.yml` 文件: + +```yaml +on: + push: + branches: + - main # 或你的主分支名称 + workflow_dispatch: +``` + +**4. 查看部署状态** + +- 在 Actions 页面可以看到部署进度 +- 部署成功后,访问 `https://your-project-name.your-account.workers.dev` +- 也可以在 Cloudflare Dashboard 的 Workers & Pages 中查看部署的应用 + +**5. 绑定自定义域名(可选)** + +- 在 Cloudflare Dashboard 中进入你的 Worker +- 点击 Settings > Triggers > Custom Domains +- 添加你的自定义域名 + +**6.配置外部定时任务(可选)** + +可使用外部定时请求/api/cron/mtvpls端点以触发定时任务,或新建一个workers请求触发,推荐每小时请求一次。 + +--- + +### Docker 部署 + +#### Kvrocks 存储(推荐) ```yml services: @@ -163,6 +250,7 @@ networks: 1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。 2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN** 3. 使用如下 docker compose + ```yml services: moontv-core: @@ -234,47 +322,47 @@ dockge/komodo 等 docker compose UI 也有自动更新功能 ## 环境变量 -| 变量 | 说明 | 可选值 | 默认值 | -| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 | -| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 | -| CRON_PASSWORD | 定时任务 API 访问密码(用于保护 /api/cron 端点) | 任意字符串 | mtvpls | -| SITE_BASE | 站点 url | 形如 https://example.com | 空 | -| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV | -| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | -| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 | -| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 | -| REDIS_URL | redis 连接 url | 连接 url | 空 | -| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 | -| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 | -| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 | -| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct | -| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) | -| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct | -| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) | -| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false | -| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true | -| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) | -| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) | -| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false | -| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) | -| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false | -| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal | -| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) | -| WATCH_ROOM_EXTERNAL_SERVER_AUTH | 外部观影室服务器认证令牌(当 SERVER_TYPE 为 external 时必填) | 任意字符串 | (空) | -| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback | -| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false | -| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data | -| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) | -| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true | -| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 | -| INIT_CONFIG | 初始配置(JSON 格式,包含 api_site、custom_category、lives 等) | JSON 字符串 | (空) | -| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) | -| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) | -| TMDB_PROXY | TMDB 代理地址 | URL | (空) | -| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) | -| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 | -| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 | +| 变量 | 说明 | 可选值 | 默认值 | +| ---------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------------------------------------------------------------ | +| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 | +| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 | +| CRON_PASSWORD | 定时任务 API 访问密码(用于保护 /api/cron 端点) | 任意字符串 | mtvpls | +| SITE_BASE | 站点 url | 形如 https://example.com | 空 | +| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV | +| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 | +| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 | +| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 | +| REDIS_URL | redis 连接 url | 连接 url | 空 | +| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 | +| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 | +| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 | +| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct | +| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) | +| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct | +| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) | +| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false | +| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true | +| NEXT_PUBLIC_PROXY_M3U8_TOKEN | M3U8 代理 API 鉴权 Token(外部播放器跳转时的鉴权token,不填为无鉴权) | 任意字符串 | (空) | +| NEXT_PUBLIC_DANMAKU_CACHE_EXPIRE_MINUTES | 弹幕缓存失效时间(分钟数,设为 0 时不缓存) | 0 或正整数 | 4320(3天) | +| ENABLE_TVBOX_SUBSCRIBE | 是否启用 TVBOX 订阅功能 | true/false | false | +| TVBOX_SUBSCRIBE_TOKEN | TVBOX 订阅 API 访问 Token,如启用TVBOX功能必须设置该项 | 任意字符串 | (空) | +| WATCH_ROOM_ENABLED | 是否启用观影室功能(vercel部署不支持该功能,可使用外部服务器) | true/false | false | +| WATCH_ROOM_SERVER_TYPE | 观影室服务器类型 | internal/external | internal | +| WATCH_ROOM_EXTERNAL_SERVER_URL | 外部观影室服务器地址(当 SERVER_TYPE 为 external 时必填) | WebSocket URL | (空) | +| WATCH_ROOM_EXTERNAL_SERVER_AUTH | 外部观影室服务器认证令牌(当 SERVER_TYPE 为 external 时必填) | 任意字符串 | (空) | +| NEXT_PUBLIC_VOICE_CHAT_STRATEGY | 观影室语音聊天策略 | webrtc-fallback/server-only | webrtc-fallback | +| NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD | 是否启用服务器离线下载功能(开启后也仅管理员和站长可用) | true/false | false | +| OFFLINE_DOWNLOAD_DIR | 离线下载文件存储目录 | 任意有效路径 | /data | +| VIDEOINFO_CACHE_MINUTES | 私人影库视频信息在内存中的缓存时长(分钟) | 正整数 | 1440(1天) | +| NEXT_PUBLIC_ENABLE_SOURCE_SEARCH | 是否开启源站寻片功能 | true/false | true | +| MAX_PLAY_RECORDS_PER_USER | 单个用户播放记录清理阈值(超过此数量将自动清理旧记录) | 正整数 | 100 | +| INIT_CONFIG | 初始配置(JSON 格式,包含 api_site、custom_category、lives 等) | JSON 字符串 | (空) | +| CONFIG_SUBSCRIPTION_URL | 配置订阅 URL(Base58 编码的配置文件地址,优先级高于 INIT_CONFIG) | URL | (空) | +| TMDB_API_KEY | TMDB API 密钥 | 任意字符串 | (空) | +| TMDB_PROXY | TMDB 代理地址 | URL | (空) | +| TMDB_REVERSE_PROXY | TMDB 反向代理地址 | URL | (空) | +| DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 | +| DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 | NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: @@ -307,13 +395,16 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释: **配置步骤:** 1. 按照 [watch-room-server](https://github.com/tgs9915/watch-room-server) 的文档部署外部服务器 + 2. 在 MoonTVPlus 中设置以下环境变量: + ```env WATCH_ROOM_ENABLED=true WATCH_ROOM_SERVER_TYPE=external WATCH_ROOM_EXTERNAL_SERVER_URL=wss://your-watch-room-server.com WATCH_ROOM_EXTERNAL_SERVER_AUTH=your_secure_token ``` + 3. 重启应用即可使用外部观影室服务器 @@ -332,6 +423,7 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释: ## 超分功能说明 + 超分功能需要浏览器支持webgpu并且你的浏览器环境不能是http(如非要在http中使用,需要在浏览器端设置允许不安全的内容) @@ -350,13 +442,14 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释: ### 配置步骤 1. 在环境变量中设置以下配置: + ```env # 启用 TVBOX 订阅功能 ENABLE_TVBOX_SUBSCRIBE=true # 设置订阅访问 Token(请使用强密码) TVBOX_SUBSCRIBE_TOKEN=your_secure_random_token ``` - + 2. 重启应用后,登录网站,点击用户菜单中的"订阅"按钮 3. 复制生成的订阅链接到 TVBOX 应用中使用 @@ -398,4 +491,4 @@ NEXT_PUBLIC_VOICE_CHAT_STRATEGY 选项解释: ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=mtvpls/moontvplus&type=Date)](https://www.star-history.com/#mtvpls/moontvplus&Date) +[![Star History Chart](https://api.star-history.com/svg?repos=mtvpls/moontvplus&type=Date)](https://www.star-history.com/#mtvpls/moontvplus&Date) \ No newline at end of file From 69ad303045c42b0c367b21248b9fc8b4f66c041d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 27 Jan 2026 00:25:02 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E5=88=B7=E6=96=B0token=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=94=BE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/middleware.ts b/src/middleware.ts index 3b4dd3d..8862515 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -215,6 +215,6 @@ function shouldSkipAuth(pathname: string): boolean { // 配置middleware匹配规则 export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play|api/emby/sources).*)', + '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/auth/refresh|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play|api/emby/sources).*)', ], }; From f5c36330d9d8f9d5777811057b6b222a04b31d68 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 27 Jan 2026 09:08:44 +0800 Subject: [PATCH 08/11] =?UTF-8?q?token=E5=88=B7=E6=96=B0=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 2 + src/components/TokenRefreshManager.tsx | 141 +++++++++++++++++++++++++ src/middleware.ts | 63 +---------- 3 files changed, 147 insertions(+), 59 deletions(-) create mode 100644 src/components/TokenRefreshManager.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index beb03d7..f3f35d5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,7 @@ import { DownloadPanel } from '../components/DownloadPanel'; import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator'; import { SiteProvider } from '../components/SiteProvider'; import { ThemeProvider } from '../components/ThemeProvider'; +import { TokenRefreshManager } from '../components/TokenRefreshManager'; import TopProgressBar from '../components/TopProgressBar'; import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow'; import { WatchRoomProvider } from '../components/WatchRoomProvider'; @@ -222,6 +223,7 @@ export default async function RootLayout({ disableTransitionOnChange > + diff --git a/src/components/TokenRefreshManager.tsx b/src/components/TokenRefreshManager.tsx new file mode 100644 index 0000000..ddad9ab --- /dev/null +++ b/src/components/TokenRefreshManager.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect } from 'react'; + +import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; + +/** + * Token 自动刷新管理器 + * + * 功能: + * 1. 拦截所有 fetch 请求 + * 2. 检测到 401 错误时自动刷新 Token 并重试 + * 3. 在请求前检查 Token 是否即将过期,主动刷新 + * + * 策略: + * - 响应拦截:401 错误 → 刷新 Token → 重试请求 + * - 请求拦截:剩余时间 < 10 分钟 → 主动刷新 + */ +export function TokenRefreshManager() { + useEffect(() => { + // localStorage 模式不需要刷新 + const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage'; + if (storageType === 'localstorage') { + return; + } + + // 刷新状态管理 + let isRefreshing = false; + let refreshPromise: Promise | null = null; + + // Token 刷新函数 + const refreshToken = async (): Promise => { + // 如果正在刷新,返回现有的 Promise + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = (async () => { + try { + // 使用原始 fetch 避免递归 + const response = await window.fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + }); + + if (response.ok) { + console.log('[Token] Refreshed successfully'); + return true; + } else { + console.error('[Token] Refresh failed:', response.status); + + // 刷新失败,跳转登录 + if (response.status === 401 || response.status === 403) { + window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`; + } + return false; + } + } catch (error) { + console.error('[Token] Refresh error:', error); + return false; + } finally { + isRefreshing = false; + refreshPromise = null; + } + })(); + + return refreshPromise; + }; + + // 检查 Token 是否需要刷新 + const shouldRefreshToken = (): boolean => { + const authInfo = getAuthInfoFromBrowserCookie(); + if (!authInfo || !authInfo.timestamp || !authInfo.refreshExpires) { + return false; + } + + const now = Date.now(); + + // Refresh Token 已过期 + if (now >= authInfo.refreshExpires) { + console.log('[Token] Refresh token expired, redirecting to login'); + window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`; + return false; + } + + // 计算 Access Token 剩余时间 + const ACCESS_TOKEN_AGE = 4 * 60 * 60 * 1000; // 4 小时 + const age = now - authInfo.timestamp; + const remaining = ACCESS_TOKEN_AGE - age; + + // 剩余时间 < 10 分钟时需要刷新 + const REFRESH_THRESHOLD = 10 * 60 * 1000; // 10 分钟 + return remaining < REFRESH_THRESHOLD && remaining > 0; + }; + + // 保存原始 fetch + const originalFetch = window.fetch; + + // 拦截 fetch + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + // 跳过刷新 API 本身 + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + if (url.includes('/api/auth/refresh')) { + return originalFetch(input, init); + } + + // 请求前检查:Token 即将过期时主动刷新 + if (shouldRefreshToken() && !isRefreshing) { + console.log('[Token] Expiring soon, refreshing proactively...'); + await refreshToken(); + } + + // 发送请求 + let response = await originalFetch(input, init); + + // 响应拦截:401 错误时刷新 Token 并重试 + if (response.status === 401 && !isRefreshing) { + console.log('[Token] Received 401, attempting refresh and retry...'); + + const refreshed = await refreshToken(); + + if (refreshed) { + // 刷新成功,重试原请求 + response = await originalFetch(input, init); + } + } + + return response; + }; + + // 清理:恢复原始 fetch + return () => { + window.fetch = originalFetch; + }; + }, []); + + // 这是一个纯逻辑组件,不渲染任何内容 + return null; +} diff --git a/src/middleware.ts b/src/middleware.ts index 8862515..2ad737d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; -import { refreshAccessToken, shouldRenewToken } from '@/lib/middleware-auth'; import { TOKEN_CONFIG } from '@/lib/refresh-token'; export async function middleware(request: NextRequest) { @@ -54,37 +53,9 @@ export async function middleware(request: NextRequest) { const now = Date.now(); const age = now - authInfo.timestamp; - // Access Token 已过期,尝试使用 Refresh Token 刷新 + // Access Token 已过期,前端负责刷新 if (age > ACCESS_TOKEN_AGE) { - if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) { - // 检查 Refresh Token 是否过期 - if (now < authInfo.refreshExpires) { - // 尝试刷新 Access Token - const newAuthData = await refreshAccessToken( - authInfo.username, - authInfo.role, - authInfo.tokenId, - authInfo.refreshToken, - authInfo.refreshExpires - ); - - if (newAuthData) { - // 刷新成功,设置新 Cookie - const response = NextResponse.next(); - const expires = new Date(authInfo.refreshExpires); - response.cookies.set('auth', newAuthData, { - path: '/', - expires, - sameSite: 'lax', - httpOnly: false, - secure: false, - }); - return response; - } - } - } - - // Refresh Token 也过期或刷新失败,需要重新登录 + console.log(`Access token expired for ${authInfo.username}, redirecting to login`); return handleAuthFailure(request, pathname); } @@ -101,34 +72,8 @@ export async function middleware(request: NextRequest) { return handleAuthFailure(request, pathname); } - // 签名验证通过,检查是否需要续期 - if (shouldRenewToken(authInfo.timestamp)) { - // 快过期了,自动续期 - if (authInfo.refreshToken && authInfo.tokenId && authInfo.refreshExpires) { - const newAuthData = await refreshAccessToken( - authInfo.username, - authInfo.role, - authInfo.tokenId, - authInfo.refreshToken, - authInfo.refreshExpires - ); - - if (newAuthData) { - const response = NextResponse.next(); - const expires = new Date(authInfo.refreshExpires); - response.cookies.set('auth', newAuthData, { - path: '/', - expires, - sameSite: 'lax', - httpOnly: false, - secure: false, - }); - return response; - } - } - } - - // 正常通过 + // 签名验证通过 + // 注意:Token 续期由前端负责,Middleware 不再自动刷新 return NextResponse.next(); } From 8b0d999619c6a293f3b3b11a3824278a4898bf16 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 27 Jan 2026 09:14:58 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dtoken=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E6=97=A0=E9=99=90=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/TokenRefreshManager.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/TokenRefreshManager.tsx b/src/components/TokenRefreshManager.tsx index ddad9ab..06e883d 100644 --- a/src/components/TokenRefreshManager.tsx +++ b/src/components/TokenRefreshManager.tsx @@ -115,15 +115,21 @@ export function TokenRefreshManager() { // 发送请求 let response = await originalFetch(input, init); - // 响应拦截:401 错误时刷新 Token 并重试 + // 响应拦截:401 错误时刷新 Token 并重试(仅重试一次) if (response.status === 401 && !isRefreshing) { console.log('[Token] Received 401, attempting refresh and retry...'); const refreshed = await refreshToken(); if (refreshed) { - // 刷新成功,重试原请求 + // 刷新成功,重试原请求(仅此一次) response = await originalFetch(input, init); + + // 如果重试后仍然是 401,说明有其他问题,不再重试 + if (response.status === 401) { + console.error('[Token] Still 401 after refresh, redirecting to login'); + window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`; + } } } From d8dbd10389e30a1a7d9aaa993300c3fa94d5b655 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 27 Jan 2026 09:25:15 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E6=8B=A6=E6=88=AA=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E7=99=BB=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/TokenRefreshManager.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/TokenRefreshManager.tsx b/src/components/TokenRefreshManager.tsx index 06e883d..28fe0f7 100644 --- a/src/components/TokenRefreshManager.tsx +++ b/src/components/TokenRefreshManager.tsx @@ -99,10 +99,17 @@ export function TokenRefreshManager() { // 拦截 fetch window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - // 跳过刷新 API 本身 + // 跳过不需要 Token 刷新的 API const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url.includes('/api/auth/refresh')) { + // 跳过:刷新 API、登录、登出、注册等认证相关接口 + if ( + url.includes('/api/auth/refresh') || + url.includes('/api/login') || + url.includes('/api/logout') || + url.includes('/api/register') || + url.includes('/api/auth/oidc') + ) { return originalFetch(input, init); } From 64bedb04b6ca6023fc6320cde20811002bc9f529 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 27 Jan 2026 09:42:09 +0800 Subject: [PATCH 11/11] v2101 --- CHANGELOG | 9 +++++++++ VERSION.txt | 2 +- src/lib/changelog.ts | 14 ++++++++++++++ src/lib/version.ts | 2 +- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 35d1465..4ef8c36 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,12 @@ +## [210.1.0] - 2026-01-27 +### Added +- 增加直链播放 +- 直播兼容txt格式 + +### Changed +- 首页轮播图和继续观看可隐藏 +- 刷新token改为前端进行防止redis数据库方式报错 + ## [210.0.0] - 2026-01-25 ### Added - 新增网络直播功能 diff --git a/VERSION.txt b/VERSION.txt index bf607e4..152f09c 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1,2 +1,2 @@ -210.0.0 +210.1.0 diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 3e6bf21..0853745 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -11,6 +11,20 @@ export interface ChangelogEntry { export const changelog: ChangelogEntry[] = [ { + version: '210.1.0', + date: '2026-01-27', + added: [ + "增加直链播放", + "直播兼容txt格式", + ], + changed: [ + "首页轮播图和继续观看可隐藏", + "刷新token改为前端进行防止redis数据库方式报错" + ], + fixed: [ + ] + }, + { version: '210.0.0', date: '2026-01-25', added: [ diff --git a/src/lib/version.ts b/src/lib/version.ts index ff6405d..33bb1e8 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ -const CURRENT_VERSION = '210.0.0'; +const CURRENT_VERSION = '210.1.0'; // 导出当前版本号供其他地方使用 export { CURRENT_VERSION };