From 0322b2650b50d96607d858df49ecae0d0acea3cc Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 19 Mar 2026 23:23:14 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dlive=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E4=BB=A3=E7=90=86not=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/proxy/m3u8/route.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/app/api/proxy/m3u8/route.ts b/src/app/api/proxy/m3u8/route.ts index 05b4d33..9b375ad 100644 --- a/src/app/api/proxy/m3u8/route.ts +++ b/src/app/api/proxy/m3u8/route.ts @@ -110,6 +110,10 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo const host = req.headers.get('host'); const proxyBase = `${protocol}://${host}/api/proxy`; + // 获取 moontv-source 参数 + const reqUrl = new URL(req.url); + const source = reqUrl.searchParams.get('moontv-source') || ''; + const lines = content.split('\n'); const rewrittenLines: string[] = []; @@ -119,19 +123,19 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo // 处理 TS 片段 URL 和其他媒体文件 if (line && !line.startsWith('#')) { const resolvedUrl = resolveUrl(baseUrl, line); - const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`; + const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`; rewrittenLines.push(proxyUrl); continue; } // 处理 EXT-X-MAP 标签中的 URI if (line.startsWith('#EXT-X-MAP:')) { - line = rewriteMapUri(line, baseUrl, proxyBase, allowCORS); + line = rewriteMapUri(line, baseUrl, proxyBase, allowCORS, source); } // 处理 EXT-X-KEY 标签中的 URI if (line.startsWith('#EXT-X-KEY:')) { - line = rewriteKeyUri(line, baseUrl, proxyBase, allowCORS); + line = rewriteKeyUri(line, baseUrl, proxyBase, allowCORS, source); } // 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF) @@ -143,7 +147,7 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo const nextLine = lines[i].trim(); if (nextLine && !nextLine.startsWith('#')) { const resolvedUrl = resolveUrl(baseUrl, nextLine); - const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}`; + const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`; rewrittenLines.push(proxyUrl); } else { rewrittenLines.push(nextLine); @@ -158,23 +162,23 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo return rewrittenLines.join('\n'); } -function rewriteMapUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean) { +function rewriteMapUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean, source: string) { const uriMatch = line.match(/URI="([^"]+)"/); if (uriMatch) { const originalUri = uriMatch[1]; const resolvedUrl = resolveUrl(baseUrl, originalUri); - const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`; + const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`; return line.replace(uriMatch[0], `URI="${proxyUrl}"`); } return line; } -function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean) { +function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean, source: string) { const uriMatch = line.match(/URI="([^"]+)"/); if (uriMatch) { const originalUri = uriMatch[1]; const resolvedUrl = resolveUrl(baseUrl, originalUri); - const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}`; + const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`; return line.replace(uriMatch[0], `URI="${proxyUrl}"`); } return line; From dbc32a2a37c77a83b7aab2b043ef7dac33d3e0c2 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 19 Mar 2026 23:37:33 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BB=A7=E7=BB=AD?= =?UTF-8?q?=E8=A7=82=E7=9C=8B=E6=B8=90=E8=BF=9B=E5=BC=8F=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ContinueWatching.tsx | 392 ++++++++++++++-------------- src/lib/db.client.ts | 36 +++ 2 files changed, 229 insertions(+), 199 deletions(-) diff --git a/src/components/ContinueWatching.tsx b/src/components/ContinueWatching.tsx index 895dc26..6b066fc 100644 --- a/src/components/ContinueWatching.tsx +++ b/src/components/ContinueWatching.tsx @@ -8,60 +8,71 @@ import { createPortal } from 'react-dom'; import type { PlayRecord } from '@/lib/db.client'; import { clearAllPlayRecords, + getCachedPlayRecordsSnapshot, getAllPlayRecords, subscribeToDataUpdates, } from '@/lib/db.client'; -import VideoCard from '@/components/VideoCard'; import PlayRecordsPanel from '@/components/PlayRecordsPanel'; +import VideoCard from '@/components/VideoCard'; import VirtualScrollableRow from '@/components/VirtualScrollableRow'; 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([]); @@ -71,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([]); @@ -133,173 +130,170 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) { )} - {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 - )} + )} + - {showPlayRecordsPanel && createPortal( - setShowPlayRecordsPanel(false)} - />, - document.body - )} - + {showConfirmDialog && + createPortal( +
setShowConfirmDialog(false)} + > +
event.stopPropagation()} + > +
+
+
+ +
+
+

+ 清空播放记录 +

+

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

+
+
+ +
+ + +
+
+
+
, + document.body + )} + + {showPlayRecordsPanel && + createPortal( + setShowPlayRecordsPanel(false)} + />, + document.body + )} + ); } 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 {}; + } +} + /** * 保存播放记录。 * 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。 From 5a1e578f82b1e08522dee00613ca52bc607350c9 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 20 Mar 2026 20:22:29 +0800 Subject: [PATCH 3/3] v215 --- CHANGELOG | 24 ++++++++++++++++++++++++ VERSION.txt | 2 +- src/lib/changelog.ts | 27 +++++++++++++++++++++++++++ src/lib/version.ts | 2 +- 4 files changed, 53 insertions(+), 2 deletions(-) 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/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/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/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 };