/** * 在线播放器的 HTTP 路由。 * * 三条路由(都挂在自己拼的 `/kkk/player` 前缀下,和配置面板的 /kkk 互不干扰): * GET /kkk/player/:token 播放页(HTML) * GET /kkk/player/:token/video 视频本体,支持 Range(拖进度条靠它);`?download=1` = 下载 * GET /kkk/player/:token/audio 单独的音轨(B站这种音视频分离的);`?download=1` = 下载音频 * GET /kkk/player/:token/merged **按需**用 ffmpeg 合成音视频后下载/播放(第一次会慢一两秒) * GET /kkk/player/:token/download 等价于 `/video?download=1`(带 Content-Disposition) * GET /kkk/player/:token/danmaku 弹幕 JSON * GET /kkk/player/:token/story 互动视频的当前剧情(题目 + 选项;带 ?cid=&edge= 取下一段) * GET /kkk/player/:token/segment/:cid 互动视频某一段的视频(没下过就按需下载,支持 Range) * * 两种落地方式: * - `playerPort` 为 0(默认):挂到 Koishi 自己的 `ctx.server` 上,不额外占端口; * - `playerPort` 非 0:用 node:http 另起一个独立服务(端口被别人占了就只打日志并退回 ctx.server, * 绝不让端口冲突把整个解析流程带崩)。 * * 请求处理被抽成「请求 → 响应描述」的纯函数,Koa 与 node:http 两个适配器共用同一份逻辑, * 测试也能直接喂请求对象验证(不用真的起服务)。 */ import fs from 'node:fs' import http from 'node:http' import path from 'node:path' import { logger } from 'node-karin' // 注意只有一层 .. :src/player/server.ts → src/compat/runtime(写两层会变成根目录下的 compat,直接 MODULE_NOT_FOUND) import { tryGetRuntime } from '../compat/runtime' import { renderExpiredPage, renderPlayerPage } from './page' import { adoptPlayerSegment, getPlayerSession, getPlayerStorySource, hasPlayerSegmentDanmaku, isValidPlayerToken, isValidStoryCid, markPlayerMerged, readPlayerDanmaku, readPlayerStory, resolvePlayerAudio, resolvePlayerCover, resolvePlayerMerged, resolvePlayerSegment, resolvePlayerVideo, writePlayerSegmentDanmaku, type SegmentKind, type SegmentReporter, type SegmentStage } from './store' /** 一次请求(只取用得到的字段) */ export interface PlayerHttpRequest { method: string /** 只含路径,不含查询串 */ path: string /** Range 头原文 */ range?: string /** 查询串(可带前导 '?'):目前只认 download=1(下载而不是在页面里播放) */ query?: string } /** 一次响应:body 或 file 二选一 */ export interface PlayerHttpResponse { status: number headers: Record body?: Buffer /** 要流式发送的文件片段(视频走这条,避免大文件全读进内存) */ file?: { path: string, start: number, end: number } } /** 路由前缀 */ export const PLAYER_ROUTE_PREFIX = '/kkk/player/' /** 独立端口是否真的监听上了:没监听上时链接指向的公网域名很可能打不到本实例 */ let standaloneReady = false /** 查询独立端口是否监听上了(给「生成链接」那边做提示用) */ export const isStandalonePlayerReady = (): boolean => standaloneReady /** 面板里配的「播放器公网地址」(没配就是空串) */ function publicBaseUrl (): string { try { return String((tryGetRuntime()?.config as any)?.playerBaseUrl ?? '').trim() } catch { return '' } } const TEXT_TYPE = 'text/plain; charset=utf-8' const HTML_TYPE = 'text/html; charset=utf-8' /** 过期 / 无效令牌的统一回应:页面直接渲染「链接已过期」,接口退回纯文本 */ function notFound (asPage: boolean): PlayerHttpResponse { if (asPage) { return { status: 404, headers: { 'Content-Type': HTML_TYPE, 'Cache-Control': 'no-store' }, body: Buffer.from(renderExpiredPage()) } } return { status: 404, headers: { 'Content-Type': TEXT_TYPE, 'Cache-Control': 'no-store' }, body: Buffer.from('链接已过期') } } /** * 解析 Range 头。 * @returns null 表示没有 Range(按完整文件发);'invalid' 表示范围越界(回 416) */ function parseRange (range: string | undefined, size: number): { start: number, end: number } | null | 'invalid' { const text = String(range ?? '').trim() if (!text) return null const matched = /^bytes=(\d*)-(\d*)$/.exec(text) if (!matched) return null const hasStart = matched[1] !== '' const hasEnd = matched[2] !== '' if (!hasStart && !hasEnd) return null let start: number let end: number if (hasStart) { start = Number(matched[1]) end = hasEnd ? Number(matched[2]) : size - 1 } else { // bytes=-500:最后 500 字节 const suffix = Number(matched[2]) if (!suffix) return 'invalid' start = Math.max(0, size - suffix) end = size - 1 } if (!Number.isFinite(start) || !Number.isFinite(end)) return null if (start > end || start >= size) return 'invalid' return { start, end: Math.min(end, size - 1) } } /** * 下载文件名(不含路径):把视频标题清洗成一个能安全落盘的名字。 * * 标题是用户内容,直接拿来当文件名有两个坑: * 1. 里面可能有 `/` `\\` `..` 与控制字符 —— 客户端落盘时就是路径穿越 / 写坏文件名; * 2. HTTP 头只能装 Latin-1 字节,中文直接写进 `filename=` 会让 Node 抛 ERR_INVALID_CHAR。 * 所以这里清洗 + 截断(80 字符),空的就退回 video。 */ export function sanitizeDownloadName (title: unknown): string { const name = String(title ?? '') .replace(/[\u0000-\u001f\u007f]/g, '') .replace(/[\\/:*?"<>|]/g, ' ') .replace(/\s+/g, ' ') .replace(/^[.\s]+/, '') .slice(0, 80) .replace(/[.\s]+$/, '') .trim() return name || 'video' } /** * `Content-Disposition` 的值:`attachment` + 安全文件名。 * * 两个文件名都给:`filename=` 用 ASCII 兜底名(老客户端 / 头编码限制), * `filename*=` 用 RFC 5987 的 UTF-8 形式放原名(现代浏览器优先用它,中文名不会变成下划线)。 */ export function downloadDisposition (title: unknown, ext = '.mp4'): string { const full = sanitizeDownloadName(title) + ext // 纯中文标题会变成一排下划线(对老客户端毫无意义),这种情况直接给个通用名 const asciiRaw = full.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_') // 只在**扩展名之前**数有效字符:纯中文标题会变成「_____.mp4」,那 3 个字母来自 .mp4 不能算数 const asciiBase = asciiRaw.replace(/\.(mp4|mkv|webm|mov|m4a|aac|mp3)$/i, '') const ascii = /[A-Za-z0-9]/.test(asciiBase) ? asciiRaw : 'video' + ext const encoded = encodeURIComponent(full).replace(/['()*]/g, (char) => '%' + char.charCodeAt(0).toString(16).toUpperCase()) return 'attachment; filename="' + ascii + '"; filename*=UTF-8\'\'' + encoded } /** * 视频响应:支持 Range,未过期时是 video/mp4。 * @param download 为 true 时带上 `Content-Disposition: attachment`(浏览器直接下载),Range 照旧支持 */ /** * 通用文件响应:支持 Range。 * * @param file 文件路径与大小 * @param contentType 响应的 Content-Type(视频 / 音轨 / 合并后的视频各不同) * @param ext 下载时的扩展名(决定 Content-Disposition 里的文件名) * @param download 是否带 \`Content-Disposition: attachment\` */ function fileResponse ( token: string, file: { path: string, size: number }, contentType: string, range?: string, head = false, download = false, ext = '.mp4' ): PlayerHttpResponse { const base: Record = { 'Content-Type': contentType, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' } // 下载:文件名取会话标题(清洗过),Range 行为不变(浏览器正常下载时也不会带 Range) if (download) base['Content-Disposition'] = downloadDisposition(getPlayerSession(token)?.title, ext) const parsed = parseRange(range, file.size) if (parsed === 'invalid') { return { status: 416, headers: { ...base, 'Content-Range': 'bytes */' + file.size }, body: Buffer.from('请求的范围无效') } } if (!parsed) { base['Content-Length'] = String(file.size) return { status: 200, headers: base, file: head ? undefined : { path: file.path, start: 0, end: file.size - 1 } } } const length = parsed.end - parsed.start + 1 return { status: 206, headers: { ...base, 'Content-Length': String(length), 'Content-Range': 'bytes ' + parsed.start + '-' + parsed.end + '/' + file.size }, file: head ? undefined : { path: file.path, start: parsed.start, end: parsed.end } } } /** 视频本体(分离音轨时这里只有画面) */ function videoResponse (token: string, range?: string, head = false, download = false): PlayerHttpResponse { const video = resolvePlayerVideo(token) if (!video) return notFound(false) return fileResponse(token, video, 'video/mp4', range, head, download, '.mp4') } /** 单独的音轨(B站这类音视频分离的):\`