私人影库小雅初步实现

This commit is contained in:
mtvpls
2026-01-10 21:21:08 +08:00
parent a74911d655
commit 03a59b07a9
12 changed files with 1212 additions and 18 deletions

View File

@@ -8502,6 +8502,215 @@ const CustomAdFilterConfig = ({
);
};
// 小雅配置组件
const XiaoyaConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
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 (
<div className='space-y-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<h3 className='text-sm font-medium text-blue-900 dark:text-blue-100 mb-2'>
</h3>
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
<p> Alist </p>
<p> TMDb ID () {'{tmdb-id}'}</p>
<p> NFO poster.jpgbackground.jpg</p>
<p> </p>
</div>
</div>
<div className='space-y-4'>
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Alist
</label>
<input
type='text'
value={serverURL}
onChange={(e) => 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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Alist
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Token
</label>
<input
type='password'
value={token}
onChange={(e) => 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'
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='text'
value={username}
onChange={(e) => 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'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
value={password}
onChange={(e) => 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'
/>
</div>
</div>
<div className='flex gap-3'>
<button
onClick={handleTest}
disabled={!serverURL || isLoading('testXiaoya')}
className={buttonStyles.primary}
>
{isLoading('testXiaoya') ? '测试中...' : '测试连接'}
</button>
<button
onClick={handleSave}
disabled={isLoading('saveXiaoya')}
className={buttonStyles.success}
>
{isLoading('saveXiaoya') ? '保存中...' : '保存配置'}
</button>
</div>
</div>
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
};
// AI配置组件
const AIConfigComponent = ({
config,
@@ -9991,6 +10200,18 @@ function AdminPageClient() {
<EmbyConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* 小雅配置标签 */}
<CollapsibleTab
title='小雅配置'
icon={
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.xiaoyaConfig}
onToggle={() => toggleTab('xiaoyaConfig')}
>
<XiaoyaConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* AI配置标签 */}
<CollapsibleTab
title='AI设定'

View File

@@ -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 }
);
}
}

View File

@@ -124,6 +124,65 @@ 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 client = new XiaoyaClient(
xiaoyaConfig.ServerURL,
xiaoyaConfig.Username,
xiaoyaConfig.Password,
xiaoyaConfig.Token
);
// 获取元数据
const metadata = await getXiaoyaMetadata(
client,
id, // id 就是视频路径
config.SiteConfig.TMDBApiKey,
config.SiteConfig.TMDBProxy
);
// 获取集数列表
const episodes = await getXiaoyaEpisodes(client, id);
const result = {
source: 'xiaoya',
source_name: '小雅',
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(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 {

View File

@@ -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=<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 }
);
}
}

View File

@@ -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=<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 }
);
}
}

View File

@@ -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=<keyword>&page=<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 }
);
}
}

View File

@@ -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,

View File

@@ -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<EmbyView[]>([]);
const [selectedView, setSelectedView] = useState<string>('all');
const [loadingViews, setLoadingViews] = useState(false);
// 小雅相关状态
const [xiaoyaPath, setXiaoyaPath] = useState<string>('/');
const [xiaoyaFolders, setXiaoyaFolders] = useState<Array<{ name: string; path: string }>>([]);
const [xiaoyaFiles, setXiaoyaFiles] = useState<Array<{ name: string; path: string }>>([]);
const pageSize = 20;
const observerTarget = useRef<HTMLDivElement>(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() {
</p>
</div>
{/* 第一级源类型选择OpenList / Emby */}
{/* 第一级源类型选择OpenList / Emby / 小雅 */}
<div className='mb-6 flex justify-center'>
<CapsuleSwitch
options={[
{ label: 'OpenList', value: 'openlist' },
{ label: 'Emby', value: 'emby' },
...(runtimeConfig.OPENLIST_ENABLED ? [{ label: 'OpenList', value: 'openlist' }] : []),
...(runtimeConfig.EMBY_ENABLED ? [{ label: 'Emby', value: 'emby' }] : []),
...(runtimeConfig.XIAOYA_ENABLED ? [{ label: '小雅', value: 'xiaoya' }] : []),
]}
active={sourceType}
onChange={(value) => setSourceType(value as LibrarySourceType)}
@@ -535,6 +556,91 @@ export default function PrivateLibraryPage() {
/>
))}
</div>
) : sourceType === 'xiaoya' ? (
// 小雅浏览模式
<div className='space-y-4'>
{/* 面包屑导航 */}
<div className='flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400'>
<button
onClick={() => setXiaoyaPath('/')}
className='hover:text-blue-600 dark:hover:text-blue-400'
>
</button>
{xiaoyaPath.split('/').filter(Boolean).map((part, index, arr) => {
const path = '/' + arr.slice(0, index + 1).join('/');
return (
<span key={path} className='flex items-center gap-2'>
<span>/</span>
<button
onClick={() => setXiaoyaPath(path)}
className='hover:text-blue-600 dark:hover:text-blue-400'
>
{part}
</button>
</span>
);
})}
</div>
{/* 文件夹列表 */}
{xiaoyaFolders.length > 0 && (
<div className='space-y-2'>
<h3 className='text-sm font-medium text-gray-700 dark:text-gray-300'></h3>
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2'>
{xiaoyaFolders.map((folder) => (
<button
key={folder.path}
onClick={() => setXiaoyaPath(folder.path)}
className='flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-left'
>
<svg className='w-5 h-5 text-blue-600' fill='currentColor' viewBox='0 0 20 20'>
<path d='M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z' />
</svg>
<span className='text-sm truncate'>{folder.name}</span>
</button>
))}
</div>
</div>
)}
{/* 视频文件列表 */}
{xiaoyaFiles.length > 0 && (
<div className='space-y-2'>
<h3 className='text-sm font-medium text-gray-700 dark:text-gray-300'></h3>
<div className='grid grid-cols-1 gap-2'>
{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 (
<button
key={file.path}
onClick={() => router.push(`/play?source=xiaoya&id=${encodeURIComponent(file.path)}&title=${encodeURIComponent(title)}`)}
className='flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-left'
>
<svg className='w-5 h-5 text-green-600' fill='currentColor' viewBox='0 0 20 20'>
<path d='M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z' />
</svg>
<span className='text-sm truncate'>{file.name}</span>
</button>
);
})}
</div>
</div>
)}
{xiaoyaFolders.length === 0 && xiaoyaFiles.length === 0 && (
<div className='text-center py-12'>
<p className='text-gray-500 dark:text-gray-400'></p>
</div>
)}
</div>
) : videos.length === 0 ? (
<div className='text-center py-12'>
<p className='text-gray-500 dark:text-gray-400'>

View File

@@ -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 {

47
src/lib/nfo-parser.ts Normal file
View File

@@ -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<NFOMetadata | null> {
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);
});
});
}

224
src/lib/xiaoya-metadata.ts Normal file
View File

@@ -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<NFOMetadata | null> {
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<XiaoyaMetadata> {
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<Array<{ path: string; title: string }>> {
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,
}];
}
}

244
src/lib/xiaoya.client.ts Normal file
View File

@@ -0,0 +1,244 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Token 内存缓存
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
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<string> {
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<string> {
// 如果配置了 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<XiaoyaListResponse> {
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<XiaoyaListResponse> {
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<XiaoyaFile> {
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<string> {
// Alist 的直接下载链接格式
return `${this.baseURL}/d${path}`;
}
/**
* 获取文件内容(用于读取 NFO 等文本文件)
*/
async getFileContent(path: string): Promise<string> {
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<boolean> {
try {
await this.getFileInfo(path);
return true;
} catch (error) {
return false;
}
}
}