diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e3ce343..2555bfd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,8 @@ "WebFetch(domain:github.com)", "WebFetch(domain:www.artplayer.org)", "WebFetch(domain:m.douban.com)", - "WebFetch(domain:movie.douban.com)" + "WebFetch(domain:movie.douban.com)", + "Bash(cat:*)" ], "deny": [], "ask": [] diff --git a/server.js b/server.js index 8b12066..e09fdab 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,82 @@ const port = parseInt(process.env.PORT || '3000', 10); const app = next({ dev, hostname, port }); const handle = app.getRequestHandler(); +// 读取观影室配置的辅助函数 +async function getWatchRoomConfig() { + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + + // 如果使用 localStorage,无法在服务器端读取,返回默认配置(不启用) + if (storageType === 'localstorage') { + console.log('[WatchRoom] Using localStorage storage type.'); + // 在 localStorage 模式下,可以通过环境变量控制是否启用观影室 + const enabled = process.env.WATCH_ROOM_ENABLED === 'true'; + console.log(`[WatchRoom] Watch room ${enabled ? 'enabled' : 'disabled'} via environment variable.`); + return { enabled, serverType: 'internal' }; + } + + try { + if (storageType === 'redis') { + // 检查 Redis 配置 + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + console.log('[WatchRoom] Attempting to read config from Redis...'); + + const { createClient } = require('redis'); + const client = createClient({ url: redisUrl }); + await client.connect(); + + const configStr = await client.get('admin:config'); // 注意:使用冒号而不是下划线 + await client.disconnect(); + + if (configStr) { + const config = JSON.parse(configStr); + return config.WatchRoomConfig || { enabled: false, serverType: 'internal' }; + } + } else if (storageType === 'upstash') { + // 检查 Upstash 环境变量 + if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) { + console.log('[WatchRoom] Upstash credentials not configured. Socket.IO disabled by default.'); + return { enabled: false, serverType: 'internal' }; + } + + console.log('[WatchRoom] Attempting to read config from Upstash...'); + const { Redis } = require('@upstash/redis'); + const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL, + token: process.env.UPSTASH_REDIS_REST_TOKEN, + }); + + const configStr = await redis.get('admin:config'); // 注意:使用冒号而不是下划线 + + if (configStr) { + const config = typeof configStr === 'string' ? JSON.parse(configStr) : configStr; + return config.WatchRoomConfig || { enabled: false, serverType: 'internal' }; + } + } else if (storageType === 'kvrocks') { + // 检查 Kvrocks 配置 + const kvrocksUrl = process.env.KVROCKS_URL || 'redis://localhost:6666'; + console.log('[WatchRoom] Attempting to read config from Kvrocks...'); + + const { createClient } = require('redis'); + const client = createClient({ url: kvrocksUrl }); + await client.connect(); + + const configStr = await client.get('admin:config'); // 注意:使用冒号而不是下划线 + await client.disconnect(); + + if (configStr) { + const config = JSON.parse(configStr); + return config.WatchRoomConfig || { enabled: false, serverType: 'internal' }; + } + } + } catch (error) { + console.error('[WatchRoom] Failed to read config from storage:', error.message); + } + + // 默认不启用观影室 + console.log('[WatchRoom] No config found or error occurred. Socket.IO disabled by default.'); + return { enabled: false, serverType: 'internal' }; +} + // 观影室服务器类 class WatchRoomServer { constructor(io) { @@ -443,7 +519,7 @@ class WatchRoomServer { } } -app.prepare().then(() => { +app.prepare().then(async () => { const httpServer = createServer(async (req, res) => { try { const parsedUrl = parse(req.url, true); @@ -455,18 +531,35 @@ app.prepare().then(() => { } }); - // 初始化 Socket.IO - const io = new Server(httpServer, { - path: '/socket.io', - cors: { - origin: '*', - methods: ['GET', 'POST'], - }, - }); + // 读取观影室配置 + const watchRoomConfig = await getWatchRoomConfig(); + console.log('[WatchRoom] Config:', watchRoomConfig); - // 初始化观影室服务器 - const watchRoomServer = new WatchRoomServer(io); - console.log('[WatchRoom] Socket.IO server initialized'); + let watchRoomServer = null; + + // 只在启用观影室且使用内部服务器时初始化 Socket.IO + if (watchRoomConfig.enabled && watchRoomConfig.serverType === 'internal') { + console.log('[WatchRoom] Initializing Socket.IO server...'); + + // 初始化 Socket.IO + const io = new Server(httpServer, { + path: '/socket.io', + cors: { + origin: '*', + methods: ['GET', 'POST'], + }, + }); + + // 初始化观影室服务器 + watchRoomServer = new WatchRoomServer(io); + console.log('[WatchRoom] Socket.IO server initialized'); + } else { + if (!watchRoomConfig.enabled) { + console.log('[WatchRoom] Watch room is disabled'); + } else if (watchRoomConfig.serverType === 'external') { + console.log('[WatchRoom] Using external watch room server'); + } + } httpServer .once('error', (err) => { @@ -475,13 +568,17 @@ app.prepare().then(() => { }) .listen(port, () => { console.log(`> Ready on http://${hostname}:${port}`); - console.log(`> Socket.IO ready on ws://${hostname}:${port}`); + if (watchRoomConfig.enabled && watchRoomConfig.serverType === 'internal') { + console.log(`> Socket.IO ready on ws://${hostname}:${port}`); + } }); // 优雅关闭 process.on('SIGINT', () => { console.log('\n[Server] Shutting down...'); - watchRoomServer.destroy(); + if (watchRoomServer) { + watchRoomServer.destroy(); + } httpServer.close(() => { console.log('[Server] Server closed'); process.exit(0); @@ -490,7 +587,9 @@ app.prepare().then(() => { process.on('SIGTERM', () => { console.log('\n[Server] Shutting down...'); - watchRoomServer.destroy(); + if (watchRoomServer) { + watchRoomServer.destroy(); + } httpServer.close(() => { console.log('[Server] Server closed'); process.exit(0); diff --git a/src/app/api/debug/watch-room-config/route.ts b/src/app/api/debug/watch-room-config/route.ts new file mode 100644 index 0000000..f60e73b --- /dev/null +++ b/src/app/api/debug/watch-room-config/route.ts @@ -0,0 +1,49 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getConfig } from '@/lib/config'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + + // 调试信息 + const debugInfo = { + storageType, + envVars: { + hasRedisUrl: !!process.env.REDIS_URL, + hasUpstashUrl: !!process.env.UPSTASH_REDIS_REST_URL, + hasUpstashToken: !!process.env.UPSTASH_REDIS_REST_TOKEN, + hasKvrocksUrl: !!process.env.KVROCKS_URL, + }, + watchRoomConfig: null as any, + configReadError: null as string | null, + }; + + // 尝试读取配置 + try { + const config = await getConfig(); + debugInfo.watchRoomConfig = config.WatchRoomConfig || null; + } catch (error) { + debugInfo.configReadError = (error as Error).message; + } + + return NextResponse.json(debugInfo, { + headers: { + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + console.error('Debug API error:', error); + return NextResponse.json( + { + error: 'Failed to get debug info', + details: (error as Error).message, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts index 011a0ff..6c7ac24 100644 --- a/src/app/api/server-config/route.ts +++ b/src/app/api/server-config/route.ts @@ -10,11 +10,38 @@ export const runtime = 'nodejs'; export async function GET(request: NextRequest) { console.log('server-config called: ', request.url); + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + + // 如果使用 localStorage,返回默认配置 + if (storageType === 'localstorage') { + return NextResponse.json({ + SiteName: process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV', + StorageType: 'localstorage', + Version: CURRENT_VERSION, + // localStorage 模式下,返回默认观影室配置 + // 可以通过环境变量控制是否启用 + WatchRoom: { + enabled: process.env.WATCH_ROOM_ENABLED === 'true', + serverType: 'internal', + externalServerUrl: undefined, + externalServerAuth: undefined, + }, + }); + } + + // 非 localStorage 模式,从数据库读取配置 const config = await getConfig(); const result = { SiteName: config.SiteConfig.SiteName, - StorageType: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage', + StorageType: storageType, Version: CURRENT_VERSION, + // 添加观影室配置(供所有用户访问) + WatchRoom: { + enabled: config.WatchRoomConfig?.enabled ?? false, + serverType: config.WatchRoomConfig?.serverType ?? 'internal', + externalServerUrl: config.WatchRoomConfig?.externalServerUrl, + externalServerAuth: config.WatchRoomConfig?.externalServerAuth, + }, }; return NextResponse.json(result); } diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index 9c7bc04..3b15fe3 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -7,6 +7,8 @@ import Link from 'next/link'; import { usePathname, useSearchParams } from 'next/navigation'; import { useEffect, useState } from 'react'; +import { useWatchRoomContextSafe } from './WatchRoomProvider'; + interface MobileBottomNavProps { /** * 主动指定当前激活的路径。当未提供时,自动使用 usePathname() 获取的路径。 @@ -17,6 +19,7 @@ interface MobileBottomNavProps { const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { const pathname = usePathname(); const searchParams = useSearchParams(); + const watchRoomContext = useWatchRoomContextSafe(); // 直接使用当前路由状态,确保立即响应路由变化 const getCurrentFullPath = () => { @@ -52,26 +55,61 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { label: '直播', href: '/live', }, - { - icon: Users, - label: '观影室', - href: '/watch-room', - }, ]); useEffect(() => { const runtimeConfig = (window as any).RUNTIME_CONFIG; - if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { - setNavItems((prevItems) => [ - ...prevItems, - { - icon: Star, - label: '自定义', - href: '/douban?type=custom', - }, - ]); + + // 基础导航项(不包括观影室) + let items = [ + { icon: Home, label: '首页', href: '/' }, + { + icon: Film, + label: '电影', + href: '/douban?type=movie', + }, + { + icon: Tv, + label: '剧集', + href: '/douban?type=tv', + }, + { + icon: Cat, + label: '动漫', + href: '/douban?type=anime', + }, + { + icon: Clover, + label: '综艺', + href: '/douban?type=show', + }, + { + icon: Radio, + label: '直播', + href: '/live', + }, + ]; + + // 如果启用观影室,添加观影室入口 + if (watchRoomContext?.isEnabled) { + items.push({ + icon: Users, + label: '观影室', + href: '/watch-room', + }); } - }, []); + + // 添加自定义分类(如果有) + if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { + items.push({ + icon: Star, + label: '自定义', + href: '/douban?type=custom', + }); + } + + setNavItems(items); + }, [watchRoomContext?.isEnabled]); const isActive = (href: string) => { const typeMatch = href.match(/type=([^&]+)/)?.[1]; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 311d164..12552e4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { } from 'react'; import { useSite } from './SiteProvider'; +import { useWatchRoomContextSafe } from './WatchRoomProvider'; interface SidebarContextType { isCollapsed: boolean; @@ -59,6 +60,7 @@ declare global { const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { const pathname = usePathname(); const searchParams = useSearchParams(); + const watchRoomContext = useWatchRoomContextSafe(); // 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁 const [isCollapsed, setIsCollapsed] = useState(() => { if ( @@ -143,26 +145,60 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { label: '直播', href: '/live', }, - { - icon: Users, - label: '观影室', - href: '/watch-room', - }, ]); useEffect(() => { const runtimeConfig = (window as any).RUNTIME_CONFIG; - if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { - setMenuItems((prevItems) => [ - ...prevItems, - { - icon: Star, - label: '自定义', - href: '/douban?type=custom', - }, - ]); + + // 基础菜单项(不包括观影室) + let items = [ + { + icon: Film, + label: '电影', + href: '/douban?type=movie', + }, + { + icon: Tv, + label: '剧集', + href: '/douban?type=tv', + }, + { + icon: Cat, + label: '动漫', + href: '/douban?type=anime', + }, + { + icon: Clover, + label: '综艺', + href: '/douban?type=show', + }, + { + icon: Radio, + label: '直播', + href: '/live', + }, + ]; + + // 如果启用观影室,添加观影室入口 + if (watchRoomContext?.isEnabled) { + items.push({ + icon: Users, + label: '观影室', + href: '/watch-room', + }); } - }, []); + + // 添加自定义分类(如果有) + if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { + items.push({ + icon: Star, + label: '自定义', + href: '/douban?type=custom', + }); + } + + setMenuItems(items); + }, [watchRoomContext?.isEnabled]); return ( diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx index 32671e9..ae9ae76 100644 --- a/src/components/WatchRoomProvider.tsx +++ b/src/components/WatchRoomProvider.tsx @@ -109,43 +109,47 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { // 加载配置 useEffect(() => { const loadConfig = async () => { - // 默认配置:启用内部服务器 - const defaultConfig: WatchRoomConfig = { - enabled: true, - serverType: 'internal', - }; - try { - const response = await fetch('/api/admin/config'); + // 使用公共 API 获取观影室配置(不需要管理员权限) + const response = await fetch('/api/server-config'); if (response.ok) { const data = await response.json(); + // API 返回格式: { SiteName, StorageType, Version, WatchRoom } const watchRoomConfig: WatchRoomConfig = { - enabled: data.watchRoom?.enabled ?? true, - serverType: data.watchRoom?.serverType ?? 'internal', - externalServerUrl: data.watchRoom?.externalServerUrl, - externalServerAuth: data.watchRoom?.externalServerAuth, + enabled: data.WatchRoom?.enabled ?? false, // 默认不启用 + serverType: data.WatchRoom?.serverType ?? 'internal', + externalServerUrl: data.WatchRoom?.externalServerUrl, + externalServerAuth: data.WatchRoom?.externalServerAuth, }; setConfig(watchRoomConfig); setIsEnabled(watchRoomConfig.enabled); - // 如果启用了观影室,自动连接 + // 只在启用了观影室时才连接 if (watchRoomConfig.enabled) { console.log('[WatchRoom] Connecting with config:', watchRoomConfig); await watchRoom.connect(watchRoomConfig); + } else { + console.log('[WatchRoom] Watch room is disabled, skipping connection'); } } else { - throw new Error('Failed to load config'); + console.error('[WatchRoom] Failed to load config:', response.status); + // 加载配置失败时,不连接,保持禁用状态 + const defaultConfig: WatchRoomConfig = { + enabled: false, + serverType: 'internal', + }; + setConfig(defaultConfig); + setIsEnabled(false); } } catch (error) { - console.log('[WatchRoom] Using default config (internal server enabled)'); + console.error('[WatchRoom] Error loading config:', error); + // 加载配置失败时,不连接,保持禁用状态 + const defaultConfig: WatchRoomConfig = { + enabled: false, + serverType: 'internal', + }; setConfig(defaultConfig); - setIsEnabled(true); - - try { - await watchRoom.connect(defaultConfig); - } catch (connectError) { - console.error('[WatchRoom] Failed to connect:', connectError); - } + setIsEnabled(false); } };