diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 4cca393..3f97500 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -8502,6 +8502,215 @@ const CustomAdFilterConfig = ({ ); }; +// 小雅配置组件 +const XiaoyaConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [enabled, setEnabled] = useState(false); + const [serverURL, setServerURL] = useState(''); + const [token, setToken] = useState(''); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + + useEffect(() => { + if (config?.XiaoyaConfig) { + setEnabled(config.XiaoyaConfig.Enabled || false); + setServerURL(config.XiaoyaConfig.ServerURL || ''); + setToken(config.XiaoyaConfig.Token || ''); + setUsername(config.XiaoyaConfig.Username || ''); + setPassword(config.XiaoyaConfig.Password || ''); + } + }, [config]); + + const handleSave = async () => { + await withLoading('saveXiaoya', async () => { + try { + const response = await fetch('/api/admin/xiaoya', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + Enabled: enabled, + ServerURL: serverURL, + Token: token, + Username: username, + Password: password, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '保存失败'); + } + + showSuccess('保存成功', showAlert); + await refreshConfig(); + } catch (error) { + showError(error instanceof Error ? error.message : '保存失败', showAlert); + throw error; + } + }); + }; + + const handleTest = async () => { + await withLoading('testXiaoya', async () => { + try { + const response = await fetch('/api/admin/xiaoya', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'test', + ServerURL: serverURL, + Token: token, + Username: username, + Password: password, + }), + }); + + const data = await response.json(); + if (data.success) { + showSuccess('连接成功', showAlert); + } else { + showError(data.message || '连接失败', showAlert); + } + } catch (error) { + showError(error instanceof Error ? error.message : '连接失败', showAlert); + throw error; + } + }); + }; + + return ( +
+
+

+ 关于小雅 +

+
+

• 小雅是基于 Alist 的网盘资源聚合服务

+

• 支持文件夹名自动识别 TMDb ID(格式:标题 (年份) {'{tmdb-id}'})

+

• 支持 NFO 文件元数据(poster.jpg、background.jpg)

+

• 按需加载,无需全量扫描

+
+
+ +
+
+
+

+ 启用小雅功能 +

+

+ 关闭后将不显示小雅入口 +

+
+ +
+ +
+ + 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' + /> +
+
+ +
+ + +
+
+ + +
+ ); +}; + // AI配置组件 const AIConfigComponent = ({ config, @@ -9991,6 +10200,18 @@ function AdminPageClient() { + {/* 小雅配置标签 */} + + } + isExpanded={expandedTabs.xiaoyaConfig} + onToggle={() => toggleTab('xiaoyaConfig')} + > + + + {/* AI配置标签 */} `/api/xiaoya/play?path=${encodeURIComponent(ep.path)}`), + episodes_titles: episodes.map(ep => ep.title), + subtitles: [], + proxyMode: false, + }; + + return NextResponse.json(result); + } catch (error) { + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } + } + // 特殊处理 openlist 源 - 直接调用 /api/detail if (sourceCode === 'openlist') { try { 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..34d66ae --- /dev/null +++ b/src/app/api/xiaoya/play/route.ts @@ -0,0 +1,57 @@ +/* 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/play?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'); + + if (!path) { + 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 client = new XiaoyaClient( + xiaoyaConfig.ServerURL, + xiaoyaConfig.Username, + xiaoyaConfig.Password, + xiaoyaConfig.Token + ); + + const playUrl = await client.getDownloadUrl(path); + + // 返回 302 重定向到播放链接 + 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..b2f81b1 --- /dev/null +++ b/src/app/api/xiaoya/search/route.ts @@ -0,0 +1,74 @@ +/* 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/search?keyword=&page= + * 搜索小雅视频 + */ +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 page = parseInt(searchParams.get('page') || '1'); + + 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 client = new XiaoyaClient( + xiaoyaConfig.ServerURL, + xiaoyaConfig.Username, + xiaoyaConfig.Password, + xiaoyaConfig.Token + ); + + const result = await client.search(keyword, page, 50); + + // 只返回视频文件 + const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm']; + + const videos = result.content + .filter(item => + !item.is_dir && + videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext)) + ) + .map(item => ({ + name: item.name, + path: item.name, // Alist 搜索返回的是完整路径 + })); + + return NextResponse.json({ + videos, + total: result.total, + page, + }); + } catch (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..bb6c48c 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; @@ -136,6 +137,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 +161,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, diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx index 3bbb757..b792a99 100644 --- a/src/app/private-library/page.tsx +++ b/src/app/private-library/page.tsx @@ -9,7 +9,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch'; import PageLayout from '@/components/PageLayout'; import VideoCard from '@/components/VideoCard'; -type LibrarySourceType = 'openlist' | 'emby' | `emby:${string}` | `emby_${string}`; +type LibrarySourceType = 'openlist' | 'emby' | 'xiaoya' | `emby:${string}` | `emby_${string}`; interface EmbySourceOption { key: string; @@ -45,7 +45,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 +72,10 @@ 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 pageSize = 20; const observerTarget = useRef(null); const isFetchingRef = useRef(false); @@ -270,6 +274,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 +295,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 +313,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 +366,7 @@ export default function PrivateLibraryPage() { abortControllerRef.current.abort(); } }; - }, [sourceType, embyKey, page, selectedView, runtimeConfig]); + }, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig]); const handleVideoClick = (video: Video) => { // 构建source参数 @@ -399,12 +419,13 @@ export default function PrivateLibraryPage() {

- {/* 第一级:源类型选择(OpenList / Emby) */} + {/* 第一级:源类型选择(OpenList / Emby / 小雅) */}
setSourceType(value as LibrarySourceType)} @@ -535,6 +556,91 @@ export default function PrivateLibraryPage() { /> ))}
+ ) : sourceType === 'xiaoya' ? ( + // 小雅浏览模式 +
+ {/* 面包屑导航 */} +
+ + {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/lib/admin.types.ts b/src/lib/admin.types.ts index 66f528b..c36a6ca 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -193,6 +193,13 @@ export interface AdminConfig { LastSyncTime?: number; ItemCount?: number; }; + XiaoyaConfig?: { + Enabled: boolean; // 是否启用 + ServerURL: string; // Alist 服务器地址 + Token?: string; // Token 认证(推荐) + Username?: string; // 用户名认证(备选) + Password?: string; // 密码认证(备选) + }; } export interface AdminConfigResult { diff --git a/src/lib/nfo-parser.ts b/src/lib/nfo-parser.ts new file mode 100644 index 0000000..140c3a5 --- /dev/null +++ b/src/lib/nfo-parser.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { parseString } from 'xml2js'; + +export interface NFOMetadata { + tmdbId?: number; + title?: string; + originalTitle?: string; + year?: number; + plot?: string; + rating?: number; + genres?: string[]; + mediaType: 'movie' | 'tv'; +} + +/** + * 解析 NFO 文件(XML 格式) + */ +export async function parseNFO(xmlContent: string): Promise { + 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/xiaoya-metadata.ts b/src/lib/xiaoya-metadata.ts new file mode 100644 index 0000000..a49e992 --- /dev/null +++ b/src/lib/xiaoya-metadata.ts @@ -0,0 +1,224 @@ +/* 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'; +} + +/** + * 从文件夹名提取 TMDb ID 和年份 + * 格式: "标题 (年份) {tmdb-id}" + */ +function parseFolderName(folderName: string): { + title?: string; + year?: string; + tmdbId?: number; +} { + 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 parentDir = pathParts.slice(0, -1).join('/'); + + const isInSeasonDir = /season\s*\d+/i.test(parentDir); + + const nfoSearchPaths: string[] = []; + + if (isInSeasonDir) { + // 电视剧:查父级的 tvshow.nfo + const grandParentDir = pathParts.slice(0, -2).join('/'); + nfoSearchPaths.push(`/${grandParentDir}/tvshow.nfo`); + } else { + // 电影:查同级的 movie.nfo + 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); + const isInSeasonDir = /season\s*\d+/i.test(pathParts[pathParts.length - 2]); + + // 确定元数据目录 + const metadataDir = isInSeasonDir + ? pathParts.slice(0, -2).join('/') + : pathParts.slice(0, -1).join('/'); + + const folderName = pathParts[isInSeasonDir ? pathParts.length - 3 : pathParts.length - 2]; + + // 优先级 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' : '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(); + + 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 isInSeasonDir = /season\s*\d+/i.test(pathParts[pathParts.length - 2]); + + if (isInSeasonDir) { + // 电视剧:列出当前季的所有集 + const seasonDir = pathParts.slice(0, -1).join('/'); + const listResponse = await xiaoyaClient.listDirectory(`/${seasonDir}`); + + const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm']; + 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); + 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 fileName = pathParts[pathParts.length - 1]; + return [{ + path: videoPath, + title: fileName, + }]; + } +} diff --git a/src/lib/xiaoya.client.ts b/src/lib/xiaoya.client.ts new file mode 100644 index 0000000..c297305 --- /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 或重新登录 + */ + private 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; + } + } +}