Files
music-hub/server/src/stream.ts
T
Jocay c1ad24aace feat: 全屏「正在播放」页与跨平台兜底播放
- NowPlaying:参考 Apple Music tvOS —— 模糊封面铺底、大号逐行歌词(按距离渐隐 / 模糊、逐行跟随)、接下来列表、可拖动进度条,静置后隐藏控件;Esc 收起、←/→ 快退 / 快进
- 播放兜底链:曲库文件 → 原平台 → 另一个平台的同名歌曲,出错时依次切换并保留进度,合并为一条提示
- 新增 /api/:source/stream-alt:按歌名、歌手、时长在另一个平台匹配同一首歌并代理播放

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:11:36 +08:00

92 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 在线播放代理:按设置的播放音质解析音源后由服务端转发(支持 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<string, string> = { 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<string, string> = { '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<Source, Source> = { netease: 'qq', qq: 'netease' };
const matchCache = new TtlCache<string | null>(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<string | null> {
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);
}