From 03a59b07a9ea91253aaaba5b43aaecfce6211c76 Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Sat, 10 Jan 2026 21:21:08 +0800
Subject: [PATCH] =?UTF-8?q?=E7=A7=81=E4=BA=BA=E5=BD=B1=E5=BA=93=E5=B0=8F?=
=?UTF-8?q?=E9=9B=85=E5=88=9D=E6=AD=A5=E5=AE=9E=E7=8E=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/page.tsx | 221 ++++++++++++++++++++++++++
src/app/api/admin/xiaoya/route.ts | 72 +++++++++
src/app/api/source-detail/route.ts | 59 +++++++
src/app/api/xiaoya/browse/route.ts | 76 +++++++++
src/app/api/xiaoya/play/route.ts | 57 +++++++
src/app/api/xiaoya/search/route.ts | 74 +++++++++
src/app/layout.tsx | 9 +-
src/app/private-library/page.tsx | 140 +++++++++++++++--
src/lib/admin.types.ts | 7 +
src/lib/nfo-parser.ts | 47 ++++++
src/lib/xiaoya-metadata.ts | 224 ++++++++++++++++++++++++++
src/lib/xiaoya.client.ts | 244 +++++++++++++++++++++++++++++
12 files changed, 1212 insertions(+), 18 deletions(-)
create mode 100644 src/app/api/admin/xiaoya/route.ts
create mode 100644 src/app/api/xiaoya/browse/route.ts
create mode 100644 src/app/api/xiaoya/play/route.ts
create mode 100644 src/app/api/xiaoya/search/route.ts
create mode 100644 src/lib/nfo-parser.ts
create mode 100644 src/lib/xiaoya-metadata.ts
create mode 100644 src/lib/xiaoya.client.ts
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 4cca393..3f97500 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -8502,6 +8502,215 @@ const CustomAdFilterConfig = ({
);
};
+// 小雅配置组件
+const XiaoyaConfigComponent = ({
+ config,
+ refreshConfig,
+}: {
+ config: AdminConfig | null;
+ refreshConfig: () => Promise;
+}) => {
+ const { alertModal, showAlert, hideAlert } = useAlertModal();
+ const { isLoading, withLoading } = useLoadingState();
+ const [enabled, setEnabled] = useState(false);
+ const [serverURL, setServerURL] = useState('');
+ const [token, setToken] = useState('');
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+
+ useEffect(() => {
+ if (config?.XiaoyaConfig) {
+ setEnabled(config.XiaoyaConfig.Enabled || false);
+ setServerURL(config.XiaoyaConfig.ServerURL || '');
+ setToken(config.XiaoyaConfig.Token || '');
+ setUsername(config.XiaoyaConfig.Username || '');
+ setPassword(config.XiaoyaConfig.Password || '');
+ }
+ }, [config]);
+
+ const handleSave = async () => {
+ await withLoading('saveXiaoya', async () => {
+ try {
+ const response = await fetch('/api/admin/xiaoya', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'save',
+ Enabled: enabled,
+ ServerURL: serverURL,
+ Token: token,
+ Username: username,
+ Password: password,
+ }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || '保存失败');
+ }
+
+ showSuccess('保存成功', showAlert);
+ await refreshConfig();
+ } catch (error) {
+ showError(error instanceof Error ? error.message : '保存失败', showAlert);
+ throw error;
+ }
+ });
+ };
+
+ const handleTest = async () => {
+ await withLoading('testXiaoya', async () => {
+ try {
+ const response = await fetch('/api/admin/xiaoya', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'test',
+ ServerURL: serverURL,
+ Token: token,
+ Username: username,
+ Password: password,
+ }),
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ showSuccess('连接成功', showAlert);
+ } else {
+ showError(data.message || '连接失败', showAlert);
+ }
+ } catch (error) {
+ showError(error instanceof Error ? error.message : '连接失败', showAlert);
+ throw error;
+ }
+ });
+ };
+
+ return (
+
+
+
+ 关于小雅
+
+
+
• 小雅是基于 Alist 的网盘资源聚合服务
+
• 支持文件夹名自动识别 TMDb ID(格式:标题 (年份) {'{tmdb-id}'})
+
• 支持 NFO 文件元数据(poster.jpg、background.jpg)
+
• 按需加载,无需全量扫描
+
+
+
+
+
+
+
+ 启用小雅功能
+
+
+ 关闭后将不显示小雅入口
+
+
+
+
+
+
+
+
setServerURL(e.target.value)}
+ placeholder='http://localhost:5244'
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
+ />
+
+ 小雅 Alist 服务器的完整地址
+
+
+
+
+
+ setToken(e.target.value)}
+ placeholder='可选,使用 Token 认证'
+ className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
// AI配置组件
const AIConfigComponent = ({
config,
@@ -9991,6 +10200,18 @@ function AdminPageClient() {
+ {/* 小雅配置标签 */}
+
+ }
+ isExpanded={expandedTabs.xiaoyaConfig}
+ onToggle={() => toggleTab('xiaoyaConfig')}
+ >
+
+
+
{/* AI配置标签 */}
`/api/xiaoya/play?path=${encodeURIComponent(ep.path)}`),
+ episodes_titles: episodes.map(ep => ep.title),
+ subtitles: [],
+ proxyMode: false,
+ };
+
+ return NextResponse.json(result);
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+ }
+
// 特殊处理 openlist 源 - 直接调用 /api/detail
if (sourceCode === 'openlist') {
try {
diff --git a/src/app/api/xiaoya/browse/route.ts b/src/app/api/xiaoya/browse/route.ts
new file mode 100644
index 0000000..c9156da
--- /dev/null
+++ b/src/app/api/xiaoya/browse/route.ts
@@ -0,0 +1,76 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { XiaoyaClient } from '@/lib/xiaoya.client';
+
+export const runtime = 'nodejs';
+
+/**
+ * GET /api/xiaoya/browse?path=
+ * 浏览小雅目录
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: '未授权' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const path = searchParams.get('path') || '/';
+
+ const config = await getConfig();
+ const xiaoyaConfig = config.XiaoyaConfig;
+
+ if (
+ !xiaoyaConfig ||
+ !xiaoyaConfig.Enabled ||
+ !xiaoyaConfig.ServerURL
+ ) {
+ return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
+ }
+
+ const client = new XiaoyaClient(
+ xiaoyaConfig.ServerURL,
+ xiaoyaConfig.Username,
+ xiaoyaConfig.Password,
+ xiaoyaConfig.Token
+ );
+
+ const result = await client.listDirectory(path);
+
+ // 过滤出文件夹和视频文件
+ const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
+
+ const folders = result.content
+ .filter(item => item.is_dir)
+ .map(item => ({
+ name: item.name,
+ path: `${path}${path.endsWith('/') ? '' : '/'}${item.name}`,
+ }));
+
+ const files = result.content
+ .filter(item =>
+ !item.is_dir &&
+ videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext))
+ )
+ .map(item => ({
+ name: item.name,
+ path: `${path}${path.endsWith('/') ? '' : '/'}${item.name}`,
+ }));
+
+ return NextResponse.json({
+ folders,
+ files,
+ currentPath: path,
+ });
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/xiaoya/play/route.ts b/src/app/api/xiaoya/play/route.ts
new file mode 100644
index 0000000..34d66ae
--- /dev/null
+++ b/src/app/api/xiaoya/play/route.ts
@@ -0,0 +1,57 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { XiaoyaClient } from '@/lib/xiaoya.client';
+
+export const runtime = 'nodejs';
+
+/**
+ * GET /api/xiaoya/play?path=
+ * 获取小雅视频的播放链接
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: '未授权' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const path = searchParams.get('path');
+
+ if (!path) {
+ return NextResponse.json({ error: '缺少参数' }, { status: 400 });
+ }
+
+ const config = await getConfig();
+ const xiaoyaConfig = config.XiaoyaConfig;
+
+ if (
+ !xiaoyaConfig ||
+ !xiaoyaConfig.Enabled ||
+ !xiaoyaConfig.ServerURL
+ ) {
+ return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
+ }
+
+ const client = new XiaoyaClient(
+ xiaoyaConfig.ServerURL,
+ xiaoyaConfig.Username,
+ xiaoyaConfig.Password,
+ xiaoyaConfig.Token
+ );
+
+ const playUrl = await client.getDownloadUrl(path);
+
+ // 返回 302 重定向到播放链接
+ return NextResponse.redirect(playUrl);
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/xiaoya/search/route.ts b/src/app/api/xiaoya/search/route.ts
new file mode 100644
index 0000000..b2f81b1
--- /dev/null
+++ b/src/app/api/xiaoya/search/route.ts
@@ -0,0 +1,74 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { NextRequest, NextResponse } from 'next/server';
+
+import { getAuthInfoFromCookie } from '@/lib/auth';
+import { getConfig } from '@/lib/config';
+import { XiaoyaClient } from '@/lib/xiaoya.client';
+
+export const runtime = 'nodejs';
+
+/**
+ * GET /api/xiaoya/search?keyword=&page=
+ * 搜索小雅视频
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const authInfo = getAuthInfoFromCookie(request);
+ if (!authInfo || !authInfo.username) {
+ return NextResponse.json({ error: '未授权' }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const keyword = searchParams.get('keyword');
+ const page = parseInt(searchParams.get('page') || '1');
+
+ if (!keyword) {
+ return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
+ }
+
+ const config = await getConfig();
+ const xiaoyaConfig = config.XiaoyaConfig;
+
+ if (
+ !xiaoyaConfig ||
+ !xiaoyaConfig.Enabled ||
+ !xiaoyaConfig.ServerURL
+ ) {
+ return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
+ }
+
+ const client = new XiaoyaClient(
+ xiaoyaConfig.ServerURL,
+ xiaoyaConfig.Username,
+ xiaoyaConfig.Password,
+ xiaoyaConfig.Token
+ );
+
+ const result = await client.search(keyword, page, 50);
+
+ // 只返回视频文件
+ const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
+
+ const videos = result.content
+ .filter(item =>
+ !item.is_dir &&
+ videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext))
+ )
+ .map(item => ({
+ name: item.name,
+ path: item.name, // Alist 搜索返回的是完整路径
+ }));
+
+ return NextResponse.json({
+ videos,
+ total: result.total,
+ page,
+ });
+ } catch (error) {
+ return NextResponse.json(
+ { error: (error as Error).message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 420ec68..bb6c48c 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -66,6 +66,7 @@ export default async function RootLayout({
let tmdbApiKey = '';
let openListEnabled = false;
let embyEnabled = false;
+ let xiaoyaEnabled = false;
let loginBackgroundImage = '';
let registerBackgroundImage = '';
let enableRegistration = false;
@@ -136,6 +137,11 @@ export default async function RootLayout({
config.EmbyConfig.Sources.length > 0 &&
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
);
+ // 检查是否启用了小雅功能
+ xiaoyaEnabled = !!(
+ config.XiaoyaConfig?.Enabled &&
+ config.XiaoyaConfig?.ServerURL
+ );
}
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
@@ -155,7 +161,8 @@ export default async function RootLayout({
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
OPENLIST_ENABLED: openListEnabled,
EMBY_ENABLED: embyEnabled,
- PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled,
+ XIAOYA_ENABLED: xiaoyaEnabled,
+ PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled || xiaoyaEnabled,
LOGIN_BACKGROUND_IMAGE: loginBackgroundImage,
REGISTER_BACKGROUND_IMAGE: registerBackgroundImage,
ENABLE_REGISTRATION: enableRegistration,
diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx
index 3bbb757..b792a99 100644
--- a/src/app/private-library/page.tsx
+++ b/src/app/private-library/page.tsx
@@ -9,7 +9,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch';
import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard';
-type LibrarySourceType = 'openlist' | 'emby' | `emby:${string}` | `emby_${string}`;
+type LibrarySourceType = 'openlist' | 'emby' | 'xiaoya' | `emby:${string}` | `emby_${string}`;
interface EmbySourceOption {
key: string;
@@ -45,7 +45,7 @@ export default function PrivateLibraryPage() {
if (typeof window !== 'undefined' && (window as any).RUNTIME_CONFIG) {
return (window as any).RUNTIME_CONFIG;
}
- return { OPENLIST_ENABLED: false, EMBY_ENABLED: false };
+ return { OPENLIST_ENABLED: false, EMBY_ENABLED: false, XIAOYA_ENABLED: false };
}, []);
// 解析URL中的source参数(支持 emby:emby1 格式)
@@ -72,6 +72,10 @@ export default function PrivateLibraryPage() {
const [embyViews, setEmbyViews] = useState([]);
const [selectedView, setSelectedView] = useState('all');
const [loadingViews, setLoadingViews] = useState(false);
+ // 小雅相关状态
+ const [xiaoyaPath, setXiaoyaPath] = useState('/');
+ const [xiaoyaFolders, setXiaoyaFolders] = useState>([]);
+ const [xiaoyaFiles, setXiaoyaFiles] = useState>([]);
const pageSize = 20;
const observerTarget = useRef(null);
const isFetchingRef = useRef(false);
@@ -270,6 +274,12 @@ export default function PrivateLibraryPage() {
return;
}
+ // 如果选择了 xiaoya 但未配置,不发起请求
+ if (sourceType === 'xiaoya' && !runtimeConfig.XIAOYA_ENABLED) {
+ setLoading(false);
+ return;
+ }
+
// 创建新的 AbortController
const abortController = new AbortController();
abortControllerRef.current = abortController;
@@ -285,6 +295,8 @@ export default function PrivateLibraryPage() {
const endpoint = sourceType === 'openlist'
? `/api/openlist/list?page=${page}&pageSize=${pageSize}`
+ : sourceType === 'xiaoya'
+ ? `/api/xiaoya/browse?path=${encodeURIComponent(xiaoyaPath)}`
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}`;
const response = await fetch(endpoint, { signal: abortController.signal });
@@ -301,19 +313,27 @@ export default function PrivateLibraryPage() {
setVideos([]);
}
} else {
- const newVideos = data.list || [];
-
- if (isInitial) {
- setVideos(newVideos);
+ // 小雅返回的是文件夹和文件列表
+ if (sourceType === 'xiaoya') {
+ setXiaoyaFolders(data.folders || []);
+ setXiaoyaFiles(data.files || []);
+ setVideos([]); // 小雅不使用 videos 状态
+ setHasMore(false); // 小雅不需要分页
} else {
- setVideos((prev) => [...prev, ...newVideos]);
- }
+ const newVideos = data.list || [];
- // 检查是否还有更多数据
- const currentPage = data.page || page;
- const totalPages = data.totalPages || 1;
- const hasMoreData = currentPage < totalPages;
- setHasMore(hasMoreData);
+ if (isInitial) {
+ setVideos(newVideos);
+ } else {
+ setVideos((prev) => [...prev, ...newVideos]);
+ }
+
+ // 检查是否还有更多数据
+ const currentPage = data.page || page;
+ const totalPages = data.totalPages || 1;
+ const hasMoreData = currentPage < totalPages;
+ setHasMore(hasMoreData);
+ }
}
} catch (err: any) {
// 忽略取消请求的错误
@@ -346,7 +366,7 @@ export default function PrivateLibraryPage() {
abortControllerRef.current.abort();
}
};
- }, [sourceType, embyKey, page, selectedView, runtimeConfig]);
+ }, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig]);
const handleVideoClick = (video: Video) => {
// 构建source参数
@@ -399,12 +419,13 @@ export default function PrivateLibraryPage() {
- {/* 第一级:源类型选择(OpenList / Emby) */}
+ {/* 第一级:源类型选择(OpenList / Emby / 小雅) */}
setSourceType(value as LibrarySourceType)}
@@ -535,6 +556,91 @@ export default function PrivateLibraryPage() {
/>
))}
+ ) : sourceType === 'xiaoya' ? (
+ // 小雅浏览模式
+
+ {/* 面包屑导航 */}
+
+
+ {xiaoyaPath.split('/').filter(Boolean).map((part, index, arr) => {
+ const path = '/' + arr.slice(0, index + 1).join('/');
+ return (
+
+ /
+
+
+ );
+ })}
+
+
+ {/* 文件夹列表 */}
+ {xiaoyaFolders.length > 0 && (
+
+
文件夹
+
+ {xiaoyaFolders.map((folder) => (
+
+ ))}
+
+
+ )}
+
+ {/* 视频文件列表 */}
+ {xiaoyaFiles.length > 0 && (
+
+
视频文件
+
+ {xiaoyaFiles.map((file) => {
+ // 从当前路径提取文件夹名作为标题
+ const pathParts = xiaoyaPath.split('/').filter(Boolean);
+ const folderName = pathParts[pathParts.length - 1] || '';
+ // 清理文件夹名(移除年份和 TMDb ID)
+ const title = folderName
+ .replace(/\s*\(\d{4}\)\s*\{tmdb-\d+\}$/i, '')
+ .trim() || file.name;
+
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ {xiaoyaFolders.length === 0 && xiaoyaFiles.length === 0 && (
+
+ )}
+
) : videos.length === 0 ? (
diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts
index 66f528b..c36a6ca 100644
--- a/src/lib/admin.types.ts
+++ b/src/lib/admin.types.ts
@@ -193,6 +193,13 @@ export interface AdminConfig {
LastSyncTime?: number;
ItemCount?: number;
};
+ XiaoyaConfig?: {
+ Enabled: boolean; // 是否启用
+ ServerURL: string; // Alist 服务器地址
+ Token?: string; // Token 认证(推荐)
+ Username?: string; // 用户名认证(备选)
+ Password?: string; // 密码认证(备选)
+ };
}
export interface AdminConfigResult {
diff --git a/src/lib/nfo-parser.ts b/src/lib/nfo-parser.ts
new file mode 100644
index 0000000..140c3a5
--- /dev/null
+++ b/src/lib/nfo-parser.ts
@@ -0,0 +1,47 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { parseString } from 'xml2js';
+
+export interface NFOMetadata {
+ tmdbId?: number;
+ title?: string;
+ originalTitle?: string;
+ year?: number;
+ plot?: string;
+ rating?: number;
+ genres?: string[];
+ mediaType: 'movie' | 'tv';
+}
+
+/**
+ * 解析 NFO 文件(XML 格式)
+ */
+export async function parseNFO(xmlContent: string): Promise {
+ return new Promise((resolve) => {
+ parseString(xmlContent, { explicitArray: false }, (err, result) => {
+ if (err || !result) {
+ resolve(null);
+ return;
+ }
+
+ const data = result.movie || result.tvshow;
+ if (!data) {
+ resolve(null);
+ return;
+ }
+
+ const metadata: NFOMetadata = {
+ tmdbId: data.tmdbid ? parseInt(data.tmdbid) : undefined,
+ title: data.title,
+ originalTitle: data.originaltitle,
+ year: data.year ? parseInt(data.year) : undefined,
+ plot: data.plot,
+ rating: data.rating ? parseFloat(data.rating) : undefined,
+ genres: Array.isArray(data.genre) ? data.genre : data.genre ? [data.genre] : [],
+ mediaType: result.movie ? 'movie' : 'tv',
+ };
+
+ resolve(metadata);
+ });
+ });
+}
diff --git a/src/lib/xiaoya-metadata.ts b/src/lib/xiaoya-metadata.ts
new file mode 100644
index 0000000..a49e992
--- /dev/null
+++ b/src/lib/xiaoya-metadata.ts
@@ -0,0 +1,224 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import { XiaoyaClient } from './xiaoya.client';
+import { parseNFO, NFOMetadata } from './nfo-parser';
+import { parseVideoFileName } from './video-parser';
+
+export interface XiaoyaMetadata {
+ tmdbId?: number;
+ title: string;
+ year?: string;
+ rating?: number;
+ genres?: string[];
+ plot?: string;
+ poster?: string;
+ background?: string;
+ mediaType: 'movie' | 'tv';
+ source: 'folder' | 'nfo' | 'tmdb';
+}
+
+/**
+ * 从文件夹名提取 TMDb ID 和年份
+ * 格式: "标题 (年份) {tmdb-id}"
+ */
+function parseFolderName(folderName: string): {
+ title?: string;
+ year?: string;
+ tmdbId?: number;
+} {
+ const match = folderName.match(/^(.+?)\s*\((\d{4})\)\s*\{tmdb-(\d+)\}$/);
+ if (match) {
+ return {
+ title: match[1].trim(),
+ year: match[2],
+ tmdbId: parseInt(match[3]),
+ };
+ }
+ return {};
+}
+
+/**
+ * 查找 NFO 文件并解析
+ */
+async function findNFO(
+ xiaoyaClient: XiaoyaClient,
+ videoPath: string
+): Promise {
+ const pathParts = videoPath.split('/').filter(Boolean);
+ const parentDir = pathParts.slice(0, -1).join('/');
+
+ const isInSeasonDir = /season\s*\d+/i.test(parentDir);
+
+ const nfoSearchPaths: string[] = [];
+
+ if (isInSeasonDir) {
+ // 电视剧:查父级的 tvshow.nfo
+ const grandParentDir = pathParts.slice(0, -2).join('/');
+ nfoSearchPaths.push(`/${grandParentDir}/tvshow.nfo`);
+ } else {
+ // 电影:查同级的 movie.nfo
+ nfoSearchPaths.push(`/${parentDir}/movie.nfo`);
+ }
+
+ for (const nfoPath of nfoSearchPaths) {
+ try {
+ const content = await xiaoyaClient.getFileContent(nfoPath);
+ const metadata = await parseNFO(content);
+ if (metadata) {
+ return metadata;
+ }
+ } catch (error) {
+ continue;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * 获取小雅视频的元数据
+ */
+export async function getXiaoyaMetadata(
+ xiaoyaClient: XiaoyaClient,
+ videoPath: string,
+ tmdbApiKey?: string,
+ tmdbProxy?: string
+): Promise {
+ const pathParts = videoPath.split('/').filter(Boolean);
+ const isInSeasonDir = /season\s*\d+/i.test(pathParts[pathParts.length - 2]);
+
+ // 确定元数据目录
+ const metadataDir = isInSeasonDir
+ ? pathParts.slice(0, -2).join('/')
+ : pathParts.slice(0, -1).join('/');
+
+ const folderName = pathParts[isInSeasonDir ? pathParts.length - 3 : pathParts.length - 2];
+
+ // 优先级 1: 从文件夹名提取 TMDb ID
+ const folderInfo = parseFolderName(folderName);
+ if (folderInfo.tmdbId) {
+ const baseUrl = xiaoyaClient.getBaseURL();
+ const posterUrl = `${baseUrl}/d/${metadataDir}/poster.jpg`;
+ const backgroundUrl = `${baseUrl}/d/${metadataDir}/background.jpg`;
+
+ // 尝试读取 NFO 获取详细信息
+ const nfoData = await findNFO(xiaoyaClient, videoPath);
+
+ return {
+ tmdbId: folderInfo.tmdbId,
+ title: nfoData?.title || folderInfo.title || folderName,
+ year: folderInfo.year,
+ rating: nfoData?.rating,
+ genres: nfoData?.genres,
+ plot: nfoData?.plot,
+ poster: posterUrl,
+ background: backgroundUrl,
+ mediaType: isInSeasonDir ? 'tv' : 'movie',
+ source: nfoData ? 'nfo' : 'folder',
+ };
+ }
+
+ // 优先级 2: 读取 NFO 文件
+ const nfoData = await findNFO(xiaoyaClient, videoPath);
+ if (nfoData && nfoData.tmdbId) {
+ const baseUrl = xiaoyaClient.getBaseURL();
+ const posterUrl = `${baseUrl}/d/${metadataDir}/poster.jpg`;
+ const backgroundUrl = `${baseUrl}/d/${metadataDir}/background.jpg`;
+
+ return {
+ tmdbId: nfoData.tmdbId,
+ title: nfoData.title || folderName,
+ year: nfoData.year?.toString(),
+ rating: nfoData.rating,
+ genres: nfoData.genres,
+ plot: nfoData.plot,
+ poster: posterUrl,
+ background: backgroundUrl,
+ mediaType: nfoData.mediaType,
+ source: 'nfo',
+ };
+ }
+
+ // 优先级 3: 实时搜索 TMDb
+ if (tmdbApiKey) {
+ const fileName = pathParts[pathParts.length - 1];
+ const searchQuery = fileName
+ .replace(/\.(mp4|mkv|avi|m3u8|flv|ts)$/i, '')
+ .replace(/[\[\]()]/g, ' ')
+ .trim();
+
+ const { searchTMDB, getTMDBImageUrl } = await import('./tmdb.search');
+ const tmdbResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy);
+
+ if (tmdbResult.code === 200 && tmdbResult.result) {
+ return {
+ tmdbId: tmdbResult.result.id,
+ title: tmdbResult.result.title || tmdbResult.result.name || folderName,
+ year: tmdbResult.result.release_date?.substring(0, 4) ||
+ tmdbResult.result.first_air_date?.substring(0, 4),
+ rating: tmdbResult.result.vote_average,
+ plot: tmdbResult.result.overview,
+ poster: tmdbResult.result.poster_path
+ ? getTMDBImageUrl(tmdbResult.result.poster_path)
+ : undefined,
+ mediaType: tmdbResult.result.media_type,
+ source: 'tmdb',
+ };
+ }
+ }
+
+ // 降级:只返回文件夹名
+ return {
+ title: folderName,
+ mediaType: isInSeasonDir ? 'tv' : 'movie',
+ source: 'folder',
+ };
+}
+
+/**
+ * 获取视频集数列表
+ */
+export async function getXiaoyaEpisodes(
+ xiaoyaClient: XiaoyaClient,
+ videoPath: string
+): Promise> {
+ const pathParts = videoPath.split('/').filter(Boolean);
+ const isInSeasonDir = /season\s*\d+/i.test(pathParts[pathParts.length - 2]);
+
+ if (isInSeasonDir) {
+ // 电视剧:列出当前季的所有集
+ const seasonDir = pathParts.slice(0, -1).join('/');
+ const listResponse = await xiaoyaClient.listDirectory(`/${seasonDir}`);
+
+ const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
+ const videoFiles = listResponse.content
+ .filter(item =>
+ !item.is_dir &&
+ videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext))
+ )
+ .sort((a, b) => a.name.localeCompare(b.name));
+
+ return videoFiles.map(file => {
+ const parsed = parseVideoFileName(file.name);
+ let title = file.name;
+
+ if (parsed.season && parsed.episode) {
+ title = `S${parsed.season.toString().padStart(2, '0')}E${parsed.episode.toString().padStart(2, '0')}`;
+ } else if (parsed.episode) {
+ title = parsed.isOVA ? `OVA ${parsed.episode}` : `第${parsed.episode}集`;
+ }
+
+ return {
+ path: `/${seasonDir}/${file.name}`,
+ title,
+ };
+ });
+ } else {
+ // 电影:只有一个文件
+ const fileName = pathParts[pathParts.length - 1];
+ return [{
+ path: videoPath,
+ title: fileName,
+ }];
+ }
+}
diff --git a/src/lib/xiaoya.client.ts b/src/lib/xiaoya.client.ts
new file mode 100644
index 0000000..c297305
--- /dev/null
+++ b/src/lib/xiaoya.client.ts
@@ -0,0 +1,244 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+// Token 内存缓存
+const tokenCache = new Map();
+
+export interface XiaoyaFile {
+ name: string;
+ size: number;
+ is_dir: boolean;
+ modified: string;
+}
+
+export interface XiaoyaListResponse {
+ content: XiaoyaFile[];
+ total: number;
+}
+
+export class XiaoyaClient {
+ private token: string = '';
+
+ constructor(
+ private baseURL: string,
+ private username?: string,
+ private password?: string,
+ private configToken?: string
+ ) {}
+
+ /**
+ * 使用账号密码登录获取Token
+ */
+ static async login(
+ baseURL: string,
+ username: string,
+ password: string
+ ): Promise {
+ const response = await fetch(`${baseURL}/api/auth/login`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ username,
+ password,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`小雅登录失败: ${response.status}`);
+ }
+
+ const data = await response.json();
+ if (data.code !== 200 || !data.data?.token) {
+ throw new Error('小雅登录失败: 未获取到Token');
+ }
+
+ return data.data.token;
+ }
+
+ /**
+ * 获取缓存的 Token 或重新登录
+ */
+ private async getToken(): Promise {
+ // 如果配置了 Token,直接使用
+ if (this.configToken) {
+ return this.configToken;
+ }
+
+ // 如果没有配置用户名密码,返回空字符串(guest 模式)
+ if (!this.username || !this.password) {
+ return '';
+ }
+
+ const cacheKey = `${this.baseURL}:${this.username}`;
+ const cached = tokenCache.get(cacheKey);
+
+ // 如果有缓存且未过期,直接返回
+ if (cached && cached.expiresAt > Date.now()) {
+ this.token = cached.token;
+ return this.token;
+ }
+
+ // 否则重新登录
+ console.log('[XiaoyaClient] Token 不存在或已过期,重新登录');
+ this.token = await XiaoyaClient.login(
+ this.baseURL,
+ this.username,
+ this.password
+ );
+
+ // 缓存 Token,设置 1 小时过期
+ tokenCache.set(cacheKey, {
+ token: this.token,
+ expiresAt: Date.now() + 60 * 60 * 1000,
+ });
+
+ return this.token;
+ }
+
+ /**
+ * 获取基础 URL
+ */
+ getBaseURL(): string {
+ return this.baseURL;
+ }
+
+ /**
+ * 列出目录内容
+ */
+ async listDirectory(path: string, page = 1, perPage = 100): Promise {
+ const token = await this.getToken();
+
+ const response = await fetch(`${this.baseURL}/api/fs/list`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': token,
+ },
+ body: JSON.stringify({
+ path,
+ page,
+ per_page: perPage,
+ refresh: false,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`小雅列表获取失败: ${response.status}`);
+ }
+
+ const data = await response.json();
+ if (data.code !== 200) {
+ throw new Error(`小雅列表获取失败: ${data.message}`);
+ }
+
+ return {
+ content: data.data.content || [],
+ total: data.data.total || 0,
+ };
+ }
+
+ /**
+ * 搜索文件
+ */
+ async search(keyword: string, page = 1, perPage = 100): Promise {
+ const token = await this.getToken();
+
+ const response = await fetch(`${this.baseURL}/api/fs/search`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': token,
+ },
+ body: JSON.stringify({
+ parent: '/',
+ keywords: keyword,
+ scope: 1, // 递归搜索
+ page,
+ per_page: perPage,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`小雅搜索失败: ${response.status}`);
+ }
+
+ const data = await response.json();
+ if (data.code !== 200) {
+ throw new Error(`小雅搜索失败: ${data.message}`);
+ }
+
+ return {
+ content: data.data.content || [],
+ total: data.data.total || 0,
+ };
+ }
+
+ /**
+ * 获取文件信息
+ */
+ async getFileInfo(path: string): Promise {
+ const token = await this.getToken();
+
+ const response = await fetch(`${this.baseURL}/api/fs/get`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': token,
+ },
+ body: JSON.stringify({
+ path,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`小雅文件信息获取失败: ${response.status}`);
+ }
+
+ const data = await response.json();
+ if (data.code !== 200) {
+ throw new Error(`小雅文件信息获取失败: ${data.message}`);
+ }
+
+ return data.data;
+ }
+
+ /**
+ * 获取文件下载链接
+ */
+ async getDownloadUrl(path: string): Promise {
+ // Alist 的直接下载链接格式
+ return `${this.baseURL}/d${path}`;
+ }
+
+ /**
+ * 获取文件内容(用于读取 NFO 等文本文件)
+ */
+ async getFileContent(path: string): Promise {
+ const downloadUrl = await this.getDownloadUrl(path);
+
+ const response = await fetch(downloadUrl, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`文件读取失败: ${response.status}`);
+ }
+
+ return await response.text();
+ }
+
+ /**
+ * 检查文件是否存在
+ */
+ async fileExists(path: string): Promise {
+ try {
+ await this.getFileInfo(path);
+ return true;
+ } catch (error) {
+ return false;
+ }
+ }
+}