From 65d89be93a269a47de20062cd3ddce50a9180523 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 9 Jun 2026 15:25:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=A7=86=E9=A2=91=E7=89=B9?= =?UTF-8?q?=E6=AE=8A=E6=BA=90=E9=85=8D=E7=BD=AE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 208 +++++++++++++++++- src/app/api/admin/source/route.ts | 44 ++++ src/app/api/detail/route.ts | 3 +- src/app/api/search/route.ts | 3 +- src/app/api/search/ws/route.ts | 3 +- src/app/api/source-detail/route.ts | 3 +- src/app/api/source-search/categories/route.ts | 3 +- src/app/api/source-search/search/route.ts | 3 +- src/app/api/source-search/sources/route.ts | 3 +- src/app/api/source-search/videos/route.ts | 3 +- src/app/play/page.tsx | 11 +- src/app/search/page.tsx | 8 +- src/app/source-search/page.tsx | 9 +- src/app/special/page.tsx | 98 +++++++++ src/components/DetailPanel.tsx | 5 +- src/lib/admin.types.ts | 1 + src/lib/config.ts | 44 +++- src/lib/special-source.client.ts | 28 +++ 18 files changed, 454 insertions(+), 26 deletions(-) create mode 100644 src/app/special/page.tsx create mode 100644 src/lib/special-source.client.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 58ff2b9..cb59e7c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -415,6 +415,7 @@ interface DataSource { from: 'config' | 'custom'; proxyMode?: boolean; weight?: number; + special?: boolean; } // 直播源数据类型 @@ -2069,7 +2070,7 @@ const UserConfig = ({ selectedDeviceUsername && createPortal(
{ 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([]); const [weightDraftSources, setWeightDraftSources] = useState( [] ); @@ -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 = ({ )}
+
+ + {showSpecialSourcesModal && + createPortal( +
+
e.stopPropagation()} + > +
+
+

+ 特殊源设置 +

+

+ 选中的视频源默认对普通搜索隐藏,仅在当前设备访问 /special 开启后参与普通 Web 搜索。 +

+
+ +
+ +
+
+
+ 配置说明 +
+

+ 这里维护的是特殊源列表,不是用户权限;TVBox、OrionTV、WebTV 始终不会使用这些特殊源。 +

+
+ +
+ {config?.SourceConfig?.map((source) => ( + + ))} +
+
+ +
+
+ + +
+
+ + 已选择: + + {specialSourceDraftApis.length} 个源 + + + + +
+
+
+
, + document.body + )} + {showWeightModal && createPortal( <> @@ -7636,7 +7840,7 @@ const VideoSourceConfig = ({ {confirmModal.isOpen && createPortal(
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 }>; diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 16fd81a..36bf1c2 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -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) { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index b2a783b..44f5d7b 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -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'), diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 19ec13b..32f83ce 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -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'), diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index e9198e9..333de67 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -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) { diff --git a/src/app/api/source-search/categories/route.ts b/src/app/api/source-search/categories/route.ts index c64d125..38670f7 100644 --- a/src/app/api/source-search/categories/route.ts +++ b/src/app/api/source-search/categories/route.ts @@ -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) { diff --git a/src/app/api/source-search/search/route.ts b/src/app/api/source-search/search/route.ts index eff5d41..d035843 100644 --- a/src/app/api/source-search/search/route.ts +++ b/src/app/api/source-search/search/route.ts @@ -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) { diff --git a/src/app/api/source-search/sources/route.ts b/src/app/api/source-search/sources/route.ts index 1dc88c0..74315db 100644 --- a/src/app/api/source-search/sources/route.ts +++ b/src/app/api/source-search/sources/route.ts @@ -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) => ({ diff --git a/src/app/api/source-search/videos/route.ts b/src/app/api/source-search/videos/route.ts index e6e4f0c..ed5c31d 100644 --- a/src/app/api/source-search/videos/route.ts +++ b/src/app/api/source-search/videos/route.ts @@ -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) { diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 87cc50c..90e81fe 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -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 => { 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) { diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index ae8cfde..27369b1 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -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; diff --git a/src/app/source-search/page.tsx b/src/app/source-search/page.tsx index 1c51257..8eb1741 100644 --- a/src/app/source-search/page.tsx +++ b/src/app/source-search/page.tsx @@ -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)) { diff --git a/src/app/special/page.tsx b/src/app/special/page.tsx new file mode 100644 index 0000000..272fe74 --- /dev/null +++ b/src/app/special/page.tsx @@ -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 ( +
+
+
+
+

+ 特殊源 +

+

+ 开启后,将能搜索到特殊源的视频。 +

+
+ +
+
+
当前状态
+
+ {ready ? (enabled ? '已开启' : '已关闭') : '读取中'} +
+
+ + +
+ +

+ 此开关对 TVBox、OrionTV、WebTV 渠道无效,特殊源始终无法使用特殊源。 +

+ +
+ + + 前往搜索 + +
+
+
+
+ ); +} + +export default function SpecialPage() { + return ( + + + + ); +} diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index 2479489..6d8e8b4 100644 --- a/src/components/DetailPanel.tsx +++ b/src/components/DetailPanel.tsx @@ -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 = ({ 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(); diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 21d7b6d..4beceea 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -85,6 +85,7 @@ export interface AdminConfig { permissions?: string[]; }[]; }; + SpecialSourceApis?: string[]; // 特殊源 key 列表,默认对普通入口隐藏 SourceConfig: { key: string; name: string; diff --git a/src/lib/config.ts b/src/lib/config.ts index 4a0eaa9..7dbe128 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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(); adminConfig.CustomCategories = adminConfig.CustomCategories.filter( @@ -963,9 +993,19 @@ export async function getCacheTime(): Promise { return config.SiteConfig.SiteInterfaceCacheTime || 7200; } -export async function getAvailableApiSites(user?: string): Promise { +export async function getAvailableApiSites( + user?: string, + includeSpecialSources = false +): Promise { const config = await getConfig(); - const allApiSites = config.SourceConfig.filter((s) => !s.disabled); + const specialSourceSet = new Set(config.SpecialSourceApis || []); + const filterSpecialSources = (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; diff --git a/src/lib/special-source.client.ts b/src/lib/special-source.client.ts new file mode 100644 index 0000000..572ecf0 --- /dev/null +++ b/src/lib/special-source.client.ts @@ -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; +}