diff --git a/.gitignore b/.gitignore index 1437f13..f45089d 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ public/workbox-*.js.map *.db *.db-shm *.db-wal + +# local scripts +scripts/tvbox/ diff --git a/CHANGELOG b/CHANGELOG index 36fdd2a..86fe0a3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,20 @@ +## [217.0.0] - 2026-04-10 +### Added +- 新增注册邀请码功能 +- 增加弹幕内置源 +- 增加netlify部署支持 +- 观影室增加屏幕共享功能 +- 详情面板新增照片墙功能 +- 新增夸克网盘的转存与播放功能 +- 豆瓣数据源增加备用源功能 + +### Changed +- 移除音乐功能 + +### Fixed +- 修复弹幕选集分组无法鼠标滑轮滚动 +- 修复高级推荐报错无限刷新 + ## [216.0.0] - 2026-03-30 ### Added - 新增视频源脚本 diff --git a/README.md b/README.md index 7f2d057..d51b088 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,12 @@ ## 部署 -本项目**支持 Docker、Vercel 和 Cloudflare Workers 平台** 部署。 +本项目**支持 Docker、Vercel、Netlify 和 Cloudflare Workers 平台** 部署。 [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus) +[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/mtvpls/MoonTVPlus) + **一键部署到 Zeabur** [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/SCHCAY/deploy) diff --git a/VERSION.txt b/VERSION.txt index b7ea4d1..2cc7045 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1,2 +1 @@ -216.0.0 - +217.0.0 diff --git a/server.js b/server.js index ebf7e94..2cbf79a 100644 --- a/server.js +++ b/server.js @@ -32,6 +32,8 @@ class WatchRoomServer { this.rooms = new Map(); this.members = new Map(); this.socketToRoom = new Map(); + this.screenHelpers = new Map(); + this.helperToRoom = new Map(); this.roomDeletionTimers = new Map(); // 房间延迟删除定时器 this.cleanupInterval = null; this.setupEventHandlers(); @@ -55,6 +57,7 @@ class WatchRoomServer { description: data.description, password: data.password, isPublic: data.isPublic, + roomType: data.roomType || 'sync', ownerId: userId, ownerName: data.userName, ownerToken: ownerToken, // 保存房主令牌 @@ -131,6 +134,14 @@ class WatchRoomServer { const roomMembers = this.members.get(data.roomId); if (roomMembers) { + if (isOwner) { + Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => { + if (existingMember.isOwner && memberId !== userId) { + roomMembers.delete(memberId); + } + }); + } + roomMembers.set(userId, member); room.memberCount = roomMembers.size; this.rooms.set(data.roomId, room); @@ -260,6 +271,114 @@ class WatchRoomServer { } }); + socket.on('screen:helper-register', (data, callback) => { + try { + const room = this.rooms.get(data.roomId); + if (!room) { + callback({ success: false, error: '房间不存在' }); + return; + } + + if (room.ownerToken !== data.ownerToken) { + callback({ success: false, error: '房主身份验证失败' }); + return; + } + + const oldHelperSocketId = this.screenHelpers.get(data.roomId); + if (oldHelperSocketId && oldHelperSocketId !== socket.id) { + this.helperToRoom.delete(oldHelperSocketId); + } + + this.screenHelpers.set(data.roomId, socket.id); + this.helperToRoom.set(socket.id, data.roomId); + callback({ success: true }); + } catch (error) { + console.error('[WatchRoom] Error registering screen helper:', error); + callback({ success: false, error: '注册共享控制窗口失败' }); + } + }); + + // 开始屏幕共享 + socket.on('screen:start', (state) => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + const roomId = roomInfo?.roomId || helperRoomId; + if (!roomId) return; + if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return; + if (roomInfo && !roomInfo.isOwner) return; + + const room = this.rooms.get(roomId); + if (room) { + room.currentState = state; + this.rooms.set(roomId, room); + this.io.to(roomId).emit('screen:start', state); + } + }); + + // 停止屏幕共享 + socket.on('screen:stop', () => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + const roomId = roomInfo?.roomId || helperRoomId; + if (!roomId) return; + if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return; + if (roomInfo && !roomInfo.isOwner) return; + + const room = this.rooms.get(roomId); + if (room) { + room.currentState = null; + this.rooms.set(roomId, room); + this.io.to(roomId).emit('screen:stop'); + } + }); + + socket.on('screen:viewer-ready', () => { + const roomInfo = this.socketToRoom.get(socket.id); + if (!roomInfo) return; + + const room = this.rooms.get(roomInfo.roomId); + if (!room || roomInfo.isOwner || room.currentState?.type !== 'screen') return; + + const targetSocketId = this.screenHelpers.get(roomInfo.roomId) || room.ownerId; + this.io.to(targetSocketId).emit('screen:viewer-ready', { + userId: socket.id, + }); + }); + + // 屏幕共享 WebRTC 信令 + socket.on('screen:offer', (data) => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + if (!roomInfo && !helperRoomId) return; + + this.io.to(data.targetUserId).emit('screen:offer', { + userId: socket.id, + offer: data.offer, + }); + }); + + socket.on('screen:answer', (data) => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + if (!roomInfo && !helperRoomId) return; + + this.io.to(data.targetUserId).emit('screen:answer', { + userId: socket.id, + answer: data.answer, + }); + }); + + socket.on('screen:ice', (data) => { + const roomInfo = this.socketToRoom.get(socket.id); + const helperRoomId = this.helperToRoom.get(socket.id); + if (!roomInfo && !helperRoomId) return; + + this.io.to(data.targetUserId).emit('screen:ice', { + userId: socket.id, + candidate: data.candidate, + }); + }); + // 聊天消息 socket.on('chat:message', (data) => { const roomInfo = this.socketToRoom.get(socket.id); @@ -347,6 +466,19 @@ class WatchRoomServer { // 断开连接 socket.on('disconnect', () => { console.log(`[WatchRoom] Client disconnected: ${socket.id}`); + const helperRoomId = this.helperToRoom.get(socket.id); + if (helperRoomId) { + this.helperToRoom.delete(socket.id); + if (this.screenHelpers.get(helperRoomId) === socket.id) { + this.screenHelpers.delete(helperRoomId); + const room = this.rooms.get(helperRoomId); + if (room && room.currentState?.type === 'screen') { + room.currentState = null; + this.rooms.set(helperRoomId, room); + this.io.to(helperRoomId).emit('screen:stop'); + } + } + } this.handleLeaveRoom(socket); }); }); @@ -425,6 +557,11 @@ class WatchRoomServer { this.rooms.delete(roomId); this.members.delete(roomId); + const helperSocketId = this.screenHelpers.get(roomId); + if (helperSocketId) { + this.helperToRoom.delete(helperSocketId); + this.screenHelpers.delete(roomId); + } } startCleanupTimer() { diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 52c4af0..263c6ff 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -30,13 +30,13 @@ import { CheckCircle, ChevronDown, ChevronUp, + Cloud, Database, ExternalLink, FileText, FolderOpen, Globe, Mail, - Music, Palette, Settings, Tv, @@ -341,6 +341,7 @@ interface SiteConfig { DoubanImageProxy: string; DisableYellowFilter: boolean; FluidSearch: boolean; + DanmakuSourceType?: 'builtin' | 'custom'; DanmakuApiBase: string; DanmakuApiToken: string; TMDBApiKey?: string; @@ -358,6 +359,8 @@ interface SiteConfig { MagnetAcgripReverseProxy?: string; EnableComments: boolean; EnableRegistration?: boolean; + RequireRegistrationInviteCode?: boolean; + RegistrationInviteCode?: string; RegistrationRequireTurnstile?: boolean; LoginRequireTurnstile?: boolean; TurnstileSiteKey?: string; @@ -3496,6 +3499,219 @@ const OpenListConfigComponent = ({ ); }; +const NetDiskConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [enabled, setEnabled] = useState(false); + const [cookie, setCookie] = useState(''); + const [savePath, setSavePath] = useState('/'); + const [playTempSavePath, setPlayTempSavePath] = useState('/'); + const [openListTempPath, setOpenListTempPath] = useState('/'); + + useEffect(() => { + const quark = config?.NetDiskConfig?.Quark; + setEnabled(quark?.Enabled || false); + setCookie(quark?.Cookie || ''); + setSavePath(quark?.SavePath || '/'); + setPlayTempSavePath(quark?.PlayTempSavePath || '/'); + setOpenListTempPath(quark?.OpenListTempPath || '/'); + }, [config]); + + const handleSave = async () => { + await withLoading('saveNetDisk', async () => { + const response = await fetch('/api/admin/netdisk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + Quark: { + Enabled: enabled, + Cookie: cookie, + SavePath: savePath, + PlayTempSavePath: playTempSavePath, + OpenListTempPath: openListTempPath, + }, + }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || '保存失败'); + } + + showSuccess('保存成功', showAlert); + await refreshConfig(); + }); + }; + + const handleValidate = async () => { + await withLoading('validateNetDisk', async () => { + try { + const response = await fetch('/api/admin/netdisk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'validate', + Quark: { + Cookie: cookie, + SavePath: savePath, + PlayTempSavePath: playTempSavePath, + }, + }), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || '校验失败'); + } + + showSuccess(data.message || '夸克 Cookie 可读', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '校验失败', showAlert); + throw error; + } + }); + }; + + return ( +
+
+ + 夸克网盘 + +
+
+
+ + + 夸克网盘说明 + +
+
+

• 转存:把整个分享保存到夸克正式目录。

+

• 立即播放:将分享内所有视频文件转存到临时播放目录,再通过 OpenList 临时目录直接播放。

+

• OpenList 临时目录必须映射到夸克临时播放目录,否则立即播放无法找到文件。

+
+
+ +
+
+

+ 启用夸克网盘 +

+

+ 开启后,网盘搜索中的夸克资源会显示“立即播放”和“转存”按钮 +

+
+ +
+ +
+ +