增加视频特殊源配置功能

This commit is contained in:
mtvpls
2026-06-09 15:25:22 +08:00
parent 3f9206e754
commit 65d89be93a
18 changed files with 454 additions and 26 deletions
+206 -2
View File
@@ -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'>
TVBoxOrionTVWebTV 使
</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
+44
View File
@@ -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 }>;
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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'),
+2 -1
View File
@@ -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'),
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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) => ({
+2 -1
View File
@@ -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) {
+6 -5
View File
@@ -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) {
+5 -3
View File
@@ -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 -4
View File
@@ -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)) {
+98
View File
@@ -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'>
TVBoxOrionTVWebTV 使
</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>
);
}
+3 -2
View File
@@ -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();
+1
View File
@@ -85,6 +85,7 @@ export interface AdminConfig {
permissions?: string[];
}[];
};
SpecialSourceApis?: string[]; // 特殊源 key 列表,默认对普通入口隐藏
SourceConfig: {
key: string;
name: string;
+42 -2
View File
@@ -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;
+28
View File
@@ -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;
}