From cef7f73ce0fe4668389736e17f672b9aec88ea03 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 15 May 2026 09:31:08 +0800 Subject: [PATCH 01/40] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E7=9A=84=E9=9F=B3=E4=B9=90=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E7=BD=AE=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/music/v2/history/route.ts | 2 +- src/app/music/page.tsx | 13 +++++++++---- src/lib/d1.db.ts | 2 +- src/lib/postgres.db.ts | 2 +- src/lib/redis-base.db.ts | 5 +++-- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/app/api/music/v2/history/route.ts b/src/app/api/music/v2/history/route.ts index 68de61d..c500af2 100644 --- a/src/app/api/music/v2/history/route.ts +++ b/src/app/api/music/v2/history/route.ts @@ -15,7 +15,7 @@ function toHistoryRecord(input: any, previous?: MusicV2HistoryRecord): MusicV2Hi lastPlayedAt: Number(input.lastPlayedAt ?? input.last_played_at ?? now), playCount: Number(input.playCount ?? input.play_count ?? ((previous?.playCount || 0) + 1)), lastQuality: input.lastQuality || input.last_quality || previous?.lastQuality, - createdAt: Number(input.createdAt ?? input.created_at ?? previous?.createdAt ?? now), + createdAt: Number(previous?.createdAt ?? input.createdAt ?? input.created_at ?? now), updatedAt: now, }; } diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 58dc3a6..7e64d59 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -407,6 +407,7 @@ export default function MusicPage() { playProgressSec: playTime, lastPlayedAt, lastQuality: quality, + createdAt: record.timestamp, }), }); }; @@ -743,6 +744,7 @@ export default function MusicPage() { lastPlayedAt: baseTime + i, playCount: 1, lastQuality: quality, + createdAt: baseTime + i, })); await fetch('/api/music/v2/history', { @@ -930,6 +932,7 @@ export default function MusicPage() { lastPlayedAt: baseTime + i, playCount: 1, lastQuality: quality, + createdAt: baseTime + i, })); // 一次性批量添加所有歌曲 @@ -1131,8 +1134,10 @@ export default function MusicPage() { setShowPlayer(true); setLyrics([]); // 清空旧歌词 - // 添加到播放记录和播放列表 - const record: PlayRecord = { + // 添加到播放记录和播放列表。timestamp 表示入队时间,不能在再次播放时刷新, + // 否则会破坏按 createdAt/timestamp 维护的播放队列顺序。 + const existingRecord = playRecords.find(r => r.platform === platform && r.id === song.id); + const record: PlayRecord = existingRecord || { platform: platform, id: song.id, playTime: 0, // 初始播放时间 @@ -1146,11 +1151,11 @@ export default function MusicPage() { setPlayRecords(prev => { const existingIndex = prev.findIndex(r => r.platform === record.platform && r.id === record.id); if (existingIndex >= 0) { - // 记录已存在,更新时间戳但不重置播放时间 + // 记录已存在:保持原位置和原 timestamp,只补齐可能变化的时长信息。 const updated = [...prev]; updated[existingIndex] = { ...updated[existingIndex], - timestamp: Date.now(), + duration: updated[existingIndex].duration || song.duration || 0, }; return updated; } else { diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts index efc3004..7e1acda 100644 --- a/src/lib/d1.db.ts +++ b/src/lib/d1.db.ts @@ -749,7 +749,7 @@ export class D1Storage implements IStorage { try { const results = await this.db // 按队列顺序返回;当前播放项由最大 last_played_at 决定 - .prepare('SELECT * FROM music_v2_history WHERE username = ? ORDER BY created_at ASC, last_played_at ASC') + .prepare('SELECT * FROM music_v2_history WHERE username = ? ORDER BY created_at ASC, id ASC') .bind(userName) .all(); diff --git a/src/lib/postgres.db.ts b/src/lib/postgres.db.ts index 4399a03..8131653 100644 --- a/src/lib/postgres.db.ts +++ b/src/lib/postgres.db.ts @@ -1404,7 +1404,7 @@ export class PostgresStorage implements IStorage { try { const results = await this.db // 按队列顺序返回;当前播放项由最大 last_played_at 决定 - .prepare('SELECT * FROM music_v2_history WHERE username = $1 ORDER BY created_at ASC, last_played_at ASC') + .prepare('SELECT * FROM music_v2_history WHERE username = $1 ORDER BY created_at ASC, id ASC') .bind(userName) .all(); diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index f8a7b0e..0cacd90 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -807,11 +807,12 @@ export abstract class BaseRedisStorage implements IStorage { return Object.values(rows || {}) .filter(Boolean) .map(value => JSON.parse(value as string) as MusicV2HistoryRecord) - // 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定 + // 按队列顺序返回;当前播放项由最大 lastPlayedAt 决定。 + // createdAt 相同时使用歌曲标识做稳定兜底,避免最近播放时间把歌曲顶到队尾。 .sort((a, b) => { const createdAtDiff = (a.createdAt || 0) - (b.createdAt || 0); if (createdAtDiff !== 0) return createdAtDiff; - return (a.lastPlayedAt || 0) - (b.lastPlayedAt || 0); + return `${a.source}:${a.songId}`.localeCompare(`${b.source}:${b.songId}`); }); } From ae3a054e5654b839921436c47da6d5b6a1e95fc8 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 15 May 2026 10:19:58 +0800 Subject: [PATCH 02/40] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=97=A0=E7=BD=91?= =?UTF-8?q?=E7=9B=98=E6=9D=83=E9=99=90=E5=8F=AF=E4=BB=A5=E7=9B=B4=E9=93=BE?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E7=BD=91=E7=9B=98=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/source-detail/route.ts | 403 +++++++++++++++++++++-------- src/app/page.tsx | 207 +++++++++++---- 2 files changed, 454 insertions(+), 156 deletions(-) diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 4318b03..e9198e9 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -6,6 +6,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { getDetailFromApiV2 } from '@/lib/downstream'; import { getProxyToken } from '@/lib/emby-token'; +import { hasFeaturePermission } from '@/lib/permissions'; import { createBaiduNetdiskSession, getBaiduNetdiskSession, @@ -45,6 +46,7 @@ import { NETDISK_QUARK_SOURCE, NETDISK_TIANYI_SOURCE, NETDISK_UC_SOURCE, + isNetdiskSource, normalizeNetdiskSource, } from '@/lib/netdisk/source'; import { @@ -68,17 +70,19 @@ import { export const runtime = 'nodejs'; -function formatNetdiskEpisodeTitle(parsed: { - season?: number; - episode?: number; -}, fallback: string) { +function formatNetdiskEpisodeTitle( + parsed: { + season?: number; + episode?: number; + }, + fallback: string +) { if (parsed.season && parsed.episode) { const season = String(Math.trunc(parsed.season)).padStart(2, '0'); const episodeValue = parsed.episode; - const episode = - Number.isInteger(episodeValue) - ? String(Math.trunc(episodeValue)).padStart(2, '0') - : String(episodeValue); + const episode = Number.isInteger(episodeValue) + ? String(Math.trunc(episodeValue)).padStart(2, '0') + : String(episodeValue); return `S${season}E${episode}`; } @@ -112,6 +116,19 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } + if (isNetdiskSource(sourceCode)) { + const allowed = await hasFeaturePermission( + authInfo.username, + 'netdisk_temp_play' + ); + if (!allowed) { + return NextResponse.json( + { error: '无权限使用临时播放' }, + { status: 403 } + ); + } + } + const parsedScriptSource = parseScriptSourceValue(sourceCode); if (parsedScriptSource) { try { @@ -121,11 +138,12 @@ export async function GET(request: NextRequest) { payload: {}, }); const sources = normalizeScriptSources(sourcesExecution.result); - const sourceInfo = - sources.find((item) => item.id === parsedScriptSource.sourceId) || { - id: parsedScriptSource.sourceId, - name: parsedScriptSource.sourceId, - }; + const sourceInfo = sources.find( + (item) => item.id === parsedScriptSource.sourceId + ) || { + id: parsedScriptSource.sourceId, + name: parsedScriptSource.sourceId, + }; const detailExecution = await executeSavedSourceScript({ key: parsedScriptSource.scriptKey, @@ -161,7 +179,10 @@ export async function GET(request: NextRequest) { const config = await getConfig(); // 检查是否有启用的 Emby 源 - if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) { + if ( + !config.EmbyConfig?.Sources || + config.EmbyConfig.Sources.length === 0 + ) { throw new Error('Emby 未配置或未启用'); } @@ -174,13 +195,15 @@ export async function GET(request: NextRequest) { // 使用 EmbyManager 获取客户端和配置 const { embyManager } = await import('@/lib/emby-manager'); const sources = await embyManager.getEnabledSources(); - const sourceConfig = sources.find(s => s.key === embyKey); + const sourceConfig = sources.find((s) => s.key === embyKey); const sourceName = sourceConfig?.name || 'Emby'; const client = await embyManager.getClient(embyKey); // 获取代理 token(如果启用了代理) - const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null; + const proxyToken = client.isProxyEnabled() + ? await getProxyToken(request) + : null; // 获取媒体详情 const item = await client.getItem(id); @@ -195,7 +218,12 @@ export async function GET(request: NextRequest) { source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), + poster: client.getImageUrl( + item.Id, + 'Primary', + undefined, + proxyToken || undefined + ), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '', @@ -229,15 +257,24 @@ export async function GET(request: NextRequest) { source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), + poster: client.getImageUrl( + item.Id, + 'Primary', + undefined, + proxyToken || undefined + ), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '', - episodes: await Promise.all(allEpisodes.map((ep) => client.getStreamUrl(ep.Id))), + episodes: await Promise.all( + allEpisodes.map((ep) => client.getStreamUrl(ep.Id)) + ), episodes_titles: allEpisodes.map((ep) => { const seasonNum = ep.ParentIndexNumber || 1; const episodeNum = ep.IndexNumber || 1; - return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`; + return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum + .toString() + .padStart(2, '0')}`; }), subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)), proxyMode: false, @@ -261,16 +298,14 @@ export async function GET(request: NextRequest) { const config = await getConfig(); const xiaoyaConfig = config.XiaoyaConfig; - if ( - !xiaoyaConfig || - !xiaoyaConfig.Enabled || - !xiaoyaConfig.ServerURL - ) { + if (!xiaoyaConfig || !xiaoyaConfig.Enabled || !xiaoyaConfig.ServerURL) { throw new Error('小雅未配置或未启用'); } const { XiaoyaClient } = await import('@/lib/xiaoya.client'); - const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import('@/lib/xiaoya-metadata'); + const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import( + '@/lib/xiaoya-metadata' + ); const { base58Decode, base58Encode } = await import('@/lib/utils'); const client = new XiaoyaClient( @@ -299,7 +334,9 @@ export async function GET(request: NextRequest) { let clickedFilePath: string | undefined; if (fileName) { // 拼接目录路径和文件名 - clickedFilePath = `${decodedDirPath}${decodedDirPath.endsWith('/') ? '' : '/'}${fileName}`; + clickedFilePath = `${decodedDirPath}${ + decodedDirPath.endsWith('/') ? '' : '/' + }${fileName}`; console.log('[xiaoya] 用户点击的文件路径:', clickedFilePath); } @@ -319,7 +356,9 @@ export async function GET(request: NextRequest) { // 如果有点击的文件路径,找到对应的集数索引 let clickedFileIndex = -1; if (clickedFilePath) { - clickedFileIndex = episodes.findIndex(ep => ep.path === clickedFilePath); + clickedFileIndex = episodes.findIndex( + (ep) => ep.path === clickedFilePath + ); console.log('[xiaoya] 文件在集数列表中的索引:', clickedFileIndex); } @@ -332,12 +371,16 @@ export async function GET(request: NextRequest) { year: metadata.year || '', douban_id: 0, desc: metadata.plot || '', - episodes: episodes.map(ep => `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}`), - episodes_titles: episodes.map(ep => ep.title), + episodes: episodes.map( + (ep) => + `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}` + ), + episodes_titles: episodes.map((ep) => ep.title), subtitles: [], proxyMode: false, // 返回用户点击的文件索引(如果找到的话) - initialEpisodeIndex: clickedFileIndex >= 0 ? clickedFileIndex : undefined, + initialEpisodeIndex: + clickedFileIndex >= 0 ? clickedFileIndex : undefined, // 返回元数据来源 metadataSource: metadata.source, }; @@ -360,11 +403,17 @@ export async function GET(request: NextRequest) { throw new Error('移动云盘未配置或未启用'); } - let session = refreshMobileNetdiskSession(id) || getMobileNetdiskSession(id); + let session = + refreshMobileNetdiskSession(id) || getMobileNetdiskSession(id); if (!session) { const payload = parseMobileNetdiskId(id); - const { listMobileShareVideos } = await import('@/lib/netdisk/mobile.client'); - const result = await listMobileShareVideos(payload.shareUrl, mobileConfig.Authorization); + const { listMobileShareVideos } = await import( + '@/lib/netdisk/mobile.client' + ); + const result = await listMobileShareVideos( + payload.shareUrl, + mobileConfig.Authorization + ); session = createMobileNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -377,8 +426,9 @@ export async function GET(request: NextRequest) { } const mobileSession = session; const { parseVideoFileName } = await import('@/lib/video-parser'); - const parsedFiles = mobileSession.files.map((file, index) => { - const parsed = parseVideoFileName(file.name); + const parsedFiles = mobileSession.files + .map((file, index) => { + const parsed = parseVideoFileName(file.name); return { ...file, originalIndex: index, @@ -386,20 +436,24 @@ export async function GET(request: NextRequest) { isOVA: parsed.isOVA, displayTitle: formatNetdiskEpisodeTitle(parsed, file.name), }; - }).sort((a, b) => { - if (a.isOVA && !b.isOVA) return 1; - if (!a.isOVA && b.isOVA) return -1; - return a.sortEpisode !== b.sortEpisode - ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { - numeric: true, - sensitivity: 'base', - }); - }); + }) + .sort((a, b) => { + if (a.isOVA && !b.isOVA) return 1; + if (!a.isOVA && b.isOVA) return -1; + return a.sortEpisode !== b.sortEpisode + ? a.sortEpisode - b.sortEpisode + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); + }); - const episodes = parsedFiles.map((file) => ( - `/api/netdisk/mobile/play?id=${encodeURIComponent(mobileSession.id)}&episodeIndex=${file.originalIndex}` - )); + const episodes = parsedFiles.map( + (file) => + `/api/netdisk/mobile/play?id=${encodeURIComponent( + mobileSession.id + )}&episodeIndex=${file.originalIndex}` + ); return NextResponse.json({ source: NETDISK_MOBILE_SOURCE, @@ -430,11 +484,18 @@ export async function GET(request: NextRequest) { throw new Error('百度网盘未配置或未启用'); } - let session = refreshBaiduNetdiskSession(id) || getBaiduNetdiskSession(id); + let session = + refreshBaiduNetdiskSession(id) || getBaiduNetdiskSession(id); if (!session) { const payload = parseBaiduNetdiskId(id); - const { listBaiduShareVideos } = await import('@/lib/netdisk/baidu.client'); - const result = await listBaiduShareVideos(payload.shareUrl, baiduConfig.Cookie, payload.passcode || ''); + const { listBaiduShareVideos } = await import( + '@/lib/netdisk/baidu.client' + ); + const result = await listBaiduShareVideos( + payload.shareUrl, + baiduConfig.Cookie, + payload.passcode || '' + ); session = createBaiduNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -465,7 +526,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -477,9 +541,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `百度网盘分享:${baiduSession.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/baidu/play?id=${encodeURIComponent(baiduSession.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/baidu/play?id=${encodeURIComponent( + baiduSession.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -495,14 +562,21 @@ export async function GET(request: NextRequest) { try { const config = await getConfig(); const tianyiConfig = config.NetDiskConfig?.Tianyi; - if (!tianyiConfig?.Enabled || !tianyiConfig.Account || !tianyiConfig.Password) { + if ( + !tianyiConfig?.Enabled || + !tianyiConfig.Account || + !tianyiConfig.Password + ) { throw new Error('天翼云盘未配置或未启用'); } - let session = refreshTianyiNetdiskSession(id) || getTianyiNetdiskSession(id); + let session = + refreshTianyiNetdiskSession(id) || getTianyiNetdiskSession(id); if (!session) { const payload = parseTianyiNetdiskId(id); - const { listTianyiShareVideos } = await import('@/lib/netdisk/tianyi.client'); + const { listTianyiShareVideos } = await import( + '@/lib/netdisk/tianyi.client' + ); const result = await listTianyiShareVideos( payload.shareUrl, tianyiConfig.Account, @@ -542,7 +616,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -554,9 +631,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `天翼云盘分享:${tianyiSession.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/tianyi/play?id=${encodeURIComponent(tianyiSession.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/tianyi/play?id=${encodeURIComponent( + tianyiSession.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -572,15 +652,25 @@ export async function GET(request: NextRequest) { try { const config = await getConfig(); const pan123Config = config.NetDiskConfig?.Pan123; - if (!pan123Config?.Enabled || !pan123Config.Account || !pan123Config.Password) { + if ( + !pan123Config?.Enabled || + !pan123Config.Account || + !pan123Config.Password + ) { throw new Error('123网盘未配置或未启用'); } - let session = refreshPan123NetdiskSession(id) || getPan123NetdiskSession(id); + let session = + refreshPan123NetdiskSession(id) || getPan123NetdiskSession(id); if (!session) { const payload = parsePan123NetdiskId(id); - const { listPan123ShareVideos } = await import('@/lib/netdisk/pan123.client'); - const result = await listPan123ShareVideos(payload.shareUrl, payload.passcode || ''); + const { listPan123ShareVideos } = await import( + '@/lib/netdisk/pan123.client' + ); + const result = await listPan123ShareVideos( + payload.shareUrl, + payload.passcode || '' + ); session = createPan123NetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -610,7 +700,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.fileName.localeCompare(b.fileName, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.fileName.localeCompare(b.fileName, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -622,9 +715,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `123网盘分享:${pan123Session.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/123/play?id=${encodeURIComponent(pan123Session.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/123/play?id=${encodeURIComponent( + pan123Session.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -645,11 +741,17 @@ export async function GET(request: NextRequest) { throw new Error('115网盘未配置或未启用'); } - let session = refreshPan115NetdiskSession(id) || getPan115NetdiskSession(id); + let session = + refreshPan115NetdiskSession(id) || getPan115NetdiskSession(id); if (!session) { const payload = parsePan115NetdiskId(id); - const { listPan115ShareVideos } = await import('@/lib/netdisk/pan115.client'); - const result = await listPan115ShareVideos(payload.shareUrl, payload.passcode || ''); + const { listPan115ShareVideos } = await import( + '@/lib/netdisk/pan115.client' + ); + const result = await listPan115ShareVideos( + payload.shareUrl, + payload.passcode || '' + ); session = createPan115NetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -679,7 +781,10 @@ export async function GET(request: NextRequest) { if (!a.isOVA && b.isOVA) return -1; return a.sortEpisode !== b.sortEpisode ? a.sortEpisode - b.sortEpisode - : a.name.localeCompare(b.name, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' }); + : a.name.localeCompare(b.name, 'zh-Hans-CN', { + numeric: true, + sensitivity: 'base', + }); }); return NextResponse.json({ @@ -691,9 +796,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `115网盘分享:${pan115Session.shareUrl}`, - episodes: parsedFiles.map((file) => ( - `/api/netdisk/115/play?id=${encodeURIComponent(pan115Session.id)}&episodeIndex=${file.originalIndex}` - )), + episodes: parsedFiles.map( + (file) => + `/api/netdisk/115/play?id=${encodeURIComponent( + pan115Session.id + )}&episodeIndex=${file.originalIndex}` + ), episodes_titles: parsedFiles.map((file) => file.displayTitle), proxyMode: false, }); @@ -705,7 +813,10 @@ export async function GET(request: NextRequest) { } } - if (sourceCode === NETDISK_QUARK_SOURCE || sourceCode === LEGACY_QUARK_TEMP_SOURCE) { + if ( + sourceCode === NETDISK_QUARK_SOURCE || + sourceCode === LEGACY_QUARK_TEMP_SOURCE + ) { try { const config = await getConfig(); const quarkConfig = config.NetDiskConfig?.Quark; @@ -714,11 +825,18 @@ export async function GET(request: NextRequest) { } const { parseVideoFileName } = await import('@/lib/video-parser'); - let session = refreshQuarkNetdiskSession(id) || getQuarkNetdiskSession(id); + let session = + refreshQuarkNetdiskSession(id) || getQuarkNetdiskSession(id); if (!session) { const payload = parseQuarkNetdiskId(id); - const { listQuarkShareVideos } = await import('@/lib/netdisk/quark.client'); - const result = await listQuarkShareVideos(payload.shareUrl, quarkConfig.Cookie, payload.passcode || ''); + const { listQuarkShareVideos } = await import( + '@/lib/netdisk/quark.client' + ); + const result = await listQuarkShareVideos( + payload.shareUrl, + quarkConfig.Cookie, + payload.passcode || '' + ); session = createQuarkNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -761,9 +879,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `夸克网盘分享:${quarkSession.shareUrl}`, - episodes: episodes.map((ep) => ( - `/api/netdisk/quark/play?id=${encodeURIComponent(quarkSession.id)}&episodeIndex=${ep.originalIndex}` - )), + episodes: episodes.map( + (ep) => + `/api/netdisk/quark/play?id=${encodeURIComponent( + quarkSession.id + )}&episodeIndex=${ep.originalIndex}` + ), episodes_titles: episodes.map((ep) => ep.title), proxyMode: false, }); @@ -788,7 +909,11 @@ export async function GET(request: NextRequest) { if (!session) { const payload = parseUCNetdiskId(id); const { listUCShareVideos } = await import('@/lib/netdisk/uc.client'); - const result = await listUCShareVideos(payload.shareUrl, ucConfig.Cookie, payload.passcode || ''); + const result = await listUCShareVideos( + payload.shareUrl, + ucConfig.Cookie, + payload.passcode || '' + ); session = createUCNetdiskSession({ title: title || result.title, shareUrl: payload.shareUrl, @@ -831,9 +956,12 @@ export async function GET(request: NextRequest) { year: '', douban_id: 0, desc: `UC网盘分享:${ucSession.shareUrl}`, - episodes: episodes.map((ep) => ( - `/api/netdisk/uc/play?id=${encodeURIComponent(ucSession.id)}&episodeIndex=${ep.originalIndex}` - )), + episodes: episodes.map( + (ep) => + `/api/netdisk/uc/play?id=${encodeURIComponent( + ucSession.id + )}&episodeIndex=${ep.originalIndex}` + ), episodes_titles: episodes.map((ep) => ep.title), proxyMode: false, }); @@ -867,7 +995,9 @@ export async function GET(request: NextRequest) { let metaInfo: any = null; let folderMeta: any = null; try { - const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache'); + const { getCachedMetaInfo, setCachedMetaInfo } = await import( + '@/lib/openlist-cache' + ); const { db } = await import('@/lib/db'); metaInfo = getCachedMetaInfo(); @@ -891,11 +1021,15 @@ export async function GET(request: NextRequest) { // 使用 folderName 构建实际路径 const folderName = folderMeta.folderName; - const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`; + const folderPath = `${rootPath}${ + rootPath.endsWith('/') ? '' : '/' + }${folderName}`; // 2. 直接调用 OpenList 客户端获取视频列表 const { OpenListClient } = await import('@/lib/openlist.client'); - const { getCachedVideoInfo, setCachedVideoInfo } = await import('@/lib/openlist-cache'); + const { getCachedVideoInfo, setCachedVideoInfo } = await import( + '@/lib/openlist-cache' + ); const { parseVideoFileName } = await import('@/lib/video-parser'); const client = new OpenListClient( @@ -914,7 +1048,11 @@ export async function GET(request: NextRequest) { let hasMore = true; while (hasMore) { - const listResponse = await client.listDirectory(folderPath, currentPage, pageSize); + const listResponse = await client.listDirectory( + folderPath, + currentPage, + pageSize + ); if (listResponse.code !== 200) { throw new Error('OpenList 列表获取失败4'); @@ -927,10 +1065,35 @@ export async function GET(request: NextRequest) { currentPage++; } - const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm', '.rmvb', '.rm', '.mpg', '.mpeg', '.3gp', '.f4v', '.m4v', '.vob']; + const videoExtensions = [ + '.mp4', + '.mkv', + '.avi', + '.m3u8', + '.flv', + '.ts', + '.mov', + '.wmv', + '.webm', + '.rmvb', + '.rm', + '.mpg', + '.mpeg', + '.3gp', + '.f4v', + '.m4v', + '.vob', + ]; const videoFiles = allFiles.filter((item) => { - if (item.is_dir || item.name.startsWith('.') || item.name.endsWith('.json')) return false; - return videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext)); + if ( + item.is_dir || + item.name.startsWith('.') || + item.name.endsWith('.json') + ) + return false; + return videoExtensions.some((ext) => + item.name.toLowerCase().endsWith(ext) + ); }); if (!videoInfo) { @@ -940,7 +1103,7 @@ export async function GET(request: NextRequest) { const file = videoFiles[i]; const parsed = parseVideoFileName(file.name); videoInfo.episodes[file.name] = { - episode: parsed.episode || (i + 1), + episode: parsed.episode || i + 1, season: parsed.season, title: parsed.title, parsed_from: 'filename', @@ -955,25 +1118,46 @@ export async function GET(request: NextRequest) { const parsed = parseVideoFileName(file.name); let episodeInfo; if (parsed.episode) { - episodeInfo = { episode: parsed.episode, season: parsed.season, title: parsed.title, parsed_from: 'filename', isOVA: parsed.isOVA }; + episodeInfo = { + episode: parsed.episode, + season: parsed.season, + title: parsed.title, + parsed_from: 'filename', + isOVA: parsed.isOVA, + }; } else { - episodeInfo = videoInfo!.episodes[file.name] || { episode: index + 1, season: undefined, title: undefined, parsed_from: 'filename' }; + episodeInfo = videoInfo!.episodes[file.name] || { + episode: index + 1, + season: undefined, + title: undefined, + parsed_from: 'filename', + }; } let displayTitle = episodeInfo.title; if (!displayTitle && episodeInfo.episode) { - displayTitle = episodeInfo.isOVA ? `OVA ${episodeInfo.episode}` : `第${episodeInfo.episode}集`; + displayTitle = episodeInfo.isOVA + ? `OVA ${episodeInfo.episode}` + : `第${episodeInfo.episode}集`; } if (!displayTitle) { displayTitle = file.name; } - return { fileName: file.name, episode: episodeInfo.episode || 0, season: episodeInfo.season, title: displayTitle, isOVA: episodeInfo.isOVA }; + return { + fileName: file.name, + episode: episodeInfo.episode || 0, + season: episodeInfo.season, + title: displayTitle, + isOVA: episodeInfo.isOVA, + }; }) .sort((a, b) => { // OVA 排在最后 if (a.isOVA && !b.isOVA) return 1; if (!a.isOVA && b.isOVA) return -1; // 都是 OVA 或都不是 OVA,按集数排序 - return a.episode !== b.episode ? a.episode - b.episode : a.fileName.localeCompare(b.fileName); + return a.episode !== b.episode + ? a.episode - b.episode + : a.fileName.localeCompare(b.fileName); }); // 3. 从 metainfo 中获取元数据 @@ -984,11 +1168,20 @@ export async function GET(request: NextRequest) { source_name: '私人影库', id: id, title: folderMeta?.title || folderName, - poster: folderMeta?.poster_path ? getTMDBImageUrl(folderMeta.poster_path) : '', - year: folderMeta?.release_date ? folderMeta.release_date.split('-')[0] : '', + poster: folderMeta?.poster_path + ? getTMDBImageUrl(folderMeta.poster_path) + : '', + year: folderMeta?.release_date + ? folderMeta.release_date.split('-')[0] + : '', douban_id: 0, desc: folderMeta?.overview || '', - episodes: episodes.map((ep) => `/api/openlist/play?folder=${encodeURIComponent(folderName)}&fileName=${encodeURIComponent(ep.fileName)}`), + episodes: episodes.map( + (ep) => + `/api/openlist/play?folder=${encodeURIComponent( + folderName + )}&fileName=${encodeURIComponent(ep.fileName)}` + ), episodes_titles: episodes.map((ep) => ep.title), proxyMode: false, // openlist 源不使用代理模式 }; diff --git a/src/app/page.tsx b/src/app/page.tsx index f3251c8..f22b2b9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,15 @@ 'use client'; -import { BookMarked, BookOpen, Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react'; +import { + BookMarked, + BookOpen, + Bot, + ChevronRight, + Link as LinkIcon, + ListVideo, + Music, +} from 'lucide-react'; import Link from 'next/link'; import { Suspense, useEffect, useState } from 'react'; @@ -56,23 +64,29 @@ function HomeClient() { { id: 'upcomingContent', name: '即将上映', enabled: true, order: 5 }, ]); const [homeBannerEnabled, setHomeBannerEnabled] = useState(true); - const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = useState(true); + const [homeContinueWatchingEnabled, setHomeContinueWatchingEnabled] = + useState(true); const [showAnnouncement, setShowAnnouncement] = useState(false); const [showHttpWarning, setShowHttpWarning] = useState(true); const [showAIChat, setShowAIChat] = useState(false); const [aiEnabled, setAiEnabled] = useState(false); - const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?'); + const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState( + '你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?' + ); const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true); const [musicEnabled, setMusicEnabled] = useState(false); const [mangaEnabled, setMangaEnabled] = useState(false); const [booksEnabled, setBooksEnabled] = useState(false); + const [netdiskTempPlayEnabled, setNetdiskTempPlayEnabled] = useState(false); const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false); const [directPlayUrl, setDirectPlayUrl] = useState(''); const [directPlaySubmitting, setDirectPlaySubmitting] = useState(false); const [toast, setToast] = useState(null); - const detectNetdiskLink = (url: string): { + const detectNetdiskLink = ( + url: string + ): { provider: 'quark' | 'mobile' | 'baidu' | 'tianyi' | '123' | 'uc' | '115'; shareUrl: string; passcode?: string; @@ -84,11 +98,17 @@ function HomeClient() { const inlinePasscode = (text: string) => pickPasscode( - text.match(/(?:提取码|访问码|密码)\s*[::=]?\s*([a-zA-Z0-9]{4,8})/i)?.[1], + text.match( + /(?:提取码|访问码|密码)\s*[::=]?\s*([a-zA-Z0-9]{4,8})/i + )?.[1], text.match(/[?&](?:pwd|passcode|accessCode)=([^&\s]+)/i)?.[1] ); - if (/https:\/\/(?:www\.)?123(?:684|865|912|pan)\.(?:com|cn)\/s\//i.test(trimmed)) { + if ( + /https:\/\/(?:www\.)?123(?:684|865|912|pan)\.(?:com|cn)\/s\//i.test( + trimmed + ) + ) { return { provider: '123', shareUrl: trimmed, @@ -99,7 +119,10 @@ function HomeClient() { }; } - if (/https:\/\/cloud\.189\.cn\/(web\/share\?code=|t\/)/i.test(trimmed) || /https:\/\/h5\.cloud\.189\.cn\/share\.html#\/t\//i.test(trimmed)) { + if ( + /https:\/\/cloud\.189\.cn\/(web\/share\?code=|t\/)/i.test(trimmed) || + /https:\/\/h5\.cloud\.189\.cn\/share\.html#\/t\//i.test(trimmed) + ) { return { provider: 'tianyi', shareUrl: trimmed, @@ -172,21 +195,25 @@ function HomeClient() { setDirectPlaySubmitting(true); try { const netdisk = detectNetdiskLink(trimmed); + if (netdisk && !netdiskTempPlayEnabled) { + throw new Error('无权限使用临时播放'); + } + if (netdisk) { const source = netdisk.provider === 'mobile' ? 'netdisk-mobile' : netdisk.provider === 'baidu' - ? 'netdisk-baidu' - : netdisk.provider === 'tianyi' - ? 'netdisk-tianyi' - : netdisk.provider === '115' - ? 'netdisk-115' - : netdisk.provider === 'uc' - ? 'netdisk-uc' - : netdisk.provider === '123' - ? 'netdisk-123' - : 'netdisk-quark'; + ? 'netdisk-baidu' + : netdisk.provider === 'tianyi' + ? 'netdisk-tianyi' + : netdisk.provider === '115' + ? 'netdisk-115' + : netdisk.provider === 'uc' + ? 'netdisk-uc' + : netdisk.provider === '123' + ? 'netdisk-123' + : 'netdisk-quark'; const id = base58Encode( JSON.stringify({ shareUrl: netdisk.shareUrl, @@ -196,7 +223,11 @@ function HomeClient() { if (!id) { throw new Error('网盘链接编码失败'); } - const targetUrl = `/play?source=${encodeURIComponent(source)}&id=${encodeURIComponent(id)}&title=${encodeURIComponent('网盘直链播放')}`; + const targetUrl = `/play?source=${encodeURIComponent( + source + )}&id=${encodeURIComponent(id)}&title=${encodeURIComponent( + '网盘直链播放' + )}`; setShowDirectPlayDialog(false); setDirectPlayUrl(''); window.location.assign(targetUrl); @@ -205,7 +236,9 @@ function HomeClient() { const encoded = base58Encode(trimmed); if (!encoded) return; - const targetUrl = `/play?source=directplay&id=${encodeURIComponent(encoded)}`; + const targetUrl = `/play?source=directplay&id=${encodeURIComponent( + encoded + )}`; setShowDirectPlayDialog(false); setDirectPlayUrl(''); window.location.assign(targetUrl); @@ -237,9 +270,13 @@ function HomeClient() { setHomeBannerEnabled(savedHomeBannerEnabled === 'true'); } - const savedHomeContinueWatchingEnabled = localStorage.getItem('homeContinueWatchingEnabled'); + const savedHomeContinueWatchingEnabled = localStorage.getItem( + 'homeContinueWatchingEnabled' + ); if (savedHomeContinueWatchingEnabled !== null) { - setHomeContinueWatchingEnabled(savedHomeContinueWatchingEnabled === 'true'); + setHomeContinueWatchingEnabled( + savedHomeContinueWatchingEnabled === 'true' + ); } }; @@ -256,7 +293,10 @@ function HomeClient() { window.addEventListener('homeModulesUpdated', handleHomeModulesUpdated); return () => { - window.removeEventListener('homeModulesUpdated', handleHomeModulesUpdated); + window.removeEventListener( + 'homeModulesUpdated', + handleHomeModulesUpdated + ); }; }, []); @@ -269,7 +309,8 @@ function HomeClient() { setAiEnabled(enabled); // 加载AI默认消息配置 - const defaultMsg = (window as any).RUNTIME_CONFIG?.AI_DEFAULT_MESSAGE_NO_VIDEO; + const defaultMsg = (window as any).RUNTIME_CONFIG + ?.AI_DEFAULT_MESSAGE_NO_VIDEO; if (defaultMsg) { setAiDefaultMessageNoVideo(defaultMsg); } @@ -279,7 +320,8 @@ function HomeClient() { // 检查源站寻片功能是否启用 useEffect(() => { if (typeof window !== 'undefined') { - const enabled = (window as any).RUNTIME_CONFIG?.ENABLE_SOURCE_SEARCH !== false; + const enabled = + (window as any).RUNTIME_CONFIG?.ENABLE_SOURCE_SEARCH !== false; setSourceSearchEnabled(enabled); } }, []); @@ -308,6 +350,15 @@ function HomeClient() { } }, []); + // 检查网盘临时播放权限,仅有权限时在直链播放弹窗展示网盘在线播放提示 + useEffect(() => { + if (typeof window !== 'undefined') { + const enabled = !!(window as any).RUNTIME_CONFIG + ?.NETDISK_TEMP_PLAY_ENABLED; + setNetdiskTempPlayEnabled(enabled); + } + }, []); + // 检查公告弹窗状态 useEffect(() => { if (typeof window !== 'undefined' && announcement) { @@ -336,7 +387,10 @@ function HomeClient() { const setCache = (key: string, data: any) => { try { - localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() })); + localStorage.setItem( + key, + JSON.stringify({ data, timestamp: Date.now() }) + ); } catch { // Ignore localStorage errors } @@ -356,18 +410,43 @@ function HomeClient() { if (duanjuCache?.data) setHotDuanju(duanjuCache.data); if (upcomingCache?.data) setUpcomingContent(upcomingCache.data); - const hasCache = moviesCache || tvShowsCache || varietyCache || bangumiCache || duanjuCache || upcomingCache; + const hasCache = + moviesCache || + tvShowsCache || + varietyCache || + bangumiCache || + duanjuCache || + upcomingCache; if (hasCache) setLoading(false); - const needsRefresh = !moviesCache || moviesCache.expired || !tvShowsCache || tvShowsCache.expired || - !varietyCache || varietyCache.expired || !bangumiCache || bangumiCache.expired || - !duanjuCache || duanjuCache.expired || !upcomingCache || upcomingCache.expired; + const needsRefresh = + !moviesCache || + moviesCache.expired || + !tvShowsCache || + tvShowsCache.expired || + !varietyCache || + varietyCache.expired || + !bangumiCache || + bangumiCache.expired || + !duanjuCache || + duanjuCache.expired || + !upcomingCache || + upcomingCache.expired; if (needsRefresh) { (async () => { try { - const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] = await Promise.all([ - getDoubanCategories({ kind: 'movie', category: '热门', type: '全部' }), + const [ + moviesData, + tvShowsData, + varietyShowsData, + bangumiCalendarData, + ] = await Promise.all([ + getDoubanCategories({ + kind: 'movie', + category: '热门', + type: '全部', + }), getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }), getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }), GetBangumiCalendarData(), @@ -400,7 +479,11 @@ function HomeClient() { const duanjuResponse = await fetch('/api/duanju/recommends'); if (duanjuResponse.ok) { const duanjuResult = await duanjuResponse.json(); - if (duanjuResult.code === 200 && duanjuResult.data && duanjuResult.data.length > 0) { + if ( + duanjuResult.code === 200 && + duanjuResult.data && + duanjuResult.data.length > 0 + ) { setHotDuanju(duanjuResult.data); setCache('homepage_duanju', duanjuResult.data); } @@ -413,10 +496,18 @@ function HomeClient() { const response = await fetch('/api/tmdb/upcoming'); if (response.ok) { const result = await response.json(); - if (result.code === 200 && result.data && result.data.length > 0) { + if ( + result.code === 200 && + result.data && + result.data.length > 0 + ) { const sorted = [...result.data].sort((a, b) => { - const dateA = new Date(a.release_date || '9999-12-31').getTime(); - const dateB = new Date(b.release_date || '9999-12-31').getTime(); + const dateA = new Date( + a.release_date || '9999-12-31' + ).getTime(); + const dateB = new Date( + b.release_date || '9999-12-31' + ).getTime(); return dateA - dateB; }); setUpcomingContent(sorted); @@ -436,8 +527,6 @@ function HomeClient() { } }, []); - - const handleCloseAnnouncement = (announcement: string) => { setShowAnnouncement(false); localStorage.setItem('hasSeenAnnouncement', announcement); // 记录已查看弹窗 @@ -448,7 +537,7 @@ function HomeClient() { switch (moduleId) { case 'hotMovies': return ( -
+

热门电影 @@ -496,7 +585,7 @@ function HomeClient() { case 'hotDuanju': if (hotDuanju.length === 0) return null; return ( -
+

热播短剧 @@ -550,7 +639,7 @@ function HomeClient() { case 'bangumiCalendar': return ( -
+

新番放送 @@ -578,7 +667,15 @@ function HomeClient() { )) : (() => { const today = new Date(); - const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const weekdays = [ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + ]; const currentWeekday = weekdays[today.getDay()]; const todayAnimes = bangumiCalendarData @@ -615,7 +712,7 @@ function HomeClient() { case 'hotTvShows': return ( -
+

热门剧集 @@ -662,7 +759,7 @@ function HomeClient() { case 'hotVarietyShows': return ( -
+

热门综艺 @@ -699,7 +796,9 @@ function HomeClient() { rate={varietyShow.rate} type='tv' from='douban' - douban_id={varietyShow.id ? parseInt(varietyShow.id) : undefined} + douban_id={ + varietyShow.id ? parseInt(varietyShow.id) : undefined + } />

))} @@ -710,7 +809,7 @@ function HomeClient() { case 'upcomingContent': if (upcomingContent.length === 0) return null; return ( -
+

即将上映 @@ -762,7 +861,11 @@ function HomeClient() { {/* 首页内容 */} <> {/* 源站寻片和AI问片入口 */} -
+
@@ -899,9 +1002,11 @@ function HomeClient() {
请输入可直接播放的视频链接。
-
- 支持夸克、UC、百度、天翼、移动、123、115 网盘在线播放。 -
+ {netdiskTempPlayEnabled && ( +
+ 支持夸克、UC、百度、天翼、移动、123、115 网盘在线播放。 +
+ )} setDirectPlayUrl(event.target.value)} From 09fc63c1163452b9d61a7f6a516a1efb8f43724c Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 15 May 2026 12:06:34 +0800 Subject: [PATCH 03/40] =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=A8=E6=88=B7=E5=90=8D=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 168 ++++++++++++++++++++++++------- src/app/api/admin/users/route.ts | 3 +- src/lib/d1.db.ts | 62 ++++++++---- src/lib/db.ts | 5 +- src/lib/postgres.db.ts | 62 ++++++++---- src/lib/redis-base.db.ts | 48 +++++++-- 6 files changed, 268 insertions(+), 80 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d52179f..b223beb 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -41,12 +41,14 @@ import { Mail, Palette, Plus, + Search, Settings, Trash2, Tv, UserPlus, Users, Video, + X, } from 'lucide-react'; import { GripVertical } from 'lucide-react'; import { @@ -512,8 +514,10 @@ interface UserConfigProps { userPage: number; userTotalPages: number; userTotal: number; - fetchUsersV2: (page: number) => Promise; + fetchUsersV2: (page: number, search?: string) => Promise; userListLoading: boolean; + userSearch: string; + setUserSearch: (value: string) => void; } const UserConfig = ({ @@ -526,6 +530,8 @@ const UserConfig = ({ userTotal, fetchUsersV2, userListLoading, + userSearch, + setUserSearch, }: UserConfigProps) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); @@ -582,6 +588,7 @@ const UserConfig = ({ } | null>(null); const [showDeleteUserModal, setShowDeleteUserModal] = useState(false); const [deletingUser, setDeletingUser] = useState(null); + const trimmedUserSearch = userSearch.trim(); // 当前登录用户名 const currentUsername = getAuthInfoFromBrowserCookie()?.username || null; @@ -887,7 +894,7 @@ const UserConfig = ({ if (checked) { // 只选择自己有权限操作的用户 const selectableUsernames = - config?.UserConfig?.Users?.filter( + displayUsers?.filter( (user) => role === 'owner' || (role === 'admin' && @@ -898,7 +905,7 @@ const UserConfig = ({ setSelectedUsers(new Set()); } }, - [config?.UserConfig?.Users, role, currentUsername] + [displayUsers, role, currentUsername] ); // 批量设置用户组 @@ -1240,15 +1247,87 @@ const UserConfig = ({ {/* 用户列表 */}
-
-

- 用户列表 -

-
+
+
+

+ 用户列表 +

+ +
+
+ {!hasOldUserData && usersV2 && ( +
{ + e.preventDefault(); + setSelectedUsers(new Set()); + fetchUsersV2(1, trimmedUserSearch); + }} + className='ml-auto flex min-w-0 items-center gap-2' + > +
+ + setUserSearch(e.target.value)} + placeholder='按用户名搜索' + className='w-full pl-9 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent' + /> +
+ {trimmedUserSearch && ( + + )} + +
+ )} {/* 批量操作按钮 */} {selectedUsers.size > 0 && ( <> -
+
已选择 {selectedUsers.size} 个用户 @@ -1259,23 +1338,9 @@ const UserConfig = ({ 批量设置用户组
-
+
)} -
@@ -1448,7 +1513,7 @@ const UserConfig = ({ {(() => { // 检查是否有权限操作任何用户 - const hasAnyPermission = config?.UserConfig?.Users?.some( + const hasAnyPermission = displayUsers?.some( (user) => role === 'owner' || (role === 'admin' && @@ -1516,7 +1581,7 @@ const UserConfig = ({ 加载中... @@ -1536,6 +1601,23 @@ const UserConfig = ({ }; return priority(a) - priority(b); }); + if (sortedUsers.length === 0) { + return ( + + + + {trimmedUserSearch + ? `未找到用户名包含“${trimmedUserSearch}”的用户` + : '暂无用户'} + + + + ); + } + return ( {sortedUsers.map((user) => { @@ -1778,11 +1860,14 @@ const UserConfig = ({ {!hasOldUserData && usersV2 && userTotalPages > 1 && (
- 共 {userTotal} 个用户,第 {userPage} / {userTotalPages} 页 + {trimmedUserSearch + ? `搜索结果 ${userTotal} 个用户` + : `共 ${userTotal} 个用户`} + ,第 {userPage} / {userTotalPages} 页
+
+
+

+ 多线程播放 +

+

+ 开启后,代理会把播放器请求的 Range 拆分并发拉取。 +

+
+ +
+
@@ -652,29 +823,7 @@ export default function AIChatPanel({

) : (
- { - // 如果是内部链接(以 / 开头),使用 Next.js Link - if (href?.startsWith('/')) { - // 如果当前在 /play 页面且链接也是 /play,不做处理(返回纯文本) - if (pathname === '/play' && href.startsWith('/play')) { - return {children}; - } - return ( - - {children} - - ); - } - // 外部链接使用普通 a 标签 - return {children}; - } - }} - > - {convertTitleToLink(message.content)} - + {renderAssistantContent(message.content)}
)}
From 464edee32421362f18c463d5c9bfabf9f1566faa Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 18 May 2026 01:11:33 +0800 Subject: [PATCH 13/40] =?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=9B=B4=E5=A4=9Amarkdown=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AIChatPanel.tsx | 99 ++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx index e1dc6f6..198a006 100644 --- a/src/components/AIChatPanel.tsx +++ b/src/components/AIChatPanel.tsx @@ -137,6 +137,88 @@ const splitMarkdownByTables = (content: string): MarkdownSegment[] => { return segments; }; +const transformStrikethrough = (line: string): string => { + return line; +}; + +const transformTaskList = (line: string): string => { + return line.replace(/^(\s*[-*+]\s+)\[(x|X| )\]\s+/g, (_match, prefix: string, checked: string) => { + return `${prefix}${checked.trim() ? '☑' : '☐'} `; + }); +}; + +const transformBareLinks = (line: string): string => { + return line.replace(/(https?:\/\/[^\s<>()]+|www\.[^\s<>()]+)/g, (match, _url: string, offset: number, source: string) => { + const before = source.slice(Math.max(0, offset - 2), offset); + const previousChar = source[offset - 1]; + const lastOpenBracket = source.lastIndexOf('[', offset); + const lastCloseBracket = source.lastIndexOf(']', offset); + const nextCloseBracket = source.indexOf(']', offset + match.length); + const nextOpenParen = nextCloseBracket >= 0 ? source.slice(nextCloseBracket, nextCloseBracket + 2) : ''; + + // 已经是 Markdown 链接目标或链接文本时不重复转换。 + if (before === '](' || previousChar === '<' || (lastOpenBracket > lastCloseBracket && nextOpenParen === '](')) { + return match; + } + + const trailing = match.match(/[.,!?;:,。!?;:]+$/)?.[0] || ''; + const cleanUrl = trailing ? match.slice(0, -trailing.length) : match; + const href = cleanUrl.startsWith('www.') ? `https://${cleanUrl}` : cleanUrl; + + return `[${cleanUrl}](${href})${trailing}`; + }); +}; + +const transformLightweightGfm = (content: string): string => { + const lines = content.split('\n'); + let inFence = false; + + return lines.map((line) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + return line; + } + + if (inFence) return line; + + return transformBareLinks(transformStrikethrough(transformTaskList(line))); + }).join('\n'); +}; + +const renderStrikethroughNodes = (children: React.ReactNode): React.ReactNode => { + return React.Children.map(children, (child) => { + if (typeof child === 'string') { + const parts: React.ReactNode[] = []; + const regex = /~~([^~\n]+)~~/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(child)) !== null) { + if (match.index > lastIndex) { + parts.push(child.slice(lastIndex, match.index)); + } + + parts.push({match[1]}); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < child.length) { + parts.push(child.slice(lastIndex)); + } + + return parts.length > 0 ? parts : child; + } + + if (React.isValidElement(child) && (child as any).props?.children) { + return React.cloneElement(child as any, { + children: renderStrikethroughNodes((child as any).props.children), + }); + } + + return child; + }); +}; + export default function AIChatPanel({ isOpen, onClose, @@ -178,6 +260,15 @@ export default function AIChatPanel({ }; const markdownComponents = useMemo(() => ({ + del: ({ children }: any) => {children}, + p: ({ children }: any) =>

{renderStrikethroughNodes(children)}

, + li: ({ children }: any) =>
  • {renderStrikethroughNodes(children)}
  • , + h1: ({ children }: any) =>

    {renderStrikethroughNodes(children)}

    , + h2: ({ children }: any) =>

    {renderStrikethroughNodes(children)}

    , + h3: ({ children }: any) =>

    {renderStrikethroughNodes(children)}

    , + h4: ({ children }: any) =>

    {renderStrikethroughNodes(children)}

    , + h5: ({ children }: any) =>
    {renderStrikethroughNodes(children)}
    , + h6: ({ children }: any) =>
    {renderStrikethroughNodes(children)}
    , a: ({ href, children, ...props }: any) => { // 如果是内部链接(以 / 开头),使用 Next.js Link if (href?.startsWith('/')) { @@ -198,7 +289,7 @@ export default function AIChatPanel({ const inlineMarkdownComponents = useMemo(() => ({ ...markdownComponents, - p: ({ children }: any) => {children}, + p: ({ children }: any) => {renderStrikethroughNodes(children)}, }), [markdownComponents]); const renderAssistantContent = (content: string) => { @@ -206,7 +297,7 @@ export default function AIChatPanel({ if (segment.type === 'markdown') { return ( - {convertTitleToLink(segment.content)} + {transformLightweightGfm(convertTitleToLink(segment.content))} ); } @@ -227,7 +318,7 @@ export default function AIChatPanel({ style={{ textAlign: segment.align[cellIndex] }} > - {convertTitleToLink(cell)} + {transformLightweightGfm(convertTitleToLink(cell))} ))} @@ -246,7 +337,7 @@ export default function AIChatPanel({ style={{ textAlign: segment.align[cellIndex] }} > - {convertTitleToLink(cell)} + {transformLightweightGfm(convertTitleToLink(cell))} ))} From 2cabf76c2d5903db12c37e40696c712fc1d1ee02 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 18 May 2026 11:19:44 +0800 Subject: [PATCH 14/40] =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=A2=9E=E5=8A=A0fail2?= =?UTF-8?q?ban=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/login/route.ts | 29 ++++++ src/lib/login-fail2ban.ts | 175 +++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/lib/login-fail2ban.ts diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index aad2a47..41bbc91 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -4,6 +4,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { parseAuthInfo } from '@/lib/auth'; import { getConfig } from '@/lib/config'; import { db } from '@/lib/db'; +import { + checkLoginBan, + getLoginClientIp, + recordLoginFailure, + recordLoginSuccess, +} from '@/lib/login-fail2ban'; import { generateRefreshToken, generateTokenId, @@ -177,6 +183,20 @@ function getDeviceInfo(request: NextRequest): string { export async function POST(req: NextRequest) { try { + const clientIp = getLoginClientIp(req); + const banStatus = checkLoginBan(clientIp); + if (banStatus.banned) { + return NextResponse.json( + { error: '登录失败次数过多,请稍后再试' }, + { + status: 429, + headers: banStatus.retryAfterSeconds + ? { 'Retry-After': String(banStatus.retryAfterSeconds) } + : undefined, + } + ); + } + // 获取站点配置 const adminConfig = await getConfig(); const siteConfig = adminConfig.SiteConfig; @@ -206,12 +226,15 @@ export async function POST(req: NextRequest) { } if (password !== envPassword) { + recordLoginFailure(clientIp); return NextResponse.json( { ok: false, error: '密码错误' }, { status: 401 } ); } + recordLoginSuccess(clientIp); + // 验证成功,设置认证cookie const username = process.env.USERNAME || 'default'; const deviceInfo = getDeviceInfo(req); @@ -279,6 +302,8 @@ export async function POST(req: NextRequest) { username === process.env.USERNAME && password === process.env.PASSWORD ) { + recordLoginSuccess(clientIp); + // 验证成功,设置认证cookie const deviceInfo = getDeviceInfo(req); const cookieValue = await generateAuthCookie( @@ -302,6 +327,7 @@ export async function POST(req: NextRequest) { return response; } else if (username === process.env.USERNAME) { + recordLoginFailure(clientIp); return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }); } @@ -326,12 +352,15 @@ export async function POST(req: NextRequest) { } if (!pass) { + recordLoginFailure(clientIp); return NextResponse.json( { error: '用户名或密码错误' }, { status: 401 } ); } + recordLoginSuccess(clientIp); + // 验证成功,设置认证cookie const deviceInfo = getDeviceInfo(req); const cookieValue = await generateAuthCookie( diff --git a/src/lib/login-fail2ban.ts b/src/lib/login-fail2ban.ts new file mode 100644 index 0000000..c44d354 --- /dev/null +++ b/src/lib/login-fail2ban.ts @@ -0,0 +1,175 @@ +import { NextRequest } from 'next/server'; + +const WINDOW_MS = 10 * 60 * 1000; +const MAX_FAILURES = 5; +const BAN_DURATIONS_MS = [ + 60 * 60 * 1000, + 6 * 60 * 60 * 1000, + 24 * 60 * 60 * 1000, +]; +const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; +const RECORD_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +interface LoginFail2BanRecord { + failures: number[]; + bannedUntil: number; + banLevel: number; + lastSeen: number; +} + +interface LoginFail2BanStore { + records: Map; + lastCleanup: number; +} + +interface LoginFail2BanGlobal { + __loginFail2BanStore?: LoginFail2BanStore; +} + +export interface LoginBanStatus { + banned: boolean; + bannedUntil?: number; + retryAfterSeconds?: number; +} + +export interface LoginFailureResult extends LoginBanStatus { + failureCount: number; + banLevel: number; +} + +function getStore(): LoginFail2BanStore { + const globalStore = globalThis as typeof globalThis & LoginFail2BanGlobal; + if (!globalStore.__loginFail2BanStore) { + globalStore.__loginFail2BanStore = { + records: new Map(), + lastCleanup: Date.now(), + }; + } + + return globalStore.__loginFail2BanStore; +} + +function cleanupExpiredRecords(now: number) { + const store = getStore(); + if (now - store.lastCleanup < CLEANUP_INTERVAL_MS) return; + + for (const [ip, record] of Array.from(store.records.entries())) { + const hasActiveBan = record.bannedUntil > now; + const recentlySeen = now - record.lastSeen < RECORD_TTL_MS; + + if (!hasActiveBan && !recentlySeen) { + store.records.delete(ip); + } + } + + store.lastCleanup = now; +} + +function pruneFailures(record: LoginFail2BanRecord, now: number) { + record.failures = record.failures.filter((time) => now - time <= WINDOW_MS); +} + +function normalizeIp(ip: string): string | null { + const normalized = ip.trim(); + if (!normalized || normalized.toLowerCase() === 'unknown') return null; + return normalized; +} + +export function getLoginClientIp(req: NextRequest): string | null { + const cfConnectingIp = normalizeIp(req.headers.get('cf-connecting-ip') || ''); + if (cfConnectingIp) return cfConnectingIp; + + const xRealIp = normalizeIp(req.headers.get('x-real-ip') || ''); + if (xRealIp) return xRealIp; + + const xForwardedFor = req.headers.get('x-forwarded-for'); + if (xForwardedFor) { + const firstIp = normalizeIp(xForwardedFor.split(',')[0] || ''); + if (firstIp) return firstIp; + } + + const forwarded = req.headers.get('forwarded'); + if (forwarded) { + const match = forwarded.match(/for=(?:"?)([^;,\"]+)/i); + const forwardedIp = normalizeIp(match?.[1]?.replace(/^\[|\]$/g, '') || ''); + if (forwardedIp) return forwardedIp; + } + + return null; +} + +export function checkLoginBan(ip: string | null, now = Date.now()): LoginBanStatus { + if (!ip) return { banned: false }; + + cleanupExpiredRecords(now); + + const record = getStore().records.get(ip); + if (!record) return { banned: false }; + + record.lastSeen = now; + if (record.bannedUntil > now) { + return { + banned: true, + bannedUntil: record.bannedUntil, + retryAfterSeconds: Math.ceil((record.bannedUntil - now) / 1000), + }; + } + + pruneFailures(record, now); + return { banned: false }; +} + +export function recordLoginFailure(ip: string | null, now = Date.now()): LoginFailureResult { + if (!ip) { + return { banned: false, failureCount: 0, banLevel: 0 }; + } + + cleanupExpiredRecords(now); + + const store = getStore(); + const record = store.records.get(ip) || { + failures: [], + bannedUntil: 0, + banLevel: 0, + lastSeen: now, + }; + + record.lastSeen = now; + pruneFailures(record, now); + record.failures.push(now); + + if (record.failures.length >= MAX_FAILURES) { + record.banLevel += 1; + const durationIndex = Math.min(record.banLevel - 1, BAN_DURATIONS_MS.length - 1); + record.bannedUntil = now + BAN_DURATIONS_MS[durationIndex]; + record.failures = []; + } + + store.records.set(ip, record); + + if (record.bannedUntil > now) { + return { + banned: true, + bannedUntil: record.bannedUntil, + retryAfterSeconds: Math.ceil((record.bannedUntil - now) / 1000), + failureCount: record.failures.length, + banLevel: record.banLevel, + }; + } + + return { + banned: false, + failureCount: record.failures.length, + banLevel: record.banLevel, + }; +} + +export function recordLoginSuccess(ip: string | null, now = Date.now()) { + if (!ip) return; + + const record = getStore().records.get(ip); + if (!record) return; + + record.failures = []; + record.lastSeen = now; +} From b949b6da91f63f5015bf199976d76d643bfa0eba Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 19 May 2026 16:15:46 +0800 Subject: [PATCH 15/40] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/music/page.tsx | 102 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 4 deletions(-) diff --git a/src/app/music/page.tsx b/src/app/music/page.tsx index 7e64d59..7dc0b39 100644 --- a/src/app/music/page.tsx +++ b/src/app/music/page.tsx @@ -201,6 +201,10 @@ export default function MusicPage() { const [currentView, setCurrentView] = useState<'playlists' | 'songs' | 'myPlaylists'>('playlists'); const [currentPlaylistTitle, setCurrentPlaylistTitle] = useState(''); const [searchKeyword, setSearchKeyword] = useState(''); + const [activeSearchKeyword, setActiveSearchKeyword] = useState(''); + const [searchPage, setSearchPage] = useState(1); + const [searchHasMore, setSearchHasMore] = useState(false); + const [loadingMoreSearch, setLoadingMoreSearch] = useState(false); const [currentSong, setCurrentSong] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); @@ -277,6 +281,7 @@ export default function MusicPage() { const audioRef = useRef(null); const lyricsContainerRef = useRef(null); + const searchLoadMoreRef = useRef(null); const lastSaveTimeRef = useRef(0); const restoredTimeRef = useRef(0); const songStartTimeRef = useRef(0); // 歌曲开始播放的时间戳 @@ -702,6 +707,9 @@ export default function MusicPage() { const data = await response.json(); setSongs((data.data?.list || []).map(mapSong)); setCurrentPlaylistTitle(playlistName); + setActiveSearchKeyword(''); + setSearchPage(1); + setSearchHasMore(false); setCurrentView('songs'); } catch (error) { console.error('加载歌单失败:', error); @@ -785,25 +793,68 @@ export default function MusicPage() { // 搜索歌曲 const searchSongs = async () => { - if (!searchKeyword.trim()) return; + const keyword = searchKeyword.trim(); + if (!keyword) return; setLoading(true); try { const response = await fetch( - `/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(searchKeyword)}&page=1&limit=20` + `/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(keyword)}&page=1&limit=20` ); const data = await response.json(); setSongs((data.data?.list || []).map(mapSong)); - setCurrentPlaylistTitle(`搜索: ${searchKeyword}`); + setActiveSearchKeyword(keyword); + setSearchPage(1); + setSearchHasMore(Boolean(data.data?.hasMore)); + setCurrentPlaylistTitle(`搜索: ${keyword}`); setCurrentView('songs'); } catch (error) { console.error('搜索失败:', error); setSongs([]); + setActiveSearchKeyword(''); + setSearchPage(1); + setSearchHasMore(false); } finally { setLoading(false); } }; + const loadMoreSearchSongs = async () => { + const keyword = activeSearchKeyword.trim(); + if (!keyword || loadingMoreSearch || !searchHasMore) return; + + const nextPage = searchPage + 1; + setLoadingMoreSearch(true); + try { + const response = await fetch( + `/api/music/v2/search?source=${currentSource}&q=${encodeURIComponent(keyword)}&page=${nextPage}&limit=20` + ); + const data = await response.json(); + + if (response.ok && data.success) { + const nextSongs = (data.data?.list || []).map(mapSong); + setSongs((prev) => [...prev, ...nextSongs]); + setSearchPage(nextPage); + setSearchHasMore(Boolean(data.data?.hasMore)); + } else { + setToast({ + message: data.error?.message || '加载更多失败', + type: 'error', + onClose: () => setToast(null), + }); + } + } catch (error) { + console.error('加载更多搜索结果失败:', error); + setToast({ + message: '加载更多失败', + type: 'error', + onClose: () => setToast(null), + }); + } finally { + setLoadingMoreSearch(false); + } + }; + // 打开添加到歌单弹窗 const handleAddToPlaylist = (song: Song, e: React.MouseEvent) => { e.stopPropagation(); // 阻止事件冒泡,避免触发播放 @@ -1416,6 +1467,9 @@ export default function MusicPage() { if (currentView === 'songs') { setCurrentView('playlists'); setSongs([]); + setActiveSearchKeyword(''); + setSearchPage(1); + setSearchHasMore(false); } else if (currentView === 'myPlaylists') { setCurrentView('playlists'); setSelectedUserPlaylist(null); @@ -1444,6 +1498,9 @@ export default function MusicPage() { setCurrentView('playlists'); setSongs([]); setSearchKeyword(''); + setActiveSearchKeyword(''); + setSearchPage(1); + setSearchHasMore(false); }; // 音频事件监听 @@ -1602,6 +1659,27 @@ export default function MusicPage() { } }; + useEffect(() => { + const sentinel = searchLoadMoreRef.current; + if (!sentinel || !activeSearchKeyword || !searchHasMore) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + loadMoreSearchSongs(); + } + }, + { + root: null, + rootMargin: '240px 0px 240px 0px', + threshold: 0, + } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [activeSearchKeyword, searchHasMore, loadingMoreSearch, searchPage]); + // 进度条拖动 const handleProgressChange = (e: React.ChangeEvent) => { const newTime = (parseFloat(e.target.value) / 100) * duration; @@ -2122,9 +2200,25 @@ export default function MusicPage() {
    ))}
    + {activeSearchKeyword && ( +
    + {searchHasMore ? ( + loadingMoreSearch ? ( +
    + +
    + ) : ( +
    + 继续向下滚动加载更多 +
    + ) + ) : songs.length > 0 ? ( +
    没有更多搜索结果了
    + ) : null} +
    + )}
    )} - {/* My Playlists View */} {currentView === 'myPlaylists' && (
    From 87c3da046b62c09e08480d5f840020b2c232aaaf Mon Sep 17 00:00:00 2001 From: mtvpls Date: Tue, 19 May 2026 19:25:56 +0800 Subject: [PATCH 16/40] =?UTF-8?q?legado=E5=88=9D=E6=AD=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 252 ++++++++- src/app/api/admin/opds/route.ts | 8 +- src/app/api/books/catalog/route.ts | 4 +- src/app/api/books/detail/route.ts | 4 +- src/app/api/books/file/route.ts | 14 +- src/app/api/books/read/chapter/route.ts | 24 + src/app/api/books/read/chapters/route.ts | 31 + src/app/api/books/read/manifest/route.ts | 15 +- src/app/api/books/search/route.ts | 4 +- src/app/api/books/search/ws/route.ts | 6 +- src/app/api/books/sources/route.ts | 4 +- src/app/books/detail/page.tsx | 90 ++- src/app/books/page.tsx | 5 +- src/app/books/read/page.tsx | 155 ++++- src/components/books/BooksLayout.tsx | 2 +- src/lib/admin.types.ts | 2 + src/lib/book-provider.ts | 84 +++ src/lib/book-route-cache.client.ts | 18 +- src/lib/book.types.ts | 87 ++- src/lib/config.ts | 1 + src/lib/legado.client.ts | 687 +++++++++++++++++++++++ src/lib/opds.client.ts | 2 +- 22 files changed, 1433 insertions(+), 66 deletions(-) create mode 100644 src/app/api/books/read/chapter/route.ts create mode 100644 src/app/api/books/read/chapters/route.ts create mode 100644 src/lib/book-provider.ts create mode 100644 src/lib/legado.client.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8dda348..0d106cd 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -12217,6 +12217,8 @@ const OPDSConfigComponent = ({ const [cacheTTL, setCacheTTL] = useState(10 * 60 * 1000); const [sources, setSources] = useState([]); const [editingIndex, setEditingIndex] = useState(null); + const [legadoImportText, setLegadoImportText] = useState(''); + const [legadoRuleDrafts, setLegadoRuleDrafts] = useState>({}); useEffect(() => { if (config?.OPDSConfig) { @@ -12226,6 +12228,7 @@ const OPDSConfigComponent = ({ (config.OPDSConfig.Sources || []).map((item, index) => ({ id: item.id || `source_${index + 1}`, name: item.name || `书源 ${index + 1}`, + type: item.type || 'opds', url: item.url || '', enabled: item.enabled !== false, authMode: item.authMode || 'none', @@ -12236,9 +12239,11 @@ const OPDSConfigComponent = ({ searchTemplate: item.searchTemplate || '', preferFormat: item.preferFormat || ['epub', 'pdf'], language: item.language || '', + legado: item.legado, })) ); setEditingIndex(null); + setLegadoRuleDrafts({}); } }, [config]); @@ -12264,6 +12269,7 @@ const OPDSConfigComponent = ({ { id: `source_${prev.length + 1}`, name: `书源 ${prev.length + 1}`, + type: 'opds', url: '', enabled: true, authMode: 'none', @@ -12274,13 +12280,76 @@ const OPDSConfigComponent = ({ searchTemplate: '', preferFormat: ['epub', 'pdf'], language: '', + legado: undefined, }, ]; }); }; + const makeLegadoSourceId = (name: string, url: string, index: number) => { + const raw = `${name}|${url}|${index}`; + let hash = 0; + for (let i = 0; i < raw.length; i += 1) { + hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0; + } + return `legado_${Math.abs(hash).toString(36)}`; + }; + + const importLegadoSources = () => { + try { + const parsed = JSON.parse(legadoImportText); + const list = Array.isArray(parsed) ? parsed : [parsed]; + const imported = list + .filter((item) => item && typeof item === 'object') + .map((rule: any, index) => { + const name = rule.bookSourceName || `Legado 书源 ${index + 1}`; + const url = rule.bookSourceUrl || ''; + return { + id: makeLegadoSourceId(name, url, index), + name, + type: 'legado' as const, + url, + enabled: rule.enabled !== false, + authMode: 'none' as const, + username: '', + password: '', + headerName: '', + headerValue: '', + searchTemplate: '', + preferFormat: ['epub' as const], + language: '', + legado: rule, + } satisfies BookSource; + }) + .filter((source) => !!source.url); + + if (imported.length === 0) { + throw new Error('没有识别到有效 Legado 书源,请确认 JSON 内含 bookSourceUrl'); + } + + setSources((prev) => { + const existed = new Set(prev.map((item) => `${item.type || 'opds'}|${item.url}|${item.name}`)); + const next = imported.filter((item) => !existed.has(`${item.type}|${item.url}|${item.name}`)); + return [...prev, ...next]; + }); + setLegadoImportText(''); + showSuccess(`已导入 ${imported.length} 个 Legado 书源`, showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : 'Legado JSON 解析失败', showAlert); + } + }; + const removeSource = (index: number) => { setSources((prev) => prev.filter((_, idx) => idx !== index)); + setLegadoRuleDrafts((prev) => { + const next: Record = {}; + Object.entries(prev).forEach(([key, value]) => { + const numericKey = Number(key); + if (numericKey < index) next[numericKey] = value; + if (numericKey > index) next[numericKey - 1] = value; + }); + return next; + }); setEditingIndex((prev) => { if (prev === null) return prev; if (prev === index) return null; @@ -12291,6 +12360,7 @@ const OPDSConfigComponent = ({ const normalizeSource = (source: BookSource, index: number) => ({ id: source.id?.trim() || `source_${index + 1}`, name: source.name?.trim() || `书源 ${index + 1}`, + type: source.type || 'opds', url: source.url?.trim() || '', enabled: source.enabled !== false, authMode: source.authMode || 'none', @@ -12299,13 +12369,28 @@ const OPDSConfigComponent = ({ headerName: source.authMode === 'header' ? source.headerName?.trim() || '' : '', headerValue: source.authMode === 'header' ? source.headerValue || '' : '', - searchTemplate: source.searchTemplate?.trim() || '', + searchTemplate: source.type === 'legado' ? '' : source.searchTemplate?.trim() || '', preferFormat: source.preferFormat?.length ? source.preferFormat : ['epub', 'pdf'], language: source.language?.trim() || '', + legado: source.type === 'legado' ? source.legado : undefined, }); + const updateLegadoRuleJson = (index: number, value: string) => { + setLegadoRuleDrafts((prev) => ({ ...prev, [index]: value })); + try { + const rule = JSON.parse(value); + updateSource(index, { + legado: rule, + name: rule.bookSourceName || sources[index]?.name, + url: rule.bookSourceUrl || sources[index]?.url, + }); + } catch { + // 允许用户继续编辑尚未完成的 JSON,保存前需修正为合法 JSON + } + }; + const buildConfig = () => ({ Enabled: enabled, CacheTTL: Math.max(60_000, cacheTTL || 10 * 60 * 1000), @@ -12316,6 +12401,14 @@ const OPDSConfigComponent = ({ await withLoading('saveOPDSConfig', async () => { try { if (!config) throw new Error('配置未加载'); + for (const [index, draft] of Object.entries(legadoRuleDrafts)) { + if (!draft.trim()) continue; + try { + JSON.parse(draft); + } catch { + throw new Error(`第 ${Number(index) + 1} 个 Legado 书源 JSON 格式不正确`); + } + } const response = await fetch('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -12385,10 +12478,10 @@ const OPDSConfigComponent = ({

    - 关于电子书馆 / OPDS + 关于电子书馆 / OPDS / Legado

    -

    • 支持多书源,每个源可独立配置认证、搜索模板与默认格式偏好。

    +

    • 支持多书源,每个源可选择 OPDS 或 Legado,并独立配置规则。

    • 有些源只支持分类浏览,有些源只支持搜索,测试连接会自动探测能力。

    @@ -12449,6 +12542,34 @@ const OPDSConfigComponent = ({
    +
    +
    +
    +

    + 导入 Legado 书源 +

    +

    + 粘贴阅读/Legado 导出的单个书源 JSON 或书源数组,导入后可逐个测试。 +

    +
    + +
    +