完善小雅

This commit is contained in:
mtvpls
2026-01-11 00:52:52 +08:00
parent 03a59b07a9
commit e04e1ce3bc
9 changed files with 566 additions and 65 deletions

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { fetchDoubanData } from '@/lib/douban';
export const runtime = 'nodejs';
interface DoubanSearchResult {
id: string;
title: string;
year: string;
type?: string;
sub_title?: string;
episode?: string;
img?: string;
}
interface DoubanSearchResponse {
code: number;
data?: DoubanSearchResult[];
}
/**
* GET /api/douban/search?q=<query>
* 搜索豆瓣影视作品
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
if (!query) {
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
}
try {
const target = `https://movie.douban.com/j/subject_suggest?q=${encodeURIComponent(query)}`;
const data = await fetchDoubanData<DoubanSearchResult[]>(target);
const response: DoubanSearchResponse = {
code: 200,
data: data,
};
return NextResponse.json(response);
} catch (error) {
return NextResponse.json(
{ error: '搜索豆瓣数据失败', details: (error as Error).message },
{ status: 500 }
);
}
}

View File

@@ -8,9 +8,58 @@ import { XiaoyaClient } from '@/lib/xiaoya.client';
export const runtime = 'nodejs';
/**
* 使用 HEAD 请求跟随重定向获取最终 URL直连方法 - 降级使用)
*/
async function getFinalUrl(url: string, maxRedirects = 5): Promise<string> {
let currentUrl = url;
let redirectCount = 0;
while (redirectCount < maxRedirects) {
try {
const response = await fetch(currentUrl, {
method: 'HEAD',
redirect: 'manual',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
return currentUrl;
}
if (location.startsWith('http://') || location.startsWith('https://')) {
currentUrl = location;
} else if (location.startsWith('/')) {
const urlObj = new URL(currentUrl);
currentUrl = `${urlObj.protocol}//${urlObj.host}${location}`;
} else {
const urlObj = new URL(currentUrl);
const pathParts = urlObj.pathname.split('/');
pathParts.pop();
pathParts.push(location);
currentUrl = `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`;
}
redirectCount++;
} else {
return currentUrl;
}
} catch (error) {
console.error('[xiaoya/play] 获取最终 URL 失败:', error);
return currentUrl;
}
}
return currentUrl;
}
/**
* GET /api/xiaoya/play?path=<path>
* 获取小雅视频的播放链接
* 获取小雅视频的播放链接(优先使用视频预览流,失败时降级到直连)
*/
export async function GET(request: NextRequest) {
try {
@@ -44,10 +93,70 @@ export async function GET(request: NextRequest) {
xiaoyaConfig.Token
);
const playUrl = await client.getDownloadUrl(path);
// 优先尝试视频预览流方法
try {
const token = await client.getToken();
// 返回 302 重定向到播放链接
return NextResponse.redirect(playUrl);
const response = await fetch(`${xiaoyaConfig.ServerURL}/api/fs/other`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token,
},
body: JSON.stringify({
path: path,
method: 'video_preview',
password: '',
}),
});
if (!response.ok) {
throw new Error(`视频预览请求失败: ${response.status}`);
}
const data = await response.json();
if (data.code !== 200) {
throw new Error(`视频预览失败: ${data.message}`);
}
const taskList = data.data?.video_preview_play_info?.live_transcoding_task_list;
if (!taskList || taskList.length === 0) {
throw new Error('未找到可用的播放链接');
}
const qualityOrder: Record<string, number> = {
'FHD': 1,
'HD': 2,
'SD': 3,
'LD': 4,
};
const qualities = taskList
.filter((task: any) => task.status === 'finished')
.map((task: any) => ({
name: task.template_id,
url: task.url,
}))
.sort((a, b) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999));
if (qualities.length === 0) {
throw new Error('未找到已完成的播放链接');
}
return NextResponse.json({
url: qualities[0].url,
qualities
});
} catch (error) {
// 视频预览流失败,降级到直连方法
console.log('[xiaoya/play] 视频预览流失败,降级到直连方法:', (error as Error).message);
const playUrl = await client.getDownloadUrl(path);
const finalUrl = await getFinalUrl(playUrl);
return NextResponse.json({ url: finalUrl });
}
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },

View File

@@ -4,13 +4,12 @@ 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>
* 搜索小雅视频
* GET /api/xiaoya/search?keyword=<keyword>&type=<type>
* 搜索小雅视频(使用小雅的网页搜索引擎)
*/
export async function GET(request: NextRequest) {
try {
@@ -21,7 +20,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const keyword = searchParams.get('keyword');
const page = parseInt(searchParams.get('page') || '1');
const type = searchParams.get('type') || 'video'; // video, music, ebook, all
if (!keyword) {
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
@@ -38,34 +37,59 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
}
const client = new XiaoyaClient(
xiaoyaConfig.ServerURL,
xiaoyaConfig.Username,
xiaoyaConfig.Password,
xiaoyaConfig.Token
);
// 使用小雅的搜索引擎
const searchUrl = `${xiaoyaConfig.ServerURL}/search?box=${encodeURIComponent(keyword)}&type=${type}&url=`;
const result = await client.search(keyword, page, 50);
const response = await fetch(searchUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
},
});
// 只返回视频文件
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
if (!response.ok) {
throw new Error(`搜索请求失败: ${response.status}`);
}
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 搜索返回的是完整路径
}));
const html = await response.text();
// 解析 HTML 中的链接
// 格式: <a href=/path/to/file>path/to/file</a>
const linkRegex = /<a href=([^>]+)>([^<]+)<\/a>/g;
const results: Array<{ name: string; path: string }> = [];
let match;
while ((match = linkRegex.exec(html)) !== null) {
let path = match[1];
const displayText = match[2];
// 跳过返回首页和频道链接
if (path === '/' || path.startsWith('http')) {
continue;
}
// URL 解码路径
try {
path = decodeURIComponent(path);
} catch (e) {
console.error('URL 解码失败:', path, e);
}
// 提取文件名(路径的最后一部分)
const pathParts = displayText.split('/');
const fileName = pathParts[pathParts.length - 1];
results.push({
name: fileName,
path: path,
});
}
return NextResponse.json({
videos,
total: result.total,
page,
videos: results,
total: results.length,
});
} catch (error) {
console.error('小雅搜索失败:', error);
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }

View File

@@ -993,6 +993,11 @@ function PlayPageClient() {
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
console.log('使用缓存的TMDB详情数据');
setTmdbBackdrop(data.backdrop);
// 如果没有豆瓣ID使用TMDb数据补充
if (!videoDoubanId || videoDoubanId === 0) {
populateDoubanFieldsFromTMDB(data);
}
return;
}
} catch (e) {
@@ -1023,6 +1028,11 @@ function PlayPageClient() {
if (result.backdrop) {
setTmdbBackdrop(result.backdrop);
// 如果没有豆瓣ID使用TMDb数据补充
if (!videoDoubanId || videoDoubanId === 0) {
populateDoubanFieldsFromTMDB(result);
}
// 保存title到tmdbId的映射到localStorage1个月
if (result.tmdbId) {
try {
@@ -1056,13 +1066,42 @@ function PlayPageClient() {
}
};
// 辅助函数使用TMDb数据填充豆瓣字段
const populateDoubanFieldsFromTMDB = (tmdbData: any) => {
// 设置评分
if (tmdbData.rating) {
const ratingValue = parseFloat(tmdbData.rating);
setDoubanRating({
value: ratingValue,
count: 0, // TMDb不提供评分人数
star_count: Math.round(ratingValue / 2), // 转换为5星制
});
}
// 设置年份
if (tmdbData.releaseDate) {
const year = tmdbData.releaseDate.split('-')[0];
setDoubanYear(year);
}
// 设置card_subtitle使用mediaType和年份
if (tmdbData.mediaType && tmdbData.releaseDate) {
const year = tmdbData.releaseDate.split('-')[0];
const typeText = tmdbData.mediaType === 'movie' ? '电影' : '电视剧';
setDoubanCardSubtitle(`${year} / ${typeText}`);
}
};
fetchTMDBBackdrop();
}, [videoTitle]);
}, [videoTitle, videoDoubanId]);
// 视频播放地址
const [videoUrl, setVideoUrl] = useState('');
// 视频清晰度列表
const [videoQualities, setVideoQualities] = useState<Array<{name: string, url: string}>>([]);
// 视频源代理模式状态
const [sourceProxyMode, setSourceProxyMode] = useState(false);
@@ -1419,6 +1458,21 @@ function PlayPageClient() {
detailData: SearchResult | null,
episodeIndex: number
) => {
// 动态设置 referrer policy只在小雅源时不发送 Referer
const existingMeta = document.querySelector('meta[name="referrer"]');
if (detailData?.source === 'xiaoya') {
if (!existingMeta) {
const meta = document.createElement('meta');
meta.name = 'referrer';
meta.content = 'no-referrer';
document.head.appendChild(meta);
}
} else {
if (existingMeta) {
existingMeta.remove();
}
}
if (
!detailData ||
!detailData.episodes ||
@@ -1434,6 +1488,29 @@ function PlayPageClient() {
let newUrl = detailData?.episodes[episodeIndex] || '';
// 如果是小雅接口,先请求获取真实 URL
if (newUrl.startsWith('/api/xiaoya/play')) {
try {
const response = await fetch(newUrl);
const data = await response.json();
if (data.url) {
newUrl = data.url;
// 保存清晰度列表
if (data.qualities && data.qualities.length > 0) {
setVideoQualities(data.qualities);
} else {
setVideoQualities([]);
}
}
} catch (error) {
console.error('获取小雅播放链接失败:', error);
setVideoQualities([]);
}
} else {
// 非小雅源,清空清晰度列表
setVideoQualities([]);
}
// 检查是否有本地下载的文件
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
@@ -3992,10 +4069,17 @@ function PlayPageClient() {
fastForward: true,
autoOrientation: true,
lock: true,
...(videoQualities.length > 0 ? {
quality: videoQualities.map((q, index) => ({
default: index === 0,
html: q.name,
url: q.url,
})),
} : {}),
moreVideoAttr: {
crossOrigin: 'anonymous',
playsInline: true,
'webkit-playsinline': 'true',
...(detail?.source === 'xiaoya' ? { referrerpolicy: 'no-referrer' } : {}),
} as any,
// HLS 支持配置
customType: {

View File

@@ -76,6 +76,10 @@ export default function PrivateLibraryPage() {
const [xiaoyaPath, setXiaoyaPath] = useState<string>('/');
const [xiaoyaFolders, setXiaoyaFolders] = useState<Array<{ name: string; path: string }>>([]);
const [xiaoyaFiles, setXiaoyaFiles] = useState<Array<{ name: string; path: string }>>([]);
const [xiaoyaSearchKeyword, setXiaoyaSearchKeyword] = useState<string>('');
const [xiaoyaSearchResults, setXiaoyaSearchResults] = useState<Array<{ name: string; path: string }>>([]);
const [isSearching, setIsSearching] = useState(false);
const [mounted, setMounted] = useState(false);
const pageSize = 20;
const observerTarget = useRef<HTMLDivElement>(null);
const isFetchingRef = useRef(false);
@@ -88,6 +92,38 @@ export default function PrivateLibraryPage() {
const isInitializedRef = useRef(false);
const hasRestoredViewRef = useRef(false);
// 客户端挂载标记
useEffect(() => {
setMounted(true);
}, []);
// 小雅搜索处理函数
const handleXiaoyaSearch = async () => {
if (!xiaoyaSearchKeyword.trim()) return;
setIsSearching(true);
try {
const response = await fetch(`/api/xiaoya/search?keyword=${encodeURIComponent(xiaoyaSearchKeyword)}`);
if (!response.ok) {
throw new Error('搜索失败');
}
const data = await response.json();
if (data.error) {
setError(data.error);
setXiaoyaSearchResults([]);
} else {
setXiaoyaSearchResults(data.videos || []);
}
} catch (err) {
console.error('搜索失败:', err);
setError('搜索失败');
setXiaoyaSearchResults([]);
} finally {
setIsSearching(false);
}
};
// 从URL初始化状态并检查配置自动跳转
useEffect(() => {
const urlSourceParam = searchParams.get('source');
@@ -420,17 +456,19 @@ export default function PrivateLibraryPage() {
</div>
{/* 第一级源类型选择OpenList / Emby / 小雅) */}
<div className='mb-6 flex justify-center'>
<CapsuleSwitch
options={[
...(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)}
/>
</div>
{mounted && (
<div className='mb-6 flex justify-center'>
<CapsuleSwitch
options={[
...(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)}
/>
</div>
)}
{/* 第二级Emby源选择仅当选择Emby且有多个源时显示 */}
{sourceType === 'emby' && embySourceOptions.length > 1 && (
@@ -548,17 +586,150 @@ export default function PrivateLibraryPage() {
)}
{loading ? (
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
{Array.from({ length: pageSize }).map((_, index) => (
<div
key={index}
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
/>
))}
</div>
sourceType === 'xiaoya' ? (
// 小雅加载骨架屏 - 文件夹列表样式
<div className='space-y-4'>
{/* 文件夹骨架屏 */}
<div className='space-y-2'>
<div className='h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse' />
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2'>
{Array.from({ length: 12 }).map((_, index) => (
<div
key={index}
className='h-12 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse'
/>
))}
</div>
</div>
</div>
) : (
// OpenList/Emby 加载骨架屏 - 海报卡片样式
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
{Array.from({ length: pageSize }).map((_, index) => (
<div
key={index}
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
/>
))}
</div>
)
) : sourceType === 'xiaoya' ? (
// 小雅浏览模式
<div className='space-y-4'>
{/* 搜索框 */}
<div className='flex justify-center md:justify-end'>
<div className='relative w-full max-w-md'>
<input
type='text'
placeholder='搜索视频...'
value={xiaoyaSearchKeyword}
onChange={(e) => setXiaoyaSearchKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && xiaoyaSearchKeyword.trim()) {
handleXiaoyaSearch();
}
}}
className='w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
/>
{xiaoyaSearchKeyword ? (
<button
onClick={() => {
setXiaoyaSearchKeyword('');
setXiaoyaSearchResults([]);
}}
className='absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
>
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 20 20'>
<path fillRule='evenodd' d='M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z' clipRule='evenodd' />
</svg>
</button>
) : (
<button
onClick={handleXiaoyaSearch}
disabled={!xiaoyaSearchKeyword.trim() || isSearching}
className='absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed'
>
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 20 20'>
<path fillRule='evenodd' d='M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z' clipRule='evenodd' />
</svg>
</button>
)}
</div>
</div>
{/* 搜索结果 */}
{xiaoyaSearchResults.length > 0 ? (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<h3 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
({xiaoyaSearchResults.length})
</h3>
<button
onClick={() => {
setXiaoyaSearchKeyword('');
setXiaoyaSearchResults([]);
}}
className='text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300'
>
</button>
</div>
<div className='grid grid-cols-1 gap-2'>
{xiaoyaSearchResults.map((item) => {
// 判断是否为视频文件
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
const isVideoFile = videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
// 从路径中提取文件夹名作为标题
const pathParts = item.path.split('/').filter(Boolean);
const folderName = pathParts[pathParts.length - (isVideoFile ? 2 : 1)] || '';
const title = folderName
.replace(/\s*\(\d{4}\)\s*\{tmdb-\d+\}$/i, '')
.trim() || item.name;
return (
<button
key={item.path}
onClick={() => {
if (isVideoFile) {
// 视频文件:直接播放
router.push(`/play?source=xiaoya&id=${encodeURIComponent(item.path)}&title=${encodeURIComponent(title)}`);
} else {
// 文件夹:进入浏览
setXiaoyaPath(item.path);
setXiaoyaSearchKeyword('');
setXiaoyaSearchResults([]);
}
}}
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'
>
{isVideoFile ? (
<svg className='w-5 h-5 text-green-600 flex-shrink-0' 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>
) : (
<svg className='w-5 h-5 text-blue-600 flex-shrink-0' 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>
)}
<div className='flex-1 min-w-0'>
<div className='text-sm truncate'>{item.name}</div>
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>{item.path}</div>
</div>
</button>
);
})}
</div>
</div>
) : isSearching ? (
<div className='flex justify-center py-8'>
<div className='flex items-center gap-2 text-gray-600 dark:text-gray-400'>
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
<span>...</span>
</div>
</div>
) : (
<>
{/* 面包屑导航 */}
<div className='flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400'>
<button
@@ -640,6 +811,8 @@ export default function PrivateLibraryPage() {
<p className='text-gray-500 dark:text-gray-400'></p>
</div>
)}
</>
)}
</div>
) : videos.length === 0 ? (
<div className='text-center py-12'>

View File

@@ -683,6 +683,13 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
if (title.match(/^OVA\s+\d+/i)) {
return title;
}
// 如果匹配 S01E01 格式,提取并返回
const sxxexxMatch = title.match(/[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/);
if (sxxexxMatch) {
const season = sxxexxMatch[1].padStart(2, '0');
const episode = sxxexxMatch[2];
return `S${season}E${episode}`;
}
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
if (match) {

View File

@@ -28,11 +28,11 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo {
// 降级方案:使用多种正则模式提取集数
// 按优先级排序:更具体的模式优先
const patterns: Array<{ pattern: RegExp; isOVA?: boolean }> = [
const patterns: Array<{ pattern: RegExp; isOVA?: boolean; extractSeason?: boolean }> = [
// OVA01, OVA 01, ova01, ova 01 (OVA特殊处理) - 最优先
{ pattern: /OVA\s*(\d+(?:\.\d+)?)/i, isOVA: true },
// S01E01, s01e01, S01E01.5 (支持小数) - 最具体
{ pattern: /[Ss]\d+[Ee](\d+(?:\.\d+)?)/ },
// S01E01, s01e01, S01E1234, S01E01.5 (支持1-4位数字和小数) - 最具体
{ pattern: /[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/, extractSeason: true },
// [01], (01), [01.5], (01.5) (支持小数,但要排除中文括号内容) - 很具体
{ pattern: /[\[\(](\d+(?:\.\d+)?)[\]\)]/ },
// E01, E1, e01, e1, E01.5 (支持小数)
@@ -45,12 +45,22 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo {
{ pattern: /^(\d+(?:\.\d+)?)[^\d.]/ },
];
for (const { pattern, isOVA } of patterns) {
for (const { pattern, isOVA, extractSeason } of patterns) {
const match = fileName.match(pattern);
if (match && match[1]) {
const episode = parseFloat(match[1]);
if (episode > 0 && episode < 10000) { // 合理的集数范围
return { episode, isOVA };
if (extractSeason && match[2]) {
// 同时提取 season 和 episode
const season = parseInt(match[1]);
const episode = parseFloat(match[2]);
if (season > 0 && season < 100 && episode > 0 && episode < 10000) {
return { season, episode };
}
} else {
// 只提取 episode
const episode = parseFloat(match[1]);
if (episode > 0 && episode < 10000) {
return { episode, isOVA };
}
}
}
}

View File

@@ -139,7 +139,7 @@ export async function getXiaoyaMetadata(
};
}
// 优先级 3: 实时搜索 TMDb
// 优先级 3: 实时搜索 TMDb(使用文件名)
if (tmdbApiKey) {
const fileName = pathParts[pathParts.length - 1];
const searchQuery = fileName
@@ -147,6 +147,39 @@ export async function getXiaoyaMetadata(
.replace(/[\[\]()]/g, ' ')
.trim();
// 如果文件名是纯数字(可能带小数点)或者是 SxxExx 格式,跳过文件名搜索,直接使用文件夹名
const isPureNumber = /^[\d.]+$/.test(searchQuery);
const isSeasonEpisode = /^S\d+E\d+/i.test(searchQuery);
if (!isPureNumber && !isSeasonEpisode) {
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',
};
}
}
}
// 优先级 4: 实时搜索 TMDb使用文件夹名
if (tmdbApiKey) {
const searchQuery = folderName
.replace(/[\[\](){}]/g, ' ')
.replace(/\d{4}/g, '')
.trim();
const { searchTMDB, getTMDBImageUrl } = await import('./tmdb.search');
const tmdbResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy);
@@ -200,6 +233,7 @@ export async function getXiaoyaEpisodes(
return videoFiles.map(file => {
const parsed = parseVideoFileName(file.name);
console.log('[xiaoya-metadata] 解析文件名:', file.name, '结果:', parsed);
let title = file.name;
if (parsed.season && parsed.episode) {
@@ -214,11 +248,21 @@ export async function getXiaoyaEpisodes(
};
});
} else {
// 电影:只有一个文件
const fileName = pathParts[pathParts.length - 1];
return [{
path: videoPath,
title: fileName,
}];
// 电影:列出同一文件夹下的所有视频
const parentDir = pathParts.slice(0, -1).join('/');
const listResponse = await xiaoyaClient.listDirectory(`/${parentDir}`);
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 => ({
path: `/${parentDir}/${file.name}`,
title: file.name,
}));
}
}

View File

@@ -59,7 +59,7 @@ export class XiaoyaClient {
/**
* 获取缓存的 Token 或重新登录
*/
private async getToken(): Promise<string> {
async getToken(): Promise<string> {
// 如果配置了 Token直接使用
if (this.configToken) {
return this.configToken;