diff --git a/package.json b/package.json index d16d2e3..0d2d47c 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,13 @@ "lucide-react": "^0.438.0", "media-icons": "^1.1.5", "mux.js": "^6.3.0", + "nanoid": "^5.1.6", "next": "^14.2.33", "next-pwa": "^5.6.0", "next-themes": "^0.4.6", "node-fetch": "^2.7.0", "nprogress": "^0.2.0", + "opencc-js": "^1.0.5", "parse-torrent-name": "^0.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70c30ed..917ed6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: mux.js: specifier: ^6.3.0 version: 6.3.0 + nanoid: + specifier: ^5.1.6 + version: 5.1.6 next: specifier: ^14.2.33 version: 14.2.35(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -95,6 +98,9 @@ importers: nprogress: specifier: ^0.2.0 version: 0.2.0 + opencc-js: + specifier: ^1.0.5 + version: 1.0.5 parse-torrent-name: specifier: ^0.5.4 version: 0.5.4 @@ -4420,6 +4426,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -4562,6 +4573,9 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + opencc-js@1.0.5: + resolution: {integrity: sha512-LD+1SoNnZdlRwtYTjnQdFrSVCAaYpuDqL5CkmOaHOkKoKh7mFxUicLTRVNLU5C+Jmi1vXQ3QL4jWdgSaa4sKjg==} + option-validator@2.0.6: resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==} @@ -11386,6 +11400,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + napi-postinstall@0.3.4: {} natural-compare-lite@1.4.0: {} @@ -11547,6 +11563,8 @@ snapshots: dependencies: mimic-fn: 2.1.0 + opencc-js@1.0.5: {} + option-validator@2.0.6: dependencies: kind-of: 6.0.3 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 26ba098..5caab46 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -390,6 +390,7 @@ interface CollapsibleTabProps { isExpanded: boolean; onToggle: () => void; children: React.ReactNode; + isParent?: boolean; } const CollapsibleTab = ({ @@ -398,25 +399,38 @@ const CollapsibleTab = ({ isExpanded, onToggle, children, + isParent = false, }: CollapsibleTabProps) => { return ( -
+
- {isExpanded &&
{children}
} + {isExpanded &&
{children}
}
); }; @@ -2763,7 +2777,7 @@ const OpenListConfigComponent = ({ const [url, setUrl] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [rootPath, setRootPath] = useState('/'); + const [rootPaths, setRootPaths] = useState(['/']); const [offlineDownloadPath, setOfflineDownloadPath] = useState('/'); const [scanInterval, setScanInterval] = useState(0); const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>('hybrid'); @@ -2783,7 +2797,7 @@ const OpenListConfigComponent = ({ setUrl(config.OpenListConfig.URL || ''); setUsername(config.OpenListConfig.Username || ''); setPassword(config.OpenListConfig.Password || ''); - setRootPath(config.OpenListConfig.RootPath || '/'); + setRootPaths(config.OpenListConfig.RootPaths || (config.OpenListConfig.RootPath ? [config.OpenListConfig.RootPath] : ['/'])); setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/'); setScanInterval(config.OpenListConfig.ScanInterval || 0); setScanMode(config.OpenListConfig.ScanMode || 'hybrid'); @@ -2824,7 +2838,7 @@ const OpenListConfigComponent = ({ URL: url, Username: username, Password: password, - RootPath: rootPath, + RootPaths: rootPaths, OfflineDownloadPath: offlineDownloadPath, ScanInterval: scanInterval, ScanMode: scanMode, @@ -3104,18 +3118,49 @@ const OpenListConfigComponent = ({
- setRootPath(e.target.value)} - disabled={!enabled} - placeholder='/' - className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' - /> +
+ {rootPaths.map((path, index) => ( +
+ { + const newPaths = [...rootPaths]; + newPaths[index] = e.target.value; + setRootPaths(newPaths); + }} + disabled={!enabled} + placeholder='/' + className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed' + /> + {rootPaths.length > 1 && ( + + )} +
+ ))} + +

- OpenList 中的视频文件夹路径,默认为根目录 / + OpenList 中的视频文件夹路径,可以配置多个根目录

@@ -3414,6 +3459,7 @@ const EmbyConfigComponent = ({ const [sources, setSources] = useState([]); const [editingSource, setEditingSource] = useState(null); const [showAddForm, setShowAddForm] = useState(false); + const [selectedSources, setSelectedSources] = useState>(new Set()); // 表单状态 const [formData, setFormData] = useState({ @@ -3663,6 +3709,81 @@ const EmbyConfigComponent = ({ }); }; + // 批量启用 + const handleBatchEnable = async () => { + if (selectedSources.size === 0) return; + await withLoading('batchEnableEmby', async () => { + try { + const newSources = sources.map(s => + selectedSources.has(s.key) ? { ...s, enabled: true } : s + ); + const response = await fetch('/api/admin/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }), + }); + if (!response.ok) throw new Error('批量启用失败'); + await refreshConfig(); + setSelectedSources(new Set()); + showSuccess(`已启用 ${selectedSources.size} 个源`, showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '批量启用失败', showAlert); + } + }); + }; + + // 批量禁用 + const handleBatchDisable = async () => { + if (selectedSources.size === 0) return; + await withLoading('batchDisableEmby', async () => { + try { + const newSources = sources.map(s => + selectedSources.has(s.key) ? { ...s, enabled: false } : s + ); + const response = await fetch('/api/admin/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }), + }); + if (!response.ok) throw new Error('批量禁用失败'); + await refreshConfig(); + setSelectedSources(new Set()); + showSuccess(`已禁用 ${selectedSources.size} 个源`, showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '批量禁用失败', showAlert); + } + }); + }; + + // 批量删除 + const handleBatchDelete = async () => { + if (selectedSources.size === 0) return; + showAlert({ + type: 'warning', + title: '确认批量删除', + message: `确定要删除选中的 ${selectedSources.size} 个源吗?此操作不可恢复。`, + showConfirm: true, + onConfirm: async () => { + await withLoading('batchDeleteEmby', async () => { + try { + const newSources = sources.filter(s => !selectedSources.has(s.key)); + const response = await fetch('/api/admin/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }), + }); + if (!response.ok) throw new Error('批量删除失败'); + await refreshConfig(); + setSelectedSources(new Set()); + showSuccess(`已删除 ${selectedSources.size} 个源`, showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '批量删除失败', showAlert); + } + }); + }, + }); + }; + return (
Emby 源列表 ({sources.length}) - +
+ +
+ {selectedSources.size > 0 && ( +
+ + 已选择 {selectedSources.size} 项 + + + + + +
+ )} + {sources.length === 0 ? (
暂无Emby源,点击"添加新源"开始配置 @@ -3701,37 +3859,53 @@ const EmbyConfigComponent = ({ className='border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800' >
-
-
-

- {source.name} -

- {source.isDefault && ( - - 默认 +
+ { + const newSelected = new Set(selectedSources); + if (e.target.checked) { + newSelected.add(source.key); + } else { + newSelected.delete(source.key); + } + setSelectedSources(newSelected); + }} + className='w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600' + /> +
+
+

+ {source.name} +

+ {source.isDefault && ( + + 默认 + + )} + + {source.enabled ? '已启用' : '已禁用'} - )} - - {source.enabled ? '已启用' : '已禁用'} - -
-

- 标识符: {source.key} -

-

- 服务器: {source.ServerURL} -

- {source.UserId && ( +

- 用户ID: {source.UserId} + 标识符: {source.key}

- )} +

+ 服务器: {source.ServerURL} +

+ {source.UserId && ( +

+ 用户ID: {source.UserId} +

+ )} +
+
+ +
+ + setServerURL(e.target.value)} + placeholder='http://localhost:5244' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +

+ 小雅 Alist 服务器的完整地址 +

+
+ +
+ + setToken(e.target.value)} + placeholder='可选,使用 Token 认证' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+ +
+
+ + setUsername(e.target.value)} + placeholder='可选,用户名密码认证' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+
+ + setPassword(e.target.value)} + placeholder='可选' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+
+ +
+ + +
+
+ + +
+ ); +}; + +// 求片列表组件 +const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise }) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [requests, setRequests] = useState([]); + const [filter, setFilter] = useState<'pending' | 'fulfilled'>('pending'); + const [pendingCount, setPendingCount] = useState(0); + const [fulfilledCount, setFulfilledCount] = useState(0); + const [loading, setLoading] = useState(true); + + // 求片功能设置 + const [enableMovieRequest, setEnableMovieRequest] = useState(config?.SiteConfig?.EnableMovieRequest ?? true); + const [movieRequestCooldown, setMovieRequestCooldown] = useState(config?.SiteConfig?.MovieRequestCooldown ?? 3600); + const [savingSettings, setSavingSettings] = useState(false); + + useEffect(() => { + loadRequests(); + loadCounts(); + }, [filter]); + + const loadCounts = async () => { + try { + const response = await fetch('/api/movie-requests'); + const data = await response.json(); + const allRequests = data.requests || []; + setPendingCount(allRequests.filter((r: any) => r.status === 'pending').length); + setFulfilledCount(allRequests.filter((r: any) => r.status === 'fulfilled').length); + } catch (error) { + console.error('加载求片数量失败:', error); + } + }; + + const loadRequests = async () => { + setLoading(true); + try { + const response = await fetch(`/api/movie-requests?status=${filter}&detail=true`); + const data = await response.json(); + setRequests(data.requests || []); + } catch (error) { + console.error('加载求片列表失败:', error); + } finally { + setLoading(false); + } + }; + + const handleFulfill = async (id: string) => { + await withLoading(`fulfill_${id}`, async () => { + try { + const response = await fetch(`/api/movie-requests/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'fulfilled' }), + }); + if (!response.ok) throw new Error('操作失败'); + showSuccess('已标记为已上架', showAlert); + await loadRequests(); + } catch (err) { + showError(err instanceof Error ? err.message : '操作失败', showAlert); + } + }); + }; + + const handleDelete = async (id: string) => { + await withLoading(`delete_${id}`, async () => { + try { + const response = await fetch(`/api/movie-requests/${id}`, { method: 'DELETE' }); + if (!response.ok) throw new Error('删除失败'); + showSuccess('删除成功', showAlert); + await loadRequests(); + } catch (err) { + showError(err instanceof Error ? err.message : '删除失败', showAlert); + } + }); + }; + + const handleSaveSettings = async () => { + setSavingSettings(true); + try { + if (!config) throw new Error('配置未加载'); + + const updatedConfig = { + ...config, + SiteConfig: { + ...config.SiteConfig, + EnableMovieRequest: enableMovieRequest, + MovieRequestCooldown: movieRequestCooldown, + }, + }; + + const response = await fetch('/api/admin/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedConfig), + }); + + if (!response.ok) throw new Error('保存失败'); + + showSuccess('求片设置已保存', showAlert); + await refreshConfig(); + } catch (err) { + showError(err instanceof Error ? err.message : '保存失败', showAlert); + } finally { + setSavingSettings(false); + } + }; + + return ( +
+ {/* 求片功能设置 */} +
+

求片功能设置

+
+
+
+ +

+ 关闭后用户将无法访问求片页面 +

+
+ +
+ +
+ +

+ 用户两次求片之间的最小间隔时间,默认3600秒(1小时) +

+ setMovieRequestCooldown(parseInt(e.target.value) || 0)} + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100' + /> +

+ {movieRequestCooldown >= 3600 + ? `约 ${Math.floor(movieRequestCooldown / 3600)} 小时 ${Math.floor((movieRequestCooldown % 3600) / 60)} 分钟` + : movieRequestCooldown >= 60 + ? `约 ${Math.floor(movieRequestCooldown / 60)} 分钟` + : `${movieRequestCooldown} 秒`} +

+
+ + +
+
+ + {/* 求片列表 */} +
+

求片列表

+
+ + +
+ + {loading ? ( +
+
+
+ ) : requests.length === 0 ? ( +
+ 暂无求片 +
+ ) : ( +
+ {requests.map((req) => ( +
+
+ {req.poster && ( + {req.title} + )} +
+

+ {req.title} {req.year && `(${req.year})`} +

+

+ 求片人数: {req.requestCount} 人 +

+

+ {new Date(req.createdAt).toLocaleString('zh-CN')} +

+ {req.requestedBy && ( +

+ 求片用户: {req.requestedBy.join(', ')} +

+ )} +
+
+ {filter === 'pending' && ( + + )} + +
+
+
+ ))} +
+ )} +
+ + +
+ ); +}; + // AI配置组件 const AIConfigComponent = ({ config, @@ -9504,8 +10155,10 @@ function AdminPageClient() { const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({ userConfig: false, videoSource: false, + mediaLibrary: false, openListConfig: false, embyConfig: false, + xiaoyaConfig: false, aiConfig: false, liveSource: false, siteConfig: false, @@ -9807,28 +10460,65 @@ function AdminPageClient() { - {/* 私人影库配置标签 */} + {/* 私人影库大类 */} + } - isExpanded={expandedTabs.openListConfig} - onToggle={() => toggleTab('openListConfig')} + isExpanded={expandedTabs.mediaLibrary} + onToggle={() => toggleTab('mediaLibrary')} + isParent={true} > - - +
+ {/* Openlist配置子标签 */} + + } + isExpanded={expandedTabs.openListConfig} + onToggle={() => toggleTab('openListConfig')} + > + + - {/* Emby 媒体库标签 */} - - } - isExpanded={expandedTabs.embyConfig} - onToggle={() => toggleTab('embyConfig')} - > - + {/* Emby 媒体库子标签 */} + + } + isExpanded={expandedTabs.embyConfig} + onToggle={() => toggleTab('embyConfig')} + > + + + + {/* 小雅配置子标签 */} + + } + isExpanded={expandedTabs.xiaoyaConfig} + onToggle={() => toggleTab('xiaoyaConfig')} + > + + + + {/* 求片管理子标签 */} + + } + isExpanded={expandedTabs.movieRequests} + onToggle={() => toggleTab('movieRequests')} + > + + +
{/* AI配置标签 */} diff --git a/src/app/api/admin/data_migration/export/route.ts b/src/app/api/admin/data_migration/export/route.ts index 7d974d3..61c499f 100644 --- a/src/app/api/admin/data_migration/export/route.ts +++ b/src/app/api/admin/data_migration/export/route.ts @@ -155,8 +155,8 @@ async function getUserPasswordV2(username: string): Promise { // 直接调用hGetAll获取完整用户信息(包括密码) const userInfoKey = `user:${username}:info`; - if (typeof storage.withRetry === 'function' && storage.client?.hGetAll) { - const userInfo = await storage.withRetry(() => storage.client.hGetAll(userInfoKey)); + if (typeof storage.withRetry === 'function' && storage.client?.hgetall) { + const userInfo = await storage.withRetry(() => storage.client.hgetall(userInfoKey)); if (userInfo && userInfo.password) { return userInfo.password; } diff --git a/src/app/api/admin/data_migration/import/route.ts b/src/app/api/admin/data_migration/import/route.ts index 8ddb383..79f4fc6 100644 --- a/src/app/api/admin/data_migration/import/route.ts +++ b/src/app/api/admin/data_migration/import/route.ts @@ -149,10 +149,10 @@ export async function POST(req: NextRequest) { } // 使用storage.withRetry直接设置用户信息 - await storage.withRetry(() => storage.client.hSet(userInfoKey, userInfo)); + await storage.withRetry(() => storage.client.hset(userInfoKey, userInfo)); // 添加到用户列表 - await storage.withRetry(() => storage.client.zAdd('user:list', { + await storage.withRetry(() => storage.client.zadd('user:list', { score: createdAt, value: username, })); diff --git a/src/app/api/admin/openlist/route.ts b/src/app/api/admin/openlist/route.ts index e03015e..82f5714 100644 --- a/src/app/api/admin/openlist/route.ts +++ b/src/app/api/admin/openlist/route.ts @@ -26,7 +26,7 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - const { action, Enabled, URL, Username, Password, RootPath, OfflineDownloadPath, ScanInterval, ScanMode } = body; + const { action, Enabled, URL, Username, Password, RootPaths, OfflineDownloadPath, ScanInterval, ScanMode } = body; const authInfo = getAuthInfoFromCookie(request); if (!authInfo || !authInfo.username) { @@ -53,7 +53,7 @@ export async function POST(request: NextRequest) { URL: URL || '', Username: Username || '', Password: Password || '', - RootPath: RootPath || '/', + RootPaths: RootPaths || ['/'], OfflineDownloadPath: OfflineDownloadPath || '/', LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, ResourceCount: adminConfig.OpenListConfig?.ResourceCount, @@ -77,6 +77,14 @@ export async function POST(request: NextRequest) { ); } + // 验证 RootPaths + if (!Array.isArray(RootPaths) || RootPaths.length === 0) { + return NextResponse.json( + { error: '请至少提供一个根目录' }, + { status: 400 } + ); + } + // 验证扫描间隔 let scanInterval = parseInt(ScanInterval) || 0; if (scanInterval > 0 && scanInterval < 60) { @@ -104,7 +112,7 @@ export async function POST(request: NextRequest) { URL, Username, Password, - RootPath: RootPath || '/', + RootPaths, OfflineDownloadPath: OfflineDownloadPath || '/', LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, ResourceCount: adminConfig.OpenListConfig?.ResourceCount, diff --git a/src/app/api/admin/source/route.ts b/src/app/api/admin/source/route.ts index dce01e6..39a5e24 100644 --- a/src/app/api/admin/source/route.ts +++ b/src/app/api/admin/source/route.ts @@ -65,16 +65,16 @@ export async function POST(request: NextRequest) { if (!key || !name || !api) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } - // 禁止添加 openlist 和 emby 保留关键字 - if (key === 'openlist') { + // 禁止添加保留关键字 + if (key === 'openlist' || key === 'xiaoya') { return NextResponse.json( - { error: 'openlist 是保留关键字,不能作为视频源 key' }, + { error: `${key} 是保留关键字,不能作为视频源 key` }, { status: 400 } ); } - if (key === 'emby') { + if (key.startsWith('emby')) { return NextResponse.json( - { error: 'emby 是保留关键字,不能作为视频源 key' }, + { error: 'emby 开头的 key 是保留关键字,不能作为视频源 key' }, { status: 400 } ); } diff --git a/src/app/api/admin/xiaoya/route.ts b/src/app/api/admin/xiaoya/route.ts new file mode 100644 index 0000000..be34995 --- /dev/null +++ b/src/app/api/admin/xiaoya/route.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { db } from '@/lib/db'; +import { XiaoyaClient } from '@/lib/xiaoya.client'; + +export const runtime = 'nodejs'; + +/** + * POST /api/admin/xiaoya + * 管理小雅配置 + */ +export async function POST(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) { + return NextResponse.json({ error: '无权限' }, { status: 403 }); + } + + const body = await request.json(); + const { action, ...configData } = body; + + if (action === 'test') { + // 测试连接 + try { + const client = new XiaoyaClient( + configData.ServerURL, + configData.Username, + configData.Password, + configData.Token + ); + + // 尝试列出根目录 + await client.listDirectory('/'); + + return NextResponse.json({ success: true, message: '连接成功' }); + } catch (error) { + return NextResponse.json( + { success: false, message: (error as Error).message }, + { status: 400 } + ); + } + } + + if (action === 'save') { + // 保存配置 + const config = await getConfig(); + + config.XiaoyaConfig = { + Enabled: configData.Enabled || false, + ServerURL: configData.ServerURL || '', + Token: configData.Token, + Username: configData.Username, + Password: configData.Password, + }; + + await db.saveAdminConfig(config); + + return NextResponse.json({ success: true, message: '保存成功' }); + } + + return NextResponse.json({ error: '无效的操作' }, { status: 400 }); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts index b4e1ff0..9618dfd 100644 --- a/src/app/api/cms-proxy/route.ts +++ b/src/app/api/cms-proxy/route.ts @@ -268,14 +268,14 @@ async function handleOpenListProxy(request: NextRequest) { ); // 读取 metainfo (从数据库或缓存) - let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + let metaInfo: MetaInfo | null = getCachedMetaInfo(); if (!metaInfo) { try { const metainfoJson = await db.getGlobalValue('video.metainfo'); if (metainfoJson) { metaInfo = JSON.parse(metainfoJson) as MetaInfo; - setCachedMetaInfo(rootPath, metaInfo); + setCachedMetaInfo(metaInfo); } } catch (error) { return NextResponse.json( diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 04d6825..cb6901c 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -45,13 +45,13 @@ export async function GET(request: NextRequest) { const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); const { db } = await import('@/lib/db'); - metaInfo = getCachedMetaInfo(rootPath); + metaInfo = getCachedMetaInfo(); if (!metaInfo) { const metainfoJson = await db.getGlobalValue('video.metainfo'); if (metainfoJson) { metaInfo = JSON.parse(metainfoJson); - setCachedMetaInfo(rootPath, metaInfo); + setCachedMetaInfo(metaInfo); } } diff --git a/src/app/api/douban/search/route.ts b/src/app/api/douban/search/route.ts new file mode 100644 index 0000000..d53967b --- /dev/null +++ b/src/app/api/douban/search/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { fetchDoubanData } from '@/lib/douban'; + +export const runtime = 'nodejs'; + +interface DoubanSearchResult { + id: string; + title: string; + year: string; + type?: string; + sub_title?: string; + episode?: string; + img?: string; +} + +interface DoubanSearchResponse { + code: number; + data?: DoubanSearchResult[]; +} + +/** + * GET /api/douban/search?q= + * 搜索豆瓣影视作品 + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + + if (!query) { + return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 }); + } + + try { + const target = `https://movie.douban.com/j/subject_suggest?q=${encodeURIComponent(query)}`; + const data = await fetchDoubanData(target); + + const response: DoubanSearchResponse = { + code: 200, + data: data, + }; + + return NextResponse.json(response); + } catch (error) { + return NextResponse.json( + { error: '搜索豆瓣数据失败', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/emby/sources/route.ts b/src/app/api/emby/sources/route.ts index fcfd0b5..aba7ef8 100644 --- a/src/app/api/emby/sources/route.ts +++ b/src/app/api/emby/sources/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { embyManager } from '@/lib/emby-manager'; export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; // 禁用缓存 /** * 获取所有启用的Emby源列表 diff --git a/src/app/api/movie-requests/[id]/route.ts b/src/app/api/movie-requests/[id]/route.ts new file mode 100644 index 0000000..cb708c8 --- /dev/null +++ b/src/app/api/movie-requests/[id]/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getStorage } from '@/lib/db'; + +export const runtime = 'nodejs'; + +// GET: 获取单个求片详情 +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const storage = getStorage(); + const movieRequest = await storage.getMovieRequest(params.id); + + if (!movieRequest) { + return NextResponse.json({ error: '求片不存在' }, { status: 404 }); + } + + return NextResponse.json({ request: movieRequest }); + } catch (error) { + console.error('获取求片详情失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} + +// PATCH: 更新求片状态(标记已上架) +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const storage = getStorage(); + + // 检查权限:只有管理员和站长可以操作 + if (storage.getUserInfoV2) { + const userInfo = await storage.getUserInfoV2(authInfo.username); + if (userInfo?.role !== 'admin' && userInfo?.role !== 'owner') { + return NextResponse.json({ error: '无权限操作' }, { status: 403 }); + } + } else { + // 如果不支持 getUserInfoV2,只允许站长操作 + if (authInfo.username !== process.env.USERNAME) { + return NextResponse.json({ error: '无权限操作' }, { status: 403 }); + } + } + + const body = await request.json(); + const { status, fulfilledSource, fulfilledId } = body; + + const movieRequest = await storage.getMovieRequest(params.id); + if (!movieRequest) { + return NextResponse.json({ error: '求片不存在' }, { status: 404 }); + } + + // 更新状态 + const updates: any = { + status, + updatedAt: Date.now(), + }; + + if (status === 'fulfilled') { + updates.fulfilledAt = Date.now(); + updates.fulfilledSource = fulfilledSource; + updates.fulfilledId = fulfilledId; + + // 给所有求片用户发送通知 + for (const username of movieRequest.requestedBy) { + await storage.addNotification(username, { + id: `req_fulfilled_${params.id}_${Date.now()}`, + type: 'request_fulfilled', + title: '求片已上架', + message: `您求的《${movieRequest.title}》已上架`, + timestamp: Date.now(), + read: false, + metadata: { + requestId: params.id, + source: fulfilledSource, + id: fulfilledId, + }, + }); + } + } + + await storage.updateMovieRequest(params.id, updates); + + return NextResponse.json({ + message: '更新成功', + request: { ...movieRequest, ...updates }, + }); + } catch (error) { + console.error('更新求片失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} + +// DELETE: 删除求片 +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const storage = getStorage(); + + // 检查权限:只有管理员和站长可以删除 + if (storage.getUserInfoV2) { + const userInfo = await storage.getUserInfoV2(authInfo.username); + if (userInfo?.role !== 'admin' && userInfo?.role !== 'owner') { + return NextResponse.json({ error: '无权限操作' }, { status: 403 }); + } + } else { + // 如果不支持 getUserInfoV2,只允许站长操作 + if (authInfo.username !== process.env.USERNAME) { + return NextResponse.json({ error: '无权限操作' }, { status: 403 }); + } + } + + const movieRequest = await storage.getMovieRequest(params.id); + if (!movieRequest) { + return NextResponse.json({ error: '求片不存在' }, { status: 404 }); + } + + // 删除求片 + await storage.deleteMovieRequest(params.id); + + // 从所有用户的求片列表中移除 + for (const username of movieRequest.requestedBy) { + await storage.removeUserMovieRequest(username, params.id); + } + + return NextResponse.json({ message: '删除成功' }); + } catch (error) { + console.error('删除求片失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/movie-requests/route.ts b/src/app/api/movie-requests/route.ts new file mode 100644 index 0000000..2946636 --- /dev/null +++ b/src/app/api/movie-requests/route.ts @@ -0,0 +1,218 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { nanoid } from 'nanoid'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getStorage } from '@/lib/db'; +import { MovieRequest } from '@/lib/types'; + +export const runtime = 'nodejs'; + +// GET: 获取求片列表 +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const { searchParams } = new URL(request.url); + const status = searchParams.get('status') as 'pending' | 'fulfilled' | null; + const detail = searchParams.get('detail') !== 'false'; + const myRequests = searchParams.get('my') === 'true'; + + const storage = getStorage(); + + if (myRequests) { + // 获取用户自己的求片 + const requestIds = await storage.getUserMovieRequests(authInfo.username); + const requests = await Promise.all( + requestIds.map(id => storage.getMovieRequest(id)) + ); + const filtered = requests.filter(r => r !== null) as MovieRequest[]; + return NextResponse.json({ requests: filtered }); + } + + // 获取所有求片 + let requests = await storage.getAllMovieRequests(); + + // 按状态筛选 + if (status) { + requests = requests.filter(r => r.status === status); + } + + // 列表页不返回 requestedBy + if (!detail) { + requests = requests.map(r => ({ ...r, requestedBy: [] })); + } + + // 按求片人数和时间排序 + requests.sort((a, b) => { + if (b.requestCount !== a.requestCount) { + return b.requestCount - a.requestCount; + } + return b.createdAt - a.createdAt; + }); + + return NextResponse.json({ requests }); + } catch (error) { + console.error('获取求片列表失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} + +// POST: 创建或加入求片 +export async function POST(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + // 检查求片功能是否启用并获取冷却时间 + const { getConfig } = await import('@/lib/config'); + const config = await getConfig(); + + if (config.SiteConfig.EnableMovieRequest === false) { + return NextResponse.json({ error: '求片功能已关闭' }, { status: 403 }); + } + + const body = await request.json(); + const { tmdbId, title, year, mediaType, season, poster, overview } = body; + + if (!title || !mediaType) { + return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); + } + + const storage = getStorage(); + + // 检查频率限制 - 使用配置中的冷却时间 + const cooldownSeconds = config.SiteConfig.MovieRequestCooldown ?? 3600; + const rateLimit = cooldownSeconds * 1000; + + if (storage.getUserInfoV2) { + const userInfo = await storage.getUserInfoV2(authInfo.username); + if (userInfo?.last_movie_request_time) { + const elapsed = Date.now() - userInfo.last_movie_request_time; + if (elapsed < rateLimit) { + const remaining = Math.ceil((rateLimit - elapsed) / 60000); + return NextResponse.json( + { error: `操作太频繁,请${remaining}分钟后再试` }, + { status: 429 } + ); + } + } + } + + // 查重(剧集需要匹配季度) + const allRequests = await storage.getAllMovieRequests(); + const existing = allRequests.find(r => + (tmdbId && r.tmdbId === tmdbId && r.season === season) || + (r.title === title && r.year === year && r.season === season) + ); + + if (existing) { + // 如果已上架,不允许再求 + if (existing.status === 'fulfilled') { + return NextResponse.json({ error: '该影片已上架' }, { status: 400 }); + } + + // 检查用户是否已经求过 + if (existing.requestedBy.includes(authInfo.username)) { + return NextResponse.json({ error: '您已经求过这部影片了' }, { status: 400 }); + } + + // 加入求片 + existing.requestedBy.push(authInfo.username); + existing.requestCount++; + existing.updatedAt = Date.now(); + await storage.updateMovieRequest(existing.id, existing); + await storage.addUserMovieRequest(authInfo.username, existing.id); + + // 给站长发送通知 + const ownerUsername = process.env.USERNAME; + if (ownerUsername) { + await storage.addNotification(ownerUsername, { + id: `movie_request_join_${existing.id}_${Date.now()}`, + type: 'movie_request', + title: '求片人数增加', + message: `${authInfo.username} 也想看:${existing.title}${existing.season ? ` 第${existing.season}季` : ''} (${existing.requestCount}人)`, + timestamp: Date.now(), + read: false, + metadata: { + requestId: existing.id, + username: authInfo.username, + }, + }); + } + + return NextResponse.json({ + message: '已加入求片', + request: existing + }); + } + + // 创建新求片 + const newRequest: MovieRequest = { + id: nanoid(), + tmdbId, + title, + year, + mediaType, + season, + poster, + overview, + requestedBy: [authInfo.username], + requestCount: 1, + status: 'pending', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + await storage.createMovieRequest(newRequest); + await storage.addUserMovieRequest(authInfo.username, newRequest.id); + + // 更新频率限制 - 保存到用户信息的 hash 中 + if ('client' in storage && storage.client && typeof (storage.client as any).hSet === 'function') { + await (storage.client as any).hSet( + `user:${authInfo.username}:info`, + 'last_movie_request_time', + Date.now().toString() + ); + + // 清除用户信息缓存,确保下次读取到最新数据 + const { userInfoCache } = await import('@/lib/user-cache'); + userInfoCache?.delete(authInfo.username); + } + + // 给站长发送通知 + const ownerUsername = process.env.USERNAME; + if (ownerUsername) { + await storage.addNotification(ownerUsername, { + id: `movie_request_${newRequest.id}_${Date.now()}`, + type: 'movie_request', + title: '新求片请求', + message: `${authInfo.username} 求片:${title}${season ? ` 第${season}季` : ''}`, + timestamp: Date.now(), + read: false, + metadata: { + requestId: newRequest.id, + username: authInfo.username, + }, + }); + } + + return NextResponse.json({ + message: '求片成功', + request: newRequest + }); + } catch (error) { + console.error('创建求片失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/openlist/cms-proxy/[token]/route.ts b/src/app/api/openlist/cms-proxy/[token]/route.ts index b386b39..489673f 100644 --- a/src/app/api/openlist/cms-proxy/[token]/route.ts +++ b/src/app/api/openlist/cms-proxy/[token]/route.ts @@ -76,7 +76,7 @@ export async function GET( const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); const { db } = await import('@/lib/db'); - let metaInfo = getCachedMetaInfo(rootPath); + let metaInfo = getCachedMetaInfo(); if (!metaInfo) { try { @@ -84,7 +84,7 @@ export async function GET( if (metainfoJson) { metaInfo = JSON.parse(metainfoJson); if (metaInfo) { - setCachedMetaInfo(rootPath, metaInfo); + setCachedMetaInfo(metaInfo); } } } catch (error) { diff --git a/src/app/api/openlist/correct/route.ts b/src/app/api/openlist/correct/route.ts index 1520c7b..cd188a4 100644 --- a/src/app/api/openlist/correct/route.ts +++ b/src/app/api/openlist/correct/route.ts @@ -65,7 +65,6 @@ export async function POST(request: NextRequest) { ); } - const rootPath = openListConfig.RootPath || '/'; const client = new OpenListClient( openListConfig.URL, openListConfig.Username, @@ -73,7 +72,7 @@ export async function POST(request: NextRequest) { ); // 读取现有 metainfo (从数据库或缓存) - let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath); + let metaInfo: MetaInfo | null = getCachedMetaInfo(); if (!metaInfo) { try { @@ -132,8 +131,8 @@ export async function POST(request: NextRequest) { await db.setGlobalValue('video.metainfo', metainfoContent); // 更新缓存 - invalidateMetaInfoCache(rootPath); - setCachedMetaInfo(rootPath, metaInfo); + invalidateMetaInfoCache(); + setCachedMetaInfo(metaInfo); return NextResponse.json({ success: true, diff --git a/src/app/api/openlist/delete/route.ts b/src/app/api/openlist/delete/route.ts index 1c435c9..77501ce 100644 --- a/src/app/api/openlist/delete/route.ts +++ b/src/app/api/openlist/delete/route.ts @@ -49,8 +49,6 @@ export async function POST(request: NextRequest) { ); } - const rootPath = openListConfig.RootPath || '/'; - // 从数据库读取 metainfo const metainfoContent = await db.getGlobalValue('video.metainfo'); if (!metainfoContent) { @@ -78,8 +76,8 @@ export async function POST(request: NextRequest) { await db.setGlobalValue('video.metainfo', updatedMetainfoContent); // 更新缓存 - invalidateMetaInfoCache(rootPath); - setCachedMetaInfo(rootPath, metaInfo); + invalidateMetaInfoCache(); + setCachedMetaInfo(metaInfo); // 更新配置中的资源数量 if (config.OpenListConfig) { diff --git a/src/app/api/openlist/detail/route.ts b/src/app/api/openlist/detail/route.ts index d0503e0..fc69fbe 100644 --- a/src/app/api/openlist/detail/route.ts +++ b/src/app/api/openlist/detail/route.ts @@ -45,8 +45,8 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 }); } - const rootPath = openListConfig.RootPath || '/'; - const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + // folderName 已经是完整路径,直接使用 + const folderPath = folderName; const client = new OpenListClient( openListConfig.URL, openListConfig.Username, diff --git a/src/app/api/openlist/list/route.ts b/src/app/api/openlist/list/route.ts index ad1297e..7b2798e 100644 --- a/src/app/api/openlist/list/route.ts +++ b/src/app/api/openlist/list/route.ts @@ -48,7 +48,6 @@ export async function GET(request: NextRequest) { ); } - const rootPath = openListConfig.RootPath || '/'; const client = new OpenListClient( openListConfig.URL, openListConfig.Username, @@ -62,7 +61,7 @@ export async function GET(request: NextRequest) { if (noCache) { // noCache 模式:跳过缓存 } else { - metaInfo = getCachedMetaInfo(rootPath); + metaInfo = getCachedMetaInfo(); } if (!metaInfo) { @@ -83,7 +82,7 @@ export async function GET(request: NextRequest) { // 只有在不是 noCache 模式时才更新缓存 if (!noCache) { - setCachedMetaInfo(rootPath, metaInfo); + setCachedMetaInfo(metaInfo); } } catch (parseError) { console.error('[OpenList List] JSON 解析或验证失败:', parseError); diff --git a/src/app/api/openlist/play/route.ts b/src/app/api/openlist/play/route.ts index ea7c812..a9ef8dd 100644 --- a/src/app/api/openlist/play/route.ts +++ b/src/app/api/openlist/play/route.ts @@ -9,9 +9,59 @@ import { OpenListClient } from '@/lib/openlist.client'; export const runtime = 'nodejs'; /** - * GET /api/openlist/play?folder=xxx&fileName=xxx - * 获取单个视频文件的播放链接(懒加载) - * 返回重定向到真实播放 URL + * 使用 HEAD 请求跟随重定向获取最终 URL(直连方法 - 降级使用) + */ +async function getFinalUrl(url: string, maxRedirects = 5): Promise { + let currentUrl = url; + let redirectCount = 0; + + while (redirectCount < maxRedirects) { + try { + const response = await fetch(currentUrl, { + method: 'HEAD', + redirect: 'manual', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) { + return currentUrl; + } + + if (location.startsWith('http://') || location.startsWith('https://')) { + currentUrl = location; + } else if (location.startsWith('/')) { + const urlObj = new URL(currentUrl); + currentUrl = `${urlObj.protocol}//${urlObj.host}${location}`; + } else { + const urlObj = new URL(currentUrl); + const pathParts = urlObj.pathname.split('/'); + pathParts.pop(); + pathParts.push(location); + currentUrl = `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`; + } + + redirectCount++; + } else { + return currentUrl; + } + } catch (error) { + console.error('[openlist/play] 获取最终 URL 失败:', error); + return currentUrl; + } + } + + return currentUrl; +} + +/** + * GET /api/openlist/play?folder=xxx&fileName=xxx&format=json + * 获取单个视频文件的播放链接(优先使用视频预览流,失败时降级到直连) + * format=json: 返回 JSON 格式(用于 play 页面) + * 默认: 返回重定向(用于 tvbox 等) */ export async function GET(request: NextRequest) { try { @@ -23,6 +73,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const folderName = searchParams.get('folder'); const fileName = searchParams.get('fileName'); + const format = searchParams.get('format'); // 新增 format 参数 if (!folderName || !fileName) { return NextResponse.json({ error: '缺少参数' }, { status: 400 }); @@ -41,8 +92,8 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 }); } - const rootPath = openListConfig.RootPath || '/'; - const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + // folderName 已经是完整路径,直接使用 + const folderPath = folderName; const filePath = `${folderPath}/${fileName}`; const client = new OpenListClient( @@ -51,23 +102,71 @@ export async function GET(request: NextRequest) { openListConfig.Password ); - // 获取文件的播放链接 - const fileResponse = await client.getFile(filePath); + // 优先尝试视频预览流方法 + try { + const data = await client.getVideoPreview(filePath); - if (fileResponse.code !== 200 || !fileResponse.data.raw_url) { - console.error('[OpenList Play] 获取播放URL失败:', { - fileName, - code: fileResponse.code, - message: fileResponse.message, - }); - return NextResponse.json( - { error: '获取播放链接失败' }, - { status: 500 } - ); + const taskList = data.data?.video_preview_play_info?.live_transcoding_task_list; + if (!taskList || taskList.length === 0) { + throw new Error('未找到可用的播放链接'); + } + + const qualityOrder: Record = { + 'FHD': 1, + 'HD': 2, + 'LD': 3, + 'SD': 4, + }; + + const qualities = taskList + .filter((task: any) => task.status === 'finished') + .map((task: any) => ({ + name: task.template_id, + url: task.url, + })) + .sort((a: any, b: any) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999)); + + if (qualities.length === 0) { + throw new Error('未找到已完成的播放链接'); + } + + // 如果指定了 format=json,返回 JSON 格式 + if (format === 'json') { + return NextResponse.json({ + url: qualities[0].url, + qualities + }); + } + + // 默认返回重定向(用于 tvbox) + return NextResponse.redirect(qualities[0].url); + } catch (error) { + // 视频预览流失败,降级到直连方法 + console.log('[openlist/play] 视频预览流失败,降级到直连方法:', (error as Error).message); + + const fileResponse = await client.getFile(filePath); + + if (fileResponse.code !== 200 || !fileResponse.data.raw_url) { + console.error('[OpenList Play] 获取播放URL失败:', { + fileName, + code: fileResponse.code, + message: fileResponse.message, + }); + return NextResponse.json( + { error: '获取播放链接失败' }, + { status: 500 } + ); + } + + // 如果指定了 format=json,使用 getFinalUrl 并返回 JSON + if (format === 'json') { + const finalUrl = await getFinalUrl(fileResponse.data.raw_url); + return NextResponse.json({ url: finalUrl }); + } + + // 默认返回重定向(用于 tvbox) + return NextResponse.redirect(fileResponse.data.raw_url); } - - // 返回重定向到真实播放 URL - return NextResponse.redirect(fileResponse.data.raw_url); } catch (error) { console.error('获取播放链接失败:', error); return NextResponse.json( diff --git a/src/app/api/openlist/refresh-video/route.ts b/src/app/api/openlist/refresh-video/route.ts index 92a0ec2..bf22627 100644 --- a/src/app/api/openlist/refresh-video/route.ts +++ b/src/app/api/openlist/refresh-video/route.ts @@ -40,8 +40,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 }); } - const rootPath = openListConfig.RootPath || '/'; - const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`; + // folder 已经是完整路径,直接使用 + const folderPath = folder; const client = new OpenListClient( openListConfig.URL, openListConfig.Username, diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index add5724..4438ecd 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -105,15 +105,14 @@ export async function GET(request: NextRequest) { const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); const { db } = await import('@/lib/db'); - const rootPath = config.OpenListConfig!.RootPath || '/'; - let metaInfo = getCachedMetaInfo(rootPath); + let metaInfo = getCachedMetaInfo(); if (!metaInfo) { const metainfoJson = await db.getGlobalValue('video.metainfo'); if (metainfoJson) { metaInfo = JSON.parse(metainfoJson); if (metaInfo) { - setCachedMetaInfo(rootPath, metaInfo); + setCachedMetaInfo(metaInfo); } } } diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index a271efa..22d16de 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -213,15 +213,14 @@ export async function GET(request: NextRequest) { const { getTMDBImageUrl } = await import('@/lib/tmdb.search'); const { db } = await import('@/lib/db'); - const rootPath = config.OpenListConfig!.RootPath || '/'; - let metaInfo = getCachedMetaInfo(rootPath); + let metaInfo = getCachedMetaInfo(); if (!metaInfo) { const metainfoJson = await db.getGlobalValue('video.metainfo'); if (metainfoJson) { metaInfo = JSON.parse(metainfoJson); if (metaInfo) { - setCachedMetaInfo(rootPath, metaInfo); + setCachedMetaInfo(metaInfo); } } } diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts index 8b33681..127bb87 100644 --- a/src/app/api/server-config/route.ts +++ b/src/app/api/server-config/route.ts @@ -6,6 +6,7 @@ import { getConfig } from '@/lib/config'; import { CURRENT_VERSION } from '@/lib/version' export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; // 禁用缓存 export async function GET(request: NextRequest) { console.log('server-config called: ', request.url); diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 1cebc28..6f019ce 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -22,6 +22,7 @@ export async function GET(request: NextRequest) { const id = searchParams.get('id'); const sourceCode = searchParams.get('source'); const title = searchParams.get('title'); // 用于搜索的标题 + const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名 if (!id || !sourceCode || !title) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); @@ -124,6 +125,102 @@ export async function GET(request: NextRequest) { } } + // 特殊处理 xiaoya 源 + if (sourceCode === 'xiaoya') { + try { + const config = await getConfig(); + const xiaoyaConfig = config.XiaoyaConfig; + + if ( + !xiaoyaConfig || + !xiaoyaConfig.Enabled || + !xiaoyaConfig.ServerURL + ) { + throw new Error('小雅未配置或未启用'); + } + + const { XiaoyaClient } = await import('@/lib/xiaoya.client'); + const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import('@/lib/xiaoya-metadata'); + const { base58Decode, base58Encode } = await import('@/lib/utils'); + + const client = new XiaoyaClient( + xiaoyaConfig.ServerURL, + xiaoyaConfig.Username, + xiaoyaConfig.Password, + xiaoyaConfig.Token + ); + + // 对id进行base58解码得到目录路径 + let decodedDirPath: string; + try { + decodedDirPath = base58Decode(id); + console.log('[xiaoya] 解码目录路径:', decodedDirPath); + } catch (decodeError) { + console.error('[xiaoya] Base58解码失败:', decodeError); + throw new Error('无效的视频ID'); + } + + // 验证解码后的路径 + if (!decodedDirPath || decodedDirPath.trim() === '') { + throw new Error('解码后的路径为空'); + } + + // 如果有fileName参数,拼接完整文件路径 + let clickedFilePath: string | undefined; + if (fileName) { + // 拼接目录路径和文件名 + clickedFilePath = `${decodedDirPath}${decodedDirPath.endsWith('/') ? '' : '/'}${fileName}`; + console.log('[xiaoya] 用户点击的文件路径:', clickedFilePath); + } + + // 获取元数据(使用目录路径或点击的文件路径) + const metadataPath = clickedFilePath || decodedDirPath; + const metadata = await getXiaoyaMetadata( + client, + metadataPath, + config.SiteConfig.TMDBApiKey, + config.SiteConfig.TMDBProxy + ); + + // 获取集数列表(使用目录路径或点击的文件路径) + const episodes = await getXiaoyaEpisodes(client, metadataPath); + + // 如果有点击的文件路径,找到对应的集数索引 + let clickedFileIndex = -1; + if (clickedFilePath) { + clickedFileIndex = episodes.findIndex(ep => ep.path === clickedFilePath); + console.log('[xiaoya] 文件在集数列表中的索引:', clickedFileIndex); + } + + const result = { + source: 'xiaoya', + source_name: '小雅', + id: id, // 保持编码后的目录id + title: metadata.title, + poster: metadata.poster || '', + year: metadata.year || '', + douban_id: 0, + desc: metadata.plot || '', + episodes: episodes.map(ep => `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}`), + episodes_titles: episodes.map(ep => ep.title), + subtitles: [], + proxyMode: false, + // 返回用户点击的文件索引(如果找到的话) + initialEpisodeIndex: clickedFileIndex >= 0 ? clickedFileIndex : undefined, + // 返回元数据来源 + metadataSource: metadata.source, + }; + + return NextResponse.json(result); + } catch (error) { + console.error('[xiaoya] 获取详情失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } + } + // 特殊处理 openlist 源 - 直接调用 /api/detail if (sourceCode === 'openlist') { try { @@ -149,13 +246,13 @@ export async function GET(request: NextRequest) { const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); const { db } = await import('@/lib/db'); - metaInfo = getCachedMetaInfo(rootPath); + metaInfo = getCachedMetaInfo(); if (!metaInfo) { const metainfoJson = await db.getGlobalValue('video.metainfo'); if (metainfoJson) { metaInfo = JSON.parse(metainfoJson); - setCachedMetaInfo(rootPath, metaInfo); + setCachedMetaInfo(metaInfo); } } diff --git a/src/app/api/theme/css/route.ts b/src/app/api/theme/css/route.ts index 83de1c3..3a13fc4 100644 --- a/src/app/api/theme/css/route.ts +++ b/src/app/api/theme/css/route.ts @@ -6,6 +6,7 @@ import { getConfig } from '@/lib/config'; import { getThemeCSS } from '@/styles/themes'; export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; // 禁用缓存 export async function GET(request: NextRequest) { try { diff --git a/src/app/api/tmdb-details/route.ts b/src/app/api/tmdb-details/route.ts index f82a4f6..531e4a8 100644 --- a/src/app/api/tmdb-details/route.ts +++ b/src/app/api/tmdb-details/route.ts @@ -162,6 +162,7 @@ export async function GET(request: NextRequest) { overview: details.overview || '', rating: details.vote_average ? details.vote_average.toFixed(1) : '', releaseDate: details.release_date || details.first_air_date || '', + genres: details.genres || [], // 添加类型标签 }; return NextResponse.json(responseData, { diff --git a/src/app/api/xiaoya/browse/route.ts b/src/app/api/xiaoya/browse/route.ts new file mode 100644 index 0000000..c9156da --- /dev/null +++ b/src/app/api/xiaoya/browse/route.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { XiaoyaClient } from '@/lib/xiaoya.client'; + +export const runtime = 'nodejs'; + +/** + * GET /api/xiaoya/browse?path= + * 浏览小雅目录 + */ +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const path = searchParams.get('path') || '/'; + + const config = await getConfig(); + const xiaoyaConfig = config.XiaoyaConfig; + + if ( + !xiaoyaConfig || + !xiaoyaConfig.Enabled || + !xiaoyaConfig.ServerURL + ) { + return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 }); + } + + const client = new XiaoyaClient( + xiaoyaConfig.ServerURL, + xiaoyaConfig.Username, + xiaoyaConfig.Password, + xiaoyaConfig.Token + ); + + const result = await client.listDirectory(path); + + // 过滤出文件夹和视频文件 + const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm']; + + const folders = result.content + .filter(item => item.is_dir) + .map(item => ({ + name: item.name, + path: `${path}${path.endsWith('/') ? '' : '/'}${item.name}`, + })); + + const files = result.content + .filter(item => + !item.is_dir && + videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext)) + ) + .map(item => ({ + name: item.name, + path: `${path}${path.endsWith('/') ? '' : '/'}${item.name}`, + })); + + return NextResponse.json({ + folders, + files, + currentPath: path, + }); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/xiaoya/play/route.ts b/src/app/api/xiaoya/play/route.ts new file mode 100644 index 0000000..ee5cb02 --- /dev/null +++ b/src/app/api/xiaoya/play/route.ts @@ -0,0 +1,186 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { XiaoyaClient } from '@/lib/xiaoya.client'; + +export const runtime = 'nodejs'; + +/** + * 使用 HEAD 请求跟随重定向获取最终 URL(直连方法 - 降级使用) + */ +async function getFinalUrl(url: string, maxRedirects = 5): Promise { + let currentUrl = url; + let redirectCount = 0; + + while (redirectCount < maxRedirects) { + try { + const response = await fetch(currentUrl, { + method: 'HEAD', + redirect: 'manual', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (!location) { + return currentUrl; + } + + if (location.startsWith('http://') || location.startsWith('https://')) { + currentUrl = location; + } else if (location.startsWith('/')) { + const urlObj = new URL(currentUrl); + currentUrl = `${urlObj.protocol}//${urlObj.host}${location}`; + } else { + const urlObj = new URL(currentUrl); + const pathParts = urlObj.pathname.split('/'); + pathParts.pop(); + pathParts.push(location); + currentUrl = `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`; + } + + redirectCount++; + } else { + return currentUrl; + } + } catch (error) { + console.error('[xiaoya/play] 获取最终 URL 失败:', error); + return currentUrl; + } + } + + return currentUrl; +} + +/** + * GET /api/xiaoya/play?path=&format=json + * 获取小雅视频的播放链接(优先使用视频预览流,失败时降级到直连) + * path参数为base58编码的路径 + * format=json: 返回 JSON 格式(用于 play 页面) + * 默认: 返回重定向(用于 tvbox 等) + */ +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const encodedPath = searchParams.get('path'); + const format = searchParams.get('format'); // 新增 format 参数 + + if (!encodedPath) { + return NextResponse.json({ error: '缺少参数' }, { status: 400 }); + } + + // 对path进行base58解码 + const { base58Decode } = await import('@/lib/utils'); + const path = base58Decode(encodedPath); + + const config = await getConfig(); + const xiaoyaConfig = config.XiaoyaConfig; + + if ( + !xiaoyaConfig || + !xiaoyaConfig.Enabled || + !xiaoyaConfig.ServerURL + ) { + return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 }); + } + + const client = new XiaoyaClient( + xiaoyaConfig.ServerURL, + xiaoyaConfig.Username, + xiaoyaConfig.Password, + xiaoyaConfig.Token + ); + + // 优先尝试视频预览流方法 + try { + const token = await client.getToken(); + + const response = await fetch(`${xiaoyaConfig.ServerURL}/api/fs/other`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + body: JSON.stringify({ + path: path, + method: 'video_preview', + password: '', + }), + }); + + if (!response.ok) { + throw new Error(`视频预览请求失败: ${response.status}`); + } + + const data = await response.json(); + + if (data.code !== 200) { + throw new Error(`视频预览失败: ${data.message}`); + } + + const taskList = data.data?.video_preview_play_info?.live_transcoding_task_list; + if (!taskList || taskList.length === 0) { + throw new Error('未找到可用的播放链接'); + } + + const qualityOrder: Record = { + 'FHD': 1, + 'HD': 2, + 'LD': 3, + 'SD': 4, + }; + + const qualities = taskList + .filter((task: any) => task.status === 'finished') + .map((task: any) => ({ + name: task.template_id, + url: task.url, + })) + .sort((a: any, b: any) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999)); + + if (qualities.length === 0) { + throw new Error('未找到已完成的播放链接'); + } + + // 如果指定了 format=json,返回 JSON 格式 + if (format === 'json') { + return NextResponse.json({ + url: qualities[0].url, + qualities + }); + } + + // 默认返回重定向(用于 tvbox) + return NextResponse.redirect(qualities[0].url); + } catch (error) { + // 视频预览流失败,降级到直连方法 + console.log('[xiaoya/play] 视频预览流失败,降级到直连方法:', (error as Error).message); + + const playUrl = await client.getDownloadUrl(path); + + // 如果指定了 format=json,使用 getFinalUrl 并返回 JSON + if (format === 'json') { + const finalUrl = await getFinalUrl(playUrl); + return NextResponse.json({ url: finalUrl }); + } + + // 默认返回重定向(用于 tvbox) + return NextResponse.redirect(playUrl); + } + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/xiaoya/search/route.ts b/src/app/api/xiaoya/search/route.ts new file mode 100644 index 0000000..da3b273 --- /dev/null +++ b/src/app/api/xiaoya/search/route.ts @@ -0,0 +1,98 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; + +export const runtime = 'nodejs'; + +/** + * GET /api/xiaoya/search?keyword=&type= + * 搜索小雅视频(使用小雅的网页搜索引擎) + */ +export async function GET(request: NextRequest) { + try { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const keyword = searchParams.get('keyword'); + const type = searchParams.get('type') || 'video'; // video, music, ebook, all + + if (!keyword) { + return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 }); + } + + const config = await getConfig(); + const xiaoyaConfig = config.XiaoyaConfig; + + if ( + !xiaoyaConfig || + !xiaoyaConfig.Enabled || + !xiaoyaConfig.ServerURL + ) { + return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 }); + } + + // 使用小雅的搜索引擎 + const searchUrl = `${xiaoyaConfig.ServerURL}/search?box=${encodeURIComponent(keyword)}&type=${type}&url=`; + + const response = await fetch(searchUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + + if (!response.ok) { + throw new Error(`搜索请求失败: ${response.status}`); + } + + const html = await response.text(); + + // 解析 HTML 中的链接 + // 格式: path/to/file + const linkRegex = /]+)>([^<]+)<\/a>/g; + const results: Array<{ name: string; path: string }> = []; + + let match; + while ((match = linkRegex.exec(html)) !== null) { + let path = match[1]; + const displayText = match[2]; + + // 跳过返回首页和频道链接 + if (path === '/' || path.startsWith('http')) { + continue; + } + + // URL 解码路径 + try { + path = decodeURIComponent(path); + } catch (e) { + console.error('URL 解码失败:', path, e); + } + + // 提取文件名(路径的最后一部分) + const pathParts = displayText.split('/'); + const fileName = pathParts[pathParts.length - 1]; + + results.push({ + name: fileName, + path: path, + }); + } + + return NextResponse.json({ + videos: results, + total: results.length, + }); + } catch (error) { + console.error('小雅搜索失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 420ec68..62a91b1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -66,6 +66,7 @@ export default async function RootLayout({ let tmdbApiKey = ''; let openListEnabled = false; let embyEnabled = false; + let xiaoyaEnabled = false; let loginBackgroundImage = ''; let registerBackgroundImage = ''; let enableRegistration = false; @@ -81,6 +82,7 @@ export default async function RootLayout({ let aiEnablePlayPageEntry = false; let aiDefaultMessageNoVideo = ''; let aiDefaultMessageWithVideo = ''; + let enableMovieRequest = true; let customCategories = [] as { name: string; type: 'movie' | 'tv'; @@ -123,6 +125,8 @@ export default async function RootLayout({ aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false; aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || ''; aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || ''; + // 求片功能配置 + enableMovieRequest = config.SiteConfig.EnableMovieRequest ?? true; // 检查是否启用了 OpenList 功能 openListEnabled = !!( config.OpenListConfig?.Enabled && @@ -136,6 +140,11 @@ export default async function RootLayout({ config.EmbyConfig.Sources.length > 0 && config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL) ); + // 检查是否启用了小雅功能 + xiaoyaEnabled = !!( + config.XiaoyaConfig?.Enabled && + config.XiaoyaConfig?.ServerURL + ); } // 将运行时配置注入到全局 window 对象,供客户端在运行时读取 @@ -155,7 +164,8 @@ export default async function RootLayout({ VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback', OPENLIST_ENABLED: openListEnabled, EMBY_ENABLED: embyEnabled, - PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled, + XIAOYA_ENABLED: xiaoyaEnabled, + PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled || xiaoyaEnabled, LOGIN_BACKGROUND_IMAGE: loginBackgroundImage, REGISTER_BACKGROUND_IMAGE: registerBackgroundImage, ENABLE_REGISTRATION: enableRegistration, @@ -171,7 +181,7 @@ export default async function RootLayout({ AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry, AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo, AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo, - ENABLE_SOURCE_SEARCH: process.env.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH !== 'false', + ENABLE_MOVIE_REQUEST: enableMovieRequest, }; return ( diff --git a/src/app/movie-request/page.tsx b/src/app/movie-request/page.tsx new file mode 100644 index 0000000..a213ea2 --- /dev/null +++ b/src/app/movie-request/page.tsx @@ -0,0 +1,430 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { createPortal } from 'react-dom'; +import { CheckCircle, AlertCircle, Plus } from 'lucide-react'; + +import PageLayout from '@/components/PageLayout'; + +interface TMDBResult { + id: number; + title?: string; + name?: string; + release_date?: string; + first_air_date?: string; + poster_path?: string; + overview?: string; + media_type: 'movie' | 'tv'; +} + +interface MovieRequest { + id: string; + title: string; + year?: string; + mediaType: 'movie' | 'tv'; + season?: number; + poster?: string; + requestCount: number; + status: 'pending' | 'fulfilled'; + createdAt: number; +} + +export default function MovieRequestPage() { + const router = useRouter(); + const [searchKeyword, setSearchKeyword] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showSeasonDialog, setShowSeasonDialog] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [seasons, setSeasons] = useState>([]); + const [loadingSeasons, setLoadingSeasons] = useState(false); + const [selectedSeason, setSelectedSeason] = useState(1); + const [alertModal, setAlertModal] = useState<{ + isOpen: boolean; + type: 'success' | 'error'; + title: string; + message: string; + }>({ isOpen: false, type: 'success', title: '', message: '' }); + const [myRequests, setMyRequests] = useState([]); + const [loadingMyRequests, setLoadingMyRequests] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [isFeatureEnabled, setIsFeatureEnabled] = useState(true); + + // 检查求片功能是否启用 + useEffect(() => { + const runtimeConfig = (window as any).RUNTIME_CONFIG; + if (runtimeConfig && runtimeConfig.ENABLE_MOVIE_REQUEST === false) { + setIsFeatureEnabled(false); + } + }, []); + + // TMDB搜索 + const handleSearch = async () => { + if (!searchKeyword.trim()) return; + + setIsSearching(true); + try { + const response = await fetch(`/api/tmdb/search?query=${encodeURIComponent(searchKeyword)}`); + const data = await response.json(); + + if (data.results) { + setSearchResults(data.results.slice(0, 20)); + } else { + setSearchResults([]); + } + } catch (err) { + console.error('搜索失败:', err); + setAlertModal({ isOpen: true, type: 'error', title: '搜索失败', message: '请稍后重试' }); + } finally { + setIsSearching(false); + } + }; + + // 提交求片 + const handleRequest = async (item: TMDBResult) => { + if (item.media_type === 'tv') { + setSelectedItem(item); + setLoadingSeasons(true); + setShowSeasonDialog(true); + + try { + const response = await fetch(`/api/tmdb/seasons?tvId=${item.id}`); + const data = await response.json(); + if (data.seasons) { + const validSeasons = data.seasons.filter((s: any) => s.season_number > 0); + setSeasons(validSeasons); + + if (validSeasons.length === 1) { + // 只有一季,自动提交 + setShowSeasonDialog(false); + submitRequest(item, validSeasons[0].season_number); + } else { + setSelectedSeason(1); + } + } + } catch (err) { + console.error('加载季度失败:', err); + } finally { + setLoadingSeasons(false); + } + } else { + submitRequest(item); + } + }; + + const submitRequest = async (item: TMDBResult, season?: number) => { + setSubmitting(true); + try { + let poster = item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : undefined; + let title = item.title || item.name || ''; + + if (season && seasons.length > 0) { + const seasonData = seasons.find(s => s.season_number === season); + if (seasonData) { + title = `${title} ${seasonData.name}`; + if (seasonData.poster_path) { + poster = `https://image.tmdb.org/t/p/w500${seasonData.poster_path}`; + } + } + } + + const response = await fetch('/api/movie-requests', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tmdbId: item.id, + title, + year: (item.release_date || item.first_air_date)?.split('-')[0], + mediaType: item.media_type, + season, + poster, + overview: item.overview, + }), + }); + + const data = await response.json(); + + if (response.ok) { + setShowSeasonDialog(false); + setAlertModal({ isOpen: true, type: 'success', title: '求片成功', message: data.message }); + refreshMyRequests(); + } else { + setAlertModal({ isOpen: true, type: 'error', title: '求片失败', message: data.error || '请稍后重试' }); + } + } catch (err) { + console.error('求片失败:', err); + setAlertModal({ isOpen: true, type: 'error', title: '求片失败', message: '请稍后重试' }); + } finally { + setSubmitting(false); + } + }; + + const handleSeasonConfirm = () => { + if (selectedItem) { + submitRequest(selectedItem, selectedSeason); + } + setShowSeasonDialog(false); + setSelectedItem(null); + }; + + // 加载我的求片列表 + useEffect(() => { + const fetchMyRequests = async () => { + try { + const response = await fetch('/api/movie-requests?my=true'); + const data = await response.json(); + if (data.requests) { + setMyRequests(data.requests); + } + } catch (err) { + console.error('加载求片列表失败:', err); + } finally { + setLoadingMyRequests(false); + } + }; + + fetchMyRequests(); + }, []); + + // 刷新我的求片列表 + const refreshMyRequests = async () => { + try { + const response = await fetch('/api/movie-requests?my=true'); + const data = await response.json(); + if (data.requests) { + setMyRequests(data.requests); + } + } catch (err) { + console.error('刷新求片列表失败:', err); + } + }; + + return ( + +
+
+

+ 求片 +

+

+ {isFeatureEnabled ? '搜索并提交您想看的影片' : '求片功能已关闭,仅可查看已求片列表'} +

+
+ + {/* 功能关闭提示 */} + {!isFeatureEnabled && ( +
+

+ 求片功能已被管理员关闭,您可以查看已提交的求片记录 +

+
+ )} + + {/* 搜索框 - 仅在功能启用时显示 */} + {isFeatureEnabled && ( +
+
+ setSearchKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSearch(); + }} + className='flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500' + /> + +
+
+ )} + + {/* 我的求片列表 */} + {searchResults.length === 0 && ( +
+

+ 我的求片 +

+ {loadingMyRequests ? ( +
+
+
+ ) : myRequests.length === 0 ? ( +
+ 暂无求片记录 +
+ ) : ( +
+ {myRequests.map((request) => ( +
+ {request.poster ? ( + {request.title} + ) : ( +
+ 无海报 +
+ )} +
+

+ {request.title} +

+

+ {request.year || '未知'} · {request.requestCount}人求片 +

+
+ {request.status === 'fulfilled' ? '已上架' : '待处理'} +
+
+
+ ))} +
+ )} +
+ )} + + {/* 搜索结果 */} + {searchResults.length > 0 ? ( +
+ {searchResults.map((item) => ( +
+ {item.poster_path ? ( + {item.title + ) : ( +
+ 无海报 +
+ )} +
+

+ {item.title || item.name} +

+

+ {(item.release_date || item.first_air_date)?.split('-')[0] || '未知'} +

+ +
+
+ ))} +
+ ) : searchKeyword && !isSearching ? ( +
+ 未找到相关影片 +
+ ) : null} +
+ + {/* 提示弹窗 */} + {alertModal.isOpen && typeof window !== 'undefined' && createPortal( +
+
+
+ {alertModal.type === 'success' ? ( + + ) : ( + + )} +
+

+ {alertModal.title} +

+

+ {alertModal.message} +

+ +
+
, + document.body + )} + + {/* 季度选择弹窗 */} + {showSeasonDialog && typeof window !== 'undefined' && createPortal( + <> +
setShowSeasonDialog(false)} + /> +
+

+ 选择季度 +

+

+ {selectedItem?.title || selectedItem?.name} +

+ {loadingSeasons ? ( +
+
+
+ ) : ( +
+ {seasons.map((season) => ( + + ))} +
+ )} +
+ + +
+
+ , + document.body + )} + + ); +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index c6bdcb5..440d170 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -2,7 +2,7 @@ 'use client'; -import { Heart, Search, X, Cloud, Sparkles } from 'lucide-react'; +import { Heart, Search, X, Cloud, Sparkles, AlertCircle } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useRef, useState } from 'react'; @@ -51,6 +51,7 @@ import { import type { DanmakuAnime, DanmakuSelection, DanmakuSettings, DanmakuComment } from '@/lib/danmaku/types'; import { SearchResult, DanmakuFilterConfig, EpisodeFilterConfig } from '@/lib/types'; import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; +import { getTMDBImageUrl } from '@/lib/tmdb.search'; import EpisodeSelector from '@/components/EpisodeSelector'; import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector'; @@ -63,6 +64,7 @@ import AIChatPanel from '@/components/AIChatPanel'; import { useEnableComments } from '@/hooks/useEnableComments'; import PansouSearch from '@/components/PansouSearch'; import CustomHeatmap from '@/components/CustomHeatmap'; +import CorrectDialog from '@/components/CorrectDialog'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 declare global { @@ -122,6 +124,9 @@ function PlayPageClient() { const [aiEnabled, setAiEnabled] = useState(false); const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState(''); + // 纠错弹窗状态 + const [showCorrectDialog, setShowCorrectDialog] = useState(false); + // 检查AI功能是否启用 useEffect(() => { if (typeof window !== 'undefined') { @@ -464,9 +469,13 @@ function PlayPageClient() { const [doubanAka, setDoubanAka] = useState([]); const [doubanYear, setDoubanYear] = useState(''); // 从 pubdate 提取的年份 + // 纠错后的描述信息(用于显示,不触发 detail 更新) + const [correctedDesc, setCorrectedDesc] = useState(''); + // 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby') const [currentSource, setCurrentSource] = useState(searchParams.get('source') || ''); const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); + const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名 // 解析 source 参数以获取 embyKey(仅用于 API 调用) const parseSourceForApi = (source: string): { source: string; embyKey?: string } => { @@ -993,6 +1002,11 @@ function PlayPageClient() { if (detCacheAge < detCacheMaxAge && data && data.backdrop) { console.log('使用缓存的TMDB详情数据'); setTmdbBackdrop(data.backdrop); + + // 如果没有豆瓣ID,使用TMDb数据补充 + if (!videoDoubanId || videoDoubanId === 0) { + populateDoubanFieldsFromTMDB(data); + } return; } } catch (e) { @@ -1023,6 +1037,11 @@ function PlayPageClient() { if (result.backdrop) { setTmdbBackdrop(result.backdrop); + // 如果没有豆瓣ID,使用TMDb数据补充 + if (!videoDoubanId || videoDoubanId === 0) { + populateDoubanFieldsFromTMDB(result); + } + // 保存title到tmdbId的映射到localStorage(1个月) if (result.tmdbId) { try { @@ -1056,13 +1075,46 @@ function PlayPageClient() { } }; + // 辅助函数:使用TMDb数据填充豆瓣字段 + const populateDoubanFieldsFromTMDB = (tmdbData: any) => { + // 设置评分 + if (tmdbData.rating) { + const ratingValue = parseFloat(tmdbData.rating); + setDoubanRating({ + value: ratingValue, + count: 0, // TMDb不提供评分人数 + star_count: Math.round(ratingValue / 2), // 转换为5星制 + }); + } + + // 设置年份 + if (tmdbData.releaseDate) { + const year = tmdbData.releaseDate.split('-')[0]; + setDoubanYear(year); + } + + // 设置card_subtitle(优先使用genres标签,否则使用年份和类型) + if (tmdbData.genres && Array.isArray(tmdbData.genres) && tmdbData.genres.length > 0) { + const genreNames = tmdbData.genres.map((g: any) => g.name).join(' / '); + setDoubanCardSubtitle(genreNames); + } else if (tmdbData.mediaType && tmdbData.releaseDate) { + // 兜底:如果没有genres,使用年份和类型 + const year = tmdbData.releaseDate.split('-')[0]; + const typeText = tmdbData.mediaType === 'movie' ? '电影' : '电视剧'; + setDoubanCardSubtitle(`${year} / ${typeText}`); + } + }; + fetchTMDBBackdrop(); - }, [videoTitle]); + }, [videoTitle, videoDoubanId]); // 视频播放地址 const [videoUrl, setVideoUrl] = useState(''); + // 视频清晰度列表 + const [videoQualities, setVideoQualities] = useState>([]); + // 视频源代理模式状态 const [sourceProxyMode, setSourceProxyMode] = useState(false); @@ -1101,7 +1153,7 @@ function PlayPageClient() { // 保存优选时的测速结果,避免EpisodeSelector重复测速 const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState< - Map + Map >(new Map()); // 折叠状态(仅在 lg 及以上屏幕有效) @@ -1199,7 +1251,7 @@ function PlayPageClient() { const batchSize = Math.ceil(sources.length / 2); const allResults: Array<{ source: SearchResult; - testResult: { quality: string; loadSpeed: string; pingTime: number }; + testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string }; } | null> = []; for (let start = 0; start < sources.length; start += batchSize) { @@ -1239,6 +1291,7 @@ function PlayPageClient() { quality: string; loadSpeed: string; pingTime: number; + bitrate: string; hasError?: boolean; } >(); @@ -1255,7 +1308,7 @@ function PlayPageClient() { // 过滤出成功的结果用于优选计算 const successfulResults = allResults.filter(Boolean) as Array<{ source: SearchResult; - testResult: { quality: string; loadSpeed: string; pingTime: number }; + testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string }; }>; setPrecomputedVideoInfo(newVideoInfoMap); @@ -1419,6 +1472,21 @@ function PlayPageClient() { detailData: SearchResult | null, episodeIndex: number ) => { + // 动态设置 referrer policy:只在小雅源时不发送 Referer + const existingMeta = document.querySelector('meta[name="referrer"]'); + if (detailData?.source === 'xiaoya') { + if (!existingMeta) { + const meta = document.createElement('meta'); + meta.name = 'referrer'; + meta.content = 'no-referrer'; + document.head.appendChild(meta); + } + } else { + if (existingMeta) { + existingMeta.remove(); + } + } + if ( !detailData || !detailData.episodes || @@ -1434,6 +1502,33 @@ function PlayPageClient() { let newUrl = detailData?.episodes[episodeIndex] || ''; + // 如果是小雅或 openlist 接口,先请求获取真实 URL + if (newUrl.startsWith('/api/xiaoya/play') || newUrl.startsWith('/api/openlist/play')) { + try { + // 添加 format=json 参数 + const separator = newUrl.includes('?') ? '&' : '?'; + const fetchUrl = `${newUrl}${separator}format=json`; + + const response = await fetch(fetchUrl); + const data = await response.json(); + if (data.url) { + newUrl = data.url; + // 保存清晰度列表 + if (data.qualities && data.qualities.length > 0) { + setVideoQualities(data.qualities); + } else { + setVideoQualities([]); + } + } + } catch (error) { + console.error('获取播放链接失败:', error); + setVideoQualities([]); + } + } else { + // 非小雅/openlist 源,清空清晰度列表 + setVideoQualities([]); + } + // 检查是否有本地下载的文件 const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex); @@ -2267,18 +2362,23 @@ function PlayPageClient() { const fetchSourceDetail = async ( source: string, id: string, - title: string + title: string, + fileNameParam?: string ): Promise => { try { - const detailResponse = await fetch( - `/api/source-detail?source=${source}&id=${id}&title=${encodeURIComponent(title)}` - ); + let url = `/api/source-detail?source=${source}&id=${id}&title=${encodeURIComponent(title)}`; + // 如果有fileName参数(小雅源),添加到URL + if (fileNameParam) { + url += `&fileName=${encodeURIComponent(fileNameParam)}`; + } + const detailResponse = await fetch(url); if (!detailResponse.ok) { throw new Error('获取视频详情失败'); } const detailData = (await detailResponse.json()) as SearchResult; - setAvailableSources([detailData]); - return [detailData]; + const sourcesWithCorrections = applyCorrectionsToSources([detailData]); + setAvailableSources(sourcesWithCorrections); + return sourcesWithCorrections; } catch (err) { console.error('获取视频详情失败:', err); return []; @@ -2361,7 +2461,7 @@ function PlayPageClient() { : true) ); - setAvailableSources(results); + setAvailableSources(applyCorrectionsToSources(results)); return results; } } catch (error) { @@ -2395,7 +2495,7 @@ function PlayPageClient() { ? getType(result) === searchType : true) ); - setAvailableSources(results); + setAvailableSources(applyCorrectionsToSources(results)); return results; } catch (err) { setSourceSearchError(err instanceof Error ? err.message : '搜索失败'); @@ -2428,7 +2528,13 @@ function PlayPageClient() { // 先快速获取当前源的详情 try { // currentSource 已经是完整格式(如 'emby_wumei') - const currentSourceDetail = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle); + // 如果是小雅源且有fileName参数,传递给API + const currentSourceDetail = await fetchSourceDetail( + currentSource, + currentId, + searchTitle || videoTitle, + currentSource === 'xiaoya' ? fileName : undefined + ); if (currentSourceDetail.length > 0) { detailData = currentSourceDetail[0]; sourcesInfo = currentSourceDetail; @@ -2448,7 +2554,7 @@ function PlayPageClient() { allSources.push(source); } }); - setAvailableSources(allSources); + setAvailableSources(applyCorrectionsToSources(allSources)); setBackgroundSourcesLoading(false); }).catch((err) => { console.error('异步获取其他源失败:', err); @@ -2500,7 +2606,7 @@ function PlayPageClient() { setLoadingStage('preferring'); setLoadingMessage('⚡ 正在优选最佳播放源...'); - // 过滤掉 openlist 和所有 emby 源,它们不参与测速 + // 过滤掉 openlist、所有 emby 源和 xiaoya 源,它们不参与测速 const sourcesToTest = sourcesInfo.filter(s => { // 检查是否为 openlist if (s.source === 'openlist') return false; @@ -2508,19 +2614,23 @@ function PlayPageClient() { // 检查是否为 emby 源(包括 emby 和 emby_xxx 格式) if (s.source === 'emby' || s.source.startsWith('emby_')) return false; + // 检查是否为 xiaoya 源 + if (s.source === 'xiaoya') return false; + return true; }); const excludedSources = sourcesInfo.filter(s => s.source === 'openlist' || s.source === 'emby' || - s.source.startsWith('emby_') + s.source.startsWith('emby_') || + s.source === 'xiaoya' ); if (sourcesToTest.length > 0) { detailData = await preferBestSource(sourcesToTest); } else if (excludedSources.length > 0) { - // 如果只有 openlist/emby 源,直接使用第一个 + // 如果只有 openlist/emby/xiaoya 源,直接使用第一个 detailData = excludedSources[0]; } else { detailData = sourcesInfo[0]; @@ -2542,23 +2652,43 @@ function PlayPageClient() { // 直接使用 detailData.source(已经是完整格式) setCurrentSource(detailData.source); setCurrentId(detailData.id); + + // 如果是小雅源,检查并应用纠错信息 + if (detailData.source === 'xiaoya') { + const correction = getXiaoyaCorrection(detailData.source, detailData.id); + if (correction) { + console.log('发现小雅源纠错信息,正在应用...', correction); + detailData = applyCorrection(detailData, correction); + // 同时设置纠错后的描述 + if (correction.overview) { + setCorrectedDesc(correction.overview); + } + } + } + + // 更新所有相关状态(在应用纠错信息之后) setVideoYear(detailData.year); setVideoTitle(detailData.title || videoTitleRef.current); setVideoCover(detailData.poster); setVideoDoubanId(detailData.douban_id || 0); + setDetail(detailData); setSourceProxyMode(detailData.proxyMode || false); // 从 detail 数据中读取代理模式 if (currentEpisodeIndex >= detailData.episodes.length) { setCurrentEpisodeIndex(0); } - // 规范URL参数(不更新title,避免循环刷新) + // 规范URL参数 const newUrl = new URL(window.location.href); newUrl.searchParams.set('source', detailData.source); newUrl.searchParams.set('id', detailData.id); newUrl.searchParams.set('year', detailData.year); - // 保持原有的 title,不更新 + newUrl.searchParams.set('title', detailData.title); newUrl.searchParams.delete('prefer'); + // 只有当元数据不是从文件获取时,才删除fileName参数 + if (detailData.metadataSource !== 'file') { + newUrl.searchParams.delete('fileName'); + } window.history.replaceState({}, '', newUrl.toString()); setLoadingStage('ready'); @@ -2570,18 +2700,52 @@ function PlayPageClient() { const key = generateStorageKey(detailData.source, detailData.id); const record = allRecords[key]; + // 确定初始集数索引 + let initialIndex = 0; + let shouldResumeTime = false; + if (record) { - const targetIndex = record.index - 1; - const targetTime = record.play_time; + // 有播放记录 + const recordIndex = record.index - 1; + const recordTime = record.play_time; - // 更新当前选集索引 - if (targetIndex < detailData.episodes.length && targetIndex >= 0) { - setCurrentEpisodeIndex(targetIndex); - currentEpisodeIndexRef.current = targetIndex; + // 如果有initialEpisodeIndex(用户从文件点击进入) + if (detailData.initialEpisodeIndex !== undefined) { + // 如果播放记录的集数和点击的文件集数一致,则使用播放记录的时间 + if (recordIndex === detailData.initialEpisodeIndex) { + initialIndex = recordIndex; + shouldResumeTime = true; + resumeTimeRef.current = recordTime; + console.log('[Play] 播放记录集数与点击文件一致,恢复播放进度:', recordTime); + } else { + // 否则使用点击的文件集数,从头开始播放 + initialIndex = detailData.initialEpisodeIndex; + console.log('[Play] 使用点击的文件集数:', initialIndex); + } + } else { + // 没有initialEpisodeIndex,使用播放记录 + initialIndex = recordIndex; + shouldResumeTime = true; + resumeTimeRef.current = recordTime; + console.log('[Play] 使用播放记录集数:', initialIndex); } + } else { + // 没有播放记录 + if (detailData.initialEpisodeIndex !== undefined) { + // 使用点击的文件集数 + initialIndex = detailData.initialEpisodeIndex; + console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex); + } else { + // 默认从第0集开始 + initialIndex = 0; + console.log('[Play] 没有播放记录,从第0集开始'); + } + } - // 保存待恢复的播放进度,待播放器就绪后跳转 - resumeTimeRef.current = targetTime; + // 更新当前选集索引 + if (initialIndex < detailData.episodes.length && initialIndex >= 0) { + setCurrentEpisodeIndex(initialIndex); + currentEpisodeIndexRef.current = initialIndex; } } catch (err) { console.error('读取播放记录失败:', err); @@ -2836,9 +3000,33 @@ function PlayPageClient() { newUrl.searchParams.set('title', newDetail.title || newTitle); window.history.replaceState({}, '', newUrl.toString()); - setVideoTitle(newDetail.title || newTitle); + // 如果是小雅源,检查并应用纠错信息 + let finalTitle = newDetail.title || newTitle; + let finalCover = newDetail.poster; + let finalDesc = ''; + + if (newDetail.source === 'xiaoya') { + const correction = getXiaoyaCorrection(newDetail.source, newDetail.id); + if (correction) { + console.log('换源到小雅源,发现纠错信息,正在应用...', correction); + if (correction.title) { + finalTitle = correction.title; + } + if (correction.posterPath) { + finalCover = getTMDBImageUrl(correction.posterPath); + } + if (correction.overview) { + finalDesc = correction.overview; + } + // 应用纠错信息到 newDetail + newDetail = applyCorrection(newDetail, correction); + } + } + + setVideoTitle(finalTitle); setVideoYear(newDetail.year); - setVideoCover(newDetail.poster); + setVideoCover(finalCover); + setCorrectedDesc(finalDesc); setVideoDoubanId(newDetail.douban_id || 0); // newSource 已经是完整格式 setCurrentSource(newSource); @@ -3164,6 +3352,18 @@ function PlayPageClient() { setDanmakuCount(danmakuData.length); console.log(`弹幕加载成功,共 ${danmakuData.length} 条`); + // 更新当前选择状态,包含弹幕数量 + if (metadata) { + setCurrentDanmakuSelection({ + animeId: metadata.animeId || 0, + episodeId: episodeId, + animeTitle: metadata.animeTitle || '', + episodeTitle: metadata.episodeTitle || '', + searchKeyword: metadata.searchKeyword, + danmakuCount: danmakuData.length, + }); + } + // 延迟一下让用户看到弹幕数量 await new Promise((resolve) => setTimeout(resolve, 1500)); } catch (error) { @@ -3820,6 +4020,45 @@ function PlayPageClient() { } }; + // 纠错成功后的回调 + const handleCorrectSuccess = () => { + if (!detail || detail.source !== 'xiaoya') return; + + // 从 localStorage 读取纠错信息 + const correction = getXiaoyaCorrection(detail.source, detail.id); + if (correction) { + console.log('应用纠错信息:', correction); + + // 只更新显示相关的状态,不更新 detail(避免触发其他 useEffect) + if (correction.title) { + setVideoTitle(correction.title); + } + if (correction.posterPath) { + const fullPosterUrl = getTMDBImageUrl(correction.posterPath); + setVideoCover(fullPosterUrl); + } + if (correction.overview) { + setCorrectedDesc(correction.overview); + } + if (correction.doubanId) { + const doubanIdNum = typeof correction.doubanId === 'string' + ? parseInt(correction.doubanId, 10) + : correction.doubanId; + setVideoDoubanId(doubanIdNum); + } + + // 更新 detailRef,这样其他地方使用 detailRef 时能获取到最新信息 + if (detailRef.current) { + detailRef.current = applyCorrection(detailRef.current, correction); + } + + // 更新 availableSources 中的小雅源信息 + setAvailableSources(prevSources => applyCorrectionsToSources(prevSources)); + + console.log('已应用纠错信息'); + } + }; + useEffect(() => { if ( !videoUrl || @@ -3947,7 +4186,7 @@ function PlayPageClient() { Artplayer.USE_RAF = true; // 获取当前集的字幕 - const currentSubtitles = detail?.subtitles?.[currentEpisodeIndex] || []; + const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || []; const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em'; artPlayerRef.current = new Artplayer({ @@ -3992,10 +4231,17 @@ function PlayPageClient() { fastForward: true, autoOrientation: true, lock: true, + ...(videoQualities.length > 0 ? { + quality: videoQualities.map((q, index) => ({ + default: index === 0, + html: q.name, + url: q.url, + })), + } : {}), moreVideoAttr: { - crossOrigin: 'anonymous', playsInline: true, 'webkit-playsinline': 'true', + ...(detail?.source === 'xiaoya' ? { referrerpolicy: 'no-referrer' } : {}), } as any, // HLS 支持配置 customType: { @@ -4901,7 +5147,7 @@ function PlayPageClient() { console.log('[PlayPage] Player ready, triggering sync setup'); // 添加字幕切换功能 - const currentSubtitles = detail?.subtitles?.[currentEpisodeIndex] || []; + const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || []; if (currentSubtitles.length > 0 && artPlayerRef.current) { const subtitleOptions = [ { @@ -5824,7 +6070,7 @@ function PlayPageClient() { // 调用异步初始化函数 initPlayer(); - }, [videoUrl, loading, blockAdEnabled, currentEpisodeIndex, detail]); + }, [videoUrl, loading, blockAdEnabled, currentEpisodeIndex]); // 当组件卸载时清理定时器、Wake Lock 和播放器资源 useEffect(() => { @@ -6777,6 +7023,19 @@ function PlayPageClient() { )} + {/* 纠错按钮 - 仅小雅源显示 */} + {detail && detail.source === 'xiaoya' && ( + + )} {/* 豆瓣评分显示 */} {doubanRating && doubanRating.value > 0 && (
@@ -6857,14 +7116,16 @@ function PlayPageClient() { {doubanYear || detail?.year || videoYear} )} {detail?.source_name && ( - + {detail.source_name} )} {detail?.type_name && {detail.type_name}}
{/* 剧情简介 */} - {(doubanCardSubtitle || detail?.desc) && ( + {(doubanCardSubtitle || correctedDesc || detail?.desc) && (
)} - {detail?.desc} + {correctedDesc || detail?.desc}
)}
@@ -7049,10 +7310,76 @@ function PlayPageClient() { welcomeMessage={aiDefaultMessageWithVideo ? aiDefaultMessageWithVideo.replace('{title}', detail.title || '') : `想了解《${detail.title}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`} /> )} + + {/* 纠错弹窗 - 仅小雅源显示 */} + {detail && detail.source === 'xiaoya' && ( + setShowCorrectDialog(false)} + videoKey={`${detail.source}_${detail.id}`} + currentTitle={detail.title} + currentVideo={{ + tmdbId: detail.tmdb_id, + doubanId: detail.douban_id ? String(detail.douban_id) : undefined, + poster: detail.poster, + releaseDate: detail.year, + overview: detail.desc, + voteAverage: detail.rating, + mediaType: detail.type_name === '电影' ? 'movie' : 'tv', + }} + source="xiaoya" + onCorrect={() => { + // 纠错成功后的回调 + handleCorrectSuccess(); + }} + /> + )} ); } +// 从 localStorage 读取小雅源的纠错信息 +const getXiaoyaCorrection = (source: string, id: string) => { + try { + const storageKey = `xiaoya_correction_${source}_${id}`; + const correctionJson = localStorage.getItem(storageKey); + if (correctionJson) { + return JSON.parse(correctionJson); + } + } catch (error) { + console.error('读取纠错信息失败:', error); + } + return null; +}; + +// 应用纠错信息到 detail 对象 +const applyCorrection = (detail: SearchResult, correction: any): SearchResult => { + return { + ...detail, + title: correction.title || detail.title, + poster: correction.posterPath ? getTMDBImageUrl(correction.posterPath) : detail.poster, + year: correction.releaseDate || detail.year, + desc: correction.overview || detail.desc, + rating: correction.voteAverage || detail.rating, + tmdb_id: correction.tmdbId || detail.tmdb_id, + douban_id: correction.doubanId ? (typeof correction.doubanId === 'string' ? parseInt(correction.doubanId, 10) : correction.doubanId) : detail.douban_id, + type_name: correction.mediaType === 'movie' ? '电影' : (correction.mediaType === 'tv' ? '电视剧' : detail.type_name), + }; +}; + +// 批量应用纠错信息到源列表 +const applyCorrectionsToSources = (sources: SearchResult[]): SearchResult[] => { + return sources.map(source => { + if (source.source === 'xiaoya') { + const correction = getXiaoyaCorrection(source.source, source.id); + if (correction) { + return applyCorrection(source, correction); + } + } + return source; + }); +}; + // FavoriteIcon 组件 const FavoriteIcon = ({ filled }: { filled: boolean }) => { if (filled) { diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx index 3bbb757..2f36979 100644 --- a/src/app/private-library/page.tsx +++ b/src/app/private-library/page.tsx @@ -4,12 +4,14 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useState, useRef, useMemo } from 'react'; +import { Film } from 'lucide-react'; import CapsuleSwitch from '@/components/CapsuleSwitch'; import PageLayout from '@/components/PageLayout'; import VideoCard from '@/components/VideoCard'; +import { base58Encode } from '@/lib/utils'; -type LibrarySourceType = 'openlist' | 'emby' | `emby:${string}` | `emby_${string}`; +type LibrarySourceType = 'openlist' | 'emby' | 'xiaoya' | `emby:${string}` | `emby_${string}`; interface EmbySourceOption { key: string; @@ -45,7 +47,7 @@ export default function PrivateLibraryPage() { if (typeof window !== 'undefined' && (window as any).RUNTIME_CONFIG) { return (window as any).RUNTIME_CONFIG; } - return { OPENLIST_ENABLED: false, EMBY_ENABLED: false }; + return { OPENLIST_ENABLED: false, EMBY_ENABLED: false, XIAOYA_ENABLED: false }; }, []); // 解析URL中的source参数(支持 emby:emby1 格式) @@ -72,6 +74,14 @@ export default function PrivateLibraryPage() { const [embyViews, setEmbyViews] = useState([]); const [selectedView, setSelectedView] = useState('all'); const [loadingViews, setLoadingViews] = useState(false); + // 小雅相关状态 + const [xiaoyaPath, setXiaoyaPath] = useState('/'); + const [xiaoyaFolders, setXiaoyaFolders] = useState>([]); + const [xiaoyaFiles, setXiaoyaFiles] = useState>([]); + const [xiaoyaSearchKeyword, setXiaoyaSearchKeyword] = useState(''); + const [xiaoyaSearchResults, setXiaoyaSearchResults] = useState>([]); + const [isSearching, setIsSearching] = useState(false); + const [mounted, setMounted] = useState(false); const pageSize = 20; const observerTarget = useRef(null); const isFetchingRef = useRef(false); @@ -84,6 +94,38 @@ export default function PrivateLibraryPage() { const isInitializedRef = useRef(false); const hasRestoredViewRef = useRef(false); + // 客户端挂载标记 + useEffect(() => { + setMounted(true); + }, []); + + // 小雅搜索处理函数 + const handleXiaoyaSearch = async () => { + if (!xiaoyaSearchKeyword.trim()) return; + + setIsSearching(true); + try { + const response = await fetch(`/api/xiaoya/search?keyword=${encodeURIComponent(xiaoyaSearchKeyword)}`); + if (!response.ok) { + throw new Error('搜索失败'); + } + + const data = await response.json(); + if (data.error) { + setError(data.error); + setXiaoyaSearchResults([]); + } else { + setXiaoyaSearchResults(data.videos || []); + } + } catch (err) { + console.error('搜索失败:', err); + setError('搜索失败'); + setXiaoyaSearchResults([]); + } finally { + setIsSearching(false); + } + }; + // 从URL初始化状态,并检查配置自动跳转 useEffect(() => { const urlSourceParam = searchParams.get('source'); @@ -270,6 +312,12 @@ export default function PrivateLibraryPage() { return; } + // 如果选择了 xiaoya 但未配置,不发起请求 + if (sourceType === 'xiaoya' && !runtimeConfig.XIAOYA_ENABLED) { + setLoading(false); + return; + } + // 创建新的 AbortController const abortController = new AbortController(); abortControllerRef.current = abortController; @@ -285,6 +333,8 @@ export default function PrivateLibraryPage() { const endpoint = sourceType === 'openlist' ? `/api/openlist/list?page=${page}&pageSize=${pageSize}` + : sourceType === 'xiaoya' + ? `/api/xiaoya/browse?path=${encodeURIComponent(xiaoyaPath)}` : `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}`; const response = await fetch(endpoint, { signal: abortController.signal }); @@ -301,19 +351,27 @@ export default function PrivateLibraryPage() { setVideos([]); } } else { - const newVideos = data.list || []; - - if (isInitial) { - setVideos(newVideos); + // 小雅返回的是文件夹和文件列表 + if (sourceType === 'xiaoya') { + setXiaoyaFolders(data.folders || []); + setXiaoyaFiles(data.files || []); + setVideos([]); // 小雅不使用 videos 状态 + setHasMore(false); // 小雅不需要分页 } else { - setVideos((prev) => [...prev, ...newVideos]); - } + const newVideos = data.list || []; - // 检查是否还有更多数据 - const currentPage = data.page || page; - const totalPages = data.totalPages || 1; - const hasMoreData = currentPage < totalPages; - setHasMore(hasMoreData); + if (isInitial) { + setVideos(newVideos); + } else { + setVideos((prev) => [...prev, ...newVideos]); + } + + // 检查是否还有更多数据 + const currentPage = data.page || page; + const totalPages = data.totalPages || 1; + const hasMoreData = currentPage < totalPages; + setHasMore(hasMoreData); + } } } catch (err: any) { // 忽略取消请求的错误 @@ -346,7 +404,7 @@ export default function PrivateLibraryPage() { abortControllerRef.current.abort(); } }; - }, [sourceType, embyKey, page, selectedView, runtimeConfig]); + }, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig]); const handleVideoClick = (video: Video) => { // 构建source参数 @@ -390,26 +448,40 @@ export default function PrivateLibraryPage() { return (
-
-

- 私人影库 -

-

- 观看自我收藏的高清视频吧 -

+
+
+

+ 私人影库 +

+

+ 观看自我收藏的高清视频吧 +

+
+ {mounted && ( + + )}
- {/* 第一级:源类型选择(OpenList / Emby) */} -
- setSourceType(value as LibrarySourceType)} - /> -
+ {/* 第一级:源类型选择(OpenList / Emby / 小雅) */} + {mounted && ( +
+ setSourceType(value as LibrarySourceType)} + /> +
+ )} {/* 第二级:Emby源选择(仅当选择Emby且有多个源时显示) */} {sourceType === 'emby' && embySourceOptions.length > 1 && ( @@ -527,13 +599,241 @@ export default function PrivateLibraryPage() { )} {loading ? ( -
- {Array.from({ length: pageSize }).map((_, index) => ( -
- ))} + sourceType === 'xiaoya' ? ( + // 小雅加载骨架屏 - 文件夹列表样式 +
+ {/* 文件夹骨架屏 */} +
+
+
+ {Array.from({ length: 12 }).map((_, index) => ( +
+ ))} +
+
+
+ ) : ( + // OpenList/Emby 加载骨架屏 - 海报卡片样式 +
+ {Array.from({ length: pageSize }).map((_, index) => ( +
+ ))} +
+ ) + ) : sourceType === 'xiaoya' ? ( + // 小雅浏览模式 +
+ {/* 搜索框 */} +
+
+ setXiaoyaSearchKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && xiaoyaSearchKeyword.trim()) { + handleXiaoyaSearch(); + } + }} + className='w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500' + /> + {xiaoyaSearchKeyword ? ( + + ) : ( + + )} +
+
+ + {/* 搜索结果 */} + {xiaoyaSearchResults.length > 0 ? ( +
+
+

+ 搜索结果 ({xiaoyaSearchResults.length}) +

+ +
+
+ {xiaoyaSearchResults.map((item) => { + // 判断是否为视频文件 + const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm']; + const isVideoFile = videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext)); + + // 从路径中提取文件夹名作为标题 + const pathParts = item.path.split('/').filter(Boolean); + const folderName = pathParts[pathParts.length - (isVideoFile ? 2 : 1)] || ''; + const title = folderName + .replace(/\s*\(\d{4}\)\s*\{tmdb-\d+\}$/i, '') + .trim() || item.name; + + return ( + + ); + })} +
+
+ ) : isSearching ? ( +
+
+
+ 搜索中... +
+
+ ) : ( + <> + {/* 面包屑导航 */} +
+ + {xiaoyaPath.split('/').filter(Boolean).map((part, index, arr) => { + const path = '/' + arr.slice(0, index + 1).join('/'); + return ( + + / + + + ); + })} +
+ + {/* 文件夹列表 */} + {xiaoyaFolders.length > 0 && ( +
+

文件夹

+
+ {xiaoyaFolders.map((folder) => ( + + ))} +
+
+ )} + + {/* 视频文件列表 */} + {xiaoyaFiles.length > 0 && ( +
+

视频文件

+
+ {xiaoyaFiles.map((file) => { + // 从当前路径提取文件夹名作为标题 + const pathParts = xiaoyaPath.split('/').filter(Boolean); + const folderName = pathParts[pathParts.length - 1] || ''; + // 清理文件夹名(移除年份和 TMDb ID) + const title = folderName + .replace(/\s*\(\d{4}\)\s*\{tmdb-\d+\}$/i, '') + .trim() || file.name; + + return ( + + ); + })} +
+
+ )} + + {xiaoyaFolders.length === 0 && xiaoyaFiles.length === 0 && ( +
+

此目录为空

+
+ )} + + )}
) : videos.length === 0 ? (
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index ca0df40..c686929 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -36,6 +36,10 @@ function SearchPageClient() { const [triggerAcgSearch, setTriggerAcgSearch] = useState(false); // 用户权限 const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(null); + // 繁体转简体转换器 + const converterRef = useRef<((text: string) => string) | null>(null); + // 转换器是否已初始化 + const [converterReady, setConverterReady] = useState(false); const router = useRouter(); const searchParams = useSearchParams(); @@ -538,6 +542,26 @@ function SearchPageClient() { const authInfo = getAuthInfoFromBrowserCookie(); setUserRole(authInfo?.role || null); + // 初始化繁体转简体转换器 + 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); + setConverterReady(true); // 即使失败也设置为 true,避免阻塞 + } + }).catch((error) => { + console.error('加载 opencc-js 失败:', error); + setConverterReady(true); // 即使失败也设置为 true,避免阻塞 + }); + } else { + setConverterReady(true); + } + // 初始加载搜索历史 getSearchHistory().then(setSearchHistory); @@ -600,13 +624,41 @@ function SearchPageClient() { }, []); useEffect(() => { + // 等待转换器初始化完成 + if (!converterReady) { + return; + } + // 当搜索参数变化时更新搜索状态 - const query = searchParams.get('q') || ''; + let query = searchParams.get('q') || ''; + + // 如果开启了繁体转简体,进行转换 + if (query && typeof window !== 'undefined') { + const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified'); + + if (searchTraditionalToSimplified === 'true' && converterRef.current) { + try { + const originalQuery = query; + query = converterRef.current(query); + + // 如果转换后的文本与原文本不同,更新 URL + if (originalQuery !== query) { + const trimmedConverted = query.trim(); + // 使用 replace 而不是 push,避免在历史记录中留下繁体版本 + router.replace(`/search?q=${encodeURIComponent(trimmedConverted)}${searchParams.get('type') ? `&type=${searchParams.get('type')}` : ''}`); + return; // 等待 URL 更新后重新触发此 effect + } + } catch (error) { + console.error('[URL参数监听] 繁体转简体转换失败:', error); + } + } + } + currentQueryRef.current = query.trim(); if (query) { setSearchQuery(query); - + const trimmed = query.trim(); // 检查是否有缓存且不是强制刷新 @@ -799,7 +851,7 @@ function SearchPageClient() { setShowResults(false); setShowSuggestions(false); } - }, [searchParams, forceRefresh]); + }, [searchParams, forceRefresh, converterReady]); // 组件卸载时,关闭可能存在的连接 useEffect(() => { @@ -838,9 +890,21 @@ function SearchPageClient() { // 搜索表单提交时触发,处理搜索逻辑 const handleSearch = (e: React.FormEvent) => { e.preventDefault(); - const trimmed = searchQuery.trim().replace(/\s+/g, ' '); + let trimmed = searchQuery.trim().replace(/\s+/g, ' '); if (!trimmed) return; + // 如果开启了繁体转简体,进行转换 + if (typeof window !== 'undefined') { + const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified'); + if (searchTraditionalToSimplified === 'true' && converterRef.current) { + try { + trimmed = converterRef.current(trimmed); + } catch (error) { + console.error('繁体转简体转换失败:', error); + } + } + } + // 回显搜索框 setSearchQuery(trimmed); setShowResults(true); @@ -863,7 +927,21 @@ function SearchPageClient() { }; const handleSuggestionSelect = (suggestion: string) => { - setSearchQuery(suggestion); + let processedSuggestion = suggestion; + + // 如果开启了繁体转简体,进行转换 + if (typeof window !== 'undefined') { + const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified'); + if (searchTraditionalToSimplified === 'true' && converterRef.current) { + try { + processedSuggestion = converterRef.current(suggestion); + } catch (error) { + console.error('繁体转简体转换失败:', error); + } + } + } + + setSearchQuery(processedSuggestion); setShowSuggestions(false); // 自动执行搜索 @@ -872,15 +950,15 @@ function SearchPageClient() { // 根据当前选项卡执行不同的搜索 if (activeTab === 'video') { // 影视搜索 - router.push(`/search?q=${encodeURIComponent(suggestion)}&type=video`); + router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=video`); // 其余由 searchParams 变化的 effect 处理 } else if (activeTab === 'pansou') { // 网盘搜索 - 触发搜索 - router.push(`/search?q=${encodeURIComponent(suggestion)}&type=pansou`); + router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=pansou`); setTriggerPansouSearch(prev => !prev); } else if (activeTab === 'acg') { // ACG 磁力搜索 - 触发搜索 - router.push(`/search?q=${encodeURIComponent(suggestion)}&type=acg`); + router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=acg`); setTriggerAcgSearch(prev => !prev); } }; diff --git a/src/components/CorrectDialog.tsx b/src/components/CorrectDialog.tsx index d302bde..6e2a85a 100644 --- a/src/components/CorrectDialog.tsx +++ b/src/components/CorrectDialog.tsx @@ -49,6 +49,7 @@ interface CorrectDialogProps { seasonName?: string; }; onCorrect: () => void; + source?: string; // 新增:视频源类型(xiaoya, openlist等) } export default function CorrectDialog({ @@ -58,6 +59,7 @@ export default function CorrectDialog({ currentTitle, currentVideo, onCorrect, + source = 'openlist', // 默认为 openlist }: CorrectDialogProps) { const [searchQuery, setSearchQuery] = useState(currentTitle); const [searching, setSearching] = useState(false); @@ -229,8 +231,7 @@ export default function CorrectDialog({ finalTitle = `${finalTitle} ${season.name}`; } - const body: any = { - key: videoKey, + const correctionData: any = { tmdbId: finalTmdbId, title: finalTitle, posterPath: season?.poster_path || result.poster_path, @@ -240,20 +241,38 @@ export default function CorrectDialog({ mediaType: result.media_type, }; - // 如果有季度信息,添加到请求中 + // 如果有季度信息,添加到数据中 if (season) { - body.seasonNumber = season.season_number; - body.seasonName = season.name; + correctionData.seasonNumber = season.season_number; + correctionData.seasonName = season.name; } - const response = await fetch('/api/openlist/correct', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + // 根据源类型选择不同的存储方式 + if (source === 'xiaoya') { + // 小雅源:存储到 localStorage + const storageKey = `xiaoya_correction_${videoKey}`; + const correctionInfo = { + ...correctionData, + correctedAt: Date.now(), + }; + localStorage.setItem(storageKey, JSON.stringify(correctionInfo)); + console.log('小雅源纠错信息已存储到 localStorage:', storageKey, correctionInfo); + } else { + // openlist 等其他源:调用 API + const body: any = { + key: videoKey, + ...correctionData, + }; - if (!response.ok) { - throw new Error('纠错失败'); + const response = await fetch('/api/openlist/correct', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error('纠错失败'); + } } onCorrect(); @@ -313,8 +332,7 @@ export default function CorrectDialog({ setError(''); try { - const body: any = { - key: videoKey, + const correctionData: any = { title: manualData.title.trim(), posterPath: manualData.posterPath.trim() || null, releaseDate: manualData.releaseDate.trim() || '', @@ -325,28 +343,46 @@ export default function CorrectDialog({ // 添加 TMDB ID(如果提供) if (manualData.tmdbId.trim()) { - body.tmdbId = Number(manualData.tmdbId); + correctionData.tmdbId = Number(manualData.tmdbId); } // 添加豆瓣 ID(如果提供) if (manualData.doubanId.trim()) { - body.doubanId = manualData.doubanId.trim(); + correctionData.doubanId = manualData.doubanId.trim(); } // 如果是电视剧且有季度信息 if (manualData.mediaType === 'tv' && manualData.seasonNumber) { - body.seasonNumber = Number(manualData.seasonNumber); - body.seasonName = manualData.seasonName.trim() || `第 ${manualData.seasonNumber} 季`; + correctionData.seasonNumber = Number(manualData.seasonNumber); + correctionData.seasonName = manualData.seasonName.trim() || `第 ${manualData.seasonNumber} 季`; } - const response = await fetch('/api/openlist/correct', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + // 根据源类型选择不同的存储方式 + if (source === 'xiaoya') { + // 小雅源:存储到 localStorage + const storageKey = `xiaoya_correction_${videoKey}`; + const correctionInfo = { + ...correctionData, + correctedAt: Date.now(), + }; + localStorage.setItem(storageKey, JSON.stringify(correctionInfo)); + console.log('小雅源纠错信息已存储到 localStorage:', storageKey, correctionInfo); + } else { + // openlist 等其他源:调用 API + const body: any = { + key: videoKey, + ...correctionData, + }; - if (!response.ok) { - throw new Error('纠错失败'); + const response = await fetch('/api/openlist/correct', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error('纠错失败'); + } } onCorrect(); diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 0794870..e9bdd34 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -21,6 +21,7 @@ interface VideoInfo { quality: string; loadSpeed: string; pingTime: number; + bitrate: string; // 视频码率 hasError?: boolean; // 添加错误状态标识 } @@ -213,6 +214,7 @@ const EpisodeSelector: React.FC = ({ quality: '错误', loadSpeed: '未知', pingTime: 0, + bitrate: '未知', hasError: true, }) ); @@ -271,16 +273,18 @@ const EpisodeSelector: React.FC = ({ if ( !optimizationEnabled || // 若关闭测速则直接退出 activeTab !== 'sources' || - availableSources.length === 0 || - currentSource === 'openlist' || // 私人影库不进行测速 - currentSource === 'emby' // Emby 不进行测速 + availableSources.length === 0 ) return; - // 筛选出尚未测速的播放源 + // 筛选出尚未测速的播放源,并排除不需要测速的源(openlist/emby/xiaoya) const pendingSources = availableSources.filter((source) => { const sourceKey = `${source.source}-${source.id}`; - return !attemptedSourcesRef.current.has(sourceKey); + // 跳过已测速的源 + if (attemptedSourcesRef.current.has(sourceKey)) return false; + // 跳过不需要测速的源 + if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') return false; + return true; }); if (pendingSources.length === 0) return; @@ -308,11 +312,15 @@ const EpisodeSelector: React.FC = ({ // 当后台加载从 true 变为 false 时(即加载完成) if (prevBackgroundLoadingRef.current && !backgroundSourcesLoading) { // 如果当前选项卡在换源位置,触发测速 - if (activeTab === 'sources' && optimizationEnabled && currentSource !== 'openlist' && currentSource !== 'emby') { - // 筛选出尚未测速的播放源 + if (activeTab === 'sources' && optimizationEnabled) { + // 筛选出尚未测速的播放源,并排除不需要测速的源(openlist/emby/xiaoya) const pendingSources = availableSources.filter((source) => { const sourceKey = `${source.source}-${source.id}`; - return !attemptedSourcesRef.current.has(sourceKey); + // 跳过已测速的源 + if (attemptedSourcesRef.current.has(sourceKey)) return false; + // 跳过不需要测速的源 + if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') return false; + return true; }); if (pendingSources.length > 0) { @@ -683,6 +691,13 @@ const EpisodeSelector: React.FC = ({ if (title.match(/^OVA\s+\d+/i)) { return title; } + // 如果匹配 S01E01 格式,提取并返回 + const sxxexxMatch = title.match(/[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/); + if (sxxexxMatch) { + const season = sxxexxMatch[1].padStart(2, '0'); + const episode = sxxexxMatch[2]; + return `S${season}E${episode}`; + } // 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数) const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/); if (match) { @@ -856,7 +871,7 @@ const EpisodeSelector: React.FC = ({ {/* 源名称和集数信息 - 垂直居中 */}
@@ -885,6 +900,11 @@ const EpisodeSelector: React.FC = ({
{videoInfo.pingTime}ms
+ {videoInfo.bitrate && videoInfo.bitrate !== '未知' && ( +
+ {videoInfo.bitrate} +
+ )}
); } else { @@ -900,8 +920,8 @@ const EpisodeSelector: React.FC = ({
{/* 重新测试按钮 */} {(() => { - // 私人影库和 Emby 不显示重新测试按钮 - if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_')) { + // 私人影库、Emby 和小雅不显示重新测试按钮 + if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') { return null; } diff --git a/src/components/MultiLevelSelector.tsx b/src/components/MultiLevelSelector.tsx index b7f8bfc..908d4ec 100644 --- a/src/components/MultiLevelSelector.tsx +++ b/src/components/MultiLevelSelector.tsx @@ -259,6 +259,25 @@ const MultiLevelSelector: React.FC = ({ } }; + // 动态生成年份选项 + const currentYear = new Date().getFullYear(); + const currentDecade = Math.floor(currentYear / 10) * 10; + const yearOptions: MultiLevelOption[] = [ + { label: '全部', value: 'all' }, + { label: `${currentDecade}年代`, value: `${currentDecade}s` }, + ]; + + // 添加当前年份到当前年代的起始年份 + for (let year = currentYear; year >= currentDecade; year--) { + yearOptions.push({ label: String(year), value: String(year) }); + } + + // 添加历史年代 + for (let decade = currentDecade - 10; decade >= 1960; decade -= 10) { + yearOptions.push({ label: `${decade}年代`, value: `${decade}s` }); + } + yearOptions.push({ label: '更早', value: 'earlier' }); + // 分类配置 const categories: MultiLevelCategory[] = [ ...(contentType !== 'anime-tv' && contentType !== 'anime-movie' @@ -284,24 +303,7 @@ const MultiLevelSelector: React.FC = ({ { key: 'year', label: '年代', - options: [ - { label: '全部', value: 'all' }, - { label: '2020年代', value: '2020s' }, - { label: '2025', value: '2025' }, - { label: '2024', value: '2024' }, - { label: '2023', value: '2023' }, - { label: '2022', value: '2022' }, - { label: '2021', value: '2021' }, - { label: '2020', value: '2020' }, - { label: '2019', value: '2019' }, - { label: '2010年代', value: '2010s' }, - { label: '2000年代', value: '2000s' }, - { label: '90年代', value: '1990s' }, - { label: '80年代', value: '1980s' }, - { label: '70年代', value: '1970s' }, - { label: '60年代', value: '1960s' }, - { label: '更早', value: 'earlier' }, - ], + options: yearOptions, }, // 只在电视剧和综艺时显示平台选项 ...(contentType === 'tv' || diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 8cd7ba6..1f7f507 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -106,6 +106,7 @@ export const UserMenu: React.FC = () => { const [bufferStrategy, setBufferStrategy] = useState('medium'); const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true); const [isBufferStrategyDropdownOpen, setIsBufferStrategyDropdownOpen] = useState(false); + const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false); // 折叠面板状态 const [isDoubanSectionOpen, setIsDoubanSectionOpen] = useState(true); @@ -378,6 +379,12 @@ export const UserMenu: React.FC = () => { console.error('解析首页模块配置失败:', error); } } + + // 加载搜索繁体转简体设置 + const savedSearchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified'); + if (savedSearchTraditionalToSimplified !== null) { + setSearchTraditionalToSimplified(savedSearchTraditionalToSimplified === 'true'); + } } }, []); @@ -641,6 +648,13 @@ export const UserMenu: React.FC = () => { } }; + const handleSearchTraditionalToSimplifiedToggle = (value: boolean) => { + setSearchTraditionalToSimplified(value); + if (typeof window !== 'undefined') { + localStorage.setItem('searchTraditionalToSimplified', String(value)); + } + }; + // 首页模块配置处理函数 const handleHomeModuleToggle = (id: string, enabled: boolean) => { const updatedModules = homeModules.map(module => @@ -732,6 +746,7 @@ export const UserMenu: React.FC = () => { setBufferStrategy('medium'); setNextEpisodePreCache(true); setHomeModules(defaultHomeModules); + setSearchTraditionalToSimplified(false); if (typeof window !== 'undefined') { localStorage.setItem('defaultAggregateSearch', JSON.stringify(true)); @@ -747,6 +762,7 @@ export const UserMenu: React.FC = () => { localStorage.setItem('bufferStrategy', 'medium'); localStorage.setItem('nextEpisodePreCache', 'true'); localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules)); + localStorage.setItem('searchTraditionalToSimplified', 'false'); window.dispatchEvent(new CustomEvent('homeModulesUpdated')); } }; @@ -1423,6 +1439,30 @@ export const UserMenu: React.FC = () => {
+ + {/* 搜索繁体转简体 */} +
+
+

+ 搜索繁体转简体 +

+

+ 搜索时自动将繁体中文转换为简体中文 +

+
+ +
)}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index e1da28a..147c782 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1246,7 +1246,7 @@ const VideoCard = forwardRef(function VideoCard {config.showSourceName && source_name && !cmsData && ( { + return new Promise((resolve) => { + parseString(xmlContent, { explicitArray: false }, (err, result) => { + if (err || !result) { + resolve(null); + return; + } + + const data = result.movie || result.tvshow; + if (!data) { + resolve(null); + return; + } + + const metadata: NFOMetadata = { + tmdbId: data.tmdbid ? parseInt(data.tmdbid) : undefined, + title: data.title, + originalTitle: data.originaltitle, + year: data.year ? parseInt(data.year) : undefined, + plot: data.plot, + rating: data.rating ? parseFloat(data.rating) : undefined, + genres: Array.isArray(data.genre) ? data.genre : data.genre ? [data.genre] : [], + mediaType: result.movie ? 'movie' : 'tv', + }; + + resolve(metadata); + }); + }); +} diff --git a/src/lib/openlist-cache.ts b/src/lib/openlist-cache.ts index 18b51d4..016cd33 100644 --- a/src/lib/openlist-cache.ts +++ b/src/lib/openlist-cache.ts @@ -49,28 +49,30 @@ export interface VideoInfo { last_updated: number; } -// MetaInfo 缓存操作 -export function getCachedMetaInfo(rootPath: string): MetaInfo | null { - const entry = METAINFO_CACHE.get(rootPath); +// MetaInfo 缓存操作(使用固定键) +const METAINFO_CACHE_KEY = 'openlist_meta'; + +export function getCachedMetaInfo(): MetaInfo | null { + const entry = METAINFO_CACHE.get(METAINFO_CACHE_KEY); if (!entry) return null; if (entry.expiresAt <= Date.now()) { - METAINFO_CACHE.delete(rootPath); + METAINFO_CACHE.delete(METAINFO_CACHE_KEY); return null; } return entry.data; } -export function setCachedMetaInfo(rootPath: string, data: MetaInfo): void { - METAINFO_CACHE.set(rootPath, { +export function setCachedMetaInfo(data: MetaInfo): void { + METAINFO_CACHE.set(METAINFO_CACHE_KEY, { expiresAt: Date.now() + METAINFO_CACHE_TTL_MS, data, }); } -export function invalidateMetaInfoCache(rootPath: string): void { - METAINFO_CACHE.delete(rootPath); +export function invalidateMetaInfoCache(): void { + METAINFO_CACHE.delete(METAINFO_CACHE_KEY); } // VideoInfo 缓存操作 diff --git a/src/lib/openlist-refresh.ts b/src/lib/openlist-refresh.ts index 7688f7c..f59269a 100644 --- a/src/lib/openlist-refresh.ts +++ b/src/lib/openlist-refresh.ts @@ -20,6 +20,65 @@ import { import { parseSeasonFromTitle } from '@/lib/season-parser'; import { searchTMDB, getTVSeasonDetails } from '@/lib/tmdb.search'; import parseTorrentName from 'parse-torrent-name'; +import type { AdminConfig } from '@/lib/admin.types'; + +/** + * 获取根目录列表(兼容新旧配置) + */ +function getRootPaths(openListConfig: AdminConfig['OpenListConfig']): string[] { + if (!openListConfig) { + return ['/']; + } + + // 如果有新字段 RootPaths,直接使用 + if (openListConfig.RootPaths && openListConfig.RootPaths.length > 0) { + return openListConfig.RootPaths; + } + + // 如果只tPath,返回单元素数组 + if (openListConfig.RootPath) { + return [openListConfig.RootPath]; + } + + // 默认值 + return ['/']; +} + +/** + * 迁移旧版单根目录配置到多根目录 + */ +async function migrateToMultiRoot(openListConfig: NonNullable): Promise { + const oldRootPath = openListConfig.RootPath!; + + console.log('[OpenList Migration] 检测到旧版配置,开始迁移...'); + + // 1. 读取现有 metainfo + const metainfoContent = await db.getGlobalValue('video.metainfo'); + if (metainfoContent) { + const metaInfo: MetaInfo = JSON.parse(metainfoContent); + + // 2. 迁移 folderName:加上原根路径前缀 + for (const [key, info] of Object.entries(metaInfo.folders)) { + const oldFolderName = info.folderName; + const newFolderName = `${oldRootPath}${oldRootPath.endsWith('/') ? '' : '/'}${oldFolderName}`; + info.folderName = newFolderName; + + console.log(`[Migration] ${oldFolderName} -> ${newFolderName}`); + } + + // 3. 保存迁移后的 metainfo + await db.setGlobalValue('video.metainfo', JSON.stringify(metaInfo)); + console.log('[OpenList Migration] MetaInfo 迁移完成'); + } + + // 4. 更新配置:RootPath -> RootPaths + const config = await getConfig(); + config.OpenListConfig!.RootPaths = [oldRootPath]; + delete config.OpenListConfig!.RootPath; + await db.saveAdminConfig(config); + + console.log('[OpenList Migration] 配置迁移完成'); +} /** * 启动 OpenList 刷新任务 @@ -45,13 +104,24 @@ export async function startOpenListRefresh(clearMetaInfo: boolean = false): Prom throw new Error('TMDB API Key 未配置'); } + // 检测是否需要迁移 + if (openListConfig.RootPath && !openListConfig.RootPaths) { + await migrateToMultiRoot(openListConfig); + // 重新加载配置 + const newConfig = await getConfig(); + Object.assign(openListConfig, newConfig.OpenListConfig); + } + cleanupOldTasks(); const taskId = createScanTask(); - performScan( + const rootPaths = getRootPaths(openListConfig); + + // 顺序扫描多个根目录 + performMultiRootScan( taskId, openListConfig.URL, - openListConfig.RootPath || '/', + rootPaths, tmdbApiKey, tmdbProxy, openListConfig.Username, @@ -66,6 +136,43 @@ export async function startOpenListRefresh(clearMetaInfo: boolean = false): Prom return { taskId }; } +/** + * 扫描多个根目录 + */ +async function performMultiRootScan( + taskId: string, + url: string, + rootPaths: string[], + tmdbApiKey: string, + tmdbProxy: string | undefined, + username: string, + password: string, + clearMetaInfo: boolean, + scanMode: 'torrent' | 'name' | 'hybrid' +): Promise { + for (let i = 0; i < rootPaths.length; i++) { + const rootPath = rootPaths[i]; + console.log(`[OpenList Refresh] 扫描根目录 (${i + 1}/${rootPaths.length}): ${rootPath}`); + + try { + await performScan( + taskId, + url, + rootPath, + tmdbApiKey, + tmdbProxy, + username, + password, + clearMetaInfo && i === 0, // 只在第一个根目录时清除 + scanMode + ); + } catch (error) { + console.error(`[OpenList Refresh] 根目录 ${rootPath} 扫描失败:`, error); + // 继续扫描其他根目录 + } + } +} + /** * 执行扫描任务 */ @@ -112,7 +219,7 @@ async function performScan( } } - invalidateMetaInfoCache(rootPath); + invalidateMetaInfoCache(); const folders: any[] = []; let currentPage = 1; @@ -155,12 +262,15 @@ async function performScan( updateScanTaskProgress(taskId, i + 1, folders.length, folder.name); - if (!clearMetaInfo && folderNameToKey.has(folder.name)) { + // folderName 存储完整路径(包含根目录) + const fullFolderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder.name}`; + + if (!clearMetaInfo && folderNameToKey.has(fullFolderPath)) { existingCount++; continue; } - const folderKey = generateFolderKey(folder.name, existingKeys); + const folderKey = generateFolderKey(fullFolderPath, existingKeys); existingKeys.add(folderKey); try { @@ -197,7 +307,7 @@ async function performScan( const result = searchResult.result; const folderInfo: any = { - folderName: folder.name, + folderName: fullFolderPath, tmdb_id: result.id, title: result.title || result.name || folder.name, poster_path: result.poster_path, @@ -249,7 +359,7 @@ async function performScan( newCount++; } else { metaInfo.folders[folderKey] = { - folderName: folder.name, + folderName: fullFolderPath, tmdb_id: 0, title: folder.name, poster_path: null, @@ -267,7 +377,7 @@ async function performScan( } catch (error) { console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error); metaInfo.folders[folderKey] = { - folderName: folder.name, + folderName: fullFolderPath, tmdb_id: 0, title: folder.name, poster_path: null, @@ -287,8 +397,8 @@ async function performScan( const metainfoContent = JSON.stringify(metaInfo); await db.setGlobalValue('video.metainfo', metainfoContent); - invalidateMetaInfoCache(rootPath); - setCachedMetaInfo(rootPath, metaInfo); + invalidateMetaInfoCache(); + setCachedMetaInfo(metaInfo); const config = await getConfig(); config.OpenListConfig!.LastRefreshTime = Date.now(); diff --git a/src/lib/openlist.client.ts b/src/lib/openlist.client.ts index 7d4bdcc..612c9f8 100644 --- a/src/lib/openlist.client.ts +++ b/src/lib/openlist.client.ts @@ -280,6 +280,31 @@ export class OpenListClient { } } + // 获取视频预览流 + async getVideoPreview(path: string): Promise { + const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/other`, { + method: 'POST', + headers: await this.getHeaders(), + body: JSON.stringify({ + path: path, + method: 'video_preview', + password: '', + }), + }); + + if (!response.ok) { + throw new Error(`视频预览请求失败: ${response.status}`); + } + + const data = await response.json(); + + if (data.code !== 200) { + throw new Error(`视频预览失败: ${data.message}`); + } + + return data; + } + // 检查连通性 async checkConnectivity(): Promise<{ success: boolean; message: string }> { try { diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index b5dc01b..ec1ecbb 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -659,6 +659,7 @@ export abstract class BaseRedisStorage implements IStorage { playrecord_migrated?: boolean; favorite_migrated?: boolean; skip_migrated?: boolean; + last_movie_request_time?: number; } | null> { const userInfo = await this.withRetry(() => this.client.hGetAll(this.userInfoKey(userName)) @@ -678,6 +679,7 @@ export abstract class BaseRedisStorage implements IStorage { playrecord_migrated: userInfo.playrecord_migrated === 'true', favorite_migrated: userInfo.favorite_migrated === 'true', skip_migrated: userInfo.skip_migrated === 'true', + last_movie_request_time: userInfo.last_movie_request_time ? parseInt(userInfo.last_movie_request_time, 10) : undefined, }; } @@ -1240,4 +1242,52 @@ export abstract class BaseRedisStorage implements IStorage { this.client.set(this.lastFavoriteCheckKey(userName), timestamp.toString()) ); } + + // ---------- 求片相关 ---------- + private movieRequestsKey() { + return 'movie_requests:all'; + } + + private userMovieRequestsKey(userName: string) { + return `u:${userName}:mr`; + } + + async getAllMovieRequests(): Promise { + const data = await this.withRetry(() => this.client.hGetAll(this.movieRequestsKey())); + if (!data || Object.keys(data).length === 0) return []; + return Object.values(data).map(v => JSON.parse(v) as import('./types').MovieRequest); + } + + async getMovieRequest(requestId: string): Promise { + const val = await this.withRetry(() => this.client.hGet(this.movieRequestsKey(), requestId)); + return val ? (JSON.parse(val) as import('./types').MovieRequest) : null; + } + + async createMovieRequest(request: import('./types').MovieRequest): Promise { + await this.withRetry(() => this.client.hSet(this.movieRequestsKey(), request.id, JSON.stringify(request))); + } + + async updateMovieRequest(requestId: string, updates: Partial): Promise { + const existing = await this.getMovieRequest(requestId); + if (!existing) throw new Error('Movie request not found'); + const updated = { ...existing, ...updates }; + await this.withRetry(() => this.client.hSet(this.movieRequestsKey(), requestId, JSON.stringify(updated))); + } + + async deleteMovieRequest(requestId: string): Promise { + await this.withRetry(() => this.client.hDel(this.movieRequestsKey(), requestId)); + } + + async getUserMovieRequests(userName: string): Promise { + const val = await this.withRetry(() => this.client.sMembers(this.userMovieRequestsKey(userName))); + return val ? ensureStringArray(val) : []; + } + + async addUserMovieRequest(userName: string, requestId: string): Promise { + await this.withRetry(() => this.client.sAdd(this.userMovieRequestsKey(userName), requestId)); + } + + async removeUserMovieRequest(userName: string, requestId: string): Promise { + await this.withRetry(() => this.client.sRem(this.userMovieRequestsKey(userName), requestId)); + } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 19a4508..45d5215 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -117,6 +117,30 @@ export interface IStorage { // 收藏更新检查相关 getLastFavoriteCheckTime(userName: string): Promise; setLastFavoriteCheckTime(userName: string, timestamp: number): Promise; + + // 求片相关 + getAllMovieRequests(): Promise; + getMovieRequest(requestId: string): Promise; + createMovieRequest(request: MovieRequest): Promise; + updateMovieRequest(requestId: string, updates: Partial): Promise; + deleteMovieRequest(requestId: string): Promise; + getUserMovieRequests(userName: string): Promise; + addUserMovieRequest(userName: string, requestId: string): Promise; + removeUserMovieRequest(userName: string, requestId: string): Promise; + + // 新版用户存储(V2)- 可选方法 + getUserInfoV2?(userName: string): Promise<{ + role: 'owner' | 'admin' | 'user'; + banned: boolean; + tags?: string[]; + oidcSub?: string; + enabledApis?: string[]; + created_at: number; + playrecord_migrated?: boolean; + favorite_migrated?: boolean; + skip_migrated?: boolean; + last_movie_request_time?: number; + } | null>; } // 搜索结果数据结构 @@ -137,6 +161,10 @@ export interface SearchResult { vod_total?: number; // 总集数 proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片 subtitles?: Array>; // 字幕列表(按集数索引) + tmdb_id?: number; // TMDB ID + rating?: number; // 评分 + initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数) + metadataSource?: 'folder' | 'nfo' | 'tmdb' | 'file'; // 元数据来源(用于小雅源判断是否保留fileName) } // 豆瓣数据结构 @@ -191,7 +219,9 @@ export interface EpisodeFilterConfig { export type NotificationType = | 'favorite_update' // 收藏更新 | 'system' // 系统通知 - | 'announcement'; // 公告 + | 'announcement' // 公告 + | 'movie_request' // 新求片通知(给管理员) + | 'request_fulfilled'; // 求片已上架通知(给求片用户) // 通知数据结构 export interface Notification { @@ -215,3 +245,23 @@ export interface FavoriteUpdateCheck { new_episodes: number; }>; } + +// 求片请求数据结构 +export interface MovieRequest { + id: string; + tmdbId?: number; + title: string; + year?: string; + mediaType: 'movie' | 'tv'; + season?: number; // 季度(仅剧集) + poster?: string; + overview?: string; + requestedBy: string[]; + requestCount: number; + status: 'pending' | 'fulfilled'; + createdAt: number; + updatedAt: number; + fulfilledAt?: number; + fulfilledSource?: string; + fulfilledId?: string; +} diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 4c0fa2a..4c0345f 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -58,10 +58,31 @@ async function withRetry( } export class UpstashRedisStorage implements IStorage { - private client: Redis; + private _client: Redis; + client: any; constructor() { - this.client = getUpstashRedisClient(); + this._client = getUpstashRedisClient(); + // 创建兼容Redis API的client包装器(支持camelCase和lowercase) + this.client = { + hSet: (key: string, field: string | Record, value?: string) => { + if (typeof field === 'string' && value !== undefined) { + return this._client.hset(key, { [field]: value }); + } + return this._client.hset(key, field as Record); + }, + hset: (key: string, data: Record) => this._client.hset(key, data), + zAdd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }), + zadd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }), + set: (key: string, value: string) => this._client.set(key, value), + hGetAll: (key: string) => this._client.hgetall(key), + hgetall: (key: string) => this._client.hgetall(key), + }; + } + + // 公开withRetry方法供外部使用 + withRetry(operation: () => Promise, maxRetries = 3): Promise { + return withRetry(operation, maxRetries); } // ---------- 播放记录 ---------- @@ -79,7 +100,7 @@ export class UpstashRedisStorage implements IStorage { key: string ): Promise { const val = await withRetry(() => - this.client.hget(this.prHashKey(userName), key) + this._client.hget(this.prHashKey(userName), key) ); return val ? (val as PlayRecord) : null; } @@ -89,14 +110,14 @@ export class UpstashRedisStorage implements IStorage { key: string, record: PlayRecord ): Promise { - await withRetry(() => this.client.hset(this.prHashKey(userName), { [key]: record })); + await withRetry(() => this._client.hset(this.prHashKey(userName), { [key]: record })); } async getAllPlayRecords( userName: string ): Promise> { const hashData = await withRetry(() => - this.client.hgetall(this.prHashKey(userName)) + this._client.hgetall(this.prHashKey(userName)) ); if (!hashData || Object.keys(hashData).length === 0) return {}; @@ -111,7 +132,7 @@ export class UpstashRedisStorage implements IStorage { } async deletePlayRecord(userName: string, key: string): Promise { - await withRetry(() => this.client.hdel(this.prHashKey(userName), key)); + await withRetry(() => this._client.hdel(this.prHashKey(userName), key)); } // 清理超出限制的旧播放记录 @@ -210,13 +231,13 @@ export class UpstashRedisStorage implements IStorage { // 2. 获取旧结构的所有播放记录key const pattern = `u:${userName}:pr:*`; - const oldKeys: string[] = await withRetry(() => this.client.keys(pattern)); + const oldKeys: string[] = await withRetry(() => this._client.keys(pattern)); if (oldKeys.length === 0) { console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`); // 即使没有数据也标记为已迁移 await withRetry(() => - this.client.hset(this.userInfoKey(userName), { playrecord_migrated: true }) + this._client.hset(this.userInfoKey(userName), { playrecord_migrated: true }) ); // 清除用户信息缓存 userInfoCache?.delete(userName); @@ -228,7 +249,7 @@ export class UpstashRedisStorage implements IStorage { // 3. 批量获取旧数据并转换为hash格式 const hashData: Record = {}; for (const fullKey of oldKeys) { - const value = await withRetry(() => this.client.get(fullKey)); + const value = await withRetry(() => this._client.get(fullKey)); if (value) { // 提取 source+id 部分作为hash的field const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, '')); @@ -239,18 +260,18 @@ export class UpstashRedisStorage implements IStorage { // 4. 写入新的hash结构 if (Object.keys(hashData).length > 0) { await withRetry(() => - this.client.hset(this.prHashKey(userName), hashData) + this._client.hset(this.prHashKey(userName), hashData) ); console.log(`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`); } // 5. 删除旧的key - await withRetry(() => this.client.del(...oldKeys)); + await withRetry(() => this._client.del(...oldKeys)); console.log(`删除了 ${oldKeys.length} 个旧的播放记录key`); // 6. 标记迁移完成 await withRetry(() => - this.client.hset(this.userInfoKey(userName), { playrecord_migrated: true }) + this._client.hset(this.userInfoKey(userName), { playrecord_migrated: true }) ); // 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识 @@ -271,7 +292,7 @@ export class UpstashRedisStorage implements IStorage { async getFavorite(userName: string, key: string): Promise { const val = await withRetry(() => - this.client.hget(this.favHashKey(userName), key) + this._client.hget(this.favHashKey(userName), key) ); return val ? (val as Favorite) : null; } @@ -281,12 +302,12 @@ export class UpstashRedisStorage implements IStorage { key: string, favorite: Favorite ): Promise { - await withRetry(() => this.client.hset(this.favHashKey(userName), { [key]: favorite })); + await withRetry(() => this._client.hset(this.favHashKey(userName), { [key]: favorite })); } async getAllFavorites(userName: string): Promise> { const hashData = await withRetry(() => - this.client.hgetall(this.favHashKey(userName)) + this._client.hgetall(this.favHashKey(userName)) ); if (!hashData || Object.keys(hashData).length === 0) return {}; @@ -301,7 +322,7 @@ export class UpstashRedisStorage implements IStorage { } async deleteFavorite(userName: string, key: string): Promise { - await withRetry(() => this.client.hdel(this.favHashKey(userName), key)); + await withRetry(() => this._client.hdel(this.favHashKey(userName), key)); } // 迁移收藏:从旧的多key结构迁移到新的hash结构 @@ -339,13 +360,13 @@ export class UpstashRedisStorage implements IStorage { // 2. 获取旧结构的所有收藏key const pattern = `u:${userName}:fav:*`; - const oldKeys: string[] = await withRetry(() => this.client.keys(pattern)); + const oldKeys: string[] = await withRetry(() => this._client.keys(pattern)); if (oldKeys.length === 0) { console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`); // 即使没有数据也标记为已迁移 await withRetry(() => - this.client.hset(this.userInfoKey(userName), { favorite_migrated: true }) + this._client.hset(this.userInfoKey(userName), { favorite_migrated: true }) ); // 清除用户信息缓存 userInfoCache?.delete(userName); @@ -357,7 +378,7 @@ export class UpstashRedisStorage implements IStorage { // 3. 批量获取旧数据并转换为hash格式 const hashData: Record = {}; for (const fullKey of oldKeys) { - const value = await withRetry(() => this.client.get(fullKey)); + const value = await withRetry(() => this._client.get(fullKey)); if (value) { // 提取 source+id 部分作为hash的field const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, '')); @@ -368,18 +389,18 @@ export class UpstashRedisStorage implements IStorage { // 4. 写入新的hash结构 if (Object.keys(hashData).length > 0) { await withRetry(() => - this.client.hset(this.favHashKey(userName), hashData) + this._client.hset(this.favHashKey(userName), hashData) ); console.log(`成功迁移 ${Object.keys(hashData).length} 条收藏到hash结构`); } // 5. 删除旧的key - await withRetry(() => this.client.del(...oldKeys)); + await withRetry(() => this._client.del(...oldKeys)); console.log(`删除了 ${oldKeys.length} 个旧的收藏key`); // 6. 标记迁移完成 await withRetry(() => - this.client.hset(this.userInfoKey(userName), { favorite_migrated: true }) + this._client.hset(this.userInfoKey(userName), { favorite_migrated: true }) ); // 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识 @@ -395,7 +416,7 @@ export class UpstashRedisStorage implements IStorage { async verifyUser(userName: string, password: string): Promise { const stored = await withRetry(() => - this.client.get(this.userPwdKey(userName)) + this._client.get(this.userPwdKey(userName)) ); if (stored === null) return false; // 确保比较时都是字符串类型 @@ -406,7 +427,7 @@ export class UpstashRedisStorage implements IStorage { async checkUserExist(userName: string): Promise { // 使用 EXISTS 判断 key 是否存在 const exists = await withRetry(() => - this.client.exists(this.userPwdKey(userName)) + this._client.exists(this.userPwdKey(userName)) ); return exists === 1; } @@ -415,52 +436,52 @@ export class UpstashRedisStorage implements IStorage { async changePassword(userName: string, newPassword: string): Promise { // 简单存储明文密码,生产环境应加密 await withRetry(() => - this.client.set(this.userPwdKey(userName), newPassword) + this._client.set(this.userPwdKey(userName), newPassword) ); } // 删除用户及其所有数据 async deleteUser(userName: string): Promise { // 删除用户密码 - await withRetry(() => this.client.del(this.userPwdKey(userName))); + await withRetry(() => this._client.del(this.userPwdKey(userName))); // 删除搜索历史 - await withRetry(() => this.client.del(this.shKey(userName))); + await withRetry(() => this._client.del(this.shKey(userName))); // 删除播放记录(新hash结构) - await withRetry(() => this.client.del(this.prHashKey(userName))); + await withRetry(() => this._client.del(this.prHashKey(userName))); // 删除旧的播放记录key(如果有) const playRecordPattern = `u:${userName}:pr:*`; const playRecordKeys = await withRetry(() => - this.client.keys(playRecordPattern) + this._client.keys(playRecordPattern) ); if (playRecordKeys.length > 0) { - await withRetry(() => this.client.del(...playRecordKeys)); + await withRetry(() => this._client.del(...playRecordKeys)); } // 删除收藏夹(新hash结构) - await withRetry(() => this.client.del(this.favHashKey(userName))); + await withRetry(() => this._client.del(this.favHashKey(userName))); // 删除旧的收藏key(如果有) const favoritePattern = `u:${userName}:fav:*`; const favoriteKeys = await withRetry(() => - this.client.keys(favoritePattern) + this._client.keys(favoritePattern) ); if (favoriteKeys.length > 0) { - await withRetry(() => this.client.del(...favoriteKeys)); + await withRetry(() => this._client.del(...favoriteKeys)); } // 删除跳过片头片尾配置(新hash结构) - await withRetry(() => this.client.del(this.skipHashKey(userName))); + await withRetry(() => this._client.del(this.skipHashKey(userName))); // 删除旧的跳过配置key(如果有) const skipConfigPattern = `u:${userName}:skip:*`; const skipConfigKeys = await withRetry(() => - this.client.keys(skipConfigPattern) + this._client.keys(skipConfigPattern) ); if (skipConfigKeys.length > 0) { - await withRetry(() => this.client.del(...skipConfigKeys)); + await withRetry(() => this._client.del(...skipConfigKeys)); } } @@ -497,7 +518,7 @@ export class UpstashRedisStorage implements IStorage { ): Promise { // 先检查用户是否已存在(原子性检查) const exists = await withRetry(() => - this.client.exists(this.userInfoKey(userName)) + this._client.exists(this.userInfoKey(userName)) ); if (exists === 1) { throw new Error('用户已存在'); @@ -521,17 +542,17 @@ export class UpstashRedisStorage implements IStorage { if (oidcSub) { userInfo.oidcSub = oidcSub; // 创建OIDC映射 - await withRetry(() => this.client.set(this.oidcSubKey(oidcSub), userName)); + await withRetry(() => this._client.set(this.oidcSubKey(oidcSub), userName)); } if (enabledApis && enabledApis.length > 0) { userInfo.enabledApis = JSON.stringify(enabledApis); } - await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo)); + await withRetry(() => this._client.hset(this.userInfoKey(userName), userInfo)); // 添加到用户列表(Sorted Set,按注册时间排序) - await withRetry(() => this.client.zadd(this.userListKey(), { + await withRetry(() => this._client.zadd(this.userListKey(), { score: createdAt, member: userName, })); @@ -546,7 +567,7 @@ export class UpstashRedisStorage implements IStorage { // 验证用户密码(新版本) async verifyUserV2(userName: string, password: string): Promise { const userInfo = await withRetry(() => - this.client.hgetall(this.userInfoKey(userName)) + this._client.hgetall(this.userInfoKey(userName)) ); if (!userInfo || !userInfo.password) { @@ -568,6 +589,7 @@ export class UpstashRedisStorage implements IStorage { playrecord_migrated?: boolean; favorite_migrated?: boolean; skip_migrated?: boolean; + last_movie_request_time?: number; } | null> { // 先从缓存获取 const cached = userInfoCache?.get(userName); @@ -576,7 +598,7 @@ export class UpstashRedisStorage implements IStorage { } const userInfo = await withRetry(() => - this.client.hgetall(this.userInfoKey(userName)) + this._client.hgetall(this.userInfoKey(userName)) ); if (!userInfo || Object.keys(userInfo).length === 0) { @@ -661,6 +683,11 @@ export class UpstashRedisStorage implements IStorage { playrecord_migrated, favorite_migrated, skip_migrated, + last_movie_request_time: userInfo.last_movie_request_time + ? (typeof userInfo.last_movie_request_time === 'number' + ? userInfo.last_movie_request_time + : parseInt(userInfo.last_movie_request_time as string, 10)) + : undefined, }; // 存入缓存 @@ -696,7 +723,7 @@ export class UpstashRedisStorage implements IStorage { userInfo.tags = JSON.stringify(updates.tags); } else { // 删除tags字段 - await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'tags')); + await withRetry(() => this._client.hdel(this.userInfoKey(userName), 'tags')); } } @@ -705,7 +732,7 @@ export class UpstashRedisStorage implements IStorage { userInfo.enabledApis = JSON.stringify(updates.enabledApis); } else { // 删除enabledApis字段 - await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'enabledApis')); + await withRetry(() => this._client.hdel(this.userInfoKey(userName), 'enabledApis')); } } @@ -713,15 +740,15 @@ export class UpstashRedisStorage implements IStorage { const oldInfo = await this.getUserInfoV2(userName); if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) { // 删除旧的OIDC映射 - await withRetry(() => this.client.del(this.oidcSubKey(oldInfo.oidcSub!))); + await withRetry(() => this._client.del(this.oidcSubKey(oldInfo.oidcSub!))); } userInfo.oidcSub = updates.oidcSub; // 创建新的OIDC映射 - await withRetry(() => this.client.set(this.oidcSubKey(updates.oidcSub!), userName)); + await withRetry(() => this._client.set(this.oidcSubKey(updates.oidcSub!), userName)); } if (Object.keys(userInfo).length > 0) { - await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo)); + await withRetry(() => this._client.hset(this.userInfoKey(userName), userInfo)); } // 清除缓存 @@ -732,7 +759,7 @@ export class UpstashRedisStorage implements IStorage { async changePasswordV2(userName: string, newPassword: string): Promise { const hashedPassword = await this.hashPassword(newPassword); await withRetry(() => - this.client.hset(this.userInfoKey(userName), { password: hashedPassword }) + this._client.hset(this.userInfoKey(userName), { password: hashedPassword }) ); // 清除缓存 @@ -742,7 +769,7 @@ export class UpstashRedisStorage implements IStorage { // 检查用户是否存在(新版本) async checkUserExistV2(userName: string): Promise { const exists = await withRetry(() => - this.client.exists(this.userInfoKey(userName)) + this._client.exists(this.userInfoKey(userName)) ); return exists === 1; } @@ -750,7 +777,7 @@ export class UpstashRedisStorage implements IStorage { // 通过OIDC Sub查找用户名 async getUserByOidcSub(oidcSub: string): Promise { const userName = await withRetry(() => - this.client.get(this.oidcSubKey(oidcSub)) + this._client.get(this.oidcSubKey(oidcSub)) ); return userName ? ensureString(userName) : null; } @@ -763,7 +790,7 @@ export class UpstashRedisStorage implements IStorage { let cursor: number | string = 0; do { const result = await withRetry(() => - this.client.scan(cursor as number, { match: 'user:*:info', count: 100 }) + this._client.scan(cursor as number, { match: 'user:*:info', count: 100 }) ); cursor = result[0]; @@ -771,7 +798,7 @@ export class UpstashRedisStorage implements IStorage { // 检查每个用户的 tags for (const key of keys) { - const userInfo = await withRetry(() => this.client.hgetall(key)); + const userInfo = await withRetry(() => this._client.hgetall(key)); if (userInfo && userInfo.tags) { const tags = JSON.parse(userInfo.tags as string); if (tags.includes(tagName)) { @@ -804,7 +831,7 @@ export class UpstashRedisStorage implements IStorage { total: number; }> { // 获取总数 - let total = await withRetry(() => this.client.zcard(this.userListKey())); + let total = await withRetry(() => this._client.zcard(this.userListKey())); // 检查站长是否在数据库中(使用缓存) let ownerInfo = null; @@ -851,7 +878,7 @@ export class UpstashRedisStorage implements IStorage { // 获取用户列表(按注册时间升序) const usernames = await withRetry(() => - this.client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1) + this._client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1) ); const users = []; @@ -902,14 +929,14 @@ export class UpstashRedisStorage implements IStorage { // 删除OIDC映射 if (userInfo?.oidcSub) { - await withRetry(() => this.client.del(this.oidcSubKey(userInfo.oidcSub!))); + await withRetry(() => this._client.del(this.oidcSubKey(userInfo.oidcSub!))); } // 删除用户信息Hash - await withRetry(() => this.client.del(this.userInfoKey(userName))); + await withRetry(() => this._client.del(this.userInfoKey(userName))); // 从用户列表中移除 - await withRetry(() => this.client.zrem(this.userListKey(), userName)); + await withRetry(() => this._client.zrem(this.userListKey(), userName)); // 删除用户的其他数据(播放记录、收藏等) await this.deleteUser(userName); @@ -925,7 +952,7 @@ export class UpstashRedisStorage implements IStorage { async getSearchHistory(userName: string): Promise { const result = await withRetry(() => - this.client.lrange(this.shKey(userName), 0, -1) + this._client.lrange(this.shKey(userName), 0, -1) ); // 确保返回的都是字符串类型 return ensureStringArray(result as any[]); @@ -934,19 +961,19 @@ export class UpstashRedisStorage implements IStorage { async addSearchHistory(userName: string, keyword: string): Promise { const key = this.shKey(userName); // 先去重 - await withRetry(() => this.client.lrem(key, 0, ensureString(keyword))); + await withRetry(() => this._client.lrem(key, 0, ensureString(keyword))); // 插入到最前 - await withRetry(() => this.client.lpush(key, ensureString(keyword))); + await withRetry(() => this._client.lpush(key, ensureString(keyword))); // 限制最大长度 - await withRetry(() => this.client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1)); + await withRetry(() => this._client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1)); } async deleteSearchHistory(userName: string, keyword?: string): Promise { const key = this.shKey(userName); if (keyword) { - await withRetry(() => this.client.lrem(key, 0, ensureString(keyword))); + await withRetry(() => this._client.lrem(key, 0, ensureString(keyword))); } else { - await withRetry(() => this.client.del(key)); + await withRetry(() => this._client.del(key)); } } @@ -955,7 +982,7 @@ export class UpstashRedisStorage implements IStorage { // 从新版用户列表获取 const userListKey = this.userListKey(); const users = await withRetry(() => - this.client.zrange(userListKey, 0, -1) + this._client.zrange(userListKey, 0, -1) ); const userList = users.map(u => ensureString(u)); @@ -974,12 +1001,12 @@ export class UpstashRedisStorage implements IStorage { } async getAdminConfig(): Promise { - const val = await withRetry(() => this.client.get(this.adminConfigKey())); + const val = await withRetry(() => this._client.get(this.adminConfigKey())); return val ? (val as AdminConfig) : null; } async setAdminConfig(config: AdminConfig): Promise { - await withRetry(() => this.client.set(this.adminConfigKey(), config)); + await withRetry(() => this._client.set(this.adminConfigKey(), config)); } // ---------- 跳过片头片尾配置 ---------- @@ -998,7 +1025,7 @@ export class UpstashRedisStorage implements IStorage { ): Promise { const key = `${source}+${id}`; const val = await withRetry(() => - this.client.hget(this.skipHashKey(userName), key) + this._client.hget(this.skipHashKey(userName), key) ); return val ? (val as SkipConfig) : null; } @@ -1011,7 +1038,7 @@ export class UpstashRedisStorage implements IStorage { ): Promise { const key = `${source}+${id}`; await withRetry(() => - this.client.hset(this.skipHashKey(userName), { [key]: config }) + this._client.hset(this.skipHashKey(userName), { [key]: config }) ); } @@ -1022,7 +1049,7 @@ export class UpstashRedisStorage implements IStorage { ): Promise { const key = `${source}+${id}`; await withRetry(() => - this.client.hdel(this.skipHashKey(userName), key) + this._client.hdel(this.skipHashKey(userName), key) ); } @@ -1030,7 +1057,7 @@ export class UpstashRedisStorage implements IStorage { userName: string ): Promise<{ [key: string]: SkipConfig }> { const hashData = await withRetry(() => - this.client.hgetall>(this.skipHashKey(userName)) + this._client.hgetall>(this.skipHashKey(userName)) ); return hashData || {}; @@ -1065,18 +1092,18 @@ export class UpstashRedisStorage implements IStorage { } const pattern = `u:${userName}:skip:*`; - const oldKeys: string[] = await withRetry(() => this.client.keys(pattern)); + const oldKeys: string[] = await withRetry(() => this._client.keys(pattern)); if (oldKeys.length === 0) { console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`); await withRetry(() => - this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' }) + this._client.hset(this.userInfoKey(userName), { skip_migrated: 'true' }) ); userInfoCache?.delete(userName); return; } - const values = await withRetry(() => this.client.mget(oldKeys)); + const values = await withRetry(() => this._client.mget(oldKeys)); const hashData: Record = {}; oldKeys.forEach((key, index) => { @@ -1092,16 +1119,16 @@ export class UpstashRedisStorage implements IStorage { if (Object.keys(hashData).length > 0) { await withRetry(() => - this.client.hset(this.skipHashKey(userName), hashData) + this._client.hset(this.skipHashKey(userName), hashData) ); console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`); } - await withRetry(() => this.client.del(...oldKeys)); + await withRetry(() => this._client.del(...oldKeys)); console.log(`删除了 ${oldKeys.length} 个旧的跳过配置key`); await withRetry(() => - this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' }) + this._client.hset(this.userInfoKey(userName), { skip_migrated: 'true' }) ); userInfoCache?.delete(userName); @@ -1113,7 +1140,7 @@ export class UpstashRedisStorage implements IStorage { userName: string ): Promise { const val = await withRetry(() => - this.client.get(this.danmakuFilterConfigKey(userName)) + this._client.get(this.danmakuFilterConfigKey(userName)) ); return val ? (val as import('./types').DanmakuFilterConfig) : null; } @@ -1123,13 +1150,13 @@ export class UpstashRedisStorage implements IStorage { config: import('./types').DanmakuFilterConfig ): Promise { await withRetry(() => - this.client.set(this.danmakuFilterConfigKey(userName), config) + this._client.set(this.danmakuFilterConfigKey(userName), config) ); } async deleteDanmakuFilterConfig(userName: string): Promise { await withRetry(() => - this.client.del(this.danmakuFilterConfigKey(userName)) + this._client.del(this.danmakuFilterConfigKey(userName)) ); } @@ -1145,7 +1172,7 @@ export class UpstashRedisStorage implements IStorage { } // 删除管理员配置 - await withRetry(() => this.client.del(this.adminConfigKey())); + await withRetry(() => this._client.del(this.adminConfigKey())); console.log('所有数据已清空'); } catch (error) { @@ -1161,7 +1188,7 @@ export class UpstashRedisStorage implements IStorage { async getGlobalValue(key: string): Promise { const val = await withRetry(() => - this.client.get(this.globalValueKey(key)) + this._client.get(this.globalValueKey(key)) ); // Upstash 会自动反序列化 JSON,如果值是对象,需要重新序列化为字符串 if (val === null) return null; @@ -1172,12 +1199,12 @@ export class UpstashRedisStorage implements IStorage { async setGlobalValue(key: string, value: string): Promise { await withRetry(() => - this.client.set(this.globalValueKey(key), value) + this._client.set(this.globalValueKey(key), value) ); } async deleteGlobalValue(key: string): Promise { - await withRetry(() => this.client.del(this.globalValueKey(key))); + await withRetry(() => this._client.del(this.globalValueKey(key))); } // ---------- 通知相关 ---------- @@ -1191,7 +1218,7 @@ export class UpstashRedisStorage implements IStorage { async getNotifications(userName: string): Promise { const val = await withRetry(() => - this.client.get(this.notificationsKey(userName)) + this._client.get(this.notificationsKey(userName)) ); return val ? (val as import('./types').Notification[]) : []; } @@ -1207,7 +1234,7 @@ export class UpstashRedisStorage implements IStorage { notifications.splice(100); } await withRetry(() => - this.client.set(this.notificationsKey(userName), notifications) + this._client.set(this.notificationsKey(userName), notifications) ); } @@ -1220,7 +1247,7 @@ export class UpstashRedisStorage implements IStorage { if (notification) { notification.read = true; await withRetry(() => - this.client.set(this.notificationsKey(userName), notifications) + this._client.set(this.notificationsKey(userName), notifications) ); } } @@ -1232,12 +1259,12 @@ export class UpstashRedisStorage implements IStorage { const notifications = await this.getNotifications(userName); const filtered = notifications.filter((n) => n.id !== notificationId); await withRetry(() => - this.client.set(this.notificationsKey(userName), filtered) + this._client.set(this.notificationsKey(userName), filtered) ); } async clearAllNotifications(userName: string): Promise { - await withRetry(() => this.client.del(this.notificationsKey(userName))); + await withRetry(() => this._client.del(this.notificationsKey(userName))); } async getUnreadNotificationCount(userName: string): Promise { @@ -1247,7 +1274,7 @@ export class UpstashRedisStorage implements IStorage { async getLastFavoriteCheckTime(userName: string): Promise { const val = await withRetry(() => - this.client.get(this.lastFavoriteCheckKey(userName)) + this._client.get(this.lastFavoriteCheckKey(userName)) ); return val ? (val as number) : 0; } @@ -1257,9 +1284,57 @@ export class UpstashRedisStorage implements IStorage { timestamp: number ): Promise { await withRetry(() => - this.client.set(this.lastFavoriteCheckKey(userName), timestamp) + this._client.set(this.lastFavoriteCheckKey(userName), timestamp) ); } + + // ---------- 求片相关 ---------- + private movieRequestsKey() { + return 'movie_requests:all'; + } + + private userMovieRequestsKey(userName: string) { + return `u:${userName}:mr`; + } + + async getAllMovieRequests(): Promise { + const data = await withRetry(() => this._client.hgetall(this.movieRequestsKey())); + if (!data) return []; + return Object.values(data) as import('./types').MovieRequest[]; + } + + async getMovieRequest(requestId: string): Promise { + const val = await withRetry(() => this._client.hget(this.movieRequestsKey(), requestId)); + return val ? (val as import('./types').MovieRequest) : null; + } + + async createMovieRequest(request: import('./types').MovieRequest): Promise { + await withRetry(() => this._client.hset(this.movieRequestsKey(), { [request.id]: request })); + } + + async updateMovieRequest(requestId: string, updates: Partial): Promise { + const existing = await this.getMovieRequest(requestId); + if (!existing) throw new Error('Movie request not found'); + const updated = { ...existing, ...updates }; + await withRetry(() => this._client.hset(this.movieRequestsKey(), { [requestId]: updated })); + } + + async deleteMovieRequest(requestId: string): Promise { + await withRetry(() => this._client.hdel(this.movieRequestsKey(), requestId)); + } + + async getUserMovieRequests(userName: string): Promise { + const val = await withRetry(() => this._client.smembers(this.userMovieRequestsKey(userName))); + return val ? ensureStringArray(val) : []; + } + + async addUserMovieRequest(userName: string, requestId: string): Promise { + await withRetry(() => this._client.sadd(this.userMovieRequestsKey(userName), requestId)); + } + + async removeUserMovieRequest(userName: string, requestId: string): Promise { + await withRetry(() => this._client.srem(this.userMovieRequestsKey(userName), requestId)); + } } // 单例 Upstash Redis 客户端 diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 39d9ea3..8e85fb9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any,no-console */ import he from 'he'; import Hls from 'hls.js'; +import bs58 from 'bs58'; function getDoubanImageProxyConfig(): { proxyType: @@ -135,12 +136,13 @@ export function processVideoUrl(originalUrl: string): string { /** * 从m3u8地址获取视频质量等级和网络信息 * @param m3u8Url m3u8播放列表的URL - * @returns Promise<{quality: string, loadSpeed: string, pingTime: number}> 视频质量等级和网络信息 + * @returns Promise<{quality: string, loadSpeed: string, pingTime: number, bitrate: string}> 视频质量等级和网络信息 */ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{ quality: string; // 如720p、1080p等 loadSpeed: string; // 自动转换为KB/s或MB/s pingTime: number; // 网络延迟(毫秒) + bitrate: string; // 视频码率(如 "2.5 Mbps") }> { try { // 直接使用m3u8 URL作为视频源,避免CORS问题 @@ -182,6 +184,7 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{ let actualLoadSpeed = '未知'; let hasSpeedCalculated = false; let hasMetadataLoaded = false; + let estimatedBitrate = 0; // 估算的码率(bps) let fragmentStartTime = 0; @@ -211,17 +214,32 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{ ? '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, }); } } @@ -255,6 +273,18 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{ actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`; } hasSpeedCalculated = true; + + // 从分片估算码率 + if (data.frag && data.frag.duration > 0) { + const fragmentDuration = data.frag.duration; // 分片时长(秒) + const fragmentSize = size; // 分片大小(字节) + + // 码率 = (分片大小 × 8 bits) / 分片时长 + estimatedBitrate = Math.round((fragmentSize * 8) / fragmentDuration); + + console.log(`[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed(2)} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed(2)} MB, 时长: ${fragmentDuration.toFixed(1)}s)`); + } + checkAndResolve(); // 尝试返回结果 } } @@ -301,3 +331,43 @@ export function cleanHtmlTags(text: string): string { // 使用 he 库解码 HTML 实体 return he.decode(cleanedText); } + +/** + * 将字符串编码为 Base58 + * @param str 要编码的字符串 + * @returns Base58 编码后的字符串 + */ +export function base58Encode(str: string): string { + if (!str) return ''; + + // 在浏览器环境中使用 TextEncoder + if (typeof window !== 'undefined') { + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + return bs58.encode(bytes); + } + + // 在 Node.js 环境中使用 Buffer + const buffer = Buffer.from(str, 'utf-8'); + return bs58.encode(buffer); +} + +/** + * 将 Base58 字符串解码为原始字符串 + * @param encoded Base58 编码的字符串 + * @returns 解码后的原始字符串 + */ +export function base58Decode(encoded: string): string { + if (!encoded) return ''; + + const bytes = bs58.decode(encoded); + + // 在浏览器环境中使用 TextDecoder + if (typeof window !== 'undefined') { + const decoder = new TextDecoder(); + return decoder.decode(bytes); + } + + // 在 Node.js 环境中使用 Buffer + return Buffer.from(bytes).toString('utf-8'); +} diff --git a/src/lib/video-parser.ts b/src/lib/video-parser.ts index dc86e80..025de36 100644 --- a/src/lib/video-parser.ts +++ b/src/lib/video-parser.ts @@ -28,11 +28,11 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo { // 降级方案:使用多种正则模式提取集数 // 按优先级排序:更具体的模式优先 - const patterns: Array<{ pattern: RegExp; isOVA?: boolean }> = [ + const patterns: Array<{ pattern: RegExp; isOVA?: boolean; extractSeason?: boolean }> = [ // OVA01, OVA 01, ova01, ova 01 (OVA特殊处理) - 最优先 { pattern: /OVA\s*(\d+(?:\.\d+)?)/i, isOVA: true }, - // S01E01, s01e01, S01E01.5 (支持小数) - 最具体 - { pattern: /[Ss]\d+[Ee](\d+(?:\.\d+)?)/ }, + // S01E01, s01e01, S01E1234, S01E01.5 (支持1-4位数字和小数) - 最具体 + { pattern: /[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/, extractSeason: true }, // [01], (01), [01.5], (01.5) (支持小数,但要排除中文括号内容) - 很具体 { pattern: /[\[\(](\d+(?:\.\d+)?)[\]\)]/ }, // E01, E1, e01, e1, E01.5 (支持小数) @@ -45,12 +45,22 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo { { pattern: /^(\d+(?:\.\d+)?)[^\d.]/ }, ]; - for (const { pattern, isOVA } of patterns) { + for (const { pattern, isOVA, extractSeason } of patterns) { const match = fileName.match(pattern); if (match && match[1]) { - const episode = parseFloat(match[1]); - if (episode > 0 && episode < 10000) { // 合理的集数范围 - return { episode, isOVA }; + if (extractSeason && match[2]) { + // 同时提取 season 和 episode + const season = parseInt(match[1]); + const episode = parseFloat(match[2]); + if (season > 0 && season < 100 && episode > 0 && episode < 10000) { + return { season, episode }; + } + } else { + // 只提取 episode + const episode = parseFloat(match[1]); + if (episode > 0 && episode < 10000) { + return { episode, isOVA }; + } } } } diff --git a/src/lib/xiaoya-metadata.ts b/src/lib/xiaoya-metadata.ts new file mode 100644 index 0000000..eb31ffd --- /dev/null +++ b/src/lib/xiaoya-metadata.ts @@ -0,0 +1,335 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { XiaoyaClient } from './xiaoya.client'; +import { parseNFO, NFOMetadata } from './nfo-parser'; +import { parseVideoFileName } from './video-parser'; + +export interface XiaoyaMetadata { + tmdbId?: number; + title: string; + year?: string; + rating?: number; + genres?: string[]; + plot?: string; + poster?: string; + background?: string; + mediaType: 'movie' | 'tv'; + source: 'folder' | 'nfo' | 'tmdb' | 'file'; +} + +/** + * 从文件夹名提取 TMDb ID 和年份 + * 格式: "标题 (年份) {tmdb-id}" + */ +function parseFolderName(folderName: string | undefined): { + title?: string; + year?: string; + tmdbId?: number; +} { + if (!folderName || typeof folderName !== 'string') { + return {}; + } + const match = folderName.match(/^(.+?)\s*\((\d{4})\)\s*\{tmdb-(\d+)\}$/); + if (match) { + return { + title: match[1].trim(), + year: match[2], + tmdbId: parseInt(match[3]), + }; + } + return {}; +} + +/** + * 查找 NFO 文件并解析 + */ +async function findNFO( + xiaoyaClient: XiaoyaClient, + videoPath: string +): Promise { + const pathParts = videoPath.split('/').filter(Boolean); + + // 判断是否为文件路径 + const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm']; + const isFilePath = videoExtensions.some(ext => videoPath.toLowerCase().endsWith(ext)); + + let isInSeasonDir = false; + + if (isFilePath) { + // 文件路径:判断父目录是否为季度目录 + const parentDir = pathParts[pathParts.length - 2]; + isInSeasonDir = /(season\s*\d+|s\d+)/i.test(parentDir); + } else { + // 目录路径:判断当前目录是否为季度目录 + const currentDir = pathParts[pathParts.length - 1]; + isInSeasonDir = /(season\s*\d+|s\d+)/i.test(currentDir); + } + + const nfoSearchPaths: string[] = []; + + if (isInSeasonDir) { + // 电视剧:查父级的 tvshow.nfo + const grandParentDir = pathParts.slice(0, isFilePath ? -2 : -1).join('/'); + nfoSearchPaths.push(`/${grandParentDir}/tvshow.nfo`); + } else { + // 电影:查同级的 movie.nfo + const parentDir = pathParts.slice(0, isFilePath ? -1 : pathParts.length).join('/'); + nfoSearchPaths.push(`/${parentDir}/movie.nfo`); + } + + for (const nfoPath of nfoSearchPaths) { + try { + const content = await xiaoyaClient.getFileContent(nfoPath); + const metadata = await parseNFO(content); + if (metadata) { + return metadata; + } + } catch (error) { + continue; + } + } + + return null; +} + +/** + * 获取小雅视频的元数据 + */ +export async function getXiaoyaMetadata( + xiaoyaClient: XiaoyaClient, + videoPath: string, + tmdbApiKey?: string, + tmdbProxy?: string +): Promise { + const pathParts = videoPath.split('/').filter(Boolean); + + // 验证路径格式 + if (pathParts.length < 1) { + throw new Error(`无效的视频路径格式: ${videoPath}`); + } + + // 判断是否为文件路径(包含视频扩展名) + const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm']; + const isFilePath = videoExtensions.some(ext => videoPath.toLowerCase().endsWith(ext)); + + // 如果是文件路径,检查是否在季度目录中 + const isInSeasonDir = isFilePath && pathParts.length >= 2 && /(season\s*\d+|s\d+)/i.test(pathParts[pathParts.length - 2]); + + // 验证路径长度是否足够 + if (isInSeasonDir && pathParts.length < 3) { + throw new Error(`Season目录路径格式不正确: ${videoPath}`); + } + + // 确定元数据目录和文件夹名 + let metadataDir: string; + let folderName: string; + + if (isFilePath) { + // 文件路径 + metadataDir = isInSeasonDir + ? pathParts.slice(0, -2).join('/') + : pathParts.slice(0, -1).join('/'); + folderName = pathParts[isInSeasonDir ? pathParts.length - 3 : pathParts.length - 2]; + } else { + // 目录路径 + if (pathParts.length === 1) { + // 只有一级目录 + metadataDir = ''; + folderName = pathParts[0]; + } else { + // 判断当前目录是否为季度目录 + const currentDirName = pathParts[pathParts.length - 1]; + const isSeasonDir = /(season\s*\d+|s\d+)/i.test(currentDirName); + + if (isSeasonDir && pathParts.length >= 2) { + // 季度目录:使用父级目录名 + metadataDir = pathParts.slice(0, -2).join('/'); + folderName = pathParts[pathParts.length - 2]; + } else { + metadataDir = pathParts.slice(0, -1).join('/'); + folderName = pathParts[pathParts.length - 1]; + } + } + } + + // 验证 folderName 是否有效 + if (!folderName) { + throw new Error(`无法从路径中提取文件夹名: ${videoPath}`); + } + + // 优先级 1: 从文件夹名提取 TMDb ID + const folderInfo = parseFolderName(folderName); + if (folderInfo.tmdbId) { + const baseUrl = xiaoyaClient.getBaseURL(); + const posterUrl = `${baseUrl}/d/${metadataDir}/poster.jpg`; + const backgroundUrl = `${baseUrl}/d/${metadataDir}/background.jpg`; + + // 尝试读取 NFO 获取详细信息 + const nfoData = await findNFO(xiaoyaClient, videoPath); + + return { + tmdbId: folderInfo.tmdbId, + title: nfoData?.title || folderInfo.title || folderName, + year: folderInfo.year, + rating: nfoData?.rating, + genres: nfoData?.genres, + plot: nfoData?.plot, + poster: posterUrl, + background: backgroundUrl, + mediaType: isInSeasonDir ? 'tv' : 'movie', + source: nfoData ? 'nfo' : (isFilePath ? 'file' : 'folder'), + }; + } + + // 优先级 2: 读取 NFO 文件 + const nfoData = await findNFO(xiaoyaClient, videoPath); + if (nfoData && nfoData.tmdbId) { + const baseUrl = xiaoyaClient.getBaseURL(); + const posterUrl = `${baseUrl}/d/${metadataDir}/poster.jpg`; + const backgroundUrl = `${baseUrl}/d/${metadataDir}/background.jpg`; + + return { + tmdbId: nfoData.tmdbId, + title: nfoData.title || folderName, + year: nfoData.year?.toString(), + rating: nfoData.rating, + genres: nfoData.genres, + plot: nfoData.plot, + poster: posterUrl, + background: backgroundUrl, + mediaType: nfoData.mediaType, + source: 'nfo', + }; + } + + // 优先级 3: 实时搜索 TMDb(使用文件名) + if (tmdbApiKey) { + const fileName = pathParts[pathParts.length - 1]; + const searchQuery = fileName + .replace(/\.(mp4|mkv|avi|m3u8|flv|ts)$/i, '') + .replace(/[\[\]()]/g, ' ') + .trim(); + + // 如果文件名是纯数字(可能带小数点)或者是 SxxExx 格式,跳过文件名搜索,直接使用文件夹名 + const isPureNumber = /^[\d.]+$/.test(searchQuery); + const isSeasonEpisode = /^S\d+E\d+/i.test(searchQuery); + + if (!isPureNumber && !isSeasonEpisode) { + const { searchTMDB, getTMDBImageUrl } = await import('./tmdb.search'); + const tmdbResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy); + + if (tmdbResult.code === 200 && tmdbResult.result) { + return { + tmdbId: tmdbResult.result.id, + title: tmdbResult.result.title || tmdbResult.result.name || folderName, + year: tmdbResult.result.release_date?.substring(0, 4) || + tmdbResult.result.first_air_date?.substring(0, 4), + rating: tmdbResult.result.vote_average, + plot: tmdbResult.result.overview, + poster: tmdbResult.result.poster_path + ? getTMDBImageUrl(tmdbResult.result.poster_path) + : undefined, + mediaType: tmdbResult.result.media_type, + source: isFilePath ? 'file' : 'tmdb', + }; + } + } + } + + // 优先级 4: 实时搜索 TMDb(使用文件夹名) + if (tmdbApiKey) { + const searchQuery = folderName + .replace(/[\[\](){}]/g, ' ') + .replace(/\d{4}/g, '') + .trim(); + + const { searchTMDB, getTMDBImageUrl } = await import('./tmdb.search'); + const tmdbResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy); + + if (tmdbResult.code === 200 && tmdbResult.result) { + return { + tmdbId: tmdbResult.result.id, + title: tmdbResult.result.title || tmdbResult.result.name || folderName, + year: tmdbResult.result.release_date?.substring(0, 4) || + tmdbResult.result.first_air_date?.substring(0, 4), + rating: tmdbResult.result.vote_average, + plot: tmdbResult.result.overview, + poster: tmdbResult.result.poster_path + ? getTMDBImageUrl(tmdbResult.result.poster_path) + : undefined, + mediaType: tmdbResult.result.media_type, + source: 'tmdb', + }; + } + } + + // 降级:只返回文件夹名 + return { + title: folderName, + mediaType: isInSeasonDir ? 'tv' : 'movie', + source: 'folder', + }; +} + +/** + * 获取视频集数列表 + */ +export async function getXiaoyaEpisodes( + xiaoyaClient: XiaoyaClient, + videoPath: string +): Promise> { + const pathParts = videoPath.split('/').filter(Boolean); + + // 判断是否为文件路径(包含视频扩展名) + const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm']; + const isFilePath = videoExtensions.some(ext => videoPath.toLowerCase().endsWith(ext)); + + // 如果是文件路径,检查是否在季度目录中 + const isInSeasonDir = isFilePath && /(season\s*\d+|s\d+)/i.test(pathParts[pathParts.length - 2]); + + if (isInSeasonDir) { + // 电视剧:列出当前季的所有集 + const seasonDir = pathParts.slice(0, -1).join('/'); + const listResponse = await xiaoyaClient.listDirectory(`/${seasonDir}`); + + const videoFiles = listResponse.content + .filter(item => + !item.is_dir && + videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext)) + ) + .sort((a, b) => a.name.localeCompare(b.name)); + + return videoFiles.map(file => { + const parsed = parseVideoFileName(file.name); + console.log('[xiaoya-metadata] 解析文件名:', file.name, '结果:', parsed); + let title = file.name; + + if (parsed.season && parsed.episode) { + title = `S${parsed.season.toString().padStart(2, '0')}E${parsed.episode.toString().padStart(2, '0')}`; + } else if (parsed.episode) { + title = parsed.isOVA ? `OVA ${parsed.episode}` : `第${parsed.episode}集`; + } + + return { + path: `/${seasonDir}/${file.name}`, + title, + }; + }); + } else { + // 目录路径或电影文件路径:列出该目录下的所有视频 + const targetDir = isFilePath ? pathParts.slice(0, -1).join('/') : pathParts.join('/'); + const listResponse = await xiaoyaClient.listDirectory(`/${targetDir}`); + + const videoFiles = listResponse.content + .filter(item => + !item.is_dir && + videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext)) + ) + .sort((a, b) => a.name.localeCompare(b.name)); + + return videoFiles.map(file => ({ + path: `/${targetDir}/${file.name}`, + title: file.name, + })); + } +} diff --git a/src/lib/xiaoya.client.ts b/src/lib/xiaoya.client.ts new file mode 100644 index 0000000..a8c6988 --- /dev/null +++ b/src/lib/xiaoya.client.ts @@ -0,0 +1,244 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// Token 内存缓存 +const tokenCache = new Map(); + +export interface XiaoyaFile { + name: string; + size: number; + is_dir: boolean; + modified: string; +} + +export interface XiaoyaListResponse { + content: XiaoyaFile[]; + total: number; +} + +export class XiaoyaClient { + private token: string = ''; + + constructor( + private baseURL: string, + private username?: string, + private password?: string, + private configToken?: string + ) {} + + /** + * 使用账号密码登录获取Token + */ + static async login( + baseURL: string, + username: string, + password: string + ): Promise { + const response = await fetch(`${baseURL}/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + }), + }); + + if (!response.ok) { + throw new Error(`小雅登录失败: ${response.status}`); + } + + const data = await response.json(); + if (data.code !== 200 || !data.data?.token) { + throw new Error('小雅登录失败: 未获取到Token'); + } + + return data.data.token; + } + + /** + * 获取缓存的 Token 或重新登录 + */ + async getToken(): Promise { + // 如果配置了 Token,直接使用 + if (this.configToken) { + return this.configToken; + } + + // 如果没有配置用户名密码,返回空字符串(guest 模式) + if (!this.username || !this.password) { + return ''; + } + + const cacheKey = `${this.baseURL}:${this.username}`; + const cached = tokenCache.get(cacheKey); + + // 如果有缓存且未过期,直接返回 + if (cached && cached.expiresAt > Date.now()) { + this.token = cached.token; + return this.token; + } + + // 否则重新登录 + console.log('[XiaoyaClient] Token 不存在或已过期,重新登录'); + this.token = await XiaoyaClient.login( + this.baseURL, + this.username, + this.password + ); + + // 缓存 Token,设置 1 小时过期 + tokenCache.set(cacheKey, { + token: this.token, + expiresAt: Date.now() + 60 * 60 * 1000, + }); + + return this.token; + } + + /** + * 获取基础 URL + */ + getBaseURL(): string { + return this.baseURL; + } + + /** + * 列出目录内容 + */ + async listDirectory(path: string, page = 1, perPage = 100): Promise { + const token = await this.getToken(); + + const response = await fetch(`${this.baseURL}/api/fs/list`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + body: JSON.stringify({ + path, + page, + per_page: perPage, + refresh: false, + }), + }); + + if (!response.ok) { + throw new Error(`小雅列表获取失败: ${response.status}`); + } + + const data = await response.json(); + if (data.code !== 200) { + throw new Error(`小雅列表获取失败: ${data.message}`); + } + + return { + content: data.data.content || [], + total: data.data.total || 0, + }; + } + + /** + * 搜索文件 + */ + async search(keyword: string, page = 1, perPage = 100): Promise { + const token = await this.getToken(); + + const response = await fetch(`${this.baseURL}/api/fs/search`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + body: JSON.stringify({ + parent: '/', + keywords: keyword, + scope: 1, // 递归搜索 + page, + per_page: perPage, + }), + }); + + if (!response.ok) { + throw new Error(`小雅搜索失败: ${response.status}`); + } + + const data = await response.json(); + if (data.code !== 200) { + throw new Error(`小雅搜索失败: ${data.message}`); + } + + return { + content: data.data.content || [], + total: data.data.total || 0, + }; + } + + /** + * 获取文件信息 + */ + async getFileInfo(path: string): Promise { + const token = await this.getToken(); + + const response = await fetch(`${this.baseURL}/api/fs/get`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': token, + }, + body: JSON.stringify({ + path, + }), + }); + + if (!response.ok) { + throw new Error(`小雅文件信息获取失败: ${response.status}`); + } + + const data = await response.json(); + if (data.code !== 200) { + throw new Error(`小雅文件信息获取失败: ${data.message}`); + } + + return data.data; + } + + /** + * 获取文件下载链接 + */ + async getDownloadUrl(path: string): Promise { + // Alist 的直接下载链接格式 + return `${this.baseURL}/d${path}`; + } + + /** + * 获取文件内容(用于读取 NFO 等文本文件) + */ + async getFileContent(path: string): Promise { + const downloadUrl = await this.getDownloadUrl(path); + + const response = await fetch(downloadUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + + if (!response.ok) { + throw new Error(`文件读取失败: ${response.status}`); + } + + return await response.text(); + } + + /** + * 检查文件是否存在 + */ + async fileExists(path: string): Promise { + try { + await this.getFileInfo(path); + return true; + } catch (error) { + return false; + } + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 4e691c8..32b3727 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean { // 配置middleware匹配规则 export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play).*)', + '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play|api/emby/sources).*)', ], }; diff --git a/src/types/opencc-js.d.ts b/src/types/opencc-js.d.ts new file mode 100644 index 0000000..07dff2a --- /dev/null +++ b/src/types/opencc-js.d.ts @@ -0,0 +1,8 @@ +declare module 'opencc-js' { + interface ConverterOptions { + from: string; + to: string; + } + + export function Converter(options: ConverterOptions): (text: string) => string; +}