- {Array.from({ length: pageSize }).map((_, index) => (
-
- ))}
+ sourceType === 'xiaoya' ? (
+ // 小雅加载骨架屏 - 文件夹列表样式
+
+ )
+ ) : sourceType === 'xiaoya' ? (
+ // 小雅浏览模式
+
+ {/* 搜索框 */}
+
+
+
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 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* 搜索结果 */}
+ {xiaoyaSearchResults.length > 0 ? (
+
+
+
+ 搜索结果 ({xiaoyaSearchResults.length})
+
+
+
+
+ {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 (
+
+ );
+ })}
+
+
+ ) : isSearching ? (
+
+ ) : (
+ <>
+ {/* 面包屑导航 */}
+
+
+ {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 && (
+
+ )}
+ >
+ )}
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx
index ca0df40..c686929 100644
--- a/src/app/search/page.tsx
+++ b/src/app/search/page.tsx
@@ -36,6 +36,10 @@ function SearchPageClient() {
const [triggerAcgSearch, setTriggerAcgSearch] = useState(false);
// 用户权限
const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(null);
+ // 繁体转简体转换器
+ const converterRef = useRef<((text: string) => string) | null>(null);
+ // 转换器是否已初始化
+ const [converterReady, setConverterReady] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
@@ -538,6 +542,26 @@ function SearchPageClient() {
const authInfo = getAuthInfoFromBrowserCookie();
setUserRole(authInfo?.role || null);
+ // 初始化繁体转简体转换器
+ if (typeof window !== 'undefined') {
+ import('opencc-js').then((module) => {
+ try {
+ const OpenCC = module.default || module;
+ const converter = OpenCC.Converter({ from: 'hk', to: 'cn' });
+ converterRef.current = converter;
+ setConverterReady(true);
+ } catch (error) {
+ console.error('初始化繁体转简体转换器失败:', error);
+ setConverterReady(true); // 即使失败也设置为 true,避免阻塞
+ }
+ }).catch((error) => {
+ console.error('加载 opencc-js 失败:', error);
+ setConverterReady(true); // 即使失败也设置为 true,避免阻塞
+ });
+ } else {
+ setConverterReady(true);
+ }
+
// 初始加载搜索历史
getSearchHistory().then(setSearchHistory);
@@ -600,13 +624,41 @@ function SearchPageClient() {
}, []);
useEffect(() => {
+ // 等待转换器初始化完成
+ if (!converterReady) {
+ return;
+ }
+
// 当搜索参数变化时更新搜索状态
- const query = searchParams.get('q') || '';
+ let query = searchParams.get('q') || '';
+
+ // 如果开启了繁体转简体,进行转换
+ if (query && typeof window !== 'undefined') {
+ const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
+
+ if (searchTraditionalToSimplified === 'true' && converterRef.current) {
+ try {
+ const originalQuery = query;
+ query = converterRef.current(query);
+
+ // 如果转换后的文本与原文本不同,更新 URL
+ if (originalQuery !== query) {
+ const trimmedConverted = query.trim();
+ // 使用 replace 而不是 push,避免在历史记录中留下繁体版本
+ router.replace(`/search?q=${encodeURIComponent(trimmedConverted)}${searchParams.get('type') ? `&type=${searchParams.get('type')}` : ''}`);
+ return; // 等待 URL 更新后重新触发此 effect
+ }
+ } catch (error) {
+ console.error('[URL参数监听] 繁体转简体转换失败:', error);
+ }
+ }
+ }
+
currentQueryRef.current = query.trim();
if (query) {
setSearchQuery(query);
-
+
const trimmed = query.trim();
// 检查是否有缓存且不是强制刷新
@@ -799,7 +851,7 @@ function SearchPageClient() {
setShowResults(false);
setShowSuggestions(false);
}
- }, [searchParams, forceRefresh]);
+ }, [searchParams, forceRefresh, converterReady]);
// 组件卸载时,关闭可能存在的连接
useEffect(() => {
@@ -838,9 +890,21 @@ function SearchPageClient() {
// 搜索表单提交时触发,处理搜索逻辑
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
- const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
+ let trimmed = searchQuery.trim().replace(/\s+/g, ' ');
if (!trimmed) return;
+ // 如果开启了繁体转简体,进行转换
+ if (typeof window !== 'undefined') {
+ const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
+ if (searchTraditionalToSimplified === 'true' && converterRef.current) {
+ try {
+ trimmed = converterRef.current(trimmed);
+ } catch (error) {
+ console.error('繁体转简体转换失败:', error);
+ }
+ }
+ }
+
// 回显搜索框
setSearchQuery(trimmed);
setShowResults(true);
@@ -863,7 +927,21 @@ function SearchPageClient() {
};
const handleSuggestionSelect = (suggestion: string) => {
- setSearchQuery(suggestion);
+ let processedSuggestion = suggestion;
+
+ // 如果开启了繁体转简体,进行转换
+ if (typeof window !== 'undefined') {
+ const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
+ if (searchTraditionalToSimplified === 'true' && converterRef.current) {
+ try {
+ processedSuggestion = converterRef.current(suggestion);
+ } catch (error) {
+ console.error('繁体转简体转换失败:', error);
+ }
+ }
+ }
+
+ setSearchQuery(processedSuggestion);
setShowSuggestions(false);
// 自动执行搜索
@@ -872,15 +950,15 @@ function SearchPageClient() {
// 根据当前选项卡执行不同的搜索
if (activeTab === 'video') {
// 影视搜索
- router.push(`/search?q=${encodeURIComponent(suggestion)}&type=video`);
+ router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=video`);
// 其余由 searchParams 变化的 effect 处理
} else if (activeTab === 'pansou') {
// 网盘搜索 - 触发搜索
- router.push(`/search?q=${encodeURIComponent(suggestion)}&type=pansou`);
+ router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=pansou`);
setTriggerPansouSearch(prev => !prev);
} else if (activeTab === 'acg') {
// ACG 磁力搜索 - 触发搜索
- router.push(`/search?q=${encodeURIComponent(suggestion)}&type=acg`);
+ router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=acg`);
setTriggerAcgSearch(prev => !prev);
}
};
diff --git a/src/components/CorrectDialog.tsx b/src/components/CorrectDialog.tsx
index d302bde..6e2a85a 100644
--- a/src/components/CorrectDialog.tsx
+++ b/src/components/CorrectDialog.tsx
@@ -49,6 +49,7 @@ interface CorrectDialogProps {
seasonName?: string;
};
onCorrect: () => void;
+ source?: string; // 新增:视频源类型(xiaoya, openlist等)
}
export default function CorrectDialog({
@@ -58,6 +59,7 @@ export default function CorrectDialog({
currentTitle,
currentVideo,
onCorrect,
+ source = 'openlist', // 默认为 openlist
}: CorrectDialogProps) {
const [searchQuery, setSearchQuery] = useState(currentTitle);
const [searching, setSearching] = useState(false);
@@ -229,8 +231,7 @@ export default function CorrectDialog({
finalTitle = `${finalTitle} ${season.name}`;
}
- const body: any = {
- key: videoKey,
+ const correctionData: any = {
tmdbId: finalTmdbId,
title: finalTitle,
posterPath: season?.poster_path || result.poster_path,
@@ -240,20 +241,38 @@ export default function CorrectDialog({
mediaType: result.media_type,
};
- // 如果有季度信息,添加到请求中
+ // 如果有季度信息,添加到数据中
if (season) {
- body.seasonNumber = season.season_number;
- body.seasonName = season.name;
+ correctionData.seasonNumber = season.season_number;
+ correctionData.seasonName = season.name;
}
- const response = await fetch('/api/openlist/correct', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
+ // 根据源类型选择不同的存储方式
+ if (source === 'xiaoya') {
+ // 小雅源:存储到 localStorage
+ const storageKey = `xiaoya_correction_${videoKey}`;
+ const correctionInfo = {
+ ...correctionData,
+ correctedAt: Date.now(),
+ };
+ localStorage.setItem(storageKey, JSON.stringify(correctionInfo));
+ console.log('小雅源纠错信息已存储到 localStorage:', storageKey, correctionInfo);
+ } else {
+ // openlist 等其他源:调用 API
+ const body: any = {
+ key: videoKey,
+ ...correctionData,
+ };
- if (!response.ok) {
- throw new Error('纠错失败');
+ const response = await fetch('/api/openlist/correct', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ throw new Error('纠错失败');
+ }
}
onCorrect();
@@ -313,8 +332,7 @@ export default function CorrectDialog({
setError('');
try {
- const body: any = {
- key: videoKey,
+ const correctionData: any = {
title: manualData.title.trim(),
posterPath: manualData.posterPath.trim() || null,
releaseDate: manualData.releaseDate.trim() || '',
@@ -325,28 +343,46 @@ export default function CorrectDialog({
// 添加 TMDB ID(如果提供)
if (manualData.tmdbId.trim()) {
- body.tmdbId = Number(manualData.tmdbId);
+ correctionData.tmdbId = Number(manualData.tmdbId);
}
// 添加豆瓣 ID(如果提供)
if (manualData.doubanId.trim()) {
- body.doubanId = manualData.doubanId.trim();
+ correctionData.doubanId = manualData.doubanId.trim();
}
// 如果是电视剧且有季度信息
if (manualData.mediaType === 'tv' && manualData.seasonNumber) {
- body.seasonNumber = Number(manualData.seasonNumber);
- body.seasonName = manualData.seasonName.trim() || `第 ${manualData.seasonNumber} 季`;
+ correctionData.seasonNumber = Number(manualData.seasonNumber);
+ correctionData.seasonName = manualData.seasonName.trim() || `第 ${manualData.seasonNumber} 季`;
}
- const response = await fetch('/api/openlist/correct', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- });
+ // 根据源类型选择不同的存储方式
+ if (source === 'xiaoya') {
+ // 小雅源:存储到 localStorage
+ const storageKey = `xiaoya_correction_${videoKey}`;
+ const correctionInfo = {
+ ...correctionData,
+ correctedAt: Date.now(),
+ };
+ localStorage.setItem(storageKey, JSON.stringify(correctionInfo));
+ console.log('小雅源纠错信息已存储到 localStorage:', storageKey, correctionInfo);
+ } else {
+ // openlist 等其他源:调用 API
+ const body: any = {
+ key: videoKey,
+ ...correctionData,
+ };
- if (!response.ok) {
- throw new Error('纠错失败');
+ const response = await fetch('/api/openlist/correct', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ throw new Error('纠错失败');
+ }
}
onCorrect();
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx
index 0794870..e9bdd34 100644
--- a/src/components/EpisodeSelector.tsx
+++ b/src/components/EpisodeSelector.tsx
@@ -21,6 +21,7 @@ interface VideoInfo {
quality: string;
loadSpeed: string;
pingTime: number;
+ bitrate: string; // 视频码率
hasError?: boolean; // 添加错误状态标识
}
@@ -213,6 +214,7 @@ const EpisodeSelector: React.FC
= ({
quality: '错误',
loadSpeed: '未知',
pingTime: 0,
+ bitrate: '未知',
hasError: true,
})
);
@@ -271,16 +273,18 @@ const EpisodeSelector: React.FC = ({
if (
!optimizationEnabled || // 若关闭测速则直接退出
activeTab !== 'sources' ||
- availableSources.length === 0 ||
- currentSource === 'openlist' || // 私人影库不进行测速
- currentSource === 'emby' // Emby 不进行测速
+ availableSources.length === 0
)
return;
- // 筛选出尚未测速的播放源
+ // 筛选出尚未测速的播放源,并排除不需要测速的源(openlist/emby/xiaoya)
const pendingSources = availableSources.filter((source) => {
const sourceKey = `${source.source}-${source.id}`;
- return !attemptedSourcesRef.current.has(sourceKey);
+ // 跳过已测速的源
+ if (attemptedSourcesRef.current.has(sourceKey)) return false;
+ // 跳过不需要测速的源
+ if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') return false;
+ return true;
});
if (pendingSources.length === 0) return;
@@ -308,11 +312,15 @@ const EpisodeSelector: React.FC = ({
// 当后台加载从 true 变为 false 时(即加载完成)
if (prevBackgroundLoadingRef.current && !backgroundSourcesLoading) {
// 如果当前选项卡在换源位置,触发测速
- if (activeTab === 'sources' && optimizationEnabled && currentSource !== 'openlist' && currentSource !== 'emby') {
- // 筛选出尚未测速的播放源
+ if (activeTab === 'sources' && optimizationEnabled) {
+ // 筛选出尚未测速的播放源,并排除不需要测速的源(openlist/emby/xiaoya)
const pendingSources = availableSources.filter((source) => {
const sourceKey = `${source.source}-${source.id}`;
- return !attemptedSourcesRef.current.has(sourceKey);
+ // 跳过已测速的源
+ if (attemptedSourcesRef.current.has(sourceKey)) return false;
+ // 跳过不需要测速的源
+ if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') return false;
+ return true;
});
if (pendingSources.length > 0) {
@@ -683,6 +691,13 @@ const EpisodeSelector: React.FC = ({
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) {
@@ -856,7 +871,7 @@ const EpisodeSelector: React.FC = ({
{/* 源名称和集数信息 - 垂直居中 */}
@@ -885,6 +900,11 @@ const EpisodeSelector: React.FC = ({
{videoInfo.pingTime}ms
+ {videoInfo.bitrate && videoInfo.bitrate !== '未知' && (
+
+ {videoInfo.bitrate}
+
+ )}
);
} else {
@@ -900,8 +920,8 @@ const EpisodeSelector: React.FC = ({
{/* 重新测试按钮 */}
{(() => {
- // 私人影库和 Emby 不显示重新测试按钮
- if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_')) {
+ // 私人影库、Emby 和小雅不显示重新测试按钮
+ if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') {
return null;
}
diff --git a/src/components/MultiLevelSelector.tsx b/src/components/MultiLevelSelector.tsx
index b7f8bfc..908d4ec 100644
--- a/src/components/MultiLevelSelector.tsx
+++ b/src/components/MultiLevelSelector.tsx
@@ -259,6 +259,25 @@ const MultiLevelSelector: React.FC