feat: P7 在线播放(播放队列、媒体控制)与自建歌单

- 播放器:统一 PlayItem 队列,顺序 / 列表循环 / 单曲循环 / 随机,滚动歌词含翻译,Media Session,队列与进度存 localStorage
- 在线播放音质可在设置中选择;曲库已有的歌优先播放本地文件
- 自建歌单:混排在线曲目与本地文件,拖动排序,一键下载曲库缺失曲目

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-11 11:31:29 +08:00
parent 9f908fa73f
commit 676541457b
30 changed files with 2995 additions and 174 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ export function createApp(): Koa {
});
app.use(authGuard);
app.use(bodyParser());
// 歌单 / 播放队列一次可能提交上千首
app.use(bodyParser({ jsonLimit: '8mb' }));
app.use(api.routes()).use(api.allowedMethods());
// 前端静态资源 + SPA 回退
+25
View File
@@ -50,6 +50,31 @@ db.exec(`
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, id);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS playlist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
item_key TEXT NOT NULL,
source TEXT,
track_id TEXT,
path TEXT,
name TEXT NOT NULL,
artists TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
album_id TEXT,
cover TEXT,
duration INTEGER,
added_at TEXT NOT NULL,
UNIQUE (playlist_id, item_key)
);
CREATE INDEX IF NOT EXISTS idx_playlist_items_pos ON playlist_items(playlist_id, position);
`);
export interface HistoryEntry {
+54
View File
@@ -41,6 +41,10 @@ export interface LibraryEntry {
size: number;
mtime: number;
hasLyric?: boolean;
/** 音频文件由路径推断的歌名 / 歌手 / 专辑 */
title?: string;
artists?: string;
album?: string;
/** 文件夹内的封面图(cover.jpg / folder.jpg / artist.jpg),相对音乐目录 */
cover?: string;
/** 没有文件夹图片时,取前几个子文件夹(专辑)的封面拼图 */
@@ -94,6 +98,7 @@ export async function listDir(rel: string | undefined) {
const ext = path.extname(name).toLowerCase();
const isAudio = AUDIO_EXTS.has(ext);
const base = name.slice(0, -ext.length);
const guess = isAudio ? describeLocal(abs) : undefined;
entries.push({
name,
path: toRel(abs),
@@ -101,6 +106,9 @@ export async function listDir(rel: string | undefined) {
size: s.size,
mtime: s.mtimeMs,
hasLyric: isAudio ? nameSet.has(`${base}.lrc`) : undefined,
title: guess?.name,
artists: guess?.artists,
album: guess?.album,
});
}
}
@@ -367,3 +375,49 @@ export async function startArtistImages(): Promise<ArtistImageJob> {
})();
return current;
}
// ---------- 播放 / 歌单用的本地曲目 ----------
export interface LocalTrack {
path: string;
name: string;
artists: string;
album: string;
/** 所在文件夹的封面(相对音乐目录) */
cover?: string;
/** 同名 .lrc 歌词 */
lrc: boolean;
size: number;
}
const MAX_LOCAL_TRACKS = 5000;
/** 由路径推断显示信息;"歌手/歌名.mp3" 这种两层结构也把上层目录当歌手 */
export function describeLocal(abs: string): Pick<LocalTrack, 'name' | 'artists' | 'album'> {
const g = guessFromPath(abs);
const parent = path.dirname(abs);
const artist = g.artist ?? (path.resolve(parent) !== path.resolve(config.musicDir) ? path.basename(parent) : '');
return { name: g.title, artists: artist, album: g.album ?? '' };
}
/** 文件或目录下的全部音频(递归),最多 MAX_LOCAL_TRACKS 首 */
export async function listLocalTracks(rel: string): Promise<{ items: LocalTrack[]; truncated: boolean }> {
const target = resolveInLibrary(rel);
if (!(await fs.promises.stat(target).catch(() => null))) throw httpError(404, '路径不存在');
const files = await collectAudio(target);
const dirs = new Map<string, { names: Set<string>; cover?: string }>();
const items: LocalTrack[] = [];
for (const abs of files.slice(0, MAX_LOCAL_TRACKS)) {
const dir = path.dirname(abs);
let info = dirs.get(dir);
if (!info) {
const image = dirImage(dir);
info = { names: new Set(await fs.promises.readdir(dir).catch(() => [])), cover: image ? toRel(image) : undefined };
dirs.set(dir, info);
}
const base = path.basename(abs, path.extname(abs));
const size = (await fs.promises.stat(abs).catch(() => null))?.size ?? 0;
items.push({ path: toRel(abs), ...describeLocal(abs), cover: info.cover, lrc: info.names.has(`${base}.lrc`), size });
}
return { items, truncated: files.length > MAX_LOCAL_TRACKS };
}
+257
View File
@@ -0,0 +1,257 @@
// 自建歌单:在线曲目(source + id)与本地曲库文件(相对路径)可以混排在同一个歌单里
import fs from 'node:fs';
import type { Source } from './core/types.ts';
import { db } from './db.ts';
import { bus } from './events.ts';
import { resolveInLibrary } from './library.ts';
import { findInLibrary } from './libraryIndex.ts';
const MAX_ITEMS = 5000;
export interface PlaylistItemInput {
source?: Source | null;
id?: string | null;
path?: string | null;
name: string;
artists?: string;
album?: string;
albumId?: string | null;
cover?: string | null;
duration?: number | null;
}
export interface PlaylistItem {
id: number;
key: string;
source: Source | null;
trackId: string | null;
path: string | null;
name: string;
artists: string;
album: string;
albumId: string | null;
cover: string | null;
duration: number | null;
addedAt: string;
/** 在线曲目在曲库中的同名文件 */
localFile?: string;
/** 本地文件已被移动或删除 */
missing?: boolean;
}
export interface PlaylistSummary {
id: number;
name: string;
description: string;
count: number;
duration: number;
covers: string[];
createdAt: string;
updatedAt: string;
}
function httpError(status: number, message: string): Error {
return Object.assign(new Error(message), { status });
}
function tx<T>(fn: () => T): T {
db.exec('BEGIN');
try {
const out = fn();
db.exec('COMMIT');
return out;
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
}
function changed(id: number) {
db.prepare('UPDATE playlists SET updated_at = ? WHERE id = ?').run(new Date().toISOString(), id);
bus.emit('playlists', { id });
}
function text(value: unknown, max: number): string {
return String(value ?? '').trim().slice(0, max);
}
/** 校验并规范化前端传来的曲目;无效项丢弃 */
function normalize(raw: unknown): (PlaylistItemInput & { key: string }) | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const name = text(r.name, 300);
if (!name) return null;
const base = {
name,
artists: text(r.artists, 300),
album: text(r.album, 300),
albumId: r.albumId ? String(r.albumId) : null,
cover: r.cover ? text(r.cover, 1000) : null,
duration: Number.isFinite(Number(r.duration)) && Number(r.duration) > 0 ? Math.round(Number(r.duration)) : null,
};
if (r.source === 'netease' || r.source === 'qq') {
const id = text(r.id, 100);
if (!id) return null;
return { ...base, source: r.source, id, path: null, key: `${r.source}:${id}` };
}
if (typeof r.path === 'string' && r.path) {
resolveInLibrary(r.path); // 越界路径直接报错
const path = r.path.replace(/\\/g, '/').replace(/^\/+/, '');
return { ...base, source: null, id: null, path, key: `file:${path}` };
}
return null;
}
// ---------- 查询 ----------
const ITEM_COLUMNS = `id, item_key AS key, source, track_id AS trackId, path, name, artists, album, album_id AS albumId,
cover, duration, added_at AS addedAt`;
function querySummaries(id?: number): PlaylistSummary[] {
const one = id !== undefined;
const args = one ? [id] : [];
const rows = db
.prepare(
`SELECT p.id, p.name, p.description, p.created_at AS createdAt, p.updated_at AS updatedAt,
COUNT(i.id) AS count, COALESCE(SUM(i.duration), 0) AS duration
FROM playlists p LEFT JOIN playlist_items i ON i.playlist_id = p.id
${one ? 'WHERE p.id = ?' : ''}
GROUP BY p.id ORDER BY p.updated_at DESC, p.id DESC`,
)
.all(...args) as unknown as Array<Omit<PlaylistSummary, 'covers'>>;
// 每个歌单取前 4 张不同的封面做拼图
const coverRows = db
.prepare(
`SELECT pid, cover FROM (
SELECT playlist_id AS pid, cover, ROW_NUMBER() OVER (PARTITION BY playlist_id ORDER BY MIN(position)) AS rn
FROM playlist_items WHERE cover IS NOT NULL ${one ? 'AND playlist_id = ?' : ''} GROUP BY playlist_id, cover
) WHERE rn <= 4`,
)
.all(...args) as Array<{ pid: number; cover: string }>;
const covers = new Map<number, string[]>();
for (const r of coverRows) covers.set(r.pid, [...(covers.get(r.pid) ?? []), r.cover]);
return rows.map((r) => ({ ...r, covers: covers.get(r.id) ?? [] }));
}
export function listPlaylists(): PlaylistSummary[] {
return querySummaries();
}
function summary(id: number): PlaylistSummary {
const s = querySummaries(id)[0];
if (!s) throw httpError(404, '歌单不存在');
return s;
}
export async function getPlaylist(id: number): Promise<PlaylistSummary & { items: PlaylistItem[] }> {
const head = summary(id);
const items = db
.prepare(`SELECT ${ITEM_COLUMNS} FROM playlist_items WHERE playlist_id = ? ORDER BY position, id`)
.all(id) as unknown as PlaylistItem[];
await Promise.all(
items.map(async (it) => {
if (it.path) {
it.missing = !(await fs.promises.stat(resolveInLibrary(it.path)).catch(() => null))?.isFile();
} else {
it.localFile = findInLibrary(it);
}
}),
);
return { ...head, items };
}
// ---------- 修改 ----------
export function createPlaylist(name: unknown, description: unknown, items: unknown): PlaylistSummary {
const title = text(name, 100);
if (!title) throw httpError(400, '请输入歌单名称');
const now = new Date().toISOString();
const id = tx(() => {
const res = db
.prepare('INSERT INTO playlists (name, description, created_at, updated_at) VALUES (?, ?, ?, ?)')
.run(title, text(description, 2000), now, now);
const pid = Number(res.lastInsertRowid);
if (Array.isArray(items)) insertItems(pid, items);
return pid;
});
bus.emit('playlists', { id });
return summary(id);
}
export function updatePlaylist(id: number, patch: { name?: unknown; description?: unknown }): PlaylistSummary {
summary(id);
if (patch.name !== undefined) {
const title = text(patch.name, 100);
if (!title) throw httpError(400, '请输入歌单名称');
db.prepare('UPDATE playlists SET name = ? WHERE id = ?').run(title, id);
}
if (patch.description !== undefined) db.prepare('UPDATE playlists SET description = ? WHERE id = ?').run(text(patch.description, 2000), id);
changed(id);
return summary(id);
}
export function deletePlaylist(id: number): void {
tx(() => {
db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(id);
db.prepare('DELETE FROM playlists WHERE id = ?').run(id);
});
bus.emit('playlists', { id });
}
function insertItems(id: number, raw: unknown[]): { added: number; duplicates: number } {
const { count, maxPos } = db
.prepare('SELECT COUNT(*) AS count, COALESCE(MAX(position), -1) AS maxPos FROM playlist_items WHERE playlist_id = ?')
.get(id) as { count: number; maxPos: number };
const insert = db.prepare(
`INSERT OR IGNORE INTO playlist_items
(playlist_id, position, item_key, source, track_id, path, name, artists, album, album_id, cover, duration, added_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const now = new Date().toISOString();
let pos = maxPos + 1;
let added = 0;
let valid = 0;
for (const r of raw) {
const it = normalize(r);
if (!it) continue;
valid++;
if (count + added >= MAX_ITEMS) throw httpError(400, `歌单最多 ${MAX_ITEMS} 首`);
const res = insert.run(id, pos, it.key, it.source ?? null, it.id ?? null, it.path ?? null, it.name, it.artists ?? '', it.album ?? '', it.albumId ?? null, it.cover ?? null, it.duration ?? null, now);
if (res.changes) {
added++;
pos++;
}
}
return { added, duplicates: valid - added };
}
export function addItems(id: number, items: unknown): { added: number; duplicates: number } {
summary(id);
if (!Array.isArray(items) || !items.length) throw httpError(400, '没有要添加的歌曲');
const res = tx(() => insertItems(id, items));
changed(id);
return res;
}
export function removeItems(id: number, itemIds: unknown): { removed: number } {
if (!Array.isArray(itemIds) || !itemIds.length) return { removed: 0 };
const ids = itemIds.map(Number).filter(Number.isInteger);
const res = db
.prepare(`DELETE FROM playlist_items WHERE playlist_id = ? AND id IN (${ids.map(() => '?').join(',')})`)
.run(id, ...ids);
changed(id);
return { removed: Number(res.changes) };
}
/** 按传入的条目 id 顺序重排;未列出的条目保持原相对顺序排在最后 */
export function reorderItems(id: number, itemIds: unknown): void {
if (!Array.isArray(itemIds)) throw httpError(400, '参数错误');
const existing = (db.prepare('SELECT id FROM playlist_items WHERE playlist_id = ? ORDER BY position, id').all(id) as Array<{ id: number }>).map((r) => r.id);
const known = new Set(existing);
const ordered = [...new Set(itemIds.map(Number).filter((n) => known.has(n)))];
const seen = new Set(ordered);
for (const n of existing) if (!seen.has(n)) ordered.push(n);
const update = db.prepare('UPDATE playlist_items SET position = ? WHERE id = ?');
tx(() => ordered.forEach((itemId, i) => update.run(i, itemId)));
changed(id);
}
+60 -5
View File
@@ -19,6 +19,7 @@ import {
currentRetagJob,
emptyTrash,
listDir,
listLocalTracks,
moveToTrash,
resolveInLibrary,
startArtistImages,
@@ -28,6 +29,7 @@ import {
zipStream,
} from '../library.ts';
import { findInLibrary, libraryIndexInfo, rebuildLibraryIndex } from '../libraryIndex.ts';
import { addItems, createPlaylist, deletePlaylist, getPlaylist, listPlaylists, removeItems, reorderItems, updatePlaylist } from '../playlists.ts';
import { queue, type EnqueueItem } from '../queue.ts';
import { getSettings, updateSettings } from '../settings.ts';
import { proxyStream } from '../stream.ts';
@@ -47,15 +49,18 @@ function body(ctx: Context): Record<string, any> {
return (ctx.request as any).body || {};
}
/** 给列表中的歌曲标记是否已下载过 */
/** 给列表中的歌曲标记:下载历史里有(downloaded),或曲库里有同名文件(inLibrary) */
/**
* 给列表中的歌曲标记:下载历史里有(downloaded),或曲库里有同名文件(inLibrary)。
* localFile 不论来源都给出曲库中的文件,播放时可直接用本地文件
*/
function markDownloaded<T extends { tracks: TrackSummary[] }>(source: string, detail: T) {
const owned = downloadedIds(source, detail.tracks.map((t) => t.id));
return {
...detail,
tracks: detail.tracks.map((t) => {
const downloaded = owned.has(t.id);
return { ...t, downloaded, inLibrary: downloaded ? undefined : findInLibrary(t) };
const localFile = findInLibrary(t);
return { ...t, downloaded, inLibrary: downloaded ? undefined : localFile, localFile };
}),
};
}
@@ -156,7 +161,8 @@ api.get('/:source/track/:id', async (ctx) => {
const provider = getProvider(ctx.params.source!);
const meta = (await provider.getTrackMeta(ctx.params.id!)) ?? notFound('歌曲');
const downloaded = downloadedIds(provider.source, [meta.id]).has(meta.id);
ctx.body = { ...meta, downloaded, inLibrary: downloaded ? undefined : findInLibrary(meta) };
const localFile = findInLibrary(meta);
ctx.body = { ...meta, downloaded, inLibrary: downloaded ? undefined : localFile, localFile };
});
api.get('/:source/album/:id', async (ctx) => {
@@ -264,6 +270,7 @@ api.get('/events', (ctx) => {
history: () => send('history', {}),
retag: (d) => send('retag', d),
'artist-images': (d) => send('artist-images', d),
playlists: (d) => send('playlists', d),
};
for (const [event, fn] of Object.entries(handlers)) bus.on(event, fn);
send('hello', { summary: queue.summary(), progress: queue.progressSnapshot() });
@@ -277,6 +284,50 @@ api.get('/events', (ctx) => {
stream.on('error', cleanup);
});
// ---------- 自建歌单 ----------
function playlistId(ctx: Context): number {
const id = Number(ctx.params.id);
if (!Number.isInteger(id)) ctx.throw(404, '歌单不存在');
return id;
}
api.get('/playlists', (ctx) => {
ctx.body = { items: listPlaylists() };
});
api.post('/playlists', (ctx) => {
const { name, description, items } = body(ctx);
ctx.body = createPlaylist(name, description, items);
});
api.get('/playlists/:id', async (ctx) => {
ctx.body = await getPlaylist(playlistId(ctx));
});
api.patch('/playlists/:id', (ctx) => {
const { name, description } = body(ctx);
ctx.body = updatePlaylist(playlistId(ctx), { name, description });
});
api.delete('/playlists/:id', (ctx) => {
deletePlaylist(playlistId(ctx));
ctx.body = { ok: true };
});
api.post('/playlists/:id/items', (ctx) => {
ctx.body = addItems(playlistId(ctx), body(ctx).items);
});
api.post('/playlists/:id/items/remove', (ctx) => {
ctx.body = removeItems(playlistId(ctx), body(ctx).ids);
});
api.put('/playlists/:id/order', (ctx) => {
reorderItems(playlistId(ctx), body(ctx).ids);
ctx.body = { ok: true };
});
// ---------- 历史 ----------
api.get('/history', (ctx) => {
@@ -317,7 +368,7 @@ api.get('/library/file', async (ctx) => {
const stat = await fs.promises.stat(abs).catch(() => null);
if (!stat?.isFile()) throw Object.assign(new Error('文件不存在'), { status: 404 });
const ext = path.extname(abs).toLowerCase();
const types: Record<string, string> = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.jpg': 'image/jpeg', '.png': 'image/png', '.lrc': 'text/plain; charset=utf-8' };
const types: Record<string, string> = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.aac': 'audio/aac', '.jpg': 'image/jpeg', '.png': 'image/png', '.lrc': 'text/plain; charset=utf-8' };
ctx.type = types[ext] || 'application/octet-stream';
ctx.set('Accept-Ranges', 'bytes');
const range = ctx.get('range').match(/bytes=(\d*)-(\d*)/);
@@ -366,6 +417,10 @@ api.post('/library/index', async (ctx) => {
ctx.body = libraryIndexInfo();
});
api.get('/library/tracks', async (ctx) => {
ctx.body = await listLocalTracks(String(ctx.query.path ?? ''));
});
api.get('/library/trash', async (ctx) => {
ctx.body = await trashInfo();
});
+12 -3
View File
@@ -1,16 +1,24 @@
// 运行时设置:config.json 提供默认值,网页上修改后存入 SQLite
import { config, type DownloadDefaults } from './config.ts';
import { isQualityKey } from './core/quality.ts';
import { isQualityKey, type QualityKey } from './core/quality.ts';
import { getSetting, setSetting } from './db.ts';
import { bus } from './events.ts';
/** 在线播放可选的音质(更高音质文件过大,不适合边下边播) */
export const PLAY_QUALITIES = ['standard', 'high', 'lossless'] as const satisfies readonly QualityKey[];
export type PlayQuality = (typeof PLAY_QUALITIES)[number];
export interface Settings extends DownloadDefaults {
concurrency: number;
/** 批量任务之间的间隔,避免请求过快 */
gapMs: number;
/** 在线播放音质 */
playQuality: PlayQuality;
/** 曲库里已有同名歌曲时直接播放本地文件 */
preferLocal: boolean;
}
const BASE: Settings = { ...config.defaults, concurrency: 2, gapMs: 400 };
const BASE: Settings = { ...config.defaults, concurrency: 2, gapMs: 400, playQuality: 'high', preferLocal: true };
let current: Settings = { ...BASE, ...getSetting<Partial<Settings>>('settings', {}) };
@@ -30,7 +38,8 @@ export function updateSettings(patch: Partial<Record<keyof Settings, unknown>>):
if (patch.retries !== undefined) next.retries = clampInt(patch.retries, 1, 10, next.retries);
if (patch.concurrency !== undefined) next.concurrency = clampInt(patch.concurrency, 1, 6, next.concurrency);
if (patch.gapMs !== undefined) next.gapMs = clampInt(patch.gapMs, 0, 5000, next.gapMs);
for (const key of ['lyric', 'translatedLyric', 'embedTags', 'albumCover'] as const) {
if (PLAY_QUALITIES.includes(patch.playQuality as PlayQuality)) next.playQuality = patch.playQuality as PlayQuality;
for (const key of ['lyric', 'translatedLyric', 'embedTags', 'albumCover', 'preferLocal'] as const) {
if (typeof patch[key] === 'boolean') next[key] = patch[key] as boolean;
}
current = next;
+14 -6
View File
@@ -1,19 +1,26 @@
// 试听代理:解析出 320K 音源后由服务端转发(支持 Range),绕过防盗链与混合内容
// 在线播放代理:按设置的播放音质解析音源后由服务端转发(支持 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 { TtlCache } from './core/util.ts';
import { getSettings } from './settings.ts';
const urlCache = new TtlCache<{ url: string; ext: string }>(3 * 60 * 1000, 200);
// 音源直链通常有几分钟到几小时的有效期;拖动进度条时每个 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 audio = await urlCache.getOrSet(`${source}:${id}`, async () => {
const r = await provider.resolveAudio(id, 'high');
return { url: r.url, ext: r.ext };
});
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());
@@ -26,6 +33,7 @@ export async function proxyStream(ctx: Context, source: string, id: string) {
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);