c1ad24aace
- NowPlaying:参考 Apple Music tvOS —— 模糊封面铺底、大号逐行歌词(按距离渐隐 / 模糊、逐行跟随)、接下来列表、可拖动进度条,静置后隐藏控件;Esc 收起、←/→ 快退 / 快进 - 播放兜底链:曲库文件 → 原平台 → 另一个平台的同名歌曲,出错时依次切换并保留进度,合并为一条提示 - 新增 /api/:source/stream-alt:按歌名、歌手、时长在另一个平台匹配同一首歌并代理播放 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
4.3 KiB
TypeScript
92 lines
4.3 KiB
TypeScript
// 在线播放代理:按设置的播放音质解析音源后由服务端转发(支持 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);
|
||
}
|