完善小雅
This commit is contained in:
50
src/app/api/douban/search/route.ts
Normal file
50
src/app/api/douban/search/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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的映射到localStorage(1个月)
|
||||
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: {
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export class XiaoyaClient {
|
||||
/**
|
||||
* 获取缓存的 Token 或重新登录
|
||||
*/
|
||||
private async getToken(): Promise<string> {
|
||||
async getToken(): Promise<string> {
|
||||
// 如果配置了 Token,直接使用
|
||||
if (this.configToken) {
|
||||
return this.configToken;
|
||||
|
||||
Reference in New Issue
Block a user