增加动漫数据源配置

This commit is contained in:
mtvpls
2026-05-28 16:00:07 +08:00
parent dc31a788fc
commit cc98b21d66
16 changed files with 4448 additions and 2153 deletions
+6 -1
View File
@@ -25,6 +25,11 @@ export interface AdminConfig {
TMDBApiKey?: string;
TMDBProxy?: string;
TMDBReverseProxy?: string;
// 动漫/Bangumi配置
BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl';
BangumiApiBaseUrl?: string;
BangumiImageBaseUrl?: string;
BangumiProxy?: string;
BannerDataSource?: string; // 轮播图数据源:TMDB、TX 或 Douban
RecommendationDataSource?: string; // 更多推荐数据源:Douban、TMDB、Mixed、MixedSmart
// Pansou配置
@@ -101,7 +106,7 @@ export interface AdminConfig {
LiveConfig?: {
key: string;
name: string;
url: string; // m3u 地址
url: string; // m3u 地址
ua?: string;
epg?: string; // 节目单
from: 'config' | 'custom';
+151 -3
View File
@@ -1,5 +1,7 @@
'use client';
export type AnimeDataSource = 'direct' | 'server-proxy' | 'custom-baseurl';
export interface BangumiCalendarData {
weekday: {
en: string;
@@ -22,8 +24,154 @@ export interface BangumiCalendarData {
}[];
}
export interface BangumiSubjectData {
id?: number;
name: string;
name_cn?: string;
date?: string;
images?: {
large?: string;
common?: string;
medium?: string;
small?: string;
grid?: string;
};
rating?: {
score: number;
total: number;
};
summary?: string;
tags?: { name: string }[];
eps?: number;
}
const BANGUMI_OFFICIAL_BASE_URL = 'https://api.bgm.tv';
const SERVER_PROXY_BASE_URL = '/api/bangumi';
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '');
}
function getRuntimeConfig() {
if (typeof window === 'undefined') return {} as any;
return (window as any).RUNTIME_CONFIG || {};
}
function getPrimaryAnimeDataSource(): AnimeDataSource {
if (typeof window === 'undefined') return 'direct';
const saved = localStorage.getItem(
'animeDataSource'
) as AnimeDataSource | null;
if (
saved === 'direct' ||
saved === 'server-proxy' ||
saved === 'custom-baseurl'
) {
return saved;
}
const runtimeValue = getRuntimeConfig().BANGUMI_DATA_SOURCE as
| AnimeDataSource
| undefined;
if (
runtimeValue === 'direct' ||
runtimeValue === 'server-proxy' ||
runtimeValue === 'custom-baseurl'
) {
return runtimeValue;
}
return 'direct';
}
function getBackupAnimeDataSource(
primary: AnimeDataSource
): AnimeDataSource | null {
if (typeof window === 'undefined')
return primary === 'server-proxy' ? null : 'server-proxy';
const saved = localStorage.getItem(
'animeDataSourceBackup'
) as AnimeDataSource | null;
const backup =
saved === 'direct' || saved === 'server-proxy' || saved === 'custom-baseurl'
? saved
: 'server-proxy';
return backup === primary ? null : backup;
}
function getCustomAnimeBaseUrl(): string {
if (typeof window === 'undefined') return '';
return localStorage.getItem('animeCustomBaseUrl') || '';
}
function buildBangumiUrl(source: AnimeDataSource, path: string): string {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
switch (source) {
case 'server-proxy':
return `${SERVER_PROXY_BASE_URL}${normalizedPath}`;
case 'custom-baseurl': {
const customBaseUrl = normalizeBaseUrl(getCustomAnimeBaseUrl());
if (!customBaseUrl) {
return `${BANGUMI_OFFICIAL_BASE_URL}${normalizedPath}`;
}
return `${customBaseUrl}${normalizedPath}`;
}
case 'direct':
default:
return `${BANGUMI_OFFICIAL_BASE_URL}${normalizedPath}`;
}
}
async function fetchBangumiJson<T>(
source: AnimeDataSource,
path: string
): Promise<T> {
const response = await fetch(buildBangumiUrl(source, path), {
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
throw new Error(`Bangumi 请求失败: ${response.status}`);
}
return response.json() as Promise<T>;
}
async function requestWithFallback<T>(path: string): Promise<T> {
const primary = getPrimaryAnimeDataSource();
const backup = getBackupAnimeDataSource(primary);
try {
return await fetchBangumiJson<T>(primary, path);
} catch (primaryError) {
if (!backup) throw primaryError;
try {
return await fetchBangumiJson<T>(backup, path);
} catch (backupError) {
console.error('Bangumi 主源与备用源均请求失败:', {
primary,
backup,
primaryError,
backupError,
});
throw backupError;
}
}
}
export async function GetBangumiCalendarData(): Promise<BangumiCalendarData[]> {
const response = await fetch('https://api.bgm.tv/calendar');
const data = await response.json();
return data;
return requestWithFallback<BangumiCalendarData[]>('/calendar');
}
export async function getBangumiSubject(
id: number | string
): Promise<BangumiSubjectData> {
return requestWithFallback<BangumiSubjectData>(
`/v0/subjects/${encodeURIComponent(String(id))}`
);
}
+57
View File
@@ -0,0 +1,57 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HttpsProxyAgent } from 'https-proxy-agent';
import nodeFetch from 'node-fetch';
export type AnimeDataSource = 'direct' | 'server-proxy' | 'custom-baseurl';
export const DEFAULT_BANGUMI_BASE_URL = 'https://api.bgm.tv';
function isCloudflareEnvironment(): boolean {
return (
process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'
);
}
export function normalizeBangumiBaseUrl(baseUrl?: string): string {
const normalized = (baseUrl || DEFAULT_BANGUMI_BASE_URL)
.trim()
.replace(/\/+$/, '');
return normalized || DEFAULT_BANGUMI_BASE_URL;
}
export async function fetchBangumiFromServer(
path: string,
options?: { baseUrl?: string; proxy?: string }
): Promise<Response> {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const url = `${normalizeBangumiBaseUrl(options?.baseUrl)}${normalizedPath}`;
const proxy = options?.proxy?.trim();
if (isCloudflareEnvironment()) {
return fetch(url, {
headers: {
Accept: 'application/json',
'User-Agent': 'MoonTVPlus/1.0 (https://github.com)',
},
signal: AbortSignal.timeout(15000),
}) as Promise<Response>;
}
const fetchOptions: any = {
headers: {
Accept: 'application/json',
'User-Agent': 'MoonTVPlus/1.0 (https://github.com)',
},
signal: AbortSignal.timeout(proxy ? 30000 : 15000),
};
if (proxy) {
fetchOptions.agent = new HttpsProxyAgent(proxy, {
timeout: 30000,
keepAlive: false,
});
}
return nodeFetch(url, fetchOptions) as unknown as Promise<Response>;
}
+165 -96
View File
@@ -7,7 +7,9 @@ import { AdminConfig } from './admin.types';
const BUILTIN_DANMAKU_API_BASE = 'https://mtvpls-danmu.netlify.app/87654321';
const DEFAULT_LIVE_REFRESH_INTERVAL_HOURS = 12;
function normalizeLiveRefreshIntervalHours(refreshIntervalHours?: number): number {
function normalizeLiveRefreshIntervalHours(
refreshIntervalHours?: number
): number {
const normalizedInterval = Number(refreshIntervalHours);
if (!Number.isFinite(normalizedInterval) || normalizedInterval <= 0) {
@@ -44,7 +46,7 @@ interface ConfigFileStruct {
}[];
lives?: {
[key: string]: LiveCfg;
}
};
}
export const API_CONFIG = {
@@ -71,7 +73,6 @@ export const API_CONFIG = {
let cachedConfig: AdminConfig;
let configInitPromise: Promise<AdminConfig> | null = null;
// 从配置文件补充管理员配置
export function refineConfig(adminConfig: AdminConfig): AdminConfig {
let fileConfig: ConfigFileStruct;
@@ -197,19 +198,22 @@ export function refineConfig(adminConfig: AdminConfig): AdminConfig {
return adminConfig;
}
async function getInitConfig(configFile: string, subConfig: {
URL: string;
AutoUpdate: boolean;
LastCheck: string;
} = {
URL: "",
async function getInitConfig(
configFile: string,
subConfig: {
URL: string;
AutoUpdate: boolean;
LastCheck: string;
} = {
URL: '',
AutoUpdate: false,
LastCheck: "",
}): Promise<AdminConfig> {
LastCheck: '',
}
): Promise<AdminConfig> {
let cfgFile: ConfigFileStruct;
// 优先从环境变量读取订阅 URL
const envSubUrl = process.env.CONFIG_SUBSCRIPTION_URL || "";
const envSubUrl = process.env.CONFIG_SUBSCRIPTION_URL || '';
if (envSubUrl) {
try {
@@ -228,7 +232,7 @@ async function getInitConfig(configFile: string, subConfig: {
}
// 优先从环境变量读取配置
const envConfig = process.env.INIT_CONFIG || "";
const envConfig = process.env.INIT_CONFIG || '';
const configSource = envConfig || configFile;
try {
@@ -254,23 +258,37 @@ async function getInitConfig(configFile: string, subConfig: {
process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent',
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
DoubanImageProxyType:
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent',
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE ||
'cmliussss-cdn-tencent',
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
DisableYellowFilter:
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
FluidSearch:
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
FluidSearch: process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
// 弹幕配置
DanmakuSourceType: hasCustomDanmakuEnv ? 'custom' : 'builtin',
DanmakuApiBase:
process.env.DANMAKU_API_BASE ||
(hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE),
(hasCustomDanmakuEnv
? 'http://localhost:9321'
: BUILTIN_DANMAKU_API_BASE),
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
DanmakuAutoLoadDefault: true,
// TMDB配置
TMDBApiKey: process.env.TMDB_API_KEY || '',
TMDBProxy: process.env.TMDB_PROXY || '',
TMDBReverseProxy: process.env.TMDB_REVERSE_PROXY || '',
// 动漫/Bangumi配置
BangumiDataSource:
(process.env.NEXT_PUBLIC_BANGUMI_DATA_SOURCE as any) || 'direct',
BangumiApiBaseUrl:
process.env.BANGUMI_API_BASE_URL ||
process.env.NEXT_PUBLIC_BANGUMI_API_BASE_URL ||
'https://api.bgm.tv',
BangumiImageBaseUrl:
process.env.BANGUMI_IMAGE_BASE_URL ||
process.env.NEXT_PUBLIC_BANGUMI_IMAGE_BASE_URL ||
'',
BangumiProxy: process.env.BANGUMI_PROXY || '',
// Pansou配置
PansouApiUrl: '',
PansouUsername: '',
@@ -365,7 +383,7 @@ export async function getConfig(): Promise<AdminConfig> {
// localStorage 模式下直接从环境变量初始化
if (storageType === 'localstorage') {
console.log('localStorage 模式:从环境变量初始化配置');
const adminConfig = await getInitConfig("");
const adminConfig = await getInitConfig('');
cachedConfig = configSelfCheck(adminConfig);
configInitPromise = null;
return cachedConfig;
@@ -386,19 +404,20 @@ export async function getConfig(): Promise<AdminConfig> {
if (dbReadFailed) {
// 数据库读取失败,使用默认配置但不保存,避免覆盖数据库
console.warn('数据库读取失败,使用临时默认配置(不会保存到数据库)');
adminConfig = await getInitConfig("");
adminConfig = await getInitConfig('');
} else {
// 数据库中确实没有配置,首次初始化并保存
console.log('首次初始化配置');
adminConfig = await getInitConfig("");
adminConfig = await getInitConfig('');
await db.saveAdminConfig(adminConfig);
}
}
// 检查是否有旧格式Emby配置需要迁移
const needsEmbyMigration = adminConfig.EmbyConfig &&
adminConfig.EmbyConfig.ServerURL &&
!adminConfig.EmbyConfig.Sources;
const needsEmbyMigration =
adminConfig.EmbyConfig &&
adminConfig.EmbyConfig.ServerURL &&
!adminConfig.EmbyConfig.Sources;
adminConfig = configSelfCheck(adminConfig);
cachedConfig = adminConfig;
@@ -544,19 +563,27 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (!adminConfig.UserConfig) {
adminConfig.UserConfig = { Users: [] };
}
if (!adminConfig.UserConfig.Users || !Array.isArray(adminConfig.UserConfig.Users)) {
if (
!adminConfig.UserConfig.Users ||
!Array.isArray(adminConfig.UserConfig.Users)
) {
adminConfig.UserConfig.Users = [];
}
if (!adminConfig.SourceConfig || !Array.isArray(adminConfig.SourceConfig)) {
adminConfig.SourceConfig = [];
}
if (!adminConfig.CustomCategories || !Array.isArray(adminConfig.CustomCategories)) {
if (
!adminConfig.CustomCategories ||
!Array.isArray(adminConfig.CustomCategories)
) {
adminConfig.CustomCategories = [];
}
if (!adminConfig.LiveConfig || !Array.isArray(adminConfig.LiveConfig)) {
adminConfig.LiveConfig = [];
}
adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours(adminConfig.LiveRefreshIntervalHours);
adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours(
adminConfig.LiveRefreshIntervalHours
);
if (adminConfig.OpenListConfig) {
if (!adminConfig.OpenListConfig.RootPaths) {
@@ -567,7 +594,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (!adminConfig.OpenListConfig.OfflineDownloadPath) {
adminConfig.OpenListConfig.OfflineDownloadPath = '/';
}
if (adminConfig.OpenListConfig.OfflineDownloadUseCustomSource === undefined) {
if (
adminConfig.OpenListConfig.OfflineDownloadUseCustomSource === undefined
) {
adminConfig.OpenListConfig.OfflineDownloadUseCustomSource = false;
}
if (adminConfig.OpenListConfig.OfflineDownloadURL === undefined) {
@@ -584,11 +613,13 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
// 用户信息已迁移到新版数据库
// 这里只保留站长用户用于兼容性,其他用户从数据库读取
const ownerUser = process.env.USERNAME;
adminConfig.UserConfig.Users = [{
username: ownerUser!,
role: 'owner',
banned: false,
}];
adminConfig.UserConfig.Users = [
{
username: ownerUser!,
role: 'owner',
banned: false,
},
];
// 采集源去重
const seenSourceKeys = new Set<string>();
@@ -602,13 +633,15 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
// 自定义分类去重
const seenCustomCategoryKeys = new Set<string>();
adminConfig.CustomCategories = adminConfig.CustomCategories.filter((category) => {
if (seenCustomCategoryKeys.has(category.query + category.type)) {
return false;
adminConfig.CustomCategories = adminConfig.CustomCategories.filter(
(category) => {
if (seenCustomCategoryKeys.has(category.query + category.type)) {
return false;
}
seenCustomCategoryKeys.add(category.query + category.type);
return true;
}
seenCustomCategoryKeys.add(category.query + category.type);
return true;
});
);
// 直播源去重
const seenLiveKeys = new Set<string>();
@@ -627,42 +660,52 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
console.log('[Config] 检测到旧格式Emby配置,自动迁移到新格式');
const oldConfig = adminConfig.EmbyConfig;
adminConfig.EmbyConfig = {
Sources: [{
key: 'default',
name: 'Emby',
enabled: oldConfig.Enabled ?? false,
ServerURL: oldConfig.ServerURL || '',
ApiKey: oldConfig.ApiKey,
Username: oldConfig.Username,
Password: oldConfig.Password,
UserId: oldConfig.UserId,
AuthToken: oldConfig.AuthToken,
Libraries: oldConfig.Libraries,
LastSyncTime: oldConfig.LastSyncTime,
ItemCount: oldConfig.ItemCount,
isDefault: true,
}],
Sources: [
{
key: 'default',
name: 'Emby',
enabled: oldConfig.Enabled ?? false,
ServerURL: oldConfig.ServerURL || '',
ApiKey: oldConfig.ApiKey,
Username: oldConfig.Username,
Password: oldConfig.Password,
UserId: oldConfig.UserId,
AuthToken: oldConfig.AuthToken,
Libraries: oldConfig.Libraries,
LastSyncTime: oldConfig.LastSyncTime,
ItemCount: oldConfig.ItemCount,
isDefault: true,
},
],
};
}
// Emby源去重
if (adminConfig.EmbyConfig?.Sources) {
const seenEmbyKeys = new Set<string>();
adminConfig.EmbyConfig.Sources = adminConfig.EmbyConfig.Sources.filter((source) => {
if (seenEmbyKeys.has(source.key)) {
return false;
adminConfig.EmbyConfig.Sources = adminConfig.EmbyConfig.Sources.filter(
(source) => {
if (seenEmbyKeys.has(source.key)) {
return false;
}
seenEmbyKeys.add(source.key);
return true;
}
seenEmbyKeys.add(source.key);
return true;
});
);
}
}
if (!adminConfig.SuwayomiConfig) {
adminConfig.SuwayomiConfig = {
Enabled: process.env.SUWAYOMI_ENABLED === 'true',
ServerURL: process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '',
AuthMode: (process.env.SUWAYOMI_AUTH_MODE as 'none' | 'basic_auth' | 'simple_login' | undefined) || 'none',
ServerURL:
process.env.SUWAYOMI_URL || process.env.NEXT_PUBLIC_SUWAYOMI_URL || '',
AuthMode:
(process.env.SUWAYOMI_AUTH_MODE as
| 'none'
| 'basic_auth'
| 'simple_login'
| undefined) || 'none',
Username: process.env.SUWAYOMI_USERNAME || '',
Password: process.env.SUWAYOMI_PASSWORD || '',
DefaultLang: process.env.SUWAYOMI_DEFAULT_LANG || 'zh',
@@ -694,7 +737,10 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (!Array.isArray(adminConfig.SuwayomiConfig.SourceIds)) {
adminConfig.SuwayomiConfig.SourceIds = [];
}
if (adminConfig.SuwayomiConfig.MaxSources === undefined || Number.isNaN(adminConfig.SuwayomiConfig.MaxSources)) {
if (
adminConfig.SuwayomiConfig.MaxSources === undefined ||
Number.isNaN(adminConfig.SuwayomiConfig.MaxSources)
) {
adminConfig.SuwayomiConfig.MaxSources = 10;
}
@@ -715,19 +761,26 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
const envUrl = process.env.OPDS_URL || process.env.NEXT_PUBLIC_OPDS_URL;
if (!envUrl) return [];
return [{
id: 'default',
name: process.env.OPDS_NAME || '默认书源',
type: 'opds',
url: envUrl,
enabled: true,
authMode: (process.env.OPDS_AUTH_MODE as 'none' | 'basic' | 'header' | undefined) || 'none',
username: process.env.OPDS_USERNAME || '',
password: process.env.OPDS_PASSWORD || '',
headerName: process.env.OPDS_HEADER_NAME || '',
headerValue: process.env.OPDS_HEADER_VALUE || '',
searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '',
}];
return [
{
id: 'default',
name: process.env.OPDS_NAME || '默认书源',
type: 'opds',
url: envUrl,
enabled: true,
authMode:
(process.env.OPDS_AUTH_MODE as
| 'none'
| 'basic'
| 'header'
| undefined) || 'none',
username: process.env.OPDS_USERNAME || '',
password: process.env.OPDS_PASSWORD || '',
headerName: process.env.OPDS_HEADER_NAME || '',
headerValue: process.env.OPDS_HEADER_VALUE || '',
searchTemplate: process.env.OPDS_SEARCH_TEMPLATE || '',
},
];
})(),
CacheTTL: Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000),
};
@@ -738,15 +791,22 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
if (!Array.isArray(adminConfig.OPDSConfig.Sources)) {
adminConfig.OPDSConfig.Sources = [];
}
adminConfig.OPDSConfig.Sources = adminConfig.OPDSConfig.Sources.filter((source: any) => (source?.type || 'opds') === 'opds').map((source: any) => {
adminConfig.OPDSConfig.Sources = adminConfig.OPDSConfig.Sources.filter(
(source: any) => (source?.type || 'opds') === 'opds'
).map((source: any) => {
const { legado: _legado, ...rest } = source || {};
return { ...rest, type: 'opds' };
});
if (!Array.isArray(adminConfig.OPDSConfig.LegadoSubscriptions)) {
adminConfig.OPDSConfig.LegadoSubscriptions = [];
}
if (adminConfig.OPDSConfig.CacheTTL === undefined || Number.isNaN(adminConfig.OPDSConfig.CacheTTL)) {
adminConfig.OPDSConfig.CacheTTL = Number(process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000);
if (
adminConfig.OPDSConfig.CacheTTL === undefined ||
Number.isNaN(adminConfig.OPDSConfig.CacheTTL)
) {
adminConfig.OPDSConfig.CacheTTL = Number(
process.env.OPDS_CACHE_TTL_MS || 10 * 60 * 1000
);
}
if (!adminConfig.NetDiskConfig) {
@@ -888,7 +948,10 @@ export async function resetConfig() {
if (!originConfig) {
originConfig = {} as AdminConfig;
}
const adminConfig = await getInitConfig(originConfig.ConfigFile, originConfig.ConfigSubscribtion);
const adminConfig = await getInitConfig(
originConfig.ConfigFile,
originConfig.ConfigSubscribtion
);
cachedConfig = adminConfig;
await db.saveAdminConfig(adminConfig);
@@ -923,13 +986,15 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
// 优先根据用户自己的 enabledApis 配置查找
if (userInfoV2.enabledApis && userInfoV2.enabledApis.length > 0) {
const userApiSitesSet = new Set(userInfoV2.enabledApis);
return allApiSites.filter((s) => userApiSitesSet.has(s.key)).map((s) => ({
key: s.key,
name: s.name,
api: s.api,
detail: s.detail,
proxyMode: s.proxyMode,
}));
return allApiSites
.filter((s) => userApiSitesSet.has(s.key))
.map((s) => ({
key: s.key,
name: s.name,
api: s.api,
detail: s.detail,
proxyMode: s.proxyMode,
}));
}
// 如果没有 enabledApis 配置,则根据 tags 查找
@@ -937,21 +1002,25 @@ export async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
const enabledApisFromTags = new Set<string>();
// 遍历用户的所有 tags,收集对应的 enabledApis
userInfoV2.tags.forEach(tagName => {
const tagConfig = config.UserConfig.Tags?.find(t => t.name === tagName);
userInfoV2.tags.forEach((tagName) => {
const tagConfig = config.UserConfig.Tags?.find((t) => t.name === tagName);
if (tagConfig && tagConfig.enabledApis) {
tagConfig.enabledApis.forEach(apiKey => enabledApisFromTags.add(apiKey));
tagConfig.enabledApis.forEach((apiKey) =>
enabledApisFromTags.add(apiKey)
);
}
});
if (enabledApisFromTags.size > 0) {
return allApiSites.filter((s) => enabledApisFromTags.has(s.key)).map((s) => ({
key: s.key,
name: s.name,
api: s.api,
detail: s.detail,
proxyMode: s.proxyMode,
}));
return allApiSites
.filter((s) => enabledApisFromTags.has(s.key))
.map((s) => ({
key: s.key,
name: s.name,
api: s.api,
detail: s.detail,
proxyMode: s.proxyMode,
}));
}
}
+250 -27
View File
@@ -55,9 +55,13 @@ function buildDoubanImageUrl(
'img.doubanio.cmliussss.com'
);
case 'baidu':
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
return `https://image.baidu.com/search/down?url=${encodeURIComponent(
originalUrl
)}`;
case 'custom':
return proxyUrl ? `${proxyUrl}${encodeURIComponent(originalUrl)}` : originalUrl;
return proxyUrl
? `${proxyUrl}${encodeURIComponent(originalUrl)}`
: originalUrl;
case 'direct':
default:
return originalUrl;
@@ -89,8 +93,9 @@ function getDoubanImageProxyConfig(): {
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
'';
const doubanImageProxyBackupType =
(localStorage.getItem('doubanImageProxyTypeBackup') as DoubanImageProxyType | null) ||
'server';
(localStorage.getItem(
'doubanImageProxyTypeBackup'
) as DoubanImageProxyType | null) || 'server';
const doubanImageProxyBackupUrl =
localStorage.getItem('doubanImageProxyUrlBackup') || '';
const primaryConfig = normalizeDoubanImageProxyConfig(
@@ -130,6 +135,162 @@ export function getDoubanImageFallbackUrl(originalUrl: string): string | null {
return backupUrl;
}
function isBangumiImageUrl(url: string): boolean {
try {
const hostname = new URL(url).hostname.toLowerCase();
return (
hostname === 'lain.bgm.tv' ||
hostname === 'r.bgm.tv' ||
hostname.endsWith('.bgm.tv') ||
hostname.endsWith('.bangumi.tv')
);
} catch {
return false;
}
}
type AnimeImageSource = 'direct' | 'server-proxy' | 'custom-baseurl';
const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil';
const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature';
const BANGUMI_IMAGE_FALLBACK_DURATION = 60 * 60 * 1000;
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '');
}
function normalizeAnimeImageSource(
value: string | null | undefined
): AnimeImageSource {
return value === 'server-proxy' ||
value === 'custom-baseurl' ||
value === 'direct'
? value
: 'direct';
}
function getPrimaryBangumiImageSource(): AnimeImageSource {
if (typeof window === 'undefined') {
return 'direct';
}
return normalizeAnimeImageSource(
localStorage.getItem('animeDataSource') ||
(window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE ||
'direct'
);
}
function getBackupBangumiImageSource(
primary: AnimeImageSource
): AnimeImageSource | null {
if (typeof window === 'undefined') {
return primary === 'server-proxy' ? null : 'server-proxy';
}
const backup = normalizeAnimeImageSource(
localStorage.getItem('animeDataSourceBackup') || 'server-proxy'
);
return backup === primary ? null : backup;
}
function getBangumiImageBaseUrl(): string {
if (typeof window === 'undefined') {
return '';
}
return normalizeBaseUrl(localStorage.getItem('animeImageBaseUrl') || '');
}
function getBangumiImageFallbackSignature(): string {
if (typeof window === 'undefined') return '';
return JSON.stringify({
primary: getPrimaryBangumiImageSource(),
backup: normalizeAnimeImageSource(
localStorage.getItem('animeDataSourceBackup') || 'server-proxy'
),
imageBaseUrl: getBangumiImageBaseUrl(),
});
}
export function clearBangumiImageFallbackCache(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY);
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY);
}
export function markBangumiImageFallbackActive(): void {
if (typeof window === 'undefined') return;
localStorage.setItem(
BANGUMI_IMAGE_FALLBACK_UNTIL_KEY,
String(Date.now() + BANGUMI_IMAGE_FALLBACK_DURATION)
);
localStorage.setItem(
BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY,
getBangumiImageFallbackSignature()
);
}
function isBangumiImageFallbackActive(): boolean {
if (typeof window === 'undefined') return false;
const until = Number(localStorage.getItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY));
if (!until || Date.now() >= until) {
clearBangumiImageFallbackCache();
return false;
}
const signature = localStorage.getItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY);
if (signature !== getBangumiImageFallbackSignature()) {
clearBangumiImageFallbackCache();
return false;
}
return true;
}
function buildBangumiImageUrl(
originalUrl: string,
source: AnimeImageSource
): string {
switch (source) {
case 'server-proxy':
return `/api/image-proxy?url=${encodeURIComponent(
originalUrl
)}&source=bangumi`;
case 'custom-baseurl': {
const imageBaseUrl = getBangumiImageBaseUrl();
return imageBaseUrl ? `${imageBaseUrl}/${originalUrl}` : originalUrl;
}
case 'direct':
default:
return originalUrl;
}
}
export function getBangumiImageFallbackUrl(originalUrl: string): string | null {
if (!originalUrl || !isBangumiImageUrl(originalUrl)) {
return null;
}
const primary = getPrimaryBangumiImageSource();
const backup = getBackupBangumiImageSource(primary);
if (!backup) {
return null;
}
const primaryUrl = buildBangumiImageUrl(originalUrl, primary);
const backupUrl = buildBangumiImageUrl(originalUrl, backup);
if (backupUrl === primaryUrl) {
return null;
}
return backupUrl;
}
export function tryApplyDoubanImageFallback(
target: HTMLImageElement,
originalUrl: string
@@ -143,7 +304,11 @@ export function tryApplyDoubanImageFallback(
}
const fallbackUrl = getDoubanImageFallbackUrl(originalUrl);
if (!fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src) {
if (
!fallbackUrl ||
fallbackUrl === target.currentSrc ||
fallbackUrl === target.src
) {
return false;
}
@@ -152,6 +317,33 @@ export function tryApplyDoubanImageFallback(
return true;
}
export function tryApplyBangumiImageFallback(
target: HTMLImageElement,
originalUrl: string
): boolean {
if (!originalUrl || !isBangumiImageUrl(originalUrl)) {
return false;
}
if (target.dataset.bangumiBackupTried === 'true') {
return false;
}
const fallbackUrl = getBangumiImageFallbackUrl(originalUrl);
if (
!fallbackUrl ||
fallbackUrl === target.currentSrc ||
fallbackUrl === target.src
) {
return false;
}
target.dataset.bangumiBackupTried = 'true';
markBangumiImageFallbackActive();
target.src = fallbackUrl;
return true;
}
/**
* 处理图片 URL,根据用户设置使用相应的代理
*/
@@ -166,7 +358,8 @@ export function processImageUrl(originalUrl: string): string {
// 处理 TMDB 图片 URL 替换
if (originalUrl.includes('image.tmdb.org')) {
if (typeof window !== 'undefined') {
const tmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl') || 'https://image.tmdb.org';
const tmdbImageBaseUrl =
localStorage.getItem('tmdbImageBaseUrl') || 'https://image.tmdb.org';
// 只有当用户设置了不同的 baseUrl 时才进行替换
if (tmdbImageBaseUrl !== 'https://image.tmdb.org') {
return originalUrl.replace('https://image.tmdb.org', tmdbImageBaseUrl);
@@ -175,6 +368,17 @@ export function processImageUrl(originalUrl: string): string {
return originalUrl;
}
// 处理 Bangumi 图片代理。直连模式尊重用户选择,不代理图片;
// 仅在服务器代理 / 自定义 Base URL 模式下使用本站图片代理。
if (isBangumiImageUrl(originalUrl)) {
const primary = getPrimaryBangumiImageSource();
const backup = getBackupBangumiImageSource(primary);
if (backup && isBangumiImageFallbackActive()) {
return buildBangumiImageUrl(originalUrl, backup);
}
return buildBangumiImageUrl(originalUrl, primary);
}
// 处理豆瓣图片代理
if (!originalUrl.includes('doubanio.com')) {
return originalUrl;
@@ -229,7 +433,10 @@ export function processVideoUrl(originalUrl: string): string {
case 'custom':
// 使用自定义代理
if (proxyUrl) {
return originalUrl.replace(/https?:\/\/img\d\.doubanio\.com/g, proxyUrl);
return originalUrl.replace(
/https?:\/\/img\d\.doubanio\.com/g,
proxyUrl
);
}
return originalUrl;
@@ -291,22 +498,23 @@ export async function getVideoResolutionFromM3u8(
width >= 3840
? '4K'
: width >= 2560
? '2K'
: width >= 1920
? '1080p'
: width >= 1280
? '720p'
: width >= 854
? '480p'
: width > 0
? 'SD'
: '未知';
? '2K'
: width >= 1920
? '1080p'
: width >= 1280
? '720p'
: width >= 854
? '480p'
: width > 0
? 'SD'
: '未知';
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
const bitrateStr =
estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
hls.destroy();
video.remove();
@@ -385,9 +593,17 @@ export async function getVideoResolutionFromM3u8(
const fragmentSize = size; // 分片大小(字节)
// 码率 = (分片大小 × 8 bits) / 分片时长
estimatedBitrate = Math.round((fragmentSize * 8) / fragmentDuration);
estimatedBitrate = Math.round(
(fragmentSize * 8) / fragmentDuration
);
console.log(`[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed(2)} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed(2)} MB, 时长: ${fragmentDuration.toFixed(1)}s)`);
console.log(
`[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed(
2
)} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed(
2
)} MB, 时长: ${fragmentDuration.toFixed(1)}s)`
);
}
checkAndResolve(); // 尝试返回结果
@@ -412,8 +628,14 @@ export async function getVideoResolutionFromM3u8(
if (data.fatal) {
const statusCode = data.response?.code || data.response?.status;
// 防止 415 代理兜底熔断导致正常的二进制源在优选逻辑中被剔除
if (statusCode === 415 && (m3u8Url.includes('/api/proxy-m3u8') || m3u8Url.includes('/api/proxy/vod/m3u8'))) {
console.log('[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过');
if (
statusCode === 415 &&
(m3u8Url.includes('/api/proxy-m3u8') ||
m3u8Url.includes('/api/proxy/vod/m3u8'))
) {
console.log(
'[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过'
);
clearTimeout(timeout);
hls.destroy();
video.remove();
@@ -441,7 +663,8 @@ export async function getVideoResolutionFromM3u8(
});
} catch (error) {
throw new Error(
`Error getting video resolution: ${error instanceof Error ? error.message : String(error)
`Error getting video resolution: ${
error instanceof Error ? error.message : String(error)
}`
);
}