增加桜色动漫数据源

This commit is contained in:
mtvpls
2026-07-21 15:51:40 +08:00
parent f7e0529394
commit d54cf65927
10 changed files with 99 additions and 28 deletions
+6 -1
View File
@@ -374,7 +374,11 @@ interface SiteConfig {
TMDBApiKey?: string; TMDBApiKey?: string;
TMDBProxy?: string; TMDBProxy?: string;
TMDBReverseProxy?: string; TMDBReverseProxy?: string;
BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl'; BangumiDataSource?:
| 'direct'
| 'server-proxy'
| 'custom-baseurl'
| 'sakura';
BangumiApiBaseUrl?: string; BangumiApiBaseUrl?: string;
BangumiImageBaseUrl?: string; BangumiImageBaseUrl?: string;
BangumiProxy?: string; BangumiProxy?: string;
@@ -11513,6 +11517,7 @@ const SiteConfigComponent = ({
{[ {[
{ value: 'direct', label: '直连' }, { value: 'direct', label: '直连' },
{ value: 'server-proxy', label: '服务器代理' }, { value: 'server-proxy', label: '服务器代理' },
{ value: 'sakura', label: '桜色镜像站' },
{ value: 'custom-baseurl', label: '自定义 Base URL' }, { value: 'custom-baseurl', label: '自定义 Base URL' },
].map((option) => ( ].map((option) => (
<button <button
+7 -2
View File
@@ -107,7 +107,11 @@ export async function POST(request: NextRequest) {
TMDBApiKey?: string; TMDBApiKey?: string;
TMDBProxy?: string; TMDBProxy?: string;
TMDBReverseProxy?: string; TMDBReverseProxy?: string;
BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl'; BangumiDataSource?:
| 'direct'
| 'server-proxy'
| 'custom-baseurl'
| 'sakura';
BangumiApiBaseUrl?: string; BangumiApiBaseUrl?: string;
BangumiImageBaseUrl?: string; BangumiImageBaseUrl?: string;
BangumiProxy?: string; BangumiProxy?: string;
@@ -179,7 +183,8 @@ export async function POST(request: NextRequest) {
(BangumiDataSource !== undefined && (BangumiDataSource !== undefined &&
BangumiDataSource !== 'direct' && BangumiDataSource !== 'direct' &&
BangumiDataSource !== 'server-proxy' && BangumiDataSource !== 'server-proxy' &&
BangumiDataSource !== 'custom-baseurl') || BangumiDataSource !== 'custom-baseurl' &&
BangumiDataSource !== 'sakura') ||
(BangumiApiBaseUrl !== undefined && (BangumiApiBaseUrl !== undefined &&
typeof BangumiApiBaseUrl !== 'string') || typeof BangumiApiBaseUrl !== 'string') ||
(BangumiImageBaseUrl !== undefined && (BangumiImageBaseUrl !== undefined &&
+3 -1
View File
@@ -40,8 +40,10 @@ function isBangumiImageUrl(url: string): boolean {
return ( return (
hostname === 'lain.bgm.tv' || hostname === 'lain.bgm.tv' ||
hostname === 'r.bgm.tv' || hostname === 'r.bgm.tv' ||
hostname === 'bangumi.lol' ||
hostname.endsWith('.bgm.tv') || hostname.endsWith('.bgm.tv') ||
hostname.endsWith('.bangumi.tv') hostname.endsWith('.bangumi.tv') ||
hostname.endsWith('.bangumi.lol')
); );
} catch { } catch {
return false; return false;
+2 -2
View File
@@ -16,7 +16,7 @@ import Image from 'next/image';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { getBangumiSubject } from '@/lib/bangumi.client'; import { getBangumiSubject, getBangumiSubjectUrl } from '@/lib/bangumi.client';
import { appendSpecialSourceParam } from '@/lib/special-source.client'; import { appendSpecialSourceParam } from '@/lib/special-source.client';
import { getTMDBImageUrl } from '@/lib/tmdb.client'; import { getTMDBImageUrl } from '@/lib/tmdb.client';
import { processImageUrl } from '@/lib/utils'; import { processImageUrl } from '@/lib/utils';
@@ -159,7 +159,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
if (currentSource === 'bangumi') { if (currentSource === 'bangumi') {
const actualBangumiId = bangumiId || doubanId; const actualBangumiId = bangumiId || doubanId;
if (actualBangumiId) { if (actualBangumiId) {
return `https://bgm.tv/subject/${actualBangumiId}`; return getBangumiSubjectUrl(actualBangumiId);
} }
} }
+1
View File
@@ -321,6 +321,7 @@ export const UserMenu: React.FC = () => {
const animeDataSourceOptions = [ const animeDataSourceOptions = [
{ value: 'direct', label: '直连(浏览器直连 Bangumi' }, { value: 'direct', label: '直连(浏览器直连 Bangumi' },
{ value: 'server-proxy', label: '服务器代理(由服务器访问 Bangumi)' }, { value: 'server-proxy', label: '服务器代理(由服务器访问 Bangumi)' },
{ value: 'sakura', label: '桜色镜像站(bangumi.lol' },
{ value: 'custom-baseurl', label: '自定义 Base URL' }, { value: 'custom-baseurl', label: '自定义 Base URL' },
]; ];
+3 -2
View File
@@ -33,6 +33,7 @@ import {
saveFavorite, saveFavorite,
subscribeToDataUpdates, subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
import { getBangumiSubjectUrl } from '@/lib/bangumi.client';
import { isNetdiskSource } from '@/lib/netdisk/source'; import { isNetdiskSource } from '@/lib/netdisk/source';
import { import {
base58Decode, base58Decode,
@@ -836,7 +837,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
icon: <Link size={20} />, icon: <Link size={20} />,
onClick: () => { onClick: () => {
const url = isBangumi const url = isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}` ? getBangumiSubjectUrl(actualDoubanId.toString())
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`; : `https://movie.douban.com/subject/${actualDoubanId.toString()}`;
window.open(url, '_blank', 'noopener,noreferrer'); window.open(url, '_blank', 'noopener,noreferrer');
}, },
@@ -1432,7 +1433,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
<a <a
href={ href={
isBangumi isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}` ? getBangumiSubjectUrl(actualDoubanId.toString())
: `https://movie.douban.com/subject/${actualDoubanId.toString()}` : `https://movie.douban.com/subject/${actualDoubanId.toString()}`
} }
target='_blank' target='_blank'
+5 -1
View File
@@ -28,7 +28,11 @@ export interface AdminConfig {
TMDBProxy?: string; TMDBProxy?: string;
TMDBReverseProxy?: string; TMDBReverseProxy?: string;
// 动漫/Bangumi配置 // 动漫/Bangumi配置
BangumiDataSource?: 'direct' | 'server-proxy' | 'custom-baseurl'; BangumiDataSource?:
| 'direct'
| 'server-proxy'
| 'custom-baseurl'
| 'sakura';
BangumiApiBaseUrl?: string; BangumiApiBaseUrl?: string;
BangumiImageBaseUrl?: string; BangumiImageBaseUrl?: string;
BangumiProxy?: string; BangumiProxy?: string;
+35 -15
View File
@@ -1,6 +1,26 @@
'use client'; 'use client';
export type AnimeDataSource = 'direct' | 'server-proxy' | 'custom-baseurl'; export type AnimeDataSource =
| 'direct'
| 'server-proxy'
| 'custom-baseurl'
| 'sakura';
/** 桜色镜像站:全域名镜像 bgm.tv → bangumi.lol */
export const BANGUMI_SAKURA_API_BASE_URL = 'https://api.bangumi.lol';
export const BANGUMI_SAKURA_SITE_URL = 'https://bangumi.lol';
export const BANGUMI_OFFICIAL_SITE_URL = 'https://bgm.tv';
export function isValidAnimeDataSource(
value: string | null | undefined
): value is AnimeDataSource {
return (
value === 'direct' ||
value === 'server-proxy' ||
value === 'custom-baseurl' ||
value === 'sakura'
);
}
export interface BangumiCalendarData { export interface BangumiCalendarData {
weekday: { weekday: {
@@ -63,22 +83,14 @@ function getPrimaryAnimeDataSource(): AnimeDataSource {
const saved = localStorage.getItem( const saved = localStorage.getItem(
'animeDataSource' 'animeDataSource'
) as AnimeDataSource | null; ) as AnimeDataSource | null;
if ( if (isValidAnimeDataSource(saved)) {
saved === 'direct' ||
saved === 'server-proxy' ||
saved === 'custom-baseurl'
) {
return saved; return saved;
} }
const runtimeValue = getRuntimeConfig().BANGUMI_DATA_SOURCE as const runtimeValue = getRuntimeConfig().BANGUMI_DATA_SOURCE as
| AnimeDataSource | AnimeDataSource
| undefined; | undefined;
if ( if (isValidAnimeDataSource(runtimeValue)) {
runtimeValue === 'direct' ||
runtimeValue === 'server-proxy' ||
runtimeValue === 'custom-baseurl'
) {
return runtimeValue; return runtimeValue;
} }
@@ -94,10 +106,7 @@ function getBackupAnimeDataSource(
const saved = localStorage.getItem( const saved = localStorage.getItem(
'animeDataSourceBackup' 'animeDataSourceBackup'
) as AnimeDataSource | null; ) as AnimeDataSource | null;
const backup = const backup = isValidAnimeDataSource(saved) ? saved : 'server-proxy';
saved === 'direct' || saved === 'server-proxy' || saved === 'custom-baseurl'
? saved
: 'server-proxy';
return backup === primary ? null : backup; return backup === primary ? null : backup;
} }
@@ -120,12 +129,23 @@ function buildBangumiUrl(source: AnimeDataSource, path: string): string {
} }
return `${customBaseUrl}${normalizedPath}`; return `${customBaseUrl}${normalizedPath}`;
} }
case 'sakura':
return `${BANGUMI_SAKURA_API_BASE_URL}${normalizedPath}`;
case 'direct': case 'direct':
default: default:
return `${BANGUMI_OFFICIAL_BASE_URL}${normalizedPath}`; return `${BANGUMI_OFFICIAL_BASE_URL}${normalizedPath}`;
} }
} }
/** 按当前动漫数据源生成 Bangumi 条目外链(桜色镜像站 → bangumi.lol */
export function getBangumiSubjectUrl(id: string | number): string {
const origin =
getPrimaryAnimeDataSource() === 'sakura'
? BANGUMI_SAKURA_SITE_URL
: BANGUMI_OFFICIAL_SITE_URL;
return `${origin}/subject/${encodeURIComponent(String(id))}`;
}
async function fetchBangumiJson<T>( async function fetchBangumiJson<T>(
source: AnimeDataSource, source: AnimeDataSource,
path: string path: string
+7 -1
View File
@@ -3,9 +3,15 @@
import { HttpsProxyAgent } from 'https-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent';
import nodeFetch from 'node-fetch'; import nodeFetch from 'node-fetch';
export type AnimeDataSource = 'direct' | 'server-proxy' | 'custom-baseurl'; export type AnimeDataSource =
| 'direct'
| 'server-proxy'
| 'custom-baseurl'
| 'sakura';
export const DEFAULT_BANGUMI_BASE_URL = 'https://api.bgm.tv'; export const DEFAULT_BANGUMI_BASE_URL = 'https://api.bgm.tv';
/** 桜色镜像站 API */
export const BANGUMI_SAKURA_API_BASE_URL = 'https://api.bangumi.lol';
function isCloudflareEnvironment(): boolean { function isCloudflareEnvironment(): boolean {
return ( return (
+30 -3
View File
@@ -140,15 +140,21 @@ function isBangumiImageUrl(url: string): boolean {
return ( return (
hostname === 'lain.bgm.tv' || hostname === 'lain.bgm.tv' ||
hostname === 'r.bgm.tv' || hostname === 'r.bgm.tv' ||
hostname === 'bangumi.lol' ||
hostname.endsWith('.bgm.tv') || hostname.endsWith('.bgm.tv') ||
hostname.endsWith('.bangumi.tv') hostname.endsWith('.bangumi.tv') ||
hostname.endsWith('.bangumi.lol')
); );
} catch { } catch {
return false; return false;
} }
} }
type AnimeImageSource = 'direct' | 'server-proxy' | 'custom-baseurl'; type AnimeImageSource =
| 'direct'
| 'server-proxy'
| 'custom-baseurl'
| 'sakura';
const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil'; const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil';
const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature'; const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature';
@@ -158,12 +164,31 @@ function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, ''); return baseUrl.trim().replace(/\/+$/, '');
} }
/** 官方域名 → 桜色镜像站(bgm.tv → bangumi.lol 等) */
function rewriteBangumiUrlToSakura(url: string): string {
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
if (hostname === 'bgm.tv' || hostname === 'bangumi.tv') {
parsed.hostname = 'bangumi.lol';
} else if (hostname.endsWith('.bgm.tv')) {
parsed.hostname = `${hostname.slice(0, -'.bgm.tv'.length)}.bangumi.lol`;
} else if (hostname.endsWith('.bangumi.tv')) {
parsed.hostname = `${hostname.slice(0, -'.bangumi.tv'.length)}.bangumi.lol`;
}
return parsed.toString();
} catch {
return url;
}
}
function normalizeAnimeImageSource( function normalizeAnimeImageSource(
value: string | null | undefined value: string | null | undefined
): AnimeImageSource { ): AnimeImageSource {
return value === 'server-proxy' || return value === 'server-proxy' ||
value === 'custom-baseurl' || value === 'custom-baseurl' ||
value === 'direct' value === 'direct' ||
value === 'sakura'
? value ? value
: 'direct'; : 'direct';
} }
@@ -304,6 +329,8 @@ function buildBangumiImageUrl(
const imageBaseUrl = getBangumiImageBaseUrl(); const imageBaseUrl = getBangumiImageBaseUrl();
return imageBaseUrl ? `${imageBaseUrl}/${originalUrl}` : originalUrl; return imageBaseUrl ? `${imageBaseUrl}/${originalUrl}` : originalUrl;
} }
case 'sakura':
return rewriteBangumiUrlToSakura(originalUrl);
case 'direct': case 'direct':
default: default:
return originalUrl; return originalUrl;