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;
+}