diff --git a/CHANGELOG b/CHANGELOG index 05505df..0d90e0c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,27 @@ +## [215.0.0] - 2026-03-20 +### Added +- 增加主动恢复进度按钮 +- 新增播放记录面板 +- 弹幕搜索面板标题增加tooltip +- 影视搜索新增列表视图 +- pansou增加重试按钮 +- 新增ai评论生成 +- 增加一键render部署 +- 电视直播增加三种代理模式 + +### Changed +- 优化直链播放m3u8体验 +- 搜索页面海量数据下使用虚拟滚动提高性能 +- 优化emby代理内存泄漏问题 +- 电视直播代理控制权从用户端改为管理端 +- 获取视频源详情不再依赖title + +### Fixed +- 修复search页面僵尸历史记录tag +- 修复弹幕搜索框挤压 +- 修复搜索页面加载条显示顺序错误 +- 修复继续观看渐进式加载的一些问题 + ## [214.1.0] - 2026-03-10 ### Changed - emby兼容jellyfin diff --git a/README.md b/README.md index 977a9ed..2f2828e 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,14 @@ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus) -**一键部署到zeabur** +**一键部署到 Zeabur** [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/SCHCAY/deploy) +**一键部署到 Render** + +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/mtvpls/MoonTVPlus) + ### Cloudflare Workers 部署(通过 GitHub Actions) diff --git a/VERSION.txt b/VERSION.txt index 4928a59..a35024d 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1,2 +1,2 @@ -214.1.0 +215.0.0 diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..2c3dffe --- /dev/null +++ b/render.yaml @@ -0,0 +1,16 @@ +services: + - type: web + name: moontvplus + runtime: image + plan: free + image: + url: ghcr.io/mtvpls/moontvplus:latest + envVars: + - key: USERNAME + sync: false + - key: PASSWORD + sync: false + - key: NEXT_PUBLIC_SITE_NAME + value: MoonTVPlus + - key: CRON_PASSWORD + value: mtvpls diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d6745d0..07dbe5a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -380,6 +380,7 @@ interface LiveDataSource { channelNumber?: number; disabled?: boolean; from: 'config' | 'custom'; + proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式 } // 自定义分类数据类型 @@ -10327,6 +10328,7 @@ const AIConfigComponent = ({ const [enableHomepageEntry, setEnableHomepageEntry] = useState(true); const [enableVideoCardEntry, setEnableVideoCardEntry] = useState(true); const [enablePlayPageEntry, setEnablePlayPageEntry] = useState(true); + const [enableAIComments, setEnableAIComments] = useState(false); // 权限控制 const [allowRegularUsers, setAllowRegularUsers] = useState(true); @@ -10357,6 +10359,7 @@ const AIConfigComponent = ({ setEnableHomepageEntry(config.AIConfig.EnableHomepageEntry !== false); setEnableVideoCardEntry(config.AIConfig.EnableVideoCardEntry !== false); setEnablePlayPageEntry(config.AIConfig.EnablePlayPageEntry !== false); + setEnableAIComments(config.AIConfig.EnableAIComments || false); setAllowRegularUsers(config.AIConfig.AllowRegularUsers !== false); setTemperature(config.AIConfig.Temperature ?? 0.7); setMaxTokens(config.AIConfig.MaxTokens ?? 1000); @@ -10390,6 +10393,7 @@ const AIConfigComponent = ({ EnableHomepageEntry: enableHomepageEntry, EnableVideoCardEntry: enableVideoCardEntry, EnablePlayPageEntry: enablePlayPageEntry, + EnableAIComments: enableAIComments, AllowRegularUsers: allowRegularUsers, Temperature: temperature, MaxTokens: maxTokens, @@ -10659,6 +10663,7 @@ const AIConfigComponent = ({ { key: 'homepage', label: '首页入口', desc: '在首页显示AI问片入口', state: enableHomepageEntry, setState: setEnableHomepageEntry }, { key: 'videocard', label: '视频卡片入口', desc: '在视频卡片菜单中显示AI问片选项', state: enableVideoCardEntry, setState: setEnableVideoCardEntry }, { key: 'playpage', label: '播放页入口', desc: '在视频播放页显示AI问片功能', state: enablePlayPageEntry, setState: setEnablePlayPageEntry }, + { key: 'aicomments', label: 'AI评论功能', desc: '在播放页生成AI评论(独立于豆瓣评论)', state: enableAIComments, setState: setEnableAIComments }, ].map((item) => (
@@ -10932,6 +10937,53 @@ const LiveSourceConfig = ({ }); }; + const handleSetProxyMode = (key: string, mode: 'full' | 'm3u8-only' | 'direct') => { + withLoading(`setLiveProxyMode_${key}`, async () => { + // 保存旧值用于回滚 + const oldMode = liveSources.find((s) => s.key === key)?.proxyMode; + + // 乐观更新本地状态 + setLiveSources((prev) => + prev.map((s) => + s.key === key ? { ...s, proxyMode: mode } : s + ) + ); + + try { + const response = await fetch('/api/admin/live', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'set_proxy_mode', + key, + proxyMode: mode, + }), + }); + + if (!response.ok) { + throw new Error('设置代理模式失败'); + } + + // 成功后刷新配置 + await refreshConfig(); + } catch (error) { + // 失败时回滚本地状态 + setLiveSources((prev) => + prev.map((s) => + s.key === key ? { ...s, proxyMode: oldMode } : s + ) + ); + showError( + error instanceof Error ? error.message : '设置代理模式失败', + showAlert + ); + throw error; + } + }).catch(() => { + console.error('操作失败', 'set_proxy_mode', key); + }); + }; + const handleDelete = (key: string) => { withLoading(`deleteLiveSource_${key}`, () => callLiveSourceApi({ action: 'delete', key }) @@ -11108,6 +11160,24 @@ const LiveSourceConfig = ({ {!liveSource.disabled ? '启用中' : '已禁用'} + + +
`, - mounted: ($el: HTMLElement) => { - const container = $el.querySelector('.seek-buttons-container') as HTMLElement; - const backwardBtn = $el.querySelector('.seek-backward') as HTMLElement; - const forwardBtn = $el.querySelector('.seek-forward') as HTMLElement; + mounted: ($el: HTMLElement) => { + const container = $el.querySelector('.seek-buttons-container') as HTMLElement; + const backwardBtn = $el.querySelector('.seek-backward') as HTMLElement; + const forwardBtn = $el.querySelector('.seek-forward') as HTMLElement; - // 快退5秒 - backwardBtn.onclick = () => { - if (artPlayerRef.current) { - artPlayerRef.current.currentTime = Math.max(0, artPlayerRef.current.currentTime - 5); - } - }; - - // 快进5秒 - forwardBtn.onclick = () => { - if (artPlayerRef.current) { - artPlayerRef.current.currentTime = Math.min(artPlayerRef.current.duration, artPlayerRef.current.currentTime + 5); - } - }; - - // 监听全屏状态变化 - const updateVisibility = () => { - const isFullscreen = artPlayerRef.current?.fullscreen || artPlayerRef.current?.fullscreenWeb || !!document.fullscreenElement; - const isMobile = Math.min(window.innerWidth, window.innerHeight) < 768; - const controlsVisible = !artPlayerRef.current?.template?.$player?.classList.contains('art-hide-cursor'); - - if (container) { - const shouldShow = isFullscreen && isMobile && controlsVisible; - container.style.display = shouldShow ? 'block' : 'none'; - } - }; - - artPlayerRef.current.on('fullscreen', updateVisibility); - artPlayerRef.current.on('fullscreenWeb', updateVisibility); - document.addEventListener('fullscreenchange', updateVisibility); - window.addEventListener('resize', updateVisibility); - - // 监听鼠标移动和视频事件来检测控件显示/隐藏 - artPlayerRef.current.on('video:timeupdate', updateVisibility); - if (artPlayerRef.current.template?.$player) { - const observer = new MutationObserver(updateVisibility); - observer.observe(artPlayerRef.current.template.$player, { - attributes: true, - attributeFilter: ['class'] - }); - } - - updateVisibility(); - }, - }); - - // 监听视频可播放事件,这时恢复播放进度更可靠 - artPlayerRef.current.on('video:canplay', () => { - // 若存在需要恢复的播放进度,则跳转 - if (resumeTimeRef.current && resumeTimeRef.current > 0) { - try { - const duration = artPlayerRef.current.duration || 0; - let target = resumeTimeRef.current; - if (duration && target >= duration - 2) { - target = Math.max(0, duration - 5); - } - artPlayerRef.current.currentTime = target; - console.log('成功恢复播放进度到:', resumeTimeRef.current); - } catch (err) { - console.warn('恢复播放进度失败:', err); - } - } - resumeTimeRef.current = null; - - setTimeout(() => { - if ( - Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01 - ) { - artPlayerRef.current.volume = lastVolumeRef.current; - } - if ( - Math.abs( - artPlayerRef.current.playbackRate - lastPlaybackRateRef.current - ) > 0.01 && - isWebkit - ) { - artPlayerRef.current.playbackRate = lastPlaybackRateRef.current; - } - artPlayerRef.current.notice.show = ''; - }, 0); - - // 隐藏换源加载状态 - setIsVideoLoading(false); - setVideoError(null); - }); - - // 监听视频时间更新事件,实现跳过片头片尾 - artPlayerRef.current.on('video:timeupdate', () => { - if (!skipConfigRef.current.enable) return; - - const currentTime = artPlayerRef.current.currentTime || 0; - const duration = artPlayerRef.current.duration || 0; - const now = Date.now(); - - // 限制跳过检查频率为1.5秒一次 - if (now - lastSkipCheckRef.current < 1500) return; - lastSkipCheckRef.current = now; - - // 跳过片头 - if ( - skipConfigRef.current.intro_time > 0 && - currentTime < skipConfigRef.current.intro_time - ) { - artPlayerRef.current.currentTime = skipConfigRef.current.intro_time; - artPlayerRef.current.notice.show = `已跳过片头 (${formatTime( - skipConfigRef.current.intro_time - )})`; - } - - // 跳过片尾 - if ( - skipConfigRef.current.outro_time < 0 && - duration > 0 && - currentTime > - artPlayerRef.current.duration + skipConfigRef.current.outro_time - ) { - if ( - currentEpisodeIndexRef.current < - (detailRef.current?.episodes?.length || 1) - 1 - ) { - handleNextEpisode(); - } else { - artPlayerRef.current.pause(); - } - artPlayerRef.current.notice.show = `已跳过片尾 (${formatTime( - skipConfigRef.current.outro_time - )})`; - } - }); - - artPlayerRef.current.on('error', (err: any) => { - console.error('播放器错误:', err); - if (artPlayerRef.current.currentTime > 0) { - return; - } - }); - - // 监听视频播放结束事件,自动播放下一集(房员禁用) - artPlayerRef.current.on('video:ended', () => { - // 房员禁用自动播放下一集 - if (playSync.shouldDisableControls) { - console.log('[PlayPage] Member cannot auto-play next episode'); - if (artPlayerRef.current) { - artPlayerRef.current.notice.show = '等待房主切换下一集'; - } - return; - } - - const d = detailRef.current; - const idx = currentEpisodeIndexRef.current; - - if (!d || !d.episodes || idx >= d.episodes.length - 1) { - return; - } - - // 查找下一个未被过滤的集数 - let nextIdx = idx + 1; - while (nextIdx < d.episodes.length) { - const episodeTitle = d.episodes_titles?.[nextIdx]; - const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle); - - if (!isFiltered) { - setTimeout(() => { - setCurrentEpisodeIndex(nextIdx); - }, 1000); - return; - } - nextIdx++; - } - - // 所有后续集数都被屏蔽 - if (artPlayerRef.current) { - artPlayerRef.current.notice.show = '后续集数均已屏蔽,已自动停止'; - } - }); - - artPlayerRef.current.on('video:timeupdate', () => { - const now = Date.now(); - let interval = 5000; - if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') { - interval = 20000; - } - if (now - lastSaveTimeRef.current > interval) { - saveCurrentPlayProgress(); - lastSaveTimeRef.current = now; - } - - // 下集预缓冲逻辑 - const nextEpisodePreCacheEnabled = typeof window !== 'undefined' - ? localStorage.getItem('nextEpisodePreCache') === 'true' - : false; - - if (nextEpisodePreCacheEnabled) { - const currentTime = artPlayerRef.current?.currentTime || 0; - const duration = artPlayerRef.current?.duration || 0; - const progress = duration > 0 ? currentTime / duration : 0; - - // 检查是否已经到达90%播放进度 - if (duration > 0 && progress >= 0.9 && !nextEpisodePreCacheTriggeredRef.current) { - // 标记已触发,防止重复执行 - nextEpisodePreCacheTriggeredRef.current = true; - - // 获取下一集信息 - const currentIdx = currentEpisodeIndexRef.current; - const episodes = detailRef.current?.episodes; - - if (!episodes || currentIdx >= episodes.length - 1) { - return; - } - - const nextEpisodeIndex = currentIdx + 1; - const nextEpisodeUrl = episodes[nextEpisodeIndex]; - - if (!nextEpisodeUrl) { - return; - } - - // 使用 fetch 预加载资源,利用浏览器缓存 - const preloadNextEpisode = async () => { - try { - // 判断是否是m3u8流 - if (nextEpisodeUrl.includes('.m3u8') || nextEpisodeUrl.includes('m3u8')) { - // 1. 先fetch m3u8文件 - const m3u8Response = await fetch(nextEpisodeUrl); - const m3u8Text = await m3u8Response.text(); - - // 2. 解析m3u8,提取ts分片URL - const lines = m3u8Text.split('\n'); - const tsUrls: string[] = []; - const baseUrl = nextEpisodeUrl.substring(0, nextEpisodeUrl.lastIndexOf('/') + 1); - - for (const line of lines) { - const trimmedLine = line.trim(); - // 跳过注释和空行 - if (!trimmedLine || trimmedLine.startsWith('#')) { - continue; - } - // 构建完整的ts URL - const tsUrl = trimmedLine.startsWith('http') - ? trimmedLine - : baseUrl + trimmedLine; - tsUrls.push(tsUrl); - } - - // 3. 预加载前20个ts分片 - const maxFragmentsToPreload = Math.min(20, tsUrls.length); - - for (let i = 0; i < maxFragmentsToPreload; i++) { - try { - await fetch(tsUrls[i]); - } catch (err) { - // 静默处理分片加载失败 - } - } - } - } catch (error) { - // 静默处理预缓冲失败 + // 快退5秒 + backwardBtn.onclick = () => { + if (artPlayerRef.current) { + artPlayerRef.current.currentTime = Math.max(0, artPlayerRef.current.currentTime - 5); } }; - // 异步执行预缓冲 - preloadNextEpisode(); + // 快进5秒 + forwardBtn.onclick = () => { + if (artPlayerRef.current) { + artPlayerRef.current.currentTime = Math.min(artPlayerRef.current.duration, artPlayerRef.current.currentTime + 5); + } + }; + + // 监听全屏状态变化 + const updateVisibility = () => { + const isFullscreen = artPlayerRef.current?.fullscreen || artPlayerRef.current?.fullscreenWeb || !!document.fullscreenElement; + const isMobile = Math.min(window.innerWidth, window.innerHeight) < 768; + const controlsVisible = !artPlayerRef.current?.template?.$player?.classList.contains('art-hide-cursor'); + + if (container) { + const shouldShow = isFullscreen && isMobile && controlsVisible; + container.style.display = shouldShow ? 'block' : 'none'; + } + }; + + artPlayerRef.current.on('fullscreen', updateVisibility); + artPlayerRef.current.on('fullscreenWeb', updateVisibility); + document.addEventListener('fullscreenchange', updateVisibility); + window.addEventListener('resize', updateVisibility); + + // 监听鼠标移动和视频事件来检测控件显示/隐藏 + artPlayerRef.current.on('video:timeupdate', updateVisibility); + if (artPlayerRef.current.template?.$player) { + const observer = new MutationObserver(updateVisibility); + observer.observe(artPlayerRef.current.template.$player, { + attributes: true, + attributeFilter: ['class'] + }); + } + + updateVisibility(); + }, + }); + + // 监听视频可播放事件,这时恢复播放进度更可靠 + artPlayerRef.current.on('video:canplay', () => { + // 若存在需要恢复的播放进度,则跳转 + if (resumeTimeRef.current && resumeTimeRef.current > 0) { + try { + const duration = artPlayerRef.current.duration || 0; + let target = resumeTimeRef.current; + if (duration && target >= duration - 2) { + target = Math.max(0, duration - 5); + } + artPlayerRef.current.currentTime = target; + console.log('成功恢复播放进度到:', resumeTimeRef.current); + } catch (err) { + console.warn('恢复播放进度失败:', err); + } } - } + resumeTimeRef.current = null; - // 下集弹幕预加载逻辑 - const nextEpisodeDanmakuPreloadEnabled = typeof window !== 'undefined' - ? localStorage.getItem('nextEpisodeDanmakuPreload') === 'true' - : false; + setTimeout(() => { + if ( + Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01 + ) { + artPlayerRef.current.volume = lastVolumeRef.current; + } + if ( + Math.abs( + artPlayerRef.current.playbackRate - lastPlaybackRateRef.current + ) > 0.01 && + isWebkit + ) { + artPlayerRef.current.playbackRate = lastPlaybackRateRef.current; + } + artPlayerRef.current.notice.show = ''; + }, 0); - if (nextEpisodeDanmakuPreloadEnabled) { - const currentTime = artPlayerRef.current?.currentTime || 0; - const duration = artPlayerRef.current?.duration || 0; - const progress = duration > 0 ? currentTime / duration : 0; + // 隐藏换源加载状态 + setIsVideoLoading(false); + setVideoError(null); + setCorsFailedUrl(null); + }); - // 检查是否已经到达90%播放进度 - if (duration > 0 && progress >= 0.9 && !nextEpisodeDanmakuPreloadTriggeredRef.current) { - // 标记已触发,防止重复执行 - nextEpisodeDanmakuPreloadTriggeredRef.current = true; + // 监听视频播放事件,检查是否需要显示播放记录跳转按钮 + artPlayerRef.current.on('video:playing', () => { + // 检查是否需要显示播放记录跳转按钮 + // 条件:当前播放时间 < 10秒 且 播放记录时间 > 10秒 + const checkPlayRecordJump = async () => { + try { + // 如果用户已经关闭过跳转按钮,不再显示 + if (playRecordJumpDismissedRef.current) { + return; + } - // 异步执行弹幕预加载 - preloadNextEpisodeDanmaku(); + const currentTime = artPlayerRef.current?.currentTime || 0; + + // 如果当前播放时间已经大于等于10秒,不显示跳转按钮 + if (currentTime >= 10) { + // 标记已经进行过首次检查,避免切集后再显示 + playRecordJumpInitialCheckRef.current = false; + if (playRecordJumpLayerRef.current) { + artPlayerRef.current.layers.remove('play-record-jump'); + playRecordJumpLayerRef.current = null; + } + return; + } + + // 获取播放记录 + const allRecords = await getAllPlayRecords(); + const key = generateStorageKey( + currentSourceRef.current, + currentIdRef.current + ); + const record = allRecords[key]; + + if (record) { + const recordIndex = record.index - 1; + const recordTime = record.play_time; + + // 检查是否是当前集数且播放记录时间大于10秒且当前时间小于10秒 + if ( + recordIndex === currentEpisodeIndexRef.current && + recordTime > 10 && + currentTime < 10 + ) { + // 如果已经添加过,不重复添加 + if (playRecordJumpLayerRef.current) { + return; + } + + // 标记已经进行过首次检查 + playRecordJumpInitialCheckRef.current = false; + + // 格式化时间显示 + const formatTime = (seconds: number): string => { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) { + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } + return `${m}:${s.toString().padStart(2, '0')}`; + }; + + // 添加到播放器 layers + playRecordJumpLayerRef.current = artPlayerRef.current.layers.add({ + name: 'play-record-jump', + html: ` +
+ + 上次播放到 ${formatTime(recordTime)} + + + +
+ `, + style: { + position: 'absolute', + left: 0, + bottom: 0, + width: '100%', + height: '100%', + pointerEvents: 'none', + }, + }); + + // 绑定事件 + const jumpBtn = document.getElementById('play-record-jump-btn'); + const dismissBtn = document.getElementById('play-record-dismiss-btn'); + + if (jumpBtn) { + jumpBtn.addEventListener('mouseenter', () => { + jumpBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.3)'; + }); + jumpBtn.addEventListener('mouseleave', () => { + jumpBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.2)'; + }); + jumpBtn.addEventListener('click', () => { + if (artPlayerRef.current) { + artPlayerRef.current.currentTime = recordTime; + artPlayerRef.current.notice.show = `已跳转到 ${formatTime(recordTime)}`; + } + playRecordJumpDismissedRef.current = true; + if (playRecordJumpLayerRef.current) { + artPlayerRef.current.layers.remove('play-record-jump'); + playRecordJumpLayerRef.current = null; + } + }); + } + + if (dismissBtn) { + dismissBtn.addEventListener('mouseenter', () => { + dismissBtn.style.color = 'white'; + }); + dismissBtn.addEventListener('mouseleave', () => { + dismissBtn.style.color = 'rgba(255, 255, 255, 0.7)'; + }); + dismissBtn.addEventListener('click', () => { + playRecordJumpDismissedRef.current = true; + if (playRecordJumpLayerRef.current) { + artPlayerRef.current.layers.remove('play-record-jump'); + playRecordJumpLayerRef.current = null; + } + }); + } + + console.log('[PlayRecordJump] 显示跳转按钮,当前时间:', currentTime, '记录时间:', recordTime); + } else { + // 不满足显示条件,也标记为已检查过 + playRecordJumpInitialCheckRef.current = false; + } + } else { + // 没有播放记录,也标记为已检查过 + playRecordJumpInitialCheckRef.current = false; + } + } catch (err) { + console.error('[PlayRecordJump] 检查播放记录失败:', err); + // 即使出错也标记为已检查过 + playRecordJumpInitialCheckRef.current = false; + } + }; + + // 延迟检查,确保播放器已经稳定 + setTimeout(checkPlayRecordJump, 500); + }); + + // 监听视频时间更新事件,实现跳过片头片尾 + artPlayerRef.current.on('video:timeupdate', () => { + if (!skipConfigRef.current.enable) return; + + const currentTime = artPlayerRef.current.currentTime || 0; + const duration = artPlayerRef.current.duration || 0; + const now = Date.now(); + + // 限制跳过检查频率为1.5秒一次 + if (now - lastSkipCheckRef.current < 1500) return; + lastSkipCheckRef.current = now; + + // 跳过片头 + if ( + skipConfigRef.current.intro_time > 0 && + currentTime < skipConfigRef.current.intro_time + ) { + artPlayerRef.current.currentTime = skipConfigRef.current.intro_time; + artPlayerRef.current.notice.show = `已跳过片头 (${formatTime( + skipConfigRef.current.intro_time + )})`; } - } - }); - if (artPlayerRef.current?.video) { - ensureVideoSource( - artPlayerRef.current.video as HTMLVideoElement, - videoUrl - ); - } + // 跳过片尾 + if ( + skipConfigRef.current.outro_time < 0 && + duration > 0 && + currentTime > + artPlayerRef.current.duration + skipConfigRef.current.outro_time + ) { + if ( + currentEpisodeIndexRef.current < + (detailRef.current?.episodes?.length || 1) - 1 + ) { + handleNextEpisode(); + } else { + artPlayerRef.current.pause(); + } + artPlayerRef.current.notice.show = `已跳过片尾 (${formatTime( + skipConfigRef.current.outro_time + )})`; + } + }); + + artPlayerRef.current.on('error', (err: any) => { + console.error('播放器错误:', err); + // 如果已经成功播放过一段时间,忽略后续错误(可能是短暂网络波动) + if (artPlayerRef.current && artPlayerRef.current.currentTime > 0) { + return; + } + // 原生
@@ -7412,9 +7734,9 @@ function PlayPageClient() {
+ fill='none' stroke='currentColor' viewBox='0 0 24 24'> + d='M9 5l7 7-7 7' />
@@ -7519,11 +7841,10 @@ function PlayPageClient() { return ( {status === 'completed' ? '已完结' : '连载中'} @@ -7545,9 +7866,8 @@ function PlayPageClient() { } >
{/* 播放器 */}
{/* 播放器容器 */}
@@ -7629,6 +7946,28 @@ function PlayPageClient() { > 重试 + {/* 直链播放 CORS 失败时,显示"使用代理播放"按钮 */} + {!proxyAttemptedRef.current && (corsFailedUrl || (isDirectPlay && videoUrl && !videoUrl.includes('/api/proxy-m3u8'))) && ( + + )}
) : ( @@ -7675,8 +8014,8 @@ function PlayPageClient() {
- - + + 正在刷新链接...
@@ -7797,12 +8136,12 @@ function PlayPageClient() { @@ -7873,172 +8212,171 @@ function PlayPageClient() { - {/* VLC */} - VLC - - VLC - - + {/* VLC */} + - {/* MPV */} - + {/* MPV */} + - {/* MX Player */} - + {/* MX Player */} + - {/* nPlayer */} - + {/* nPlayer */} + - {/* IINA */} - + {/* IINA */} +
{/* 去广告开关 */}
)} + + {/* AI评论区域 */} + {videoTitle && enableAIComments && ( +
+
+ {/* 标题 */} +
+

+ + + + AI生成评论 +

+
+ + {/* 评论内容 */} +
+ +
+
+
+ )} )}
@@ -8537,18 +8895,18 @@ function PlayPageClient() { // 特殊源使用 tmdb,其他使用 cms(通过 doubanId) // 如果有豆瓣ID且不为0,传入doubanId detail.source === 'openlist' || - detail.source?.startsWith('emby') || - detail.source === 'xiaoya' + detail.source?.startsWith('emby') || + detail.source === 'xiaoya' ? undefined : detail.douban_id && detail.douban_id !== 0 - ? detail.douban_id - : undefined + ? detail.douban_id + : undefined } tmdbId={ // 特殊源使用 tmdb detail.source === 'openlist' || - detail.source?.startsWith('emby') || - detail.source === 'xiaoya' + detail.source?.startsWith('emby') || + detail.source === 'xiaoya' ? detail.tmdb_id : undefined } @@ -8558,14 +8916,14 @@ function PlayPageClient() { // 非特殊源使用 cms 数据 // 但如果有豆瓣ID且不为0,则不传入cmsData,优先使用豆瓣数据 detail.source !== 'openlist' && - !detail.source?.startsWith('emby') && - detail.source !== 'xiaoya' && - !(detail.douban_id && detail.douban_id !== 0) + !detail.source?.startsWith('emby') && + detail.source !== 'xiaoya' && + !(detail.douban_id && detail.douban_id !== 0) ? { - desc: detail.desc, - episodes: detail.episodes, - episodes_titles: detail.episodes_titles, - } + desc: detail.desc, + episodes: detail.episodes, + episodes_titles: detail.episodes_titles, + } : undefined } sourceId={detail.id} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 5f05588..39bc6ca 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,9 +1,26 @@ /* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-assertion,no-empty */ 'use client'; -import { ChevronUp, Film, HardDrive, Magnet,RefreshCw, Search, X } from 'lucide-react'; +import { + ChevronUp, + Film, + Grid2x2, + HardDrive, + List, + Magnet, + RefreshCw, + Search, + X, +} from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; -import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + startTransition, + Suspense, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { @@ -14,12 +31,16 @@ import { subscribeToDataUpdates, } from '@/lib/db.client'; import { SearchResult } from '@/lib/types'; +import { processImageUrl } from '@/lib/utils'; import AcgSearch from '@/components/AcgSearch'; import CapsuleSwitch from '@/components/CapsuleSwitch'; +import ImageViewer from '@/components/ImageViewer'; import PageLayout from '@/components/PageLayout'; import PansouSearch from '@/components/PansouSearch'; -import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter'; +import SearchResultFilter, { + SearchFilterCategory, +} from '@/components/SearchResultFilter'; import SearchSuggestions from '@/components/SearchSuggestions'; import VideoCard, { VideoCardHandle } from '@/components/VideoCard'; import VirtualScrollableGrid from '@/components/VirtualScrollableGrid'; @@ -30,13 +51,17 @@ function SearchPageClient() { // 返回顶部按钮显示状态 const [showBackToTop, setShowBackToTop] = useState(false); // 选项卡状态: 'video' 或 'pansou' 或 'acg' - const [activeTab, setActiveTab] = useState<'video' | 'pansou' | 'acg'>('video'); + const [activeTab, setActiveTab] = useState<'video' | 'pansou' | 'acg'>( + 'video' + ); // Pansou 搜索触发标志 const [triggerPansouSearch, setTriggerPansouSearch] = useState(false); // ACG 搜索触发标志 const [triggerAcgSearch, setTriggerAcgSearch] = useState(false); // 用户权限 - const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(null); + const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>( + null + ); // 繁体转简体转换器 const converterRef = useRef<((text: string) => string) | null>(null); // 转换器是否已初始化 @@ -57,8 +82,15 @@ function SearchPageClient() { const flushTimerRef = useRef(null); const [useFluidSearch, setUseFluidSearch] = useState(true); // 聚合卡片 refs 与聚合统计缓存 - const groupRefs = useRef>>(new Map()); - const groupStatsRef = useRef>(new Map()); + const groupRefs = useRef>>( + new Map() + ); + const groupStatsRef = useRef< + Map< + string, + { douban_id?: number; episodes?: number; source_names: string[] } + > + >(new Map()); // 强制刷新状态 const [forceRefresh, setForceRefresh] = useState(false); // 是否使用了缓存结果 @@ -127,11 +159,16 @@ function SearchPageClient() { let max = 0; let res = 0; countMap.forEach((v, k) => { - if (v > max) { max = v; res = k; } + if (v > max) { + max = v; + res = k; + } }); return res; })(); - const source_names = Array.from(new Set(group.map((g) => g.source_name).filter(Boolean))) as string[]; + const source_names = Array.from( + new Set(group.map((g) => g.source_name).filter(Boolean)) + ) as string[]; const douban_id = (() => { const countMap = new Map(); @@ -143,7 +180,10 @@ function SearchPageClient() { let max = 0; let res: number | undefined; countMap.forEach((v, k) => { - if (v > max) { max = v; res = k; } + if (v > max) { + max = v; + res = k; + } }); return res; })(); @@ -151,13 +191,23 @@ function SearchPageClient() { return { episodes, source_names, douban_id }; }; // 过滤器:非聚合与聚合 - const [filterAll, setFilterAll] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({ + const [filterAll, setFilterAll] = useState<{ + source: string; + title: string; + year: string; + yearOrder: 'none' | 'asc' | 'desc'; + }>({ source: 'all', title: 'all', year: 'all', yearOrder: 'none', }); - const [filterAgg, setFilterAgg] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({ + const [filterAgg, setFilterAgg] = useState<{ + source: string; + title: string; + year: string; + yearOrder: 'none' | 'asc' | 'desc'; + }>({ source: 'all', title: 'all', year: 'all', @@ -178,6 +228,24 @@ function SearchPageClient() { const [viewMode, setViewMode] = useState<'agg' | 'all'>(() => { return getDefaultAggregate() ? 'agg' : 'all'; }); + const [resultDisplayMode, setResultDisplayMode] = useState<'card' | 'list'>( + () => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('searchResultDisplayMode'); + if (saved === 'card' || saved === 'list') { + return saved; + } + } + return 'card'; + } + ); + const [expandedSourceTags, setExpandedSourceTags] = useState< + Record + >({}); + const [previewImage, setPreviewImage] = useState<{ + url: string; + alt: string; + } | null>(null); // 在“无排序”场景用于每个源批次的预排序:完全匹配标题优先,其次年份倒序,未知年份最后 const sortBatchForNoOrder = (items: SearchResult[]) => { @@ -200,7 +268,11 @@ function SearchPageClient() { }; // 简化的年份排序:unknown/空值始终在最后 - const compareYear = (aYear: string, bYear: string, order: 'none' | 'asc' | 'desc') => { + const compareYear = ( + aYear: string, + bYear: string, + order: 'none' | 'asc' | 'desc' + ) => { // 如果是无排序状态,返回0(保持原顺序) if (order === 'none') return 0; @@ -230,7 +302,11 @@ function SearchPageClient() { // 辅助函数:获取视频类型 const getType = (item: SearchResult): 'movie' | 'tv' => { // 1. Emby 和 OpenList 源:使用 type_name(基于 TMDB,最可靠) - if (item.source === 'emby' || item.source?.startsWith('emby_') || item.source === 'openlist') { + if ( + item.source === 'emby' || + item.source?.startsWith('emby_') || + item.source === 'openlist' + ) { return item.type_name === '电影' ? 'movie' : 'tv'; } @@ -238,14 +314,21 @@ function SearchPageClient() { const typeName = item.type_name?.toLowerCase() || ''; // 2.1 明确包含"电影"或"movie"或"片"的,判断为电影 - if (typeName.includes('电影') || typeName.includes('movie') || - typeName.endsWith('片') && !typeName.includes('动漫')) { + if ( + typeName.includes('电影') || + typeName.includes('movie') || + (typeName.endsWith('片') && !typeName.includes('动漫')) + ) { return 'movie'; } // 2.2 包含"剧"、"动漫"、"综艺"等关键词的,判断为剧集 - if (typeName.includes('剧') || typeName.includes('动漫') || - typeName.includes('综艺') || typeName.includes('anime')) { + if ( + typeName.includes('剧') || + typeName.includes('动漫') || + typeName.includes('综艺') || + typeName.includes('anime') + ) { return 'tv'; } @@ -275,7 +358,9 @@ function SearchPageClient() { const aggregatedResults = useMemo(() => { // 首先应用精确搜索过滤 const filteredResults = exactSearch - ? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current)) + ? searchResults.filter((item) => + titleContainsQuery(item.title, currentQueryRef.current) + ) : searchResults; //===== 阶段1:按 normalizedTitle-type 初步分组 ===== @@ -297,16 +382,21 @@ function SearchPageClient() { preliminaryMap.forEach((group, preliminaryKey) => { // 分离有年份和无年份的结果 - const withYear = new Map(); + const withYear = new Map(); const withoutYear: SearchResult[] = []; group.forEach((item) => { const year = item.year; // 判断是否为有效年份:必须是4位数字,且不能是空字符串或'unknown' - if (year && year.trim() !== '' && year !== 'unknown' && /^\d{4}$/.test(year)) { + if ( + year && + year.trim() !== '' && + year !== 'unknown' && + /^\d{4}$/.test(year) + ) { // 有有效年份 - const arr = withYear.get(year) || []; + const arr = withYear.get(year) || []; arr.push(item); withYear.set(year, arr); } else { @@ -334,7 +424,9 @@ function SearchPageClient() { }); // 按出现顺序返回聚合结果 - return keyOrder.map(key => [key, finalMap.get(key)!] as [string, SearchResult[]]); + return keyOrder.map( + (key) => [key, finalMap.get(key)!] as [string, SearchResult[]] + ); }, [searchResults, exactSearch]); // 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新 @@ -368,75 +460,181 @@ function SearchPageClient() { // 构建筛选选项 const filterOptions = useMemo(() => { - const sourcesSet = new Map(); - const titlesSet = new Set(); - const yearsSet = new Set(); + const exactSearchFiltered = exactSearch + ? searchResults.filter((item) => + titleContainsQuery(item.title, currentQueryRef.current) + ) + : searchResults; - searchResults.forEach((item) => { - if (item.source && item.source_name && item.source.trim() !== '' && item.source_name.trim() !== '') { - sourcesSet.set(item.source, item.source_name); - } - if (item.title && item.title.trim() !== '') titlesSet.add(item.title); - if (item.year && item.year.trim() !== '') yearsSet.add(item.year); - }); - - const sourceOptions: { label: string; value: string }[] = [ + const buildSourceOptions = ( + sourceEntries: Array<{ source: string; source_name: string }> + ) => [ { label: '全部来源', value: 'all' }, - ...Array.from(sourcesSet.entries()) + ...Array.from( + new Map( + sourceEntries + .filter( + (item) => + item.source && + item.source_name && + item.source.trim() !== '' && + item.source_name.trim() !== '' + ) + .map((item) => [item.source, item.source_name]) + ).entries() + ) .sort((a, b) => { - // 判断是否为 openlist const aIsOpenList = a[0] === 'openlist'; const bIsOpenList = b[0] === 'openlist'; - - // 判断是否为 emby 源(包括 emby 和 emby_xxx 格式) const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby_'); const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby_'); - // 优先级:OpenList(100) > Emby(90) > 其他(0) const aPriority = aIsOpenList ? 100 : aIsEmby ? 90 : 0; const bPriority = bIsOpenList ? 100 : bIsEmby ? 90 : 0; if (aPriority !== bPriority) { - return bPriority - aPriority; // 降序排列 + return bPriority - aPriority; } - // 同优先级内按名称排序 return a[1].localeCompare(b[1]); }) .map(([value, label]) => ({ label, value })), ]; - const titleOptions: { label: string; value: string }[] = [ + const buildTitleOptions = (titles: string[]) => [ { label: '全部标题', value: 'all' }, - ...Array.from(titlesSet.values()) + ...Array.from(new Set(titles)) + .filter((title) => title && title.trim() !== '') .sort((a, b) => a.localeCompare(b)) - .map((t) => ({ label: t, value: t })), + .map((title) => ({ label: title, value: title })), ]; - // 年份: 将 unknown 放末尾 - const years = Array.from(yearsSet.values()); - const knownYears = years.filter((y) => y !== 'unknown').sort((a, b) => parseInt(b) - parseInt(a)); - const hasUnknown = years.includes('unknown'); - const yearOptions: { label: string; value: string }[] = [ - { label: '全部年份', value: 'all' }, - ...knownYears.map((y) => ({ label: y, value: y })), - ...(hasUnknown ? [{ label: '未知', value: 'unknown' }] : []), - ]; + const buildYearOptions = (years: string[]) => { + const yearSet = Array.from( + new Set(years.filter((year) => year && year.trim() !== '')) + ); + const knownYears = yearSet + .filter((year) => year !== 'unknown') + .sort((a, b) => parseInt(b) - parseInt(a)); + const hasUnknown = yearSet.includes('unknown'); + + return [ + { label: '全部年份', value: 'all' }, + ...knownYears.map((year) => ({ label: year, value: year })), + ...(hasUnknown ? [{ label: '未知', value: 'unknown' }] : []), + ]; + }; + + const allForSourceOptions = exactSearchFiltered.filter((item) => { + if (filterAll.title !== 'all' && item.title !== filterAll.title) + return false; + if (filterAll.year !== 'all' && item.year !== filterAll.year) + return false; + return true; + }); + + const allForTitleOptions = exactSearchFiltered.filter((item) => { + if (filterAll.source !== 'all' && item.source !== filterAll.source) + return false; + if (filterAll.year !== 'all' && item.year !== filterAll.year) + return false; + return true; + }); + + const allForYearOptions = exactSearchFiltered.filter((item) => { + if (filterAll.source !== 'all' && item.source !== filterAll.source) + return false; + if (filterAll.title !== 'all' && item.title !== filterAll.title) + return false; + return true; + }); + + const aggForSourceOptions = aggregatedResults.filter(([_, group]) => { + const gTitle = group[0]?.title ?? ''; + const gYear = group[0]?.year ?? 'unknown'; + if (filterAgg.title !== 'all' && gTitle !== filterAgg.title) return false; + if (filterAgg.year !== 'all' && gYear !== filterAgg.year) return false; + return true; + }); + + const aggForTitleOptions = aggregatedResults.filter(([_, group]) => { + const gYear = group[0]?.year ?? 'unknown'; + const hasSource = + filterAgg.source === 'all' + ? true + : group.some((item) => item.source === filterAgg.source); + if (!hasSource) return false; + if (filterAgg.year !== 'all' && gYear !== filterAgg.year) return false; + return true; + }); + + const aggForYearOptions = aggregatedResults.filter(([_, group]) => { + const gTitle = group[0]?.title ?? ''; + const hasSource = + filterAgg.source === 'all' + ? true + : group.some((item) => item.source === filterAgg.source); + if (!hasSource) return false; + if (filterAgg.title !== 'all' && gTitle !== filterAgg.title) return false; + return true; + }); const categoriesAll: SearchFilterCategory[] = [ - { key: 'source', label: '来源', options: sourceOptions }, - { key: 'title', label: '标题', options: titleOptions }, - { key: 'year', label: '年份', options: yearOptions }, + { + key: 'source', + label: '来源', + options: buildSourceOptions( + allForSourceOptions.map((item) => ({ + source: item.source, + source_name: item.source_name, + })) + ), + }, + { + key: 'title', + label: '标题', + options: buildTitleOptions( + allForTitleOptions.map((item) => item.title) + ), + }, + { + key: 'year', + label: '年份', + options: buildYearOptions(allForYearOptions.map((item) => item.year)), + }, ]; const categoriesAgg: SearchFilterCategory[] = [ - { key: 'source', label: '来源', options: sourceOptions }, - { key: 'title', label: '标题', options: titleOptions }, - { key: 'year', label: '年份', options: yearOptions }, + { + key: 'source', + label: '来源', + options: buildSourceOptions( + aggForSourceOptions.flatMap(([_, group]) => + group.map((item) => ({ + source: item.source, + source_name: item.source_name, + })) + ) + ), + }, + { + key: 'title', + label: '标题', + options: buildTitleOptions( + aggForTitleOptions.map(([_, group]) => group[0]?.title ?? '') + ), + }, + { + key: 'year', + label: '年份', + options: buildYearOptions( + aggForYearOptions.map(([_, group]) => group[0]?.year ?? 'unknown') + ), + }, ]; return { categoriesAll, categoriesAgg }; - }, [searchResults]); + }, [searchResults, aggregatedResults, exactSearch, filterAll, filterAgg]); // 非聚合:应用筛选与排序 const filteredAllResults = useMemo(() => { @@ -444,7 +642,9 @@ function SearchPageClient() { // 首先应用精确搜索过滤 const exactSearchFiltered = exactSearch - ? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current)) + ? searchResults.filter((item) => + titleContainsQuery(item.title, currentQueryRef.current) + ) : searchResults; const filtered = exactSearchFiltered.filter((item) => { @@ -472,9 +672,9 @@ function SearchPageClient() { if (!aExactMatch && bExactMatch) return 1; // 最后按标题排序,正序时字母序,倒序时反字母序 - return yearOrder === 'asc' ? - a.title.localeCompare(b.title) : - b.title.localeCompare(a.title); + return yearOrder === 'asc' + ? a.title.localeCompare(b.title) + : b.title.localeCompare(a.title); }); }, [searchResults, filterAll, searchQuery, exactSearch]); @@ -484,7 +684,8 @@ function SearchPageClient() { const filtered = aggregatedResults.filter(([_, group]) => { const gTitle = group[0]?.title ?? ''; const gYear = group[0]?.year ?? 'unknown'; - const hasSource = source === 'all' ? true : group.some((item) => item.source === source); + const hasSource = + source === 'all' ? true : group.some((item) => item.source === source); if (!hasSource) return false; if (title !== 'all' && gTitle !== title) return false; if (year !== 'all' && gYear !== year) return false; @@ -513,26 +714,228 @@ function SearchPageClient() { // 最后按标题排序,正序时字母序,倒序时反字母序 const aTitle = a[1][0].title; const bTitle = b[1][0].title; - return yearOrder === 'asc' ? - aTitle.localeCompare(bTitle) : - bTitle.localeCompare(aTitle); + return yearOrder === 'asc' + ? aTitle.localeCompare(bTitle) + : bTitle.localeCompare(aTitle); }); }, [aggregatedResults, filterAgg, searchQuery]); const useVirtualGrid = useMemo(() => { - const cardCount = viewMode === 'agg' ? filteredAggResults.length : filteredAllResults.length; - return cardCount >= 100; - }, [viewMode, filteredAggResults.length, filteredAllResults.length]); + const cardCount = + viewMode === 'agg' + ? filteredAggResults.length + : filteredAllResults.length; + return resultDisplayMode === 'card' && cardCount >= 100; + }, [ + viewMode, + resultDisplayMode, + filteredAggResults.length, + filteredAllResults.length, + ]); + + useEffect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('searchResultDisplayMode', resultDisplayMode); + } + }, [resultDisplayMode]); + + const getSearchResultUrl = (params: { + title: string; + year?: string; + type?: string; + source?: string; + id?: string; + query?: string; + isAggregate?: boolean; + }) => { + const yearParam = + params.year && params.year !== 'unknown' ? `&year=${params.year}` : ''; + const queryParam = params.query + ? `&stitle=${encodeURIComponent(params.query.trim())}` + : ''; + const typeParam = params.type ? `&stype=${params.type}` : ''; + const preferParam = params.isAggregate ? '&prefer=true' : ''; + + if (params.isAggregate || !params.source || !params.id) { + return `/play?title=${encodeURIComponent( + params.title.trim() + )}${yearParam}${typeParam}${preferParam}${queryParam}`; + } + + return `/play?source=${params.source}&id=${ + params.id + }&title=${encodeURIComponent( + params.title.trim() + )}${yearParam}${preferParam}${queryParam}${typeParam}`; + }; + + const renderTag = (label: string, className: string) => ( + + {label} + + ); + + const renderListItem = (item: { + key: string; + title: string; + poster: string; + year?: string; + type: 'movie' | 'tv'; + episodes?: number; + sourceName?: string; + sourceNames?: string[]; + doubanId?: number; + desc?: string; + vodRemarks?: string; + isAggregate?: boolean; + source?: string; + id?: string; + query?: string; + }) => { + const yearText = item.year && item.year !== 'unknown' ? item.year : ''; + const sourceTags = item.isAggregate + ? Array.from(new Set(item.sourceNames || [])) + : item.sourceName + ? [item.sourceName] + : []; + const isExpanded = !!expandedSourceTags[item.key]; + const maxVisibleSourceTags = 3; + const visibleSourceTags = isExpanded + ? sourceTags + : sourceTags.slice(0, maxVisibleSourceTags); + const hiddenSourceCount = Math.max( + 0, + sourceTags.length - visibleSourceTags.length + ); + const description = (item.desc || '').trim(); + const itemUrl = getSearchResultUrl({ + title: item.title, + year: item.year, + type: item.type, + source: item.source, + id: item.id, + query: item.query, + isAggregate: item.isAggregate, + }); + + return ( + + )} + + )} + + ); + }; // 监听选项卡切换,自动执行搜索 useEffect(() => { // 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索 if (activeTab === 'pansou' && searchQuery.trim() && showResults) { - setTriggerPansouSearch(prev => !prev); + setTriggerPansouSearch((prev) => !prev); } // 如果切换到 ACG 磁力搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索 if (activeTab === 'acg' && searchQuery.trim() && showResults) { - setTriggerAcgSearch(prev => !prev); + setTriggerAcgSearch((prev) => !prev); } }, [activeTab]); @@ -552,9 +955,9 @@ function SearchPageClient() { // 延迟触发搜索,确保组件已经切换到正确的标签页 setTimeout(() => { if (typeParam === 'pansou') { - setTriggerPansouSearch(prev => !prev); + setTriggerPansouSearch((prev) => !prev); } else if (typeParam === 'acg') { - setTriggerAcgSearch(prev => !prev); + setTriggerAcgSearch((prev) => !prev); } }, 100); } @@ -574,20 +977,22 @@ function SearchPageClient() { // 初始化繁体转简体转换器 if (typeof window !== 'undefined') { - import('opencc-js').then((module) => { - try { - const OpenCC = module.default || module; - const converter = OpenCC.Converter({ from: 'hk', to: 'cn' }); - converterRef.current = converter; - setConverterReady(true); - } catch (error) { - console.error('初始化繁体转简体转换器失败:', error); + import('opencc-js') + .then((module) => { + try { + const OpenCC = module.default || module; + const converter = OpenCC.Converter({ from: 'hk', to: 'cn' }); + converterRef.current = converter; + setConverterReady(true); + } catch (error) { + console.error('初始化繁体转简体转换器失败:', error); + setConverterReady(true); // 即使失败也设置为 true,避免阻塞 + } + }) + .catch((error) => { + console.error('加载 opencc-js 失败:', error); setConverterReady(true); // 即使失败也设置为 true,避免阻塞 - } - }).catch((error) => { - console.error('加载 opencc-js 失败:', error); - setConverterReady(true); // 即使失败也设置为 true,避免阻塞 - }); + }); } else { setConverterReady(true); } @@ -670,7 +1075,9 @@ function SearchPageClient() { // 如果开启了繁体转简体,进行转换 if (query && typeof window !== 'undefined') { - const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified'); + const searchTraditionalToSimplified = localStorage.getItem( + 'searchTraditionalToSimplified' + ); if (searchTraditionalToSimplified === 'true' && converterRef.current) { try { @@ -681,7 +1088,13 @@ function SearchPageClient() { if (originalQuery !== query) { const trimmedConverted = query.trim(); // 使用 replace 而不是 push,避免在历史记录中留下繁体版本 - router.replace(`/search?q=${encodeURIComponent(trimmedConverted)}${searchParams.get('type') ? `&type=${searchParams.get('type')}` : ''}`); + router.replace( + `/search?q=${encodeURIComponent(trimmedConverted)}${ + searchParams.get('type') + ? `&type=${searchParams.get('type')}` + : '' + }` + ); return; // 等待 URL 更新后重新触发此 effect } } catch (error) { @@ -696,7 +1109,7 @@ function SearchPageClient() { setSearchQuery(query); const trimmed = query.trim(); - + // 检查是否有缓存且不是强制刷新 if (!forceRefresh) { const cachedResults = getCachedResults(trimmed); @@ -714,21 +1127,26 @@ function SearchPageClient() { return; } } - + // 如果是强制刷新,清除缓存 if (forceRefresh) { clearCachedResults(trimmed); setForceRefresh(false); } - + // 开始新搜索时,重置缓存标记 setIsFromCache(false); - + // 新搜索:关闭旧连接并清空结果 if (eventSourceRef.current) { - try { eventSourceRef.current.close(); } catch { } + try { + eventSourceRef.current.close(); + } catch {} eventSourceRef.current = null; } + // 先设置加载状态,再清空结果,避免短暂显示"暂无搜索结果" + setIsLoading(true); + setShowResults(true); setSearchResults([]); setTotalSources(0); setCompletedSources(0); @@ -738,8 +1156,6 @@ function SearchPageClient() { clearTimeout(flushTimerRef.current); flushTimerRef.current = null; } - setIsLoading(true); - setShowResults(true); // 每次搜索时重新读取设置,确保使用最新的配置 let currentFluidSearch = useFluidSearch; @@ -748,7 +1164,8 @@ function SearchPageClient() { if (savedFluidSearch !== null) { currentFluidSearch = JSON.parse(savedFluidSearch); } else { - const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false; + const defaultFluidSearch = + (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false; currentFluidSearch = defaultFluidSearch; } } @@ -760,7 +1177,9 @@ function SearchPageClient() { if (currentFluidSearch) { // 流式搜索:打开新的流式连接 - const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`); + const es = new EventSource( + `/api/search/ws?q=${encodeURIComponent(trimmed)}` + ); eventSourceRef.current = es; es.onmessage = (event) => { @@ -775,9 +1194,15 @@ function SearchPageClient() { break; case 'source_result': { setCompletedSources((prev) => prev + 1); - if (Array.isArray(payload.results) && payload.results.length > 0) { + if ( + Array.isArray(payload.results) && + payload.results.length > 0 + ) { // 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁 - const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder)); + const activeYearOrder = + viewMode === 'agg' + ? filterAgg.yearOrder + : filterAll.yearOrder; const incoming: SearchResult[] = activeYearOrder === 'none' ? sortBatchForNoOrder(payload.results as SearchResult[]) @@ -825,13 +1250,15 @@ function SearchPageClient() { }); } setIsLoading(false); - try { es.close(); } catch { } + try { + es.close(); + } catch {} if (eventSourceRef.current === es) { eventSourceRef.current = null; } break; } - } catch { } + } catch {} }; es.onerror = () => { @@ -848,7 +1275,9 @@ function SearchPageClient() { setSearchResults((prev) => prev.concat(toAppend)); }); } - try { es.close(); } catch { } + try { + es.close(); + } catch {} if (eventSourceRef.current === es) { eventSourceRef.current = null; } @@ -856,12 +1285,13 @@ function SearchPageClient() { } else { // 传统搜索:使用普通接口 fetch(`/api/search?q=${encodeURIComponent(trimmed)}`) - .then(response => response.json()) - .then(data => { + .then((response) => response.json()) + .then((data) => { if (currentQueryRef.current !== trimmed) return; if (data.results && Array.isArray(data.results)) { - const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder)); + const activeYearOrder = + viewMode === 'agg' ? filterAgg.yearOrder : filterAll.yearOrder; const results: SearchResult[] = activeYearOrder === 'none' ? sortBatchForNoOrder(data.results as SearchResult[]) @@ -893,7 +1323,9 @@ function SearchPageClient() { useEffect(() => { return () => { if (eventSourceRef.current) { - try { eventSourceRef.current.close(); } catch { } + try { + eventSourceRef.current.close(); + } catch {} eventSourceRef.current = null; } if (flushTimerRef.current) { @@ -931,7 +1363,9 @@ function SearchPageClient() { // 如果开启了繁体转简体,进行转换 if (typeof window !== 'undefined') { - const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified'); + const searchTraditionalToSimplified = localStorage.getItem( + 'searchTraditionalToSimplified' + ); if (searchTraditionalToSimplified === 'true' && converterRef.current) { try { trimmed = converterRef.current(trimmed); @@ -945,6 +1379,8 @@ function SearchPageClient() { setSearchQuery(trimmed); setShowResults(true); setShowSuggestions(false); + // 立即设置加载状态,避免显示"未找到相关结果" + setIsLoading(true); // 根据当前选项卡执行不同的搜索 if (activeTab === 'video') { @@ -954,11 +1390,11 @@ function SearchPageClient() { } else if (activeTab === 'pansou') { // 网盘搜索 - 触发搜索 router.push(`/search?q=${encodeURIComponent(trimmed)}&type=pansou`); - setTriggerPansouSearch(prev => !prev); // 切换状态来触发搜索 + setTriggerPansouSearch((prev) => !prev); // 切换状态来触发搜索 } else if (activeTab === 'acg') { // ACG 磁力搜索 - 触发搜索 router.push(`/search?q=${encodeURIComponent(trimmed)}&type=acg`); - setTriggerAcgSearch(prev => !prev); + setTriggerAcgSearch((prev) => !prev); } }; @@ -967,7 +1403,9 @@ function SearchPageClient() { // 如果开启了繁体转简体,进行转换 if (typeof window !== 'undefined') { - const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified'); + const searchTraditionalToSimplified = localStorage.getItem( + 'searchTraditionalToSimplified' + ); if (searchTraditionalToSimplified === 'true' && converterRef.current) { try { processedSuggestion = converterRef.current(suggestion); @@ -982,20 +1420,28 @@ function SearchPageClient() { // 自动执行搜索 setShowResults(true); + // 立即设置加载状态,避免显示"未找到相关结果" + setIsLoading(true); // 根据当前选项卡执行不同的搜索 if (activeTab === 'video') { // 影视搜索 - router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=video`); + router.push( + `/search?q=${encodeURIComponent(processedSuggestion)}&type=video` + ); // 其余由 searchParams 变化的 effect 处理 } else if (activeTab === 'pansou') { // 网盘搜索 - 触发搜索 - router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=pansou`); - setTriggerPansouSearch(prev => !prev); + router.push( + `/search?q=${encodeURIComponent(processedSuggestion)}&type=pansou` + ); + setTriggerPansouSearch((prev) => !prev); } else if (activeTab === 'acg') { // ACG 磁力搜索 - 触发搜索 - router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=acg`); - setTriggerAcgSearch(prev => !prev); + router.push( + `/search?q=${encodeURIComponent(processedSuggestion)}&type=acg` + ); + setTriggerAcgSearch((prev) => !prev); } }; @@ -1020,7 +1466,9 @@ function SearchPageClient() { // 如果有搜索关键词,更新 URL const currentQuery = searchParams.get('q'); if (currentQuery) { - router.push(`/search?q=${encodeURIComponent(currentQuery)}&type=${newTab}`); + router.push( + `/search?q=${encodeURIComponent(currentQuery)}&type=${newTab}` + ); } }; @@ -1039,7 +1487,7 @@ function SearchPageClient() { onChange={handleInputChange} onFocus={handleInputFocus} placeholder='搜索电影、电视剧...' - autoComplete="off" + autoComplete='off' className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-12 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700' /> @@ -1107,7 +1555,9 @@ function SearchPageClient() { : []), ]} active={activeTab} - onChange={(value) => handleTabChange(value as 'video' | 'pansou' | 'acg')} + onChange={(value) => + handleTabChange(value as 'video' | 'pansou' | 'acg') + } /> @@ -1121,178 +1571,284 @@ function SearchPageClient() { {/* 影视搜索结果 */} {/* 标题 */}
-

- 搜索结果 - {isFromCache ? ( - - 缓存 - - ) : ( - <> - {totalSources > 0 && useFluidSearch && ( - - {completedSources}/{totalSources} +

+ 搜索结果 + {isFromCache ? ( + + 缓存 + ) : ( + <> + {totalSources > 0 && useFluidSearch && ( + + {completedSources}/{totalSources} + + )} + {isLoading && useFluidSearch && ( + + + + )} + )} - {isLoading && useFluidSearch && ( - - - - )} - - )} -

- {/* 强制刷新按钮 */} - {searchQuery && ( - - )} -

- {/* 筛选器 + 聚合开关 同行 */} -
-
- {viewMode === 'agg' ? ( - setFilterAgg(v as any)} - /> - ) : ( - setFilterAll(v as any)} - /> - )} -
- {/* 聚合开关 */} - -
- {searchResults.length === 0 ? ( - isLoading ? ( -
-
-
- ) : ( -
- 未找到相关结果 -
- ) - ) : ( - (() => { - const gridClassName = - 'justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'; - - const gridChildren = - viewMode === 'agg' - ? filteredAggResults.map(([mapKey, group]) => { - const title = group[0]?.title || ''; - const poster = group[0]?.poster || ''; - const year = group[0]?.year || 'unknown'; - const { episodes, source_names, douban_id } = computeGroupStats(group); - - // 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year) - // 找到最后一个 '-' 之前的部分,再找倒数第二个 '-' - const lastDashIndex = mapKey.lastIndexOf('-'); - const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1); - const type = secondLastDashIndex > 0 - ? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv' - : (episodes === 1 ? 'movie' : 'tv'); // 兜底 - - // 如果该聚合第一次出现,写入初始统计 - if (!groupStatsRef.current.has(mapKey)) { - groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id }); - } - - return ( -
- -
- ); - }) - : filteredAllResults.map((item) => ( -
- 1 ? 'tv' : 'movie'} - /> -
- )); - - if (useVirtualGrid) { - return ( - + {searchQuery && ( + + )} + +
+
+ {viewMode === 'agg' ? ( + setFilterAgg(v as any)} + /> + ) : ( + setFilterAll(v as any)} + /> + )}
- ); - })() - )} +
+ +
+
+
+
+ + +
+
+ {searchResults.length === 0 ? ( + isLoading ? ( +
+
+
+ ) : ( +
+ 未找到相关结果 +
+ ) + ) : ( + (() => { + const gridClassName = + 'justify-start grid grid-cols-3 gap-x-2 gap-y-14 px-0 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:gap-y-20 sm:px-2'; + + const listClassName = 'space-y-4'; + + const resultChildren = + viewMode === 'agg' + ? filteredAggResults.map(([mapKey, group]) => { + const title = group[0]?.title || ''; + const poster = group[0]?.poster || ''; + const year = group[0]?.year || 'unknown'; + const desc = + group.find((entry) => entry.desc?.trim()) + ?.desc || ''; + const vodRemarks = + group.find((entry) => entry.vod_remarks?.trim()) + ?.vod_remarks || ''; + const { episodes, source_names, douban_id } = + computeGroupStats(group); + + const lastDashIndex = mapKey.lastIndexOf('-'); + const secondLastDashIndex = mapKey.lastIndexOf( + '-', + lastDashIndex - 1 + ); + const type = + secondLastDashIndex > 0 + ? (mapKey.substring( + secondLastDashIndex + 1, + lastDashIndex + ) as 'movie' | 'tv') + : episodes === 1 + ? 'movie' + : 'tv'; + + if (!groupStatsRef.current.has(mapKey)) { + groupStatsRef.current.set(mapKey, { + episodes, + source_names, + douban_id, + }); + } + + if (resultDisplayMode === 'list') { + return renderListItem({ + key: `agg-${mapKey}`, + title, + poster, + year, + type, + episodes, + sourceNames: source_names, + doubanId: douban_id, + desc, + vodRemarks, + isAggregate: true, + query: + searchQuery.trim() !== title + ? searchQuery.trim() + : '', + }); + } + + return ( +
+ +
+ ); + }) + : filteredAllResults.map((item) => { + const type = + item.episodes.length > 1 ? 'tv' : 'movie'; + + if (resultDisplayMode === 'list') { + return renderListItem({ + key: `all-${item.source}-${item.id}`, + id: item.id, + title: item.title, + poster: item.poster, + episodes: item.episodes.length, + source: item.source, + sourceName: item.source_name, + doubanId: item.douban_id, + query: + searchQuery.trim() !== item.title + ? searchQuery.trim() + : '', + year: item.year, + type, + desc: item.desc, + vodRemarks: item.vod_remarks, + }); + } + + return ( +
+ +
+ ); + }); + + if (useVirtualGrid) { + return ( + + {resultChildren} + + ); + } + + return ( +
+ {resultChildren} +
+ ); + })() + )} ) : activeTab === 'pansou' ? ( <> @@ -1345,25 +1901,33 @@ function SearchPageClient() { onClick={() => { setSearchQuery(item); setShowResults(true); + // 立即设置加载状态,避免显示"未找到相关结果" + setIsLoading(true); // 根据当前选项卡执行不同的搜索 if (activeTab === 'video') { // 影视搜索 router.push( - `/search?q=${encodeURIComponent(item.trim())}&type=video` + `/search?q=${encodeURIComponent( + item.trim() + )}&type=video` ); } else if (activeTab === 'pansou') { // 网盘搜索 router.push( - `/search?q=${encodeURIComponent(item.trim())}&type=pansou` + `/search?q=${encodeURIComponent( + item.trim() + )}&type=pansou` ); - setTriggerPansouSearch(prev => !prev); + setTriggerPansouSearch((prev) => !prev); } else if (activeTab === 'acg') { // ACG 磁力搜索 router.push( - `/search?q=${encodeURIComponent(item.trim())}&type=acg` + `/search?q=${encodeURIComponent( + item.trim() + )}&type=acg` ); - setTriggerAcgSearch(prev => !prev); + setTriggerAcgSearch((prev) => !prev); } }} className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300' @@ -1390,13 +1954,23 @@ function SearchPageClient() { + {previewImage && ( + setPreviewImage(null)} + imageUrl={previewImage.url} + alt={previewImage.alt} + /> + )} + {/* 返回顶部悬浮按钮 */} + + ); + } + + if (loading && comments.length === 0) { + return ( +
+
+ AI正在生成评论... + + 这可能需要几秒钟 + +
+ ); + } + + if (error && comments.length === 0) { + return ( +
+
+

{error}

+

+ 请检查管理面板的AI配置是否正确 +

+ +
+ ); + } + + return ( +
+ {/* 头部统计和操作 */} +
+
+ 已生成 {comments.length} 条AI评论 +
+ +
+ + {/* 评论列表 */} +
+ {comments.map((comment) => ( +
+ {/* 用户信息 */} +
+ {/* 头像 */} +
+ {comment.userName} +
+ + {/* 用户名和评分 */} +
+
+ + {comment.userName} + + {renderStars(comment.rating)} + {/* AI标识 */} + + + + + AI生成 + +
+ + {/* 时间 */} +
+ {comment.time} +
+
+ + {/* 有用数 */} + {comment.votes > 0 && ( +
+ + + + {comment.votes} +
+ )} +
+ + {/* 评论内容 */} +
+ {comment.content} +
+
+ ))} +
+ + {/* 提示信息 */} +
+ 以上评论由AI基于影片信息和网络资料生成,仅供参考 +
+
+ ); +} diff --git a/src/components/ContinueWatching.tsx b/src/components/ContinueWatching.tsx index 556e6bc..6b066fc 100644 --- a/src/components/ContinueWatching.tsx +++ b/src/components/ContinueWatching.tsx @@ -1,17 +1,19 @@ /* eslint-disable no-console */ 'use client'; -import { AlertTriangle } from 'lucide-react'; +import { AlertTriangle, ChevronRight } from 'lucide-react'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import type { PlayRecord } from '@/lib/db.client'; import { clearAllPlayRecords, + getCachedPlayRecordsSnapshot, getAllPlayRecords, subscribeToDataUpdates, } from '@/lib/db.client'; +import PlayRecordsPanel from '@/components/PlayRecordsPanel'; import VideoCard from '@/components/VideoCard'; import VirtualScrollableRow from '@/components/VirtualScrollableRow'; @@ -19,47 +21,58 @@ interface ContinueWatchingProps { className?: string; } +type PlayRecordItem = PlayRecord & { key: string }; + export default function ContinueWatching({ className }: ContinueWatchingProps) { - const [playRecords, setPlayRecords] = useState< - (PlayRecord & { key: string })[] - >([]); + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + const cachedDisplayLimit = storageType !== 'localstorage' ? 10 : undefined; + const [playRecords, setPlayRecords] = useState([]); const [loading, setLoading] = useState(true); const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [showPlayRecordsPanel, setShowPlayRecordsPanel] = useState(false); - // 处理播放记录数据更新的函数 - const updatePlayRecords = (allRecords: Record, limit?: number) => { - // 将记录转换为数组并根据 save_time 由近到远排序 + const updatePlayRecords = ( + allRecords: Record, + limit?: number + ) => { const recordsArray = Object.entries(allRecords).map(([key, record]) => ({ ...record, key, })); - // 按 save_time 降序排序(最新的在前面) - const sortedRecords = recordsArray.sort( - (a, b) => b.save_time - a.save_time - ); + const sortedRecords = recordsArray.sort((a, b) => b.save_time - a.save_time); + setPlayRecords(limit ? sortedRecords.slice(0, limit) : sortedRecords); + }; - // 如果指定了 limit,只取前 N 条 - const finalRecords = limit ? sortedRecords.slice(0, limit) : sortedRecords; + const applyCachedSnapshot = () => { + const cachedRecords = getCachedPlayRecordsSnapshot(); + if (Object.keys(cachedRecords).length === 0) { + return false; + } - setPlayRecords(finalRecords); + updatePlayRecords(cachedRecords, cachedDisplayLimit); + setLoading(false); + return true; }; useEffect(() => { + const unsubscribe = subscribeToDataUpdates( + 'playRecordsUpdated', + (newRecords: Record) => { + updatePlayRecords(newRecords); + setLoading(false); + } + ); + const fetchPlayRecords = async () => { try { - setLoading(true); - - // 从缓存或API获取所有播放记录 - const allRecords = await getAllPlayRecords(); - - // 非 localStorage 模式下,先只显示前 10 条记录 - const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; - if (storageType !== 'localstorage') { - updatePlayRecords(allRecords, 10); - } else { - updatePlayRecords(allRecords); + const hasCachedSnapshot = applyCachedSnapshot(); + if (!hasCachedSnapshot) { + setLoading(true); } + + const allRecords = await getAllPlayRecords(); + updatePlayRecords(allRecords); } catch (error) { console.error('获取播放记录失败:', error); setPlayRecords([]); @@ -69,37 +82,23 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) { }; fetchPlayRecords(); - - // 监听播放记录更新事件 - const unsubscribe = subscribeToDataUpdates( - 'playRecordsUpdated', - (newRecords: Record) => { - // 同步完成后,加载完整数据(不限制数量) - updatePlayRecords(newRecords); - } - ); - return unsubscribe; - }, []); + }, [cachedDisplayLimit]); - // 如果没有播放记录,则不渲染组件 if (!loading && playRecords.length === 0) { return null; } - // 计算播放进度百分比 const getProgress = (record: PlayRecord) => { if (record.total_time === 0) return 0; return (record.play_time / record.total_time) * 100; }; - // 从 key 中解析 source 和 id const parseKey = (key: string) => { const [source, id] = key.split('+'); return { source, id }; }; - // 处理清空确认 const handleClearConfirm = async () => { await clearAllPlayRecords(); setPlayRecords([]); @@ -114,173 +113,187 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) { 继续观看 {!loading && playRecords.length > 0 && ( - +
+ + +
)} - {loading ? ( - // 加载状态显示灰色占位数据(使用原始 ScrollableRow) -
- {Array.from({ length: 8 }).map((_, index) => ( -
-
-
+ {loading ? ( +
+ {Array.from({ length: 8 }).map((_, index) => ( +
+
+
+
+
+
-
-
-
- ))} -
- ) : ( - // 使用虚拟滚动显示真实数据 -
- - {playRecords.map((record) => { - const { source, id } = parseKey(record.key); - return ( -
- - setPlayRecords((prev) => - prev.filter((r) => r.key !== record.key) - ) - } - type={record.total_episodes > 1 ? 'tv' : ''} - origin={record.origin} - orientation='horizontal' - playTime={record.play_time} - totalTime={record.total_time} - /> - {/* 新增剧集提示 - 完全独立于 VideoCard */} - {record.new_episodes && record.new_episodes > 0 && ( -
- {/* 水波纹动画 - 第一层 */} + ))} +
+ ) : ( +
+ + {playRecords.map((record) => { + const { source, id } = parseKey(record.key); + return ( +
+ + setPlayRecords((prev) => + prev.filter((item) => item.key !== record.key) + ) + } + type={record.total_episodes > 1 ? 'tv' : ''} + origin={record.origin} + orientation='horizontal' + playTime={record.play_time} + totalTime={record.total_time} + /> + {record.new_episodes && record.new_episodes > 0 && (
- {/* 水波纹动画 - 第二层 */} -
- {/* 主体徽章 */} -
- +{record.new_episodes} +
+
+
+ +{record.new_episodes} +
-
- )} -
- ); - })} - -
- )} - - - {/* 确认对话框 */} - {showConfirmDialog && createPortal( -
setShowConfirmDialog(false)} - > -
e.stopPropagation()} - > -
- {/* 图标和标题 */} -
-
- -
-
-

- 清空播放记录 -

-

- 确定要清空所有播放记录吗?此操作不可恢复。 -

-
-
- - {/* 按钮组 */} -
- - -
+ )} +
+ ); + })} +
-
-
, - document.body - )} - + )} + + + {showConfirmDialog && + createPortal( +
setShowConfirmDialog(false)} + > +
event.stopPropagation()} + > +
+
+
+ +
+
+

+ 清空播放记录 +

+

+ 确定要清空所有播放记录吗?此操作不可恢复。 +

+
+
+ +
+ + +
+
+
+
, + document.body + )} + + {showPlayRecordsPanel && + createPortal( + setShowPlayRecordsPanel(false)} + />, + document.body + )} + ); } diff --git a/src/components/DanmakuPanel.tsx b/src/components/DanmakuPanel.tsx index 703dfa7..3265d59 100644 --- a/src/components/DanmakuPanel.tsx +++ b/src/components/DanmakuPanel.tsx @@ -166,7 +166,7 @@ export default function DanmakuPanel({
{/* 搜索区域 - 固定在顶部 */}
-
+
handleSearch(searchKeyword)} disabled={isSearching} - className='flex items-center justify-center gap-2 rounded-lg bg-green-500 px-3 py-2 + className='flex flex-shrink-0 items-center justify-center gap-2 rounded-lg bg-green-500 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700 - sm:px-4 md:gap-2' + lg:px-4 min-w-[44px]' > {isSearching ? (
) : ( )} - + {isSearching ? '搜索中...' : '搜索'} @@ -382,9 +382,18 @@ export default function DanmakuPanel({ {/* 信息 */}
-

- {anime.animeTitle} -

+
+

+ {anime.animeTitle} +

+ {/* 自定义 tooltip */} +
+ {anime.animeTitle} +
+
+
{anime.typeDescription || anime.type} diff --git a/src/components/PansouSearch.tsx b/src/components/PansouSearch.tsx index 47c7d5a..0d78a75 100644 --- a/src/components/PansouSearch.tsx +++ b/src/components/PansouSearch.tsx @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */ 'use client'; -import { AlertCircle, Copy, ExternalLink, Loader2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { AlertCircle, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react'; +import { useEffect, useState, useCallback } from 'react'; import { PansouLink, PansouSearchResult } from '@/lib/pansou.client'; @@ -57,51 +57,52 @@ export default function PansouSearch({ const [copiedUrl, setCopiedUrl] = useState(null); const [selectedType, setSelectedType] = useState('all'); // 'all' 表示显示全部 + // 提取搜索函数,以便在重试时调用 + const searchPansou = useCallback(async () => { + const currentKeyword = keyword.trim(); + if (!currentKeyword) { + return; + } + + setLoading(true); + setError(null); + setResults(null); + + try { + const response = await fetch('/api/pansou/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + keyword: currentKeyword, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || '搜索失败'); + } + + const data: PansouSearchResult = await response.json(); + setResults(data); + } catch (err: any) { + const errorMsg = err.message || '搜索失败,请检查配置'; + setError(errorMsg); + onError?.(errorMsg); + } finally { + setLoading(false); + } + }, [keyword, onError]); + useEffect(() => { // triggerSearch 变化时触发搜索(无论是 true 还是 false) if (triggerSearch === undefined) { return; } - const currentKeyword = keyword.trim(); - if (!currentKeyword) { - return; - } - - const searchPansou = async () => { - setLoading(true); - setError(null); - setResults(null); - - try { - const response = await fetch('/api/pansou/search', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - keyword: currentKeyword, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || '搜索失败'); - } - - const data: PansouSearchResult = await response.json(); - setResults(data); - } catch (err: any) { - const errorMsg = err.message || '搜索失败,请检查配置'; - setError(errorMsg); - onError?.(errorMsg); - } finally { - setLoading(false); - } - }; - searchPansou(); - }, [triggerSearch, onError]); // 移除 keyword 依赖,只依赖 triggerSearch + }, [triggerSearch, searchPansou]); // 依赖 triggerSearch 和 searchPansou const handleCopy = async (text: string, url: string) => { try { @@ -136,6 +137,13 @@ export default function PansouSearch({

{error}

+
); diff --git a/src/components/PlayRecordsPanel.tsx b/src/components/PlayRecordsPanel.tsx new file mode 100644 index 0000000..1090b9e --- /dev/null +++ b/src/components/PlayRecordsPanel.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { AlertTriangle, History, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import type { PlayRecord } from '@/lib/db.client'; +import { + clearAllPlayRecords, + getAllPlayRecords, + subscribeToDataUpdates, +} from '@/lib/db.client'; + +import VideoCard from '@/components/VideoCard'; + +type PlayRecordItem = PlayRecord & { + key: string; +}; + +interface PlayRecordsPanelProps { + isOpen: boolean; + onClose: () => void; +} + +const parseKey = (key: string) => { + const [source, id] = key.split('+'); + return { source, id }; +}; + +const getProgress = (record: PlayRecord) => { + if (record.total_time === 0) return 0; + return (record.play_time / record.total_time) * 100; +}; + +export default function PlayRecordsPanel({ + isOpen, + onClose, +}: PlayRecordsPanelProps) { + const [playRecords, setPlayRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + + const loadPlayRecords = async () => { + setLoading(true); + try { + const allRecords = await getAllPlayRecords(); + const sorted = Object.entries(allRecords) + .map(([key, record]) => ({ + ...record, + key, + })) + .sort((a, b) => b.save_time - a.save_time); + setPlayRecords(sorted); + } catch (error) { + console.error('加载播放记录失败:', error); + setPlayRecords([]); + } finally { + setLoading(false); + } + }; + + const handleClearAll = async () => { + try { + await clearAllPlayRecords(); + setPlayRecords([]); + setShowConfirmDialog(false); + } catch (error) { + console.error('清空播放记录失败:', error); + } + }; + + useEffect(() => { + if (!isOpen) return; + loadPlayRecords(); + }, [isOpen]); + + useEffect(() => { + const unsubscribe = subscribeToDataUpdates( + 'playRecordsUpdated', + (newRecords: Record) => { + if (!isOpen) return; + const sorted = Object.entries(newRecords) + .map(([key, record]) => ({ + ...record, + key, + })) + .sort((a, b) => b.save_time - a.save_time); + setPlayRecords(sorted); + } + ); + + return () => { + unsubscribe(); + }; + }, [isOpen]); + + return ( + <> +
+ +
+
+
+ +

+ 播放记录 +

+ {playRecords.length > 0 && ( + + {playRecords.length} 项 + + )} +
+
+ {playRecords.length > 0 && ( + + )} + +
+
+ +
+ {loading ? ( +
+
+
+ ) : playRecords.length === 0 ? ( +
+ +

暂无播放记录

+
+ ) : ( +
+ {playRecords.map((record) => { + const { source, id } = parseKey(record.key); + + return ( +
+ + setPlayRecords((prev) => + prev.filter((item) => item.key !== record.key) + ) + } + type={record.total_episodes > 1 ? 'tv' : ''} + origin={record.origin} + playTime={record.play_time} + totalTime={record.total_time} + /> +
+ ); + })} +
+ )} +
+
+ + {showConfirmDialog && + createPortal( +
setShowConfirmDialog(false)} + > +
e.stopPropagation()} + > +
+
+
+ +
+
+

+ 清空播放记录 +

+

+ 确定要清空所有播放记录吗?此操作不可恢复。 +

+
+
+ +
+ + +
+
+
+
, + document.body + )} + + ); +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index d7c5ca7..00a3a1c 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -114,7 +114,6 @@ export const UserMenu: React.FC = () => { const [enableOptimization, setEnableOptimization] = useState(true); const [speedTestTimeout, setSpeedTestTimeout] = useState(4000); // 测速超时时间(毫秒) const [fluidSearch, setFluidSearch] = useState(true); - const [liveDirectConnect, setLiveDirectConnect] = useState(false); const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false); const [enableTrailers, setEnableTrailers] = useState(false); const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent'); @@ -486,11 +485,6 @@ export const UserMenu: React.FC = () => { setFluidSearch(defaultFluidSearch); } - const savedLiveDirectConnect = localStorage.getItem('liveDirectConnect'); - if (savedLiveDirectConnect !== null) { - setLiveDirectConnect(JSON.parse(savedLiveDirectConnect)); - } - const savedTmdbBackdropDisabled = localStorage.getItem('tmdb_backdrop_disabled'); if (savedTmdbBackdropDisabled !== null) { setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true'); @@ -1040,13 +1034,6 @@ export const UserMenu: React.FC = () => { } }; - const handleLiveDirectConnectToggle = (value: boolean) => { - setLiveDirectConnect(value); - if (typeof window !== 'undefined') { - localStorage.setItem('liveDirectConnect', JSON.stringify(value)); - } - }; - const handleTmdbBackdropDisabledToggle = (value: boolean) => { setTmdbBackdropDisabled(value); if (typeof window !== 'undefined') { @@ -1255,7 +1242,6 @@ export const UserMenu: React.FC = () => { setDefaultAggregateSearch(true); setEnableOptimization(true); setFluidSearch(defaultFluidSearch); - setLiveDirectConnect(false); setTmdbBackdropDisabled(false); setEnableTrailers(false); setDoubanProxyUrl(defaultDoubanProxy); @@ -2031,30 +2017,6 @@ export const UserMenu: React.FC = () => {
- {/* 直播视频浏览器直连 */} -
-
-

- IPTV 视频浏览器直连 -

-

- 开启 IPTV 视频浏览器直连时,需要自备 Allow CORS 插件 -

-
- -
- {/* 禁用背景图渲染 */}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index ebff27e..2dca7f4 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1077,7 +1077,9 @@ const VideoCard = forwardRef(function VideoCard return (
{ + // 在客户端获取运行时配置 + if (typeof window !== 'undefined') { + const runtimeConfig = (window as any).RUNTIME_CONFIG as RuntimeConfig; + setEnableAIComments(runtimeConfig?.AIConfig?.EnableAIComments ?? false); + } + }, []); + + return enableAIComments; +} diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index adb8218..8737083 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -96,6 +96,7 @@ export interface AdminConfig { from: 'config' | 'custom'; channelNumber?: number; disabled?: boolean; + proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式:full=全量代理,m3u8-only=仅代理m3u8,direct=直连 }[]; WebLiveConfig?: { key: string; @@ -169,6 +170,7 @@ export interface AdminConfig { EnableHomepageEntry: boolean; // 首页入口开关 EnableVideoCardEntry: boolean; // VideoCard入口开关 EnablePlayPageEntry: boolean; // 播放页入口开关 + EnableAIComments: boolean; // AI评论生成开关 // 权限控制 AllowRegularUsers: boolean; // 是否允许普通用户使用AI问片(关闭后仅站长和管理员可用) // 高级设置 diff --git a/src/lib/ai-comment-generator.ts b/src/lib/ai-comment-generator.ts new file mode 100644 index 0000000..2f3a3d4 --- /dev/null +++ b/src/lib/ai-comment-generator.ts @@ -0,0 +1,284 @@ +// AI评论生成核心逻辑 + +export interface AIComment { + id: string; + userName: string; + userAvatar: string; + rating: number | null; + content: string; + time: string; + votes: number; + isAiGenerated: true; +} + +interface GenerateCommentsParams { + movieName: string; + movieInfo?: string; + count?: number; + aiConfig: { + CustomApiKey: string; + CustomBaseURL: string; + CustomModel: string; + Temperature?: number; + MaxTokens?: number; + EnableWebSearch?: boolean; + WebSearchProvider?: 'tavily' | 'serper' | 'serpapi'; + TavilyApiKey?: string; + SerperApiKey?: string; + SerpApiKey?: string; + }; +} + +interface CommentData { + content: string; + rating: number | null; + sentiment: 'positive' | 'neutral' | 'negative'; +} + +// 生成评论的Prompt +function buildCommentPrompt( + movieName: string, + movieInfo?: string, + searchResults?: string, + count: number = 10 +): string { + return `你是一个影评生成助手。请生成真实自然的观众评论。 + +影片:${movieName} +${movieInfo ? `简介:${movieInfo}` : ''} +${searchResults ? `\n网络评价参考:\n${searchResults}` : ''} + +任务要求: +1. 生成${count}条观众评论 +2. 每条评论50-200字,口语化、自然 +3. 观点多样化:有好评、中评、差评,比例大约6:3:1 +4. 可以包含: + - 个人观影感受和情感共鸣 + - 对演员演技的评价 + - 对剧情、节奏、画面的看法 + - 与其他作品的对比 + - 推荐或不推荐的理由 +5. 避免: + - 过于专业的影评术语 + - 千篇一律的表达 + - 明显的AI痕迹 + - 重复的内容 + +请直接输出JSON数组格式,不要有其他文字: +[ + { + "content": "评论内容", + "rating": 4, + "sentiment": "positive" + } +] + +注意:rating为1-5的整数或null(表示未评分),sentiment为positive/neutral/negative之一。`; +} + +// 联网搜索影片资料 +async function searchMovieInfo( + movieName: string, + aiConfig: GenerateCommentsParams['aiConfig'] +): Promise { + if (!aiConfig.EnableWebSearch) { + return ''; + } + + try { + const provider = aiConfig.WebSearchProvider || 'tavily'; + let searchResults = ''; + + if (provider === 'tavily' && aiConfig.TavilyApiKey) { + const response = await fetch('https://api.tavily.com/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + api_key: aiConfig.TavilyApiKey, + query: `${movieName} 影评 评价`, + max_results: 5, + }), + }); + + if (response.ok) { + const data = await response.json(); + searchResults = data.results + ?.map((r: any) => r.content) + .join('\n') + .slice(0, 1000); + } + } else if (provider === 'serper' && aiConfig.SerperApiKey) { + const response = await fetch('https://google.serper.dev/search', { + method: 'POST', + headers: { + 'X-API-KEY': aiConfig.SerperApiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + q: `${movieName} 影评 评价`, + num: 5, + }), + }); + + if (response.ok) { + const data = await response.json(); + searchResults = data.organic + ?.map((r: any) => r.snippet) + .join('\n') + .slice(0, 1000); + } + } else if (provider === 'serpapi' && aiConfig.SerpApiKey) { + const response = await fetch( + `https://serpapi.com/search?q=${encodeURIComponent(movieName + ' 影评 评价')}&api_key=${aiConfig.SerpApiKey}&num=5` + ); + + if (response.ok) { + const data = await response.json(); + searchResults = data.organic_results + ?.map((r: any) => r.snippet) + .join('\n') + .slice(0, 1000); + } + } + + return searchResults; + } catch (error) { + console.error('搜索影片资料失败:', error); + return ''; + } +} + +// 调用AI生成评论 +export async function generateAIComments( + params: GenerateCommentsParams +): Promise { + const { movieName, movieInfo, count = 10, aiConfig } = params; + + try { + // 1. 联网搜索影片资料(如果启用) + const searchResults = await searchMovieInfo(movieName, aiConfig); + + // 2. 构建Prompt + const prompt = buildCommentPrompt(movieName, movieInfo, searchResults, count); + + // 3. 调用AI API + const response = await fetch(`${aiConfig.CustomBaseURL}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${aiConfig.CustomApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: aiConfig.CustomModel, + messages: [ + { + role: 'system', + content: + '你是一个专业的影评生成助手,擅长生成真实自然的观众评论。', + }, + { + role: 'user', + content: prompt, + }, + ], + temperature: aiConfig.Temperature ?? 0.8, + max_tokens: aiConfig.MaxTokens ?? 2000, + }), + }); + + if (!response.ok) { + throw new Error(`AI API调用失败: ${response.status}`); + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content; + + if (!content) { + throw new Error('AI返回内容为空'); + } + + // 4. 解析AI返回的JSON + let commentsData: CommentData[]; + try { + // 尝试提取JSON(可能被markdown代码块包裹) + const jsonMatch = content.match(/\[[\s\S]*\]/); + if (jsonMatch) { + commentsData = JSON.parse(jsonMatch[0]); + } else { + commentsData = JSON.parse(content); + } + } catch (parseError) { + console.error('解析AI返回的JSON失败:', content); + throw new Error('AI返回格式错误'); + } + + // 5. 转换为AIComment格式 + const aiComments: AIComment[] = commentsData.map((comment, index) => { + const timestamp = Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000; // 随机过去30天内 + const date = new Date(timestamp); + + return { + id: `ai-${Date.now()}-${index}`, + userName: generateUserName(index), + userAvatar: generateAvatar(index), + rating: comment.rating, + content: comment.content, + time: formatTime(date), + votes: generateVotes(comment.sentiment), + isAiGenerated: true, + }; + }); + + return aiComments; + } catch (error) { + console.error('AI评论生成失败:', error); + throw error; + } +} + +// 生成虚拟用户名 +function generateUserName(index: number): string { + const prefixes = [ + '影迷', + '观众', + '电影爱好者', + '剧迷', + '路人', + '网友', + '看客', + ]; + const prefix = prefixes[index % prefixes.length]; + return `${prefix}${Math.floor(Math.random() * 9000) + 1000}`; +} + +// 生成头像URL(使用DiceBear API) +function generateAvatar(seed: number): string { + const styles = ['avataaars', 'bottts', 'personas', 'micah']; + const style = styles[seed % styles.length]; + return `https://api.dicebear.com/7.x/${style}/svg?seed=${seed}`; +} + +// 格式化时间 +function formatTime(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +// 根据情感生成点赞数 +function generateVotes(sentiment: string): number { + if (sentiment === 'positive') { + return Math.floor(Math.random() * 100) + 20; // 20-120 + } else if (sentiment === 'neutral') { + return Math.floor(Math.random() * 50) + 5; // 5-55 + } else { + return Math.floor(Math.random() * 30); // 0-30 + } +} diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index feb008f..42d450f 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -10,6 +10,33 @@ export interface ChangelogEntry { } export const changelog: ChangelogEntry[] = [ + { + version: "215.0.0", + date: "2026-03-20", + added: [ + "增加主动恢复进度按钮", + "新增播放记录面板", + "弹幕搜索面板标题增加tooltip", + "影视搜索新增列表视图", + "pansou增加重试按钮", + "新增ai评论生成", + "增加一键render部署", + "电视直播增加三种代理模式" + ], + changed: [ + "优化直链播放m3u8体验", + "搜索页面海量数据下使用虚拟滚动提高性能", + "优化emby代理内存泄漏问题", + "电视直播代理控制权从用户端改为管理端", + "获取视频源详情不再依赖title" + ], + fixed: [ + "修复search页面僵尸历史记录tag", + "修复弹幕搜索框挤压", + "修复搜索页面加载条显示顺序错误", + "修复继续观看渐进式加载的一些问题" + ] + }, { version: "214.1.0", date: "2026-03-10", diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index 5fcfcde..62e4bea 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -716,6 +716,42 @@ export async function getAllPlayRecords(): Promise> { } } +export function getCachedPlayRecordsSnapshot(): Record { + if (typeof window === 'undefined') { + return {}; + } + + if (STORAGE_TYPE !== 'localstorage') { + const cachedRecords = cacheManager.getCachedPlayRecords(); + if (cachedRecords) { + return cachedRecords; + } + + try { + const username = getAuthInfoFromBrowserCookie()?.username; + if (!username) return {}; + + const raw = localStorage.getItem(`${CACHE_PREFIX}${username}`); + if (!raw) return {}; + + const userCache = JSON.parse(raw) as UserCacheStore; + return userCache.playRecords?.data || {}; + } catch (err) { + console.error('读取用户播放记录快照失败:', err); + return {}; + } + } + + try { + const raw = localStorage.getItem(PLAY_RECORDS_KEY); + if (!raw) return {}; + return JSON.parse(raw) as Record; + } catch (err) { + console.error('读取本地播放记录快照失败:', err); + return {}; + } +} + /** * 保存播放记录。 * 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。 diff --git a/src/lib/downstream.ts b/src/lib/downstream.ts index 9652f61..ba85537 100644 --- a/src/lib/downstream.ts +++ b/src/lib/downstream.ts @@ -293,6 +293,90 @@ export async function getDetailFromApi( }; } +export async function getDetailFromApiV2( + apiSite: ApiSite, + id: string +): Promise { + const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(detailUrl, { + headers: API_CONFIG.detail.headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`详情请求失败: ${response.status}`); + } + + const data = await response.json(); + + if ( + !data || + !data.list || + !Array.isArray(data.list) || + data.list.length === 0 + ) { + throw new Error('获取到的详情内容无效'); + } + + const videoDetail = data.list[0]; + let episodes: string[] = []; + let titles: string[] = []; + + if (videoDetail.vod_play_url) { + const vodPlayUrlArray = videoDetail.vod_play_url.split('$$$'); + vodPlayUrlArray.forEach((url: string) => { + const matchEpisodes: string[] = []; + const matchTitles: string[] = []; + const titleUrlArray = url.split('#'); + titleUrlArray.forEach((titleUrl: string) => { + const episodeTitleUrl = titleUrl.split('$'); + if ( + episodeTitleUrl.length === 2 && + episodeTitleUrl[1].endsWith('.m3u8') + ) { + matchTitles.push(episodeTitleUrl[0]); + matchEpisodes.push(episodeTitleUrl[1]); + } + }); + if (matchEpisodes.length > episodes.length) { + episodes = matchEpisodes; + titles = matchTitles; + } + }); + } + + if (episodes.length === 0 && videoDetail.vod_content) { + const matches = videoDetail.vod_content.match(M3U8_PATTERN) || []; + episodes = matches.map((link: string) => link.replace(/^\$/, '')); + } + + return { + id: id.toString(), + title: videoDetail.vod_name, + poster: videoDetail.vod_pic, + episodes, + episodes_titles: titles, + source: apiSite.key, + source_name: apiSite.name, + class: videoDetail.vod_class, + year: videoDetail.vod_year + ? videoDetail.vod_year.match(/\d{4}/)?.[0] || '' + : 'unknown', + desc: cleanHtmlTags(videoDetail.vod_content), + type_name: videoDetail.type_name, + douban_id: videoDetail.vod_douban_id, + vod_remarks: videoDetail.vod_remarks, + vod_total: videoDetail.vod_total, + proxyMode: apiSite.proxyMode || false, + }; +} + async function handleSpecialSourceDetail( id: string, apiSite: ApiSite diff --git a/src/lib/fetchVideoDetail.ts b/src/lib/fetchVideoDetail.ts index 32168bf..6899afe 100644 --- a/src/lib/fetchVideoDetail.ts +++ b/src/lib/fetchVideoDetail.ts @@ -1,7 +1,7 @@ import { getAvailableApiSites } from '@/lib/config'; import { SearchResult } from '@/lib/types'; -import { getDetailFromApi, searchFromApi } from './downstream'; +import { getDetailFromApiV2 } from './downstream'; import { getSpecialSourceDetail, isSpecialSource } from './special-sources-detail'; interface FetchVideoDetailOptions { @@ -13,13 +13,12 @@ interface FetchVideoDetailOptions { /** * 根据 source 与 id 获取视频详情。 * 1. 如果是特殊源(emby、openlist、xiaoya),直接调用对应的获取函数。 - * 2. 若传入 fallbackTitle,则先调用 /api/search 搜索精确匹配。 - * 3. 若搜索未命中或未提供 fallbackTitle,则直接调用 /api/detail。 + * 2. 其他采集源直接调用详情接口,避免依赖搜索接口。 */ export async function fetchVideoDetail({ source, id, - fallbackTitle = '', + fallbackTitle: _fallbackTitle = '', }: FetchVideoDetailOptions): Promise { // 检查是否是特殊源(emby、openlist、xiaoya) if (isSpecialSource(source)) { @@ -30,30 +29,13 @@ export async function fetchVideoDetail({ // 如果特殊源返回 null,继续使用标准流程 } - // 优先通过搜索接口查找精确匹配 const apiSites = await getAvailableApiSites(); const apiSite = apiSites.find((site) => site.key === source); if (!apiSite) { throw new Error('无效的API来源'); } - if (fallbackTitle) { - try { - const searchData = await searchFromApi(apiSite, fallbackTitle.trim()); - const exactMatch = searchData.find( - (item: SearchResult) => - item.source.toString() === source.toString() && - item.id.toString() === id.toString() - ); - if (exactMatch) { - return exactMatch; - } - } catch (error) { - // do nothing - } - } - // 调用 /api/detail 接口 - const detail = await getDetailFromApi(apiSite, id); + const detail = await getDetailFromApiV2(apiSite, id); if (!detail) { throw new Error('获取视频详情失败'); } diff --git a/src/lib/server/proxy-headers.ts b/src/lib/server/proxy-headers.ts new file mode 100644 index 0000000..e183775 --- /dev/null +++ b/src/lib/server/proxy-headers.ts @@ -0,0 +1,39 @@ +/** + * 代理接口共享工具函数 + * 用于构建标准化的 CORS 响应头,避免各代理路由中的重复代码。 + */ + +/** + * 构建标准的代理流式响应头 (用于 ts 分片、密钥、二进制流等) + * 包含 CORS、Accept-Ranges 和 Content-Length 等标准头。 + */ +export function buildProxyStreamHeaders( + contentType: string, + contentLength?: string | null +): Headers { + const headers = new Headers(); + headers.set('Content-Type', contentType); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); + headers.set('Accept-Ranges', 'bytes'); + headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); + if (contentLength) { + headers.set('Content-Length', contentLength); + } + return headers; +} + +/** + * 构建标准的代理 M3U8 播放列表响应头 + */ +export function buildProxyM3u8Headers(contentType?: string): Headers { + const headers = new Headers(); + headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl'); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept'); + headers.set('Cache-Control', 'no-cache'); + headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range'); + return headers; +} diff --git a/src/lib/server/ssrf.ts b/src/lib/server/ssrf.ts new file mode 100644 index 0000000..2ea829e --- /dev/null +++ b/src/lib/server/ssrf.ts @@ -0,0 +1,94 @@ +import dns from 'dns'; + +/** + * 判断 IP 地址是否为内网/本地私有地址 + * 覆盖 IPv4 和 IPv6,彻底杜绝所有变体绕过。 + */ +export function isPrivateIP(ip: string): boolean { + // IPv4 私有地址和环回地址 + if (ip.includes('.')) { + const parts = ip.split('.').map(Number); + if (parts.length !== 4) return false; + + return ( + parts[0] === 10 || // 10.x.x.x + parts[0] === 127 || // 127.x.x.x (Loopback) + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.x.x - 172.31.x.x + (parts[0] === 192 && parts[1] === 168) || // 192.168.x.x + (parts[0] === 169 && parts[1] === 254) || // 169.254.x.x (Link-local) + parts[0] === 0 // 0.x.x.x ("This network") + ); + } + + // IPv6 私有地址和环回地址 + if (ip.includes(':')) { + // ::1 环回地址 (Loopback) + if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true; + + // 0::0 / :: 未指定地址 + if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true; + + const lowerIp = ip.toLowerCase(); + + // IPv4 映射到 IPv6 的地址 (例如 ::ffff:127.0.0.1) + if (lowerIp.startsWith('::ffff:')) { + return isPrivateIP(lowerIp.substring(7)); + } + + // 唯一本地地址 (Unique Local Addresses, fc00::/7) + if (lowerIp.startsWith('fc') || lowerIp.startsWith('fd')) return true; + + // 链路本地地址 (Link-Local Addresses, fe80::/10) + if (lowerIp.startsWith('fe8') || lowerIp.startsWith('fe9') || lowerIp.startsWith('fea') || lowerIp.startsWith('feb')) return true; + } + + return false; +} + +/** + * 校验代理 URL 是否安全 (防止 SSRF / DNS 重绑定漏洞) + * 只在 Node.js 服务端运行。 + */ +export async function validateProxyUrlServerSide(urlStr: string): Promise { + if (!urlStr) return false; + try { + const parsed = new URL(urlStr); + + // 1. 协议检查 + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + + // 2. 剥离认证信息防止混淆 + if (parsed.username || parsed.password) { + return false; + } + + let { hostname } = parsed; + + // 清洗 IPv6 括号边界 + if (hostname.startsWith('[') && hostname.endsWith(']')) { + hostname = hostname.substring(1, hostname.length - 1); + } + + // 3. DNS 真实解析 (获取底层物理 IP) + // 这一步能彻底打碎各种形式的短格式 IP (127.1)、八/十六进制 IP (0x7f.0.0.1)、或者指向 127.0.0.1 的恶意外部域名 DNS 重绑定。 + const lookupResult = await dns.promises.lookup(hostname); + + if (!lookupResult || !lookupResult.address) { + return false; // 解析不出 IP 则拒绝 + } + + // 4. 对物理 IP 进行内网校验 + if (isPrivateIP(lookupResult.address)) { + console.warn(`[SSRF 防护] 拦截到尝试访问内部网络的请求 URL: ${urlStr} (解析出的底层 IP: ${lookupResult.address})`); + return false; + } + + return true; + } catch (error) { + // 凡是报错(无论是 URL 解析失败,还是 DNS 解析失败,还是域名不存在),均作为不安全拒绝 + console.warn(`[SSRF 防护] URL解析失败或不合法, 拒绝代理请求: ${urlStr}`); + return false; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ccda257..8035e1f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -155,7 +155,7 @@ export function processVideoUrl(originalUrl: string): string { */ export async function getVideoResolutionFromM3u8( m3u8Url: string, - timeoutMs = 4000 + timeoutMs = 6000 ): Promise<{ quality: string; // 如720p、1080p等 loadSpeed: string; // 自动转换为KB/s或MB/s @@ -185,11 +185,55 @@ export async function getVideoResolutionFromM3u8( // 固定使用hls.js加载 const hls = new Hls(); - // 设置超时处理 - 使用传入的超时时间 - const timeout = setTimeout(() => { + let actualLoadSpeed = '未知'; + let hasSpeedCalculated = false; + let hasMetadataLoaded = false; + let estimatedBitrate = 0; // 估算的码率(bps) + + // 提取核心返回逻辑供 resolve 和 timeout 共同调用 + const resolveCurrentState = () => { + const width = video.videoWidth; + const quality = + width >= 3840 + ? '4K' + : width >= 2560 + ? '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` + : '未知'; + hls.destroy(); video.remove(); - reject(new Error('Timeout loading video metadata')); + + resolve({ + quality, + loadSpeed: actualLoadSpeed, + pingTime: Math.round(pingTime), + bitrate: bitrateStr, + }); + }; + + // 设置超时处理 - 如果部分数据已拿到,则宽容返回 + const timeout = setTimeout(() => { + if (hasMetadataLoaded || hasSpeedCalculated) { + resolveCurrentState(); + } else { + hls.destroy(); + video.remove(); + reject(new Error('Timeout loading video metadata')); + } }, timeoutMs); video.onerror = () => { @@ -199,67 +243,16 @@ export async function getVideoResolutionFromM3u8( reject(new Error('Failed to load video metadata')); }; - let actualLoadSpeed = '未知'; - let hasSpeedCalculated = false; - let hasMetadataLoaded = false; - let estimatedBitrate = 0; // 估算的码率(bps) - let fragmentStartTime = 0; - // 检查是否可以返回结果 + // 检查是否可以相互满足要求 const checkAndResolve = () => { if ( hasMetadataLoaded && (hasSpeedCalculated || actualLoadSpeed !== '未知') ) { clearTimeout(timeout); - const width = video.videoWidth; - if (width && width > 0) { - hls.destroy(); - video.remove(); - - // 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点 - const quality = - width >= 3840 - ? '4K' // 4K: 3840x2160 - : width >= 2560 - ? '2K' // 2K: 2560x1440 - : width >= 1920 - ? '1080p' // 1080p: 1920x1080 - : width >= 1280 - ? '720p' // 720p: 1280x720 - : width >= 854 - ? '480p' - : 'SD'; // 480p: 854x480 - - // 格式化码率 - const bitrateStr = estimatedBitrate > 0 - ? estimatedBitrate >= 1000000 - ? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps` - : `${Math.round(estimatedBitrate / 1000)} Kbps` - : '未知'; - - resolve({ - quality, - loadSpeed: actualLoadSpeed, - pingTime: Math.round(pingTime), - bitrate: bitrateStr, - }); - } else { - // webkit 无法获取尺寸,直接返回 - const bitrateStr = estimatedBitrate > 0 - ? estimatedBitrate >= 1000000 - ? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps` - : `${Math.round(estimatedBitrate / 1000)} Kbps` - : '未知'; - - resolve({ - quality: '未知', - loadSpeed: actualLoadSpeed, - pingTime: Math.round(pingTime), - bitrate: bitrateStr, - }); - } + resolveCurrentState(); } }; @@ -309,7 +302,7 @@ export async function getVideoResolutionFromM3u8( }); // 为分片请求添加时间戳参数破除浏览器缓存 - hls.config.xhrSetup = function(xhr: XMLHttpRequest, url: string) { + hls.config.xhrSetup = function (xhr: XMLHttpRequest, url: string) { const urlWithTimestamp = url.includes('?') ? `${url}&_t=${Date.now()}` : `${url}?_t=${Date.now()}`; @@ -323,6 +316,22 @@ export async function getVideoResolutionFromM3u8( hls.on(Hls.Events.ERROR, (event: any, data: any) => { console.error('HLS错误:', data); 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('[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过'); + clearTimeout(timeout); + hls.destroy(); + video.remove(); + resolve({ + quality: '原生画质', + loadSpeed: '直连', + pingTime: 10, + bitrate: '未知', + }); + return; + } + clearTimeout(timeout); hls.destroy(); video.remove(); @@ -397,3 +406,4 @@ export function base58Decode(encoded: string): string { // 在 Node.js 环境中使用 Buffer return Buffer.from(bytes).toString('utf-8'); } + diff --git a/src/lib/version.ts b/src/lib/version.ts index 36cf0ca..04e1c34 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -1,6 +1,6 @@ /* eslint-disable no-console */ -const CURRENT_VERSION = '214.1.0'; +const CURRENT_VERSION = '215.0.0'; // 导出当前版本号供其他地方使用 export { CURRENT_VERSION };