完善小雅
This commit is contained in:
@@ -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';
|
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>
|
* GET /api/xiaoya/play?path=<path>
|
||||||
* 获取小雅视频的播放链接
|
* 获取小雅视频的播放链接(优先使用视频预览流,失败时降级到直连)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -44,10 +93,70 @@ export async function GET(request: NextRequest) {
|
|||||||
xiaoyaConfig.Token
|
xiaoyaConfig.Token
|
||||||
);
|
);
|
||||||
|
|
||||||
const playUrl = await client.getDownloadUrl(path);
|
// 优先尝试视频预览流方法
|
||||||
|
try {
|
||||||
|
const token = await client.getToken();
|
||||||
|
|
||||||
// 返回 302 重定向到播放链接
|
const response = await fetch(`${xiaoyaConfig.ServerURL}/api/fs/other`, {
|
||||||
return NextResponse.redirect(playUrl);
|
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) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: (error as Error).message },
|
{ error: (error as Error).message },
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
import { XiaoyaClient } from '@/lib/xiaoya.client';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -21,7 +20,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const keyword = searchParams.get('keyword');
|
const keyword = searchParams.get('keyword');
|
||||||
const page = parseInt(searchParams.get('page') || '1');
|
const type = searchParams.get('type') || 'video'; // video, music, ebook, all
|
||||||
|
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
|
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
|
||||||
@@ -38,34 +37,59 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
|
return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new XiaoyaClient(
|
// 使用小雅的搜索引擎
|
||||||
xiaoyaConfig.ServerURL,
|
const searchUrl = `${xiaoyaConfig.ServerURL}/search?box=${encodeURIComponent(keyword)}&type=${type}&url=`;
|
||||||
xiaoyaConfig.Username,
|
|
||||||
xiaoyaConfig.Password,
|
|
||||||
xiaoyaConfig.Token
|
|
||||||
);
|
|
||||||
|
|
||||||
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 只返回视频文件
|
if (!response.ok) {
|
||||||
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
throw new Error(`搜索请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
const videos = result.content
|
const html = await response.text();
|
||||||
.filter(item =>
|
|
||||||
!item.is_dir &&
|
// 解析 HTML 中的链接
|
||||||
videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext))
|
// 格式: <a href=/path/to/file>path/to/file</a>
|
||||||
)
|
const linkRegex = /<a href=([^>]+)>([^<]+)<\/a>/g;
|
||||||
.map(item => ({
|
const results: Array<{ name: string; path: string }> = [];
|
||||||
name: item.name,
|
|
||||||
path: item.name, // Alist 搜索返回的是完整路径
|
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({
|
return NextResponse.json({
|
||||||
videos,
|
videos: results,
|
||||||
total: result.total,
|
total: results.length,
|
||||||
page,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('小雅搜索失败:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: (error as Error).message },
|
{ error: (error as Error).message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
+86
-2
@@ -993,6 +993,11 @@ function PlayPageClient() {
|
|||||||
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
|
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
|
||||||
console.log('使用缓存的TMDB详情数据');
|
console.log('使用缓存的TMDB详情数据');
|
||||||
setTmdbBackdrop(data.backdrop);
|
setTmdbBackdrop(data.backdrop);
|
||||||
|
|
||||||
|
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||||
|
if (!videoDoubanId || videoDoubanId === 0) {
|
||||||
|
populateDoubanFieldsFromTMDB(data);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1023,6 +1028,11 @@ function PlayPageClient() {
|
|||||||
if (result.backdrop) {
|
if (result.backdrop) {
|
||||||
setTmdbBackdrop(result.backdrop);
|
setTmdbBackdrop(result.backdrop);
|
||||||
|
|
||||||
|
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||||
|
if (!videoDoubanId || videoDoubanId === 0) {
|
||||||
|
populateDoubanFieldsFromTMDB(result);
|
||||||
|
}
|
||||||
|
|
||||||
// 保存title到tmdbId的映射到localStorage(1个月)
|
// 保存title到tmdbId的映射到localStorage(1个月)
|
||||||
if (result.tmdbId) {
|
if (result.tmdbId) {
|
||||||
try {
|
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();
|
fetchTMDBBackdrop();
|
||||||
}, [videoTitle]);
|
}, [videoTitle, videoDoubanId]);
|
||||||
|
|
||||||
|
|
||||||
// 视频播放地址
|
// 视频播放地址
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
|
||||||
|
// 视频清晰度列表
|
||||||
|
const [videoQualities, setVideoQualities] = useState<Array<{name: string, url: string}>>([]);
|
||||||
|
|
||||||
// 视频源代理模式状态
|
// 视频源代理模式状态
|
||||||
const [sourceProxyMode, setSourceProxyMode] = useState(false);
|
const [sourceProxyMode, setSourceProxyMode] = useState(false);
|
||||||
|
|
||||||
@@ -1419,6 +1458,21 @@ function PlayPageClient() {
|
|||||||
detailData: SearchResult | null,
|
detailData: SearchResult | null,
|
||||||
episodeIndex: number
|
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 (
|
if (
|
||||||
!detailData ||
|
!detailData ||
|
||||||
!detailData.episodes ||
|
!detailData.episodes ||
|
||||||
@@ -1434,6 +1488,29 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
let newUrl = detailData?.episodes[episodeIndex] || '';
|
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);
|
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
|
||||||
|
|
||||||
@@ -3992,10 +4069,17 @@ function PlayPageClient() {
|
|||||||
fastForward: true,
|
fastForward: true,
|
||||||
autoOrientation: true,
|
autoOrientation: true,
|
||||||
lock: true,
|
lock: true,
|
||||||
|
...(videoQualities.length > 0 ? {
|
||||||
|
quality: videoQualities.map((q, index) => ({
|
||||||
|
default: index === 0,
|
||||||
|
html: q.name,
|
||||||
|
url: q.url,
|
||||||
|
})),
|
||||||
|
} : {}),
|
||||||
moreVideoAttr: {
|
moreVideoAttr: {
|
||||||
crossOrigin: 'anonymous',
|
|
||||||
playsInline: true,
|
playsInline: true,
|
||||||
'webkit-playsinline': 'true',
|
'webkit-playsinline': 'true',
|
||||||
|
...(detail?.source === 'xiaoya' ? { referrerpolicy: 'no-referrer' } : {}),
|
||||||
} as any,
|
} as any,
|
||||||
// HLS 支持配置
|
// HLS 支持配置
|
||||||
customType: {
|
customType: {
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ export default function PrivateLibraryPage() {
|
|||||||
const [xiaoyaPath, setXiaoyaPath] = useState<string>('/');
|
const [xiaoyaPath, setXiaoyaPath] = useState<string>('/');
|
||||||
const [xiaoyaFolders, setXiaoyaFolders] = useState<Array<{ name: string; path: string }>>([]);
|
const [xiaoyaFolders, setXiaoyaFolders] = useState<Array<{ name: string; path: string }>>([]);
|
||||||
const [xiaoyaFiles, setXiaoyaFiles] = 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 pageSize = 20;
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
const isFetchingRef = useRef(false);
|
const isFetchingRef = useRef(false);
|
||||||
@@ -88,6 +92,38 @@ export default function PrivateLibraryPage() {
|
|||||||
const isInitializedRef = useRef(false);
|
const isInitializedRef = useRef(false);
|
||||||
const hasRestoredViewRef = 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初始化状态,并检查配置自动跳转
|
// 从URL初始化状态,并检查配置自动跳转
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlSourceParam = searchParams.get('source');
|
const urlSourceParam = searchParams.get('source');
|
||||||
@@ -420,17 +456,19 @@ export default function PrivateLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 第一级:源类型选择(OpenList / Emby / 小雅) */}
|
{/* 第一级:源类型选择(OpenList / Emby / 小雅) */}
|
||||||
<div className='mb-6 flex justify-center'>
|
{mounted && (
|
||||||
<CapsuleSwitch
|
<div className='mb-6 flex justify-center'>
|
||||||
options={[
|
<CapsuleSwitch
|
||||||
...(runtimeConfig.OPENLIST_ENABLED ? [{ label: 'OpenList', value: 'openlist' }] : []),
|
options={[
|
||||||
...(runtimeConfig.EMBY_ENABLED ? [{ label: 'Emby', value: 'emby' }] : []),
|
...(runtimeConfig.OPENLIST_ENABLED ? [{ label: 'OpenList', value: 'openlist' }] : []),
|
||||||
...(runtimeConfig.XIAOYA_ENABLED ? [{ label: '小雅', value: 'xiaoya' }] : []),
|
...(runtimeConfig.EMBY_ENABLED ? [{ label: 'Emby', value: 'emby' }] : []),
|
||||||
]}
|
...(runtimeConfig.XIAOYA_ENABLED ? [{ label: '小雅', value: 'xiaoya' }] : []),
|
||||||
active={sourceType}
|
]}
|
||||||
onChange={(value) => setSourceType(value as LibrarySourceType)}
|
active={sourceType}
|
||||||
/>
|
onChange={(value) => setSourceType(value as LibrarySourceType)}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 第二级:Emby源选择(仅当选择Emby且有多个源时显示) */}
|
{/* 第二级:Emby源选择(仅当选择Emby且有多个源时显示) */}
|
||||||
{sourceType === 'emby' && embySourceOptions.length > 1 && (
|
{sourceType === 'emby' && embySourceOptions.length > 1 && (
|
||||||
@@ -548,17 +586,150 @@ export default function PrivateLibraryPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{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'>
|
sourceType === 'xiaoya' ? (
|
||||||
{Array.from({ length: pageSize }).map((_, index) => (
|
// 小雅加载骨架屏 - 文件夹列表样式
|
||||||
<div
|
<div className='space-y-4'>
|
||||||
key={index}
|
{/* 文件夹骨架屏 */}
|
||||||
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
|
<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'>
|
||||||
</div>
|
{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' ? (
|
) : sourceType === 'xiaoya' ? (
|
||||||
// 小雅浏览模式
|
// 小雅浏览模式
|
||||||
<div className='space-y-4'>
|
<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'>
|
<div className='flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
<button
|
<button
|
||||||
@@ -640,6 +811,8 @@ export default function PrivateLibraryPage() {
|
|||||||
<p className='text-gray-500 dark:text-gray-400'>此目录为空</p>
|
<p className='text-gray-500 dark:text-gray-400'>此目录为空</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : videos.length === 0 ? (
|
) : videos.length === 0 ? (
|
||||||
<div className='text-center py-12'>
|
<div className='text-center py-12'>
|
||||||
|
|||||||
@@ -683,6 +683,13 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
if (title.match(/^OVA\s+\d+/i)) {
|
if (title.match(/^OVA\s+\d+/i)) {
|
||||||
return title;
|
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话"格式,提取中间的数字(支持小数)
|
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
|
||||||
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
|
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
+17
-7
@@ -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特殊处理) - 最优先
|
// OVA01, OVA 01, ova01, ova 01 (OVA特殊处理) - 最优先
|
||||||
{ pattern: /OVA\s*(\d+(?:\.\d+)?)/i, isOVA: true },
|
{ pattern: /OVA\s*(\d+(?:\.\d+)?)/i, isOVA: true },
|
||||||
// S01E01, s01e01, S01E01.5 (支持小数) - 最具体
|
// S01E01, s01e01, S01E1234, S01E01.5 (支持1-4位数字和小数) - 最具体
|
||||||
{ pattern: /[Ss]\d+[Ee](\d+(?:\.\d+)?)/ },
|
{ pattern: /[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/, extractSeason: true },
|
||||||
// [01], (01), [01.5], (01.5) (支持小数,但要排除中文括号内容) - 很具体
|
// [01], (01), [01.5], (01.5) (支持小数,但要排除中文括号内容) - 很具体
|
||||||
{ pattern: /[\[\(](\d+(?:\.\d+)?)[\]\)]/ },
|
{ pattern: /[\[\(](\d+(?:\.\d+)?)[\]\)]/ },
|
||||||
// E01, E1, e01, e1, E01.5 (支持小数)
|
// E01, E1, e01, e1, E01.5 (支持小数)
|
||||||
@@ -45,12 +45,22 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo {
|
|||||||
{ pattern: /^(\d+(?:\.\d+)?)[^\d.]/ },
|
{ pattern: /^(\d+(?:\.\d+)?)[^\d.]/ },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { pattern, isOVA } of patterns) {
|
for (const { pattern, isOVA, extractSeason } of patterns) {
|
||||||
const match = fileName.match(pattern);
|
const match = fileName.match(pattern);
|
||||||
if (match && match[1]) {
|
if (match && match[1]) {
|
||||||
const episode = parseFloat(match[1]);
|
if (extractSeason && match[2]) {
|
||||||
if (episode > 0 && episode < 10000) { // 合理的集数范围
|
// 同时提取 season 和 episode
|
||||||
return { episode, isOVA };
|
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) {
|
if (tmdbApiKey) {
|
||||||
const fileName = pathParts[pathParts.length - 1];
|
const fileName = pathParts[pathParts.length - 1];
|
||||||
const searchQuery = fileName
|
const searchQuery = fileName
|
||||||
@@ -147,6 +147,39 @@ export async function getXiaoyaMetadata(
|
|||||||
.replace(/[\[\]()]/g, ' ')
|
.replace(/[\[\]()]/g, ' ')
|
||||||
.trim();
|
.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 { searchTMDB, getTMDBImageUrl } = await import('./tmdb.search');
|
||||||
const tmdbResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy);
|
const tmdbResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy);
|
||||||
|
|
||||||
@@ -200,6 +233,7 @@ export async function getXiaoyaEpisodes(
|
|||||||
|
|
||||||
return videoFiles.map(file => {
|
return videoFiles.map(file => {
|
||||||
const parsed = parseVideoFileName(file.name);
|
const parsed = parseVideoFileName(file.name);
|
||||||
|
console.log('[xiaoya-metadata] 解析文件名:', file.name, '结果:', parsed);
|
||||||
let title = file.name;
|
let title = file.name;
|
||||||
|
|
||||||
if (parsed.season && parsed.episode) {
|
if (parsed.season && parsed.episode) {
|
||||||
@@ -214,11 +248,21 @@ export async function getXiaoyaEpisodes(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 电影:只有一个文件
|
// 电影:列出同一文件夹下的所有视频
|
||||||
const fileName = pathParts[pathParts.length - 1];
|
const parentDir = pathParts.slice(0, -1).join('/');
|
||||||
return [{
|
const listResponse = await xiaoyaClient.listDirectory(`/${parentDir}`);
|
||||||
path: videoPath,
|
|
||||||
title: fileName,
|
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 或重新登录
|
* 获取缓存的 Token 或重新登录
|
||||||
*/
|
*/
|
||||||
private async getToken(): Promise<string> {
|
async getToken(): Promise<string> {
|
||||||
// 如果配置了 Token,直接使用
|
// 如果配置了 Token,直接使用
|
||||||
if (this.configToken) {
|
if (this.configToken) {
|
||||||
return this.configToken;
|
return this.configToken;
|
||||||
|
|||||||
Reference in New Issue
Block a user