增加视频特殊源配置功能
This commit is contained in:
+206
-2
@@ -415,6 +415,7 @@ interface DataSource {
|
||||
from: 'config' | 'custom';
|
||||
proxyMode?: boolean;
|
||||
weight?: number;
|
||||
special?: boolean;
|
||||
}
|
||||
|
||||
// 直播源数据类型
|
||||
@@ -2069,7 +2070,7 @@ const UserConfig = ({
|
||||
selectedDeviceUsername &&
|
||||
createPortal(
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||
className='fixed inset-0 bg-black bg-opacity-50 z-[10002] flex items-center justify-center p-4'
|
||||
onClick={() => {
|
||||
setShowUserDevicesModal(false);
|
||||
setSelectedDeviceUsername(null);
|
||||
@@ -6320,6 +6321,8 @@ const VideoSourceConfig = ({
|
||||
// 有效性检测相关状态
|
||||
const [showValidationModal, setShowValidationModal] = useState(false);
|
||||
const [showWeightModal, setShowWeightModal] = useState(false);
|
||||
const [showSpecialSourcesModal, setShowSpecialSourcesModal] = useState(false);
|
||||
const [specialSourceDraftApis, setSpecialSourceDraftApis] = useState<string[]>([]);
|
||||
const [weightDraftSources, setWeightDraftSources] = useState<DataSource[]>(
|
||||
[]
|
||||
);
|
||||
@@ -6453,6 +6456,71 @@ const VideoSourceConfig = ({
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const openSpecialSourcesModal = () => {
|
||||
setSpecialSourceDraftApis(config?.SpecialSourceApis || []);
|
||||
setShowSpecialSourcesModal(true);
|
||||
};
|
||||
|
||||
const closeSpecialSourcesModal = () => {
|
||||
setShowSpecialSourcesModal(false);
|
||||
setSpecialSourceDraftApis([]);
|
||||
};
|
||||
|
||||
const doSaveSpecialSources = async () => {
|
||||
await withLoading('saveSpecialSources', async () => {
|
||||
await callSourceApi({
|
||||
action: 'set_special_sources',
|
||||
keys: specialSourceDraftApis,
|
||||
});
|
||||
closeSpecialSourcesModal();
|
||||
}).catch(() => {
|
||||
console.error('操作失败', 'set_special_sources');
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveSpecialSources = async () => {
|
||||
const enabledSourceKeys =
|
||||
config?.SourceConfig?.filter((source) => !source.disabled).map(
|
||||
(source) => source.key
|
||||
) || [];
|
||||
const selectedSet = new Set(specialSourceDraftApis);
|
||||
const selectedAllEnabledSources =
|
||||
enabledSourceKeys.length > 0 &&
|
||||
enabledSourceKeys.every((key) => selectedSet.has(key));
|
||||
|
||||
if (selectedAllEnabledSources) {
|
||||
setConfirmModal({
|
||||
isOpen: true,
|
||||
title: '确认设置特殊源',
|
||||
message:
|
||||
'你已将全部启用的视频源设置为特殊源,未开启特殊源开关的用户可能无法使用搜索。确定要继续保存吗?',
|
||||
onConfirm: async () => {
|
||||
await doSaveSpecialSources();
|
||||
setConfirmModal({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
setConfirmModal({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await doSaveSpecialSources();
|
||||
};
|
||||
|
||||
const handleUpdateWeight = (key: string, weight: number) => {
|
||||
// 先乐观更新本地状态
|
||||
setSources((prev) =>
|
||||
@@ -7273,6 +7341,19 @@ const VideoSourceConfig = ({
|
||||
</>
|
||||
)}
|
||||
<div className='flex items-center gap-2 overflow-x-auto whitespace-nowrap order-1 sm:order-2'>
|
||||
<button
|
||||
onClick={openSpecialSourcesModal}
|
||||
className={`${buttonStyles.secondary} flex shrink-0 items-center gap-1.5 whitespace-nowrap`}
|
||||
title='批量选择哪些视频源属于特殊源'
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>特殊源设置</span>
|
||||
{(config?.SpecialSourceApis?.length || 0) > 0 && (
|
||||
<span className='rounded-full bg-rose-100 px-1.5 py-0.5 text-[10px] font-semibold text-rose-700 dark:bg-rose-900/30 dark:text-rose-300'>
|
||||
{config?.SpecialSourceApis?.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={openWeightModal}
|
||||
className={`${buttonStyles.secondary} flex shrink-0 items-center gap-1.5 whitespace-nowrap`}
|
||||
@@ -7423,6 +7504,129 @@ const VideoSourceConfig = ({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
{showSpecialSourcesModal &&
|
||||
createPortal(
|
||||
<div
|
||||
className='fixed inset-0 z-[10000] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm'
|
||||
onClick={closeSpecialSourcesModal}
|
||||
>
|
||||
<div
|
||||
className='flex max-h-[84vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-5 dark:border-gray-700'>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||
特殊源设置
|
||||
</h3>
|
||||
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||
选中的视频源默认对普通搜索隐藏,仅在当前设备访问 /special 开启后参与普通 Web 搜索。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeSpecialSourcesModal}
|
||||
className='text-2xl leading-none text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300'
|
||||
aria-label='关闭特殊源设置弹窗'
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-6 py-5'>
|
||||
<div className='mb-5 rounded-lg border border-rose-200 bg-rose-50 p-4 dark:border-rose-800 dark:bg-rose-900/20'>
|
||||
<div className='text-sm font-medium text-rose-800 dark:text-rose-300'>
|
||||
配置说明
|
||||
</div>
|
||||
<p className='mt-1 text-sm text-rose-700 dark:text-rose-400'>
|
||||
这里维护的是特殊源列表,不是用户权限;TVBox、OrionTV、WebTV 始终不会使用这些特殊源。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
{config?.SourceConfig?.map((source) => (
|
||||
<label
|
||||
key={source.key}
|
||||
className='flex cursor-pointer items-center space-x-3 rounded-lg border border-gray-200 p-3 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-900/50'
|
||||
>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={specialSourceDraftApis.includes(source.key)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSpecialSourceDraftApis((prev) =>
|
||||
prev.includes(source.key) ? prev : [...prev, source.key]
|
||||
);
|
||||
} else {
|
||||
setSpecialSourceDraftApis((prev) =>
|
||||
prev.filter((api) => api !== source.key)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className='rounded border-gray-300 text-rose-600 focus:ring-rose-500 dark:border-gray-600 dark:bg-gray-700'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||
{source.name}
|
||||
</div>
|
||||
<div className='truncate text-xs text-gray-500 dark:text-gray-400'>
|
||||
{source.key}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-900/30'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<button
|
||||
onClick={() => setSpecialSourceDraftApis([])}
|
||||
className={buttonStyles.quickAction}
|
||||
>
|
||||
全不选
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const allApis =
|
||||
config?.SourceConfig?.filter((source) => !source.disabled).map(
|
||||
(source) => source.key
|
||||
) || [];
|
||||
setSpecialSourceDraftApis(allApis);
|
||||
}}
|
||||
className={buttonStyles.quickAction}
|
||||
>
|
||||
全选启用源
|
||||
</button>
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
已选择:
|
||||
<span className='font-medium text-rose-600 dark:text-rose-400'>
|
||||
{specialSourceDraftApis.length} 个源
|
||||
</span>
|
||||
</span>
|
||||
<button onClick={closeSpecialSourcesModal} className={buttonStyles.secondary}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveSpecialSources}
|
||||
disabled={isLoading('saveSpecialSources')}
|
||||
className={`px-4 py-2 ${
|
||||
isLoading('saveSpecialSources')
|
||||
? buttonStyles.disabled
|
||||
: buttonStyles.success
|
||||
}`}
|
||||
>
|
||||
{isLoading('saveSpecialSources') ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showWeightModal &&
|
||||
createPortal(
|
||||
<>
|
||||
@@ -7636,7 +7840,7 @@ const VideoSourceConfig = ({
|
||||
{confirmModal.isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'
|
||||
className='fixed inset-0 bg-black bg-opacity-50 z-[10020] flex items-center justify-center p-4'
|
||||
onClick={confirmModal.onCancel}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -19,6 +19,8 @@ type Action =
|
||||
| 'batch_enable'
|
||||
| 'batch_delete'
|
||||
| 'toggle_proxy_mode'
|
||||
| 'toggle_special_source'
|
||||
| 'set_special_sources'
|
||||
| 'update_weight'
|
||||
| 'batch_update_weights';
|
||||
|
||||
@@ -58,6 +60,8 @@ export async function POST(request: NextRequest) {
|
||||
'batch_enable',
|
||||
'batch_delete',
|
||||
'toggle_proxy_mode',
|
||||
'toggle_special_source',
|
||||
'set_special_sources',
|
||||
'update_weight',
|
||||
'batch_update_weights',
|
||||
];
|
||||
@@ -146,6 +150,9 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '该源不可删除' }, { status: 400 });
|
||||
}
|
||||
adminConfig.SourceConfig.splice(idx, 1);
|
||||
adminConfig.SpecialSourceApis = (adminConfig.SpecialSourceApis || []).filter(
|
||||
(api) => api !== key
|
||||
);
|
||||
|
||||
// 检查并清理用户组和用户的权限数组
|
||||
// 清理用户组权限
|
||||
@@ -226,6 +233,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
|
||||
adminConfig.SpecialSourceApis = (adminConfig.SpecialSourceApis || []).filter(
|
||||
(api) => !keysToDelete.includes(api)
|
||||
);
|
||||
|
||||
// 检查并清理用户组和用户的权限数组
|
||||
if (keysToDelete.length > 0) {
|
||||
// 清理用户组权限
|
||||
@@ -290,6 +301,39 @@ export async function POST(request: NextRequest) {
|
||||
entry.proxyMode = !entry.proxyMode;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'toggle_special_source': {
|
||||
const { key } = body as { key?: string };
|
||||
if (!key)
|
||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
|
||||
const specialApis = new Set(adminConfig.SpecialSourceApis || []);
|
||||
if (specialApis.has(key)) {
|
||||
specialApis.delete(key);
|
||||
} else {
|
||||
specialApis.add(key);
|
||||
}
|
||||
adminConfig.SpecialSourceApis = Array.from(specialApis).filter((api) =>
|
||||
adminConfig.SourceConfig.some((source) => source.key === api)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'set_special_sources': {
|
||||
const { keys } = body as { keys?: string[] };
|
||||
if (!Array.isArray(keys)) {
|
||||
return NextResponse.json({ error: 'keys 参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sourceKeySet = new Set(adminConfig.SourceConfig.map((source) => source.key));
|
||||
adminConfig.SpecialSourceApis = Array.from(new Set(keys)).filter((key) =>
|
||||
sourceKeySet.has(key)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'batch_update_weights': {
|
||||
const { weights, order } = body as {
|
||||
weights?: Array<{ key?: string; weight?: number }>;
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const sourceCode = searchParams.get('source');
|
||||
const includeSpecialSources = searchParams.get('special') === '1';
|
||||
|
||||
if (!id || !sourceCode) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
@@ -233,7 +234,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources);
|
||||
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
||||
|
||||
if (!apiSite) {
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
const includeSpecialSources = searchParams.get('special') === '1';
|
||||
|
||||
if (!query) {
|
||||
const cacheTime = await getCacheTime();
|
||||
@@ -42,7 +43,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources);
|
||||
const [canAccessOpenList, canAccessEmby] = await Promise.all([
|
||||
hasFeaturePermission(authInfo.username, 'private_library'),
|
||||
hasFeaturePermission(authInfo.username, 'emby'),
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
const includeSpecialSources = searchParams.get('special') === '1';
|
||||
|
||||
if (!query) {
|
||||
return new Response(
|
||||
@@ -39,7 +40,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources);
|
||||
const [canAccessOpenList, canAccessEmby] = await Promise.all([
|
||||
hasFeaturePermission(authInfo.username, 'private_library'),
|
||||
hasFeaturePermission(authInfo.username, 'emby'),
|
||||
|
||||
@@ -111,6 +111,7 @@ export async function GET(request: NextRequest) {
|
||||
const sourceCode = normalizeNetdiskSource(searchParams.get('source'));
|
||||
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
|
||||
const title = searchParams.get('title');
|
||||
const includeSpecialSources = searchParams.get('special') === '1';
|
||||
|
||||
if (!id || !sourceCode) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
@@ -1201,7 +1202,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 对于其他采集源,直接按 id 获取详情。
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources);
|
||||
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
||||
|
||||
if (!apiSite) {
|
||||
|
||||
@@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceKey = searchParams.get('source');
|
||||
const includeSpecialSources = searchParams.get('special') === '1';
|
||||
|
||||
if (!sourceKey) {
|
||||
return NextResponse.json(
|
||||
@@ -34,7 +35,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources);
|
||||
const targetSite = apiSites.find((site) => site.key === sourceKey);
|
||||
|
||||
if (!targetSite) {
|
||||
|
||||
@@ -52,7 +52,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const includeSpecialSources = request.nextUrl.searchParams.get('special') === '1';
|
||||
const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources);
|
||||
const targetSite = apiSites.find((site) => site.key === sourceKey);
|
||||
|
||||
if (!targetSite) {
|
||||
|
||||
@@ -12,7 +12,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const includeSpecialSources = request.nextUrl.searchParams.get('special') === '1';
|
||||
const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources);
|
||||
|
||||
return NextResponse.json({
|
||||
sources: apiSites.map((site) => ({
|
||||
|
||||
@@ -52,7 +52,8 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const includeSpecialSources = request.nextUrl.searchParams.get('special') === '1';
|
||||
const apiSites = await getAvailableApiSites(authInfo.username, includeSpecialSources);
|
||||
const targetSite = apiSites.find((site) => site.key === sourceKey);
|
||||
|
||||
if (!targetSite) {
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from '@/lib/db.client';
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
import { isEpisodeHiddenByFilter, normalizeEpisodeFilterConfig } from '@/lib/episode-filter';
|
||||
import { appendSpecialSourceParam, isSpecialSourcesEnabledOnDevice } from '@/lib/special-source.client';
|
||||
import {
|
||||
buildEpisodeProgressContentKey,
|
||||
loadLocalEpisodeProgress,
|
||||
@@ -3766,7 +3767,7 @@ function PlayPageClient() {
|
||||
fileNameParam?: string
|
||||
): Promise<SearchResult[]> => {
|
||||
try {
|
||||
let url = `/api/source-detail?source=${source}&id=${id}&title=${encodeURIComponent(title)}`;
|
||||
let url = appendSpecialSourceParam(`/api/source-detail?source=${source}&id=${id}&title=${encodeURIComponent(title)}`);
|
||||
// 如果有fileName参数(小雅源),添加到URL
|
||||
if (fileNameParam) {
|
||||
url += `&fileName=${encodeURIComponent(fileNameParam)}`;
|
||||
@@ -3934,7 +3935,7 @@ function PlayPageClient() {
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = `search_cache_${query.trim()}`;
|
||||
const cacheKey = `search_cache_${query.trim()}${isSpecialSourcesEnabledOnDevice() ? '_special' : ''}`;
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (!cached) return null;
|
||||
|
||||
@@ -3955,7 +3956,7 @@ function PlayPageClient() {
|
||||
if (typeof window === 'undefined' || !query.trim()) return;
|
||||
|
||||
try {
|
||||
const cacheKey = `search_cache_${query.trim()}`;
|
||||
const cacheKey = `search_cache_${query.trim()}${isSpecialSourcesEnabledOnDevice() ? '_special' : ''}`;
|
||||
const payload: SearchCachePayload = {
|
||||
status: 'complete',
|
||||
results,
|
||||
@@ -4011,7 +4012,7 @@ function PlayPageClient() {
|
||||
|
||||
// 没有缓存或只有 partial 缓存时,重新请求完整搜索结果
|
||||
const response = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||||
appendSpecialSourceParam(`/api/search?q=${encodeURIComponent(query.trim())}`)
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('搜索失败');
|
||||
@@ -4583,7 +4584,7 @@ function PlayPageClient() {
|
||||
// 这类源统一通过详情接口补全播放数据
|
||||
if (isLazyDetailSource(newDetail.source) && (!newDetail.episodes || newDetail.episodes.length === 0)) {
|
||||
try {
|
||||
const detailResponse = await fetch(`/api/source-detail?source=${newSource}&id=${newId}&title=${encodeURIComponent(newTitle)}`);
|
||||
const detailResponse = await fetch(appendSpecialSourceParam(`/api/source-detail?source=${newSource}&id=${newId}&title=${encodeURIComponent(newTitle)}`));
|
||||
if (detailResponse.ok) {
|
||||
const detailData = await detailResponse.json();
|
||||
if (!detailData) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { appendSpecialSourceParam, isSpecialSourcesEnabledOnDevice } from '@/lib/special-source.client';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import AcgSearch from '@/components/AcgSearch';
|
||||
@@ -127,7 +128,8 @@ function SearchPageClient() {
|
||||
|
||||
// 生成缓存键
|
||||
const getCacheKey = (query: string) => {
|
||||
return `search_cache_${query.trim()}`;
|
||||
const suffix = isSpecialSourcesEnabledOnDevice() ? '_special' : '';
|
||||
return `search_cache_${query.trim()}${suffix}`;
|
||||
};
|
||||
|
||||
// 从 sessionStorage 获取完整缓存的搜索结果(partial 只给播放页快速启动使用)
|
||||
@@ -1249,7 +1251,7 @@ function SearchPageClient() {
|
||||
if (currentFluidSearch) {
|
||||
// 流式搜索:打开新的流式连接
|
||||
const es = new EventSource(
|
||||
`/api/search/ws?q=${encodeURIComponent(trimmed)}`
|
||||
appendSpecialSourceParam(`/api/search/ws?q=${encodeURIComponent(trimmed)}`)
|
||||
);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
@@ -1355,7 +1357,7 @@ function SearchPageClient() {
|
||||
};
|
||||
} else {
|
||||
// 传统搜索:使用普通接口
|
||||
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
|
||||
fetch(appendSpecialSourceParam(`/api/search?q=${encodeURIComponent(trimmed)}`))
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (currentQueryRef.current !== trimmed) return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Loader2, Search } from 'lucide-react';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ApiSite } from '@/lib/config';
|
||||
import { appendSpecialSourceParam } from '@/lib/special-source.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
@@ -39,7 +40,7 @@ function SourceSearchPageClient() {
|
||||
const fetchApiSites = async () => {
|
||||
setIsLoadingSources(true);
|
||||
try {
|
||||
const response = await fetch('/api/source-search/sources');
|
||||
const response = await fetch(appendSpecialSourceParam('/api/source-search/sources'));
|
||||
const data = await response.json();
|
||||
if (data.sources && Array.isArray(data.sources)) {
|
||||
setApiSites(data.sources);
|
||||
@@ -71,7 +72,7 @@ function SourceSearchPageClient() {
|
||||
setHasMore(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/source-search/categories?source=${encodeURIComponent(selectedSource)}`
|
||||
appendSpecialSourceParam(`/api/source-search/categories?source=${encodeURIComponent(selectedSource)}`)
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.categories && Array.isArray(data.categories)) {
|
||||
@@ -99,7 +100,7 @@ function SourceSearchPageClient() {
|
||||
setIsLoadingVideos(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/source-search/videos?source=${encodeURIComponent(selectedSource)}&categoryId=${encodeURIComponent(selectedCategory)}&page=${currentPage}`
|
||||
appendSpecialSourceParam(`/api/source-search/videos?source=${encodeURIComponent(selectedSource)}&categoryId=${encodeURIComponent(selectedCategory)}&page=${currentPage}`)
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
@@ -128,7 +129,7 @@ function SourceSearchPageClient() {
|
||||
setIsLoadingVideos(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/source-search/search?source=${encodeURIComponent(selectedSource)}&keyword=${encodeURIComponent(searchKeyword)}&page=${currentPage}`
|
||||
appendSpecialSourceParam(`/api/source-search/search?source=${encodeURIComponent(selectedSource)}&keyword=${encodeURIComponent(searchKeyword)}&page=${currentPage}`)
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
isSpecialSourcesEnabledOnDevice,
|
||||
setSpecialSourcesEnabledOnDevice,
|
||||
} from '@/lib/special-source.client';
|
||||
|
||||
function SpecialPageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const enableParam = searchParams.get('enable');
|
||||
if (enableParam === '1' || enableParam === 'true') {
|
||||
setSpecialSourcesEnabledOnDevice(true);
|
||||
} else if (enableParam === '0' || enableParam === 'false') {
|
||||
setSpecialSourcesEnabledOnDevice(false);
|
||||
}
|
||||
|
||||
setEnabled(isSpecialSourcesEnabledOnDevice());
|
||||
setReady(true);
|
||||
}, [searchParams]);
|
||||
|
||||
const updateEnabled = (next: boolean) => {
|
||||
setSpecialSourcesEnabledOnDevice(next);
|
||||
setEnabled(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='min-h-screen bg-gray-50 text-gray-900 dark:bg-black dark:text-slate-100'>
|
||||
<section className='mx-auto flex min-h-screen w-full max-w-xl items-center px-5 py-10'>
|
||||
<div className='w-full rounded-2xl border border-gray-200 bg-white p-6 shadow-xl dark:border-white/10 dark:bg-zinc-950 sm:p-8'>
|
||||
<div className='space-y-3'>
|
||||
<h1 className='text-2xl font-semibold tracking-tight text-gray-900 dark:text-white'>
|
||||
特殊源
|
||||
</h1>
|
||||
<p className='text-sm leading-6 text-gray-600 dark:text-slate-400'>
|
||||
开启后,将能搜索到特殊源的视频。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/[0.03] p-4'>
|
||||
<div>
|
||||
<div className='text-sm text-gray-600 dark:text-slate-400'>当前状态</div>
|
||||
<div className='mt-1 text-lg font-medium text-gray-900 dark:text-white'>
|
||||
{ready ? (enabled ? '已开启' : '已关闭') : '读取中'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => updateEnabled(!enabled)}
|
||||
className={`relative inline-flex h-8 w-14 items-center rounded-full p-1 transition focus:outline-none focus:ring-2 focus:ring-rose-400 ${
|
||||
enabled ? 'bg-rose-600' : 'bg-gray-300 dark:bg-slate-700'
|
||||
}`}
|
||||
aria-pressed={enabled}
|
||||
aria-label={enabled ? '关闭特殊源' : '开启特殊源'}
|
||||
>
|
||||
<span
|
||||
className={`h-6 w-6 rounded-full bg-white transition-transform ${
|
||||
enabled ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className='mt-4 text-xs leading-5 text-gray-500 dark:text-slate-500'>
|
||||
此开关对 TVBox、OrionTV、WebTV 渠道无效,特殊源始终无法使用特殊源。
|
||||
</p>
|
||||
|
||||
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
|
||||
<Link
|
||||
href='/search'
|
||||
className='inline-flex flex-1 items-center justify-center gap-2 rounded-xl bg-rose-600 px-4 py-3 text-sm font-medium text-white transition hover:bg-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-400'
|
||||
>
|
||||
<Search className='h-4 w-4' />
|
||||
前往搜索
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SpecialPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SpecialPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getBangumiSubject } from '@/lib/bangumi.client';
|
||||
import { appendSpecialSourceParam } from '@/lib/special-source.client';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.client';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
@@ -408,11 +409,11 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||
if (sourceId && source) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/source-detail?id=${encodeURIComponent(
|
||||
appendSpecialSourceParam(`/api/source-detail?id=${encodeURIComponent(
|
||||
sourceId
|
||||
)}&source=${encodeURIComponent(
|
||||
source
|
||||
)}&title=${encodeURIComponent(title)}`
|
||||
)}&title=${encodeURIComponent(title)}`)
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface AdminConfig {
|
||||
permissions?: string[];
|
||||
}[];
|
||||
};
|
||||
SpecialSourceApis?: string[]; // 特殊源 key 列表,默认对普通入口隐藏
|
||||
SourceConfig: {
|
||||
key: string;
|
||||
name: string;
|
||||
|
||||
+42
-2
@@ -47,6 +47,8 @@ interface ConfigFileStruct {
|
||||
lives?: {
|
||||
[key: string]: LiveCfg;
|
||||
};
|
||||
special_source_apis?: string[];
|
||||
specialSourceApis?: string[];
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
@@ -120,6 +122,18 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
|
||||
// 将 Map 转换回数组
|
||||
adminConfig.SourceConfig = Array.from(currentApiSites.values());
|
||||
|
||||
const specialApisFromFile = Array.isArray(fileConfig.special_source_apis)
|
||||
? fileConfig.special_source_apis
|
||||
: Array.isArray(fileConfig.specialSourceApis)
|
||||
? fileConfig.specialSourceApis
|
||||
: undefined;
|
||||
if (specialApisFromFile) {
|
||||
const sourceKeys = new Set(adminConfig.SourceConfig.map((source) => source.key));
|
||||
adminConfig.SpecialSourceApis = Array.from(new Set(specialApisFromFile)).filter((key) =>
|
||||
sourceKeys.has(key)
|
||||
);
|
||||
}
|
||||
|
||||
// 覆盖 CustomCategories
|
||||
const customCategoriesFromFile = fileConfig.custom_category || [];
|
||||
const currentCustomCategories = new Map(
|
||||
@@ -316,6 +330,11 @@ async function getInitConfig(
|
||||
SourceConfig: [],
|
||||
CustomCategories: [],
|
||||
LiveConfig: [],
|
||||
SpecialSourceApis: Array.isArray(cfgFile.special_source_apis)
|
||||
? cfgFile.special_source_apis
|
||||
: Array.isArray(cfgFile.specialSourceApis)
|
||||
? cfgFile.specialSourceApis
|
||||
: [],
|
||||
};
|
||||
|
||||
// 用户信息已迁移到新版数据库,不再填充 UserConfig.Users
|
||||
@@ -581,6 +600,12 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (!adminConfig.LiveConfig || !Array.isArray(adminConfig.LiveConfig)) {
|
||||
adminConfig.LiveConfig = [];
|
||||
}
|
||||
if (
|
||||
!adminConfig.SpecialSourceApis ||
|
||||
!Array.isArray(adminConfig.SpecialSourceApis)
|
||||
) {
|
||||
adminConfig.SpecialSourceApis = [];
|
||||
}
|
||||
adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours(
|
||||
adminConfig.LiveRefreshIntervalHours
|
||||
);
|
||||
@@ -631,6 +656,11 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
return true;
|
||||
});
|
||||
|
||||
const validSourceKeys = new Set(adminConfig.SourceConfig.map((source) => source.key));
|
||||
adminConfig.SpecialSourceApis = Array.from(
|
||||
new Set((adminConfig.SpecialSourceApis || []).filter((key) => validSourceKeys.has(key)))
|
||||
);
|
||||
|
||||
// 自定义分类去重
|
||||
const seenCustomCategoryKeys = new Set<string>();
|
||||
adminConfig.CustomCategories = adminConfig.CustomCategories.filter(
|
||||
@@ -963,9 +993,19 @@ export async function getCacheTime(): Promise<number> {
|
||||
return config.SiteConfig.SiteInterfaceCacheTime || 7200;
|
||||
}
|
||||
|
||||
export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
|
||||
export async function getAvailableApiSites(
|
||||
user?: string,
|
||||
includeSpecialSources = false
|
||||
): Promise<ApiSite[]> {
|
||||
const config = await getConfig();
|
||||
const allApiSites = config.SourceConfig.filter((s) => !s.disabled);
|
||||
const specialSourceSet = new Set(config.SpecialSourceApis || []);
|
||||
const filterSpecialSources = <T extends { key: string }>(sites: T[]): T[] =>
|
||||
includeSpecialSources
|
||||
? sites
|
||||
: sites.filter((site) => !specialSourceSet.has(site.key));
|
||||
const allApiSites = filterSpecialSources(
|
||||
config.SourceConfig.filter((s) => !s.disabled)
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return allApiSites;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export const SPECIAL_SOURCE_STORAGE_KEY = 'specialSourcesEnabled';
|
||||
|
||||
export function isSpecialSourcesEnabledOnDevice(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem(SPECIAL_SOURCE_STORAGE_KEY) === '1';
|
||||
}
|
||||
|
||||
export function setSpecialSourcesEnabledOnDevice(enabled: boolean) {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (enabled) {
|
||||
localStorage.setItem(SPECIAL_SOURCE_STORAGE_KEY, '1');
|
||||
} else {
|
||||
localStorage.removeItem(SPECIAL_SOURCE_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function appendSpecialSourceParam(url: string): string {
|
||||
if (!isSpecialSourcesEnabledOnDevice()) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}special=1`;
|
||||
}
|
||||
|
||||
export function appendSpecialSourceSearchParam(params: URLSearchParams) {
|
||||
if (isSpecialSourcesEnabledOnDevice()) {
|
||||
params.set('special', '1');
|
||||
}
|
||||
return params;
|
||||
}
|
||||
Reference in New Issue
Block a user