私人影库小雅初步实现

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'>