私人影库小雅初步实现
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'>
|
||||
|
||||
Reference in New Issue
Block a user