私人影库小雅初步实现
This commit is contained in:
@@ -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.jpg、background.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设定'
|
||||
|
||||
72
src/app/api/admin/xiaoya/route.ts
Normal file
72
src/app/api/admin/xiaoya/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
76
src/app/api/xiaoya/browse/route.ts
Normal file
76
src/app/api/xiaoya/browse/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
57
src/app/api/xiaoya/play/route.ts
Normal file
57
src/app/api/xiaoya/play/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
74
src/app/api/xiaoya/search/route.ts
Normal file
74
src/app/api/xiaoya/search/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
47
src/lib/nfo-parser.ts
Normal 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
224
src/lib/xiaoya-metadata.ts
Normal 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
244
src/lib/xiaoya.client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user