// 在线播放代理:按设置的播放音质解析音源后由服务端转发(支持 Range),绕过防盗链与混合内容 import { Readable } from 'node:stream'; import type { Context } from 'koa'; import { UA } from './core/net/http.ts'; import { getProvider } from './core/providers/index.ts'; import type { QualityKey } from './core/quality.ts'; import type { Source } from './core/types.ts'; import { TtlCache } from './core/util.ts'; import { norm } from './libraryIndex.ts'; import { getSettings } from './settings.ts'; // 音源直链通常有几分钟到几小时的有效期;拖动进度条时每个 Range 请求都会命中缓存 const urlCache = new TtlCache<{ url: string; ext: string; quality: QualityKey }>(3 * 60 * 1000, 200); const MIME: Record = { mp3: 'audio/mpeg', flac: 'audio/flac', m4a: 'audio/mp4', ogg: 'audio/ogg' }; export async function proxyStream(ctx: Context, source: string, id: string) { const provider = getProvider(source); const quality = getSettings().playQuality; const audio = await urlCache .getOrSet(`${source}:${id}:${quality}`, async () => { const r = await provider.resolveAudio(id, quality); return { url: r.url, ext: r.ext, quality: r.quality }; }) // 版权下架等情况很常见,播放器会自动跳过,不必按服务端错误记日志 .catch((e: Error) => ctx.throw(404, e.message)); const controller = new AbortController(); ctx.req.on('close', () => controller.abort()); const headers: Record = { 'User-Agent': UA }; if (ctx.get('range')) headers.Range = ctx.get('range'); const upstream = await fetch(audio.url, { headers, signal: controller.signal }); if (!upstream.ok || !upstream.body) ctx.throw(502, `音源请求失败 (HTTP ${upstream.status})`); ctx.status = upstream.status; ctx.set('Content-Type', MIME[audio.ext] || upstream.headers.get('content-type') || 'application/octet-stream'); ctx.set('Accept-Ranges', 'bytes'); ctx.set('Cache-Control', 'no-store'); ctx.set('X-Audio-Quality', audio.quality); for (const h of ['content-length', 'content-range']) { const v = upstream.headers.get(h); if (v) ctx.set(h, v); } ctx.body = Readable.fromWeb(upstream.body as any); } // ---------- 跨平台兜底:本平台没有音源时,到另一个平台找同一首歌 ---------- export interface AltQuery { name: string; artists: string; /** 毫秒 */ duration?: number; } const OTHER: Record = { netease: 'qq', qq: 'netease' }; const matchCache = new TtlCache(30 * 60 * 1000, 500); const splitArtists = (s: string) => s.split(/\s*[/&,,、]\s*/).map(norm).filter(Boolean); /** 去掉括号里的版本说明,用于次一级匹配:「晴天 (Live)」→「晴天」 */ const bare = (s: string) => norm(s.replace(/[(([【].*?[))\]】]/g, '')) || norm(s); /** 在 target 平台搜同名同歌手的歌;时长差超过 15 秒视为不同版本 */ export async function findOnSource(target: Source, q: AltQuery): Promise { const key = `${target}:${norm(q.name)}:${norm(q.artists)}:${q.duration ?? ''}`; return matchCache.getOrSet(key, async () => { const artists = splitArtists(q.artists); const { items } = await getProvider(target).search(`${q.name} ${q.artists.split(/\s*\/\s*/)[0] ?? ''}`.trim(), { limit: 20 }); let best: { id: string; score: number } | null = null; for (const t of items) { let score = norm(t.name) === norm(q.name) ? 3 : bare(t.name) === bare(q.name) ? 1 : -1; if (score < 0) continue; if (artists.length && !splitArtists(t.artists).some((a) => artists.includes(a))) continue; if (q.duration && t.duration) { const diff = Math.abs(q.duration - t.duration); if (diff > 15000) continue; if (diff <= 4000) score += 2; } if (!best || score > best.score) best = { id: t.id, score }; } return best?.id ?? null; }); } export async function proxyAltStream(ctx: Context, source: string, q: AltQuery) { const target = OTHER[getProvider(source).source]; if (!q.name) ctx.throw(400, '缺少歌名'); const id = await findOnSource(target, q).catch((e: Error) => ctx.throw(404, e.message)); if (!id) ctx.throw(404, `${target === 'qq' ? 'QQ 音乐' : '网易云'}没有找到同一首歌`); ctx.set('X-Audio-Source', `${target}:${id}`); await proxyStream(ctx, target, id); }