@flow-player/test-streams 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/package.json +42 -0
- package/src/cli/extend-vod.ts +221 -0
- package/src/cli/generate.ts +138 -0
- package/src/cli/serve.ts +112 -0
- package/src/config.ts +34 -0
- package/src/fixtures/catalog.ts +99 -0
- package/src/fixtures/codec.ts +43 -0
- package/src/fixtures/format.ts +118 -0
- package/src/fixtures/generate.ts +209 -0
- package/src/fixtures/matrix.ts +94 -0
- package/src/index.ts +41 -0
- package/src/server/live.ts +220 -0
- package/src/server/routes.ts +141 -0
- package/src/server/server.ts +193 -0
- package/src/server/static.ts +83 -0
- package/src/visual/analyze.ts +234 -0
- package/src/visual/dhash.ts +76 -0
- package/src/visual/index.ts +18 -0
- package/src/visual/report.ts +80 -0
- package/src/vite-plugin.d.ts +10 -0
- package/src/vite-plugin.ts +76 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// /streams/live/<format>/<codec> 路径按需 spawn ffmpeg 子进程,
|
|
2
|
+
// pipe stdout 给 HTTP response,stream 永远不 EOF。
|
|
3
|
+
// ffmpeg 用 -stream_loop -1 + -re 实时速率无限循环,让浏览器端 fetch
|
|
4
|
+
// 持续拿到新字节,player.metrics 长时间累计,模拟真实 live 验收。
|
|
5
|
+
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
|
6
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
7
|
+
import { type Readable } from "node:stream";
|
|
8
|
+
import { type CodecId, type FormatId } from "../config.ts";
|
|
9
|
+
import { CODEC_SPEC } from "../fixtures/codec.ts";
|
|
10
|
+
import { writeJson } from "./static.ts";
|
|
11
|
+
|
|
12
|
+
const LIVE_MIME: Record<FormatId, string> = {
|
|
13
|
+
flv: "video/x-flv",
|
|
14
|
+
hls: "application/vnd.apple.mpegurl",
|
|
15
|
+
mp4: "video/mp4",
|
|
16
|
+
fmp4: "video/iso.segment",
|
|
17
|
+
ts: "video/mp2t",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const CODEC_SET: Record<string, true> = { h264: true, hevc: true };
|
|
21
|
+
const FORMAT_SET: Record<string, true> = { flv: true, hls: true, mp4: true, fmp4: true, ts: true };
|
|
22
|
+
|
|
23
|
+
const LIVE_PATTERN = /^\/streams\/live\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
|
|
24
|
+
|
|
25
|
+
export function matchLivePath(path: string): { format: FormatId; codec: CodecId } | null {
|
|
26
|
+
const match = LIVE_PATTERN.exec(path);
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
const [, formatRaw, codecRaw] = match;
|
|
29
|
+
if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
|
|
30
|
+
return { format: formatRaw as FormatId, codec: codecRaw as CodecId };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type LiveStreamOptions = {
|
|
34
|
+
ffmpegPath?: string;
|
|
35
|
+
width?: number;
|
|
36
|
+
height?: number;
|
|
37
|
+
fps?: number;
|
|
38
|
+
bitrate?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function handleLiveStream(
|
|
42
|
+
req: IncomingMessage,
|
|
43
|
+
res: ServerResponse,
|
|
44
|
+
format: FormatId,
|
|
45
|
+
codec: CodecId,
|
|
46
|
+
options: LiveStreamOptions = {},
|
|
47
|
+
): void {
|
|
48
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
49
|
+
res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
|
|
50
|
+
res.end("Method Not Allowed\n");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (format === "hls") {
|
|
54
|
+
writeJson(res, 501, {
|
|
55
|
+
error: "HLS live streaming not yet supported in this build",
|
|
56
|
+
reason:
|
|
57
|
+
"use /streams/live/flv/hevc etc. for non-HLS live, or /streams/hls/hevc/master.m3u8 for VOD HLS",
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const ffmpegPath = options.ffmpegPath ?? "ffmpeg";
|
|
62
|
+
const width = options.width ?? 1280;
|
|
63
|
+
const height = options.height ?? 720;
|
|
64
|
+
const fps = options.fps ?? 25;
|
|
65
|
+
const bitrate = options.bitrate ?? "1500k";
|
|
66
|
+
const spec = CODEC_SPEC[codec];
|
|
67
|
+
const mime = LIVE_MIME[format];
|
|
68
|
+
const ffmpegArgs = buildLiveFfmpegArgs(format, codec, spec, {
|
|
69
|
+
width,
|
|
70
|
+
height,
|
|
71
|
+
fps,
|
|
72
|
+
bitrate,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (req.method === "HEAD") {
|
|
76
|
+
res.writeHead(200, {
|
|
77
|
+
"Content-Type": mime,
|
|
78
|
+
"Access-Control-Allow-Origin": "*",
|
|
79
|
+
"X-Live-Source": "ffmpeg-stream-loop",
|
|
80
|
+
});
|
|
81
|
+
res.end();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let proc: ChildProcessByStdio<null, Readable, Readable>;
|
|
86
|
+
try {
|
|
87
|
+
proc = spawn(ffmpegPath, ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
90
|
+
writeJson(res, 503, { error: "ffmpeg spawn failed", reason: message });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
proc.once("error", (err) => {
|
|
95
|
+
if (!res.headersSent) {
|
|
96
|
+
writeJson(res, 503, { error: "ffmpeg failed to start", reason: err.message });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
res.destroy(err);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ffmpeg 第一帧到达前 stderr 经常有 stat 消息;等 stdout 第一字节再发 head。
|
|
103
|
+
proc.stdout.once("data", () => {
|
|
104
|
+
if (!res.headersSent) {
|
|
105
|
+
res.writeHead(200, {
|
|
106
|
+
"Content-Type": mime,
|
|
107
|
+
"Access-Control-Allow-Origin": "*",
|
|
108
|
+
"Transfer-Encoding": "chunked",
|
|
109
|
+
"X-Live-Source": "ffmpeg-stream-loop",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
proc.stdout.pipe(res);
|
|
115
|
+
|
|
116
|
+
const cleanup = (): void => {
|
|
117
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
118
|
+
};
|
|
119
|
+
req.once("close", cleanup);
|
|
120
|
+
res.once("close", cleanup);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type FfmpegSpec = {
|
|
124
|
+
encoder: string;
|
|
125
|
+
videoArgs: ReadonlyArray<string>;
|
|
126
|
+
audioArgs: ReadonlyArray<string>;
|
|
127
|
+
pixelFormat: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
type LiveFfmpegOptions = {
|
|
131
|
+
width: number;
|
|
132
|
+
height: number;
|
|
133
|
+
fps: number;
|
|
134
|
+
bitrate: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function buildLiveFfmpegArgs(
|
|
138
|
+
format: FormatId,
|
|
139
|
+
codec: CodecId,
|
|
140
|
+
spec: FfmpegSpec,
|
|
141
|
+
opts: LiveFfmpegOptions,
|
|
142
|
+
): string[] {
|
|
143
|
+
const gop = String(opts.fps);
|
|
144
|
+
const videoInput = `testsrc2=size=${opts.width}x${opts.height}:rate=${opts.fps},format=yuv420p`;
|
|
145
|
+
const audioInput = `sine=frequency=1000:sample_rate=44100`;
|
|
146
|
+
const hevcTag = codec === "hevc" && format === "mp4" ? ["-tag:v", "hvc1"] : [];
|
|
147
|
+
|
|
148
|
+
const baseArgs = [
|
|
149
|
+
"-hide_banner",
|
|
150
|
+
"-loglevel",
|
|
151
|
+
"error",
|
|
152
|
+
"-fflags",
|
|
153
|
+
"+genpts",
|
|
154
|
+
"-re",
|
|
155
|
+
"-stream_loop",
|
|
156
|
+
"-1",
|
|
157
|
+
"-f",
|
|
158
|
+
"lavfi",
|
|
159
|
+
"-i",
|
|
160
|
+
videoInput,
|
|
161
|
+
"-f",
|
|
162
|
+
"lavfi",
|
|
163
|
+
"-i",
|
|
164
|
+
audioInput,
|
|
165
|
+
"-c:v",
|
|
166
|
+
spec.encoder,
|
|
167
|
+
...spec.videoArgs,
|
|
168
|
+
...liveLowLatencyVideoArgs(codec),
|
|
169
|
+
"-pix_fmt",
|
|
170
|
+
spec.pixelFormat,
|
|
171
|
+
"-g",
|
|
172
|
+
gop,
|
|
173
|
+
"-keyint_min",
|
|
174
|
+
gop,
|
|
175
|
+
"-sc_threshold",
|
|
176
|
+
"0",
|
|
177
|
+
"-b:v",
|
|
178
|
+
opts.bitrate,
|
|
179
|
+
...hevcTag,
|
|
180
|
+
...spec.audioArgs,
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
if (format === "flv") {
|
|
184
|
+
return [...baseArgs, "-f", "flv", "-flvflags", "no_sequence_end", "-"];
|
|
185
|
+
}
|
|
186
|
+
if (format === "mp4") {
|
|
187
|
+
return [
|
|
188
|
+
...baseArgs,
|
|
189
|
+
"-f",
|
|
190
|
+
"mp4",
|
|
191
|
+
"-movflags",
|
|
192
|
+
"+faststart+frag_keyframe+empty_moov+default_base_moof",
|
|
193
|
+
"-",
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
if (format === "fmp4") {
|
|
197
|
+
return [
|
|
198
|
+
...baseArgs,
|
|
199
|
+
"-f",
|
|
200
|
+
"mp4",
|
|
201
|
+
"-movflags",
|
|
202
|
+
"frag_keyframe+empty_moov+default_base_moof",
|
|
203
|
+
"-frag_duration",
|
|
204
|
+
"2000000",
|
|
205
|
+
"-",
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
if (format === "ts") {
|
|
209
|
+
return [...baseArgs, "-f", "mpegts", "-"];
|
|
210
|
+
}
|
|
211
|
+
// hls 已在 handleLiveStream 早返;此处 unreachable。
|
|
212
|
+
throw new Error(`Unknown format: ${String(format)}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function liveLowLatencyVideoArgs(codec: CodecId): string[] {
|
|
216
|
+
if (codec === "h264" || codec === "hevc") {
|
|
217
|
+
return ["-tune", "zerolatency"];
|
|
218
|
+
}
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// 路由表:/streams/health、/streams/list、/streams/vod-list、/streams/:format/:codec、
|
|
2
|
+
// /streams/(hls|fmp4)/:codec/...、/streams/vod/:name。
|
|
3
|
+
// resolveRoute() 把 path 解析成 fixture-relative 路径;handleXxx 负责写 JSON 响应。
|
|
4
|
+
import { type ServerResponse } from "node:http";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { ALL_CODECS, ALL_FORMATS, type CodecId, type FormatId } from "../config.ts";
|
|
8
|
+
import { type Manifest, summarizeCatalog } from "../fixtures/catalog.ts";
|
|
9
|
+
import { writeJson } from "./static.ts";
|
|
10
|
+
|
|
11
|
+
const FORMAT_SET: Record<string, true> = Object.fromEntries(ALL_FORMATS.map((f) => [f, true]));
|
|
12
|
+
const CODEC_SET: Record<string, true> = Object.fromEntries(ALL_CODECS.map((c) => [c, true]));
|
|
13
|
+
const HLS_FMP4: Record<string, true> = { hls: true, fmp4: true };
|
|
14
|
+
|
|
15
|
+
const SINGLE = /^\/streams\/([a-z0-9]+)\/([a-z0-9]+)\/?$/;
|
|
16
|
+
const PLAYLIST = /^\/streams\/(hls|fmp4)\/([a-z0-9]+)\/(.+)$/;
|
|
17
|
+
const VOD = /^\/streams\/vod\/([a-zA-Z0-9._-]+)\/?$/;
|
|
18
|
+
|
|
19
|
+
export function resolveRoute(path: string): string | null {
|
|
20
|
+
// 单文件:/streams/flv/h264、/streams/mp4/hevc、/streams/ts/h264
|
|
21
|
+
const single = SINGLE.exec(path);
|
|
22
|
+
if (single) {
|
|
23
|
+
const [, formatRaw, codecRaw] = single;
|
|
24
|
+
if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
|
|
25
|
+
if (HLS_FMP4[formatRaw]) return null; // hls/fmp4 必须带子文件路径
|
|
26
|
+
const format = formatRaw as FormatId;
|
|
27
|
+
const codec = codecRaw as CodecId;
|
|
28
|
+
return join(format, `${codec}.${format}`);
|
|
29
|
+
}
|
|
30
|
+
// playlist 内子文件
|
|
31
|
+
const playlist = PLAYLIST.exec(path);
|
|
32
|
+
if (playlist) {
|
|
33
|
+
const [, formatRaw, codecRaw, rest] = playlist;
|
|
34
|
+
if (!FORMAT_SET[formatRaw] || !CODEC_SET[codecRaw]) return null;
|
|
35
|
+
return join(formatRaw, codecRaw, rest);
|
|
36
|
+
}
|
|
37
|
+
// /streams/vod/:name
|
|
38
|
+
const vod = VOD.exec(path);
|
|
39
|
+
if (vod) {
|
|
40
|
+
const [, name] = vod;
|
|
41
|
+
return join("vod", name);
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function handleHealth(
|
|
47
|
+
res: ServerResponse,
|
|
48
|
+
manifest: Manifest | null,
|
|
49
|
+
fixtureDir: string,
|
|
50
|
+
): void {
|
|
51
|
+
const summary = summarizeCatalog(fixtureDir);
|
|
52
|
+
const status = manifest ? 200 : 503;
|
|
53
|
+
writeJson(res, status, {
|
|
54
|
+
status: summary.exists ? "ok" : "down",
|
|
55
|
+
codecs: summary.codecs,
|
|
56
|
+
formats: summary.formats,
|
|
57
|
+
fixtures: summary.fixtureCount,
|
|
58
|
+
reason: manifest ? undefined : summary.reason,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function handleList(res: ServerResponse, manifest: Manifest | null): void {
|
|
63
|
+
if (!manifest) {
|
|
64
|
+
writeJson(res, 503, { error: "manifest not found" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
writeJson(res, 200, manifest);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type VodSegmentDescriptor = {
|
|
71
|
+
url: string;
|
|
72
|
+
startTimestamp: number;
|
|
73
|
+
endTimestamp: number;
|
|
74
|
+
timeLen: number;
|
|
75
|
+
fileSize: number;
|
|
76
|
+
channelID: number;
|
|
77
|
+
RecordType: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type VodListResponse = {
|
|
81
|
+
segments: ReadonlyArray<VodSegmentDescriptor>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function handleVodList(
|
|
85
|
+
res: ServerResponse,
|
|
86
|
+
manifest: Manifest | null,
|
|
87
|
+
fixtureDir: string,
|
|
88
|
+
): void {
|
|
89
|
+
// 优先读 vod-list.json(由 extend-vod CLI 生成),支持任意段数 + 段长。
|
|
90
|
+
const vodListPath = join(fixtureDir, "vod", "vod-list.json");
|
|
91
|
+
if (existsSync(vodListPath)) {
|
|
92
|
+
try {
|
|
93
|
+
const raw = JSON.parse(readFileSync(vodListPath, "utf8")) as {
|
|
94
|
+
entries: ReadonlyArray<{ path: string; size: number }>;
|
|
95
|
+
};
|
|
96
|
+
const segMs = manifest?.durationSeconds ? manifest.durationSeconds * 1000 : 0;
|
|
97
|
+
const segments: VodSegmentDescriptor[] = raw.entries.map((e, i) => ({
|
|
98
|
+
url: `/streams/${e.path}`,
|
|
99
|
+
startTimestamp: i * segMs,
|
|
100
|
+
endTimestamp: (i + 1) * segMs,
|
|
101
|
+
timeLen: segMs,
|
|
102
|
+
fileSize: e.size,
|
|
103
|
+
channelID: 1,
|
|
104
|
+
RecordType: 1,
|
|
105
|
+
}));
|
|
106
|
+
writeJson(res, 200, { segments });
|
|
107
|
+
return;
|
|
108
|
+
} catch {
|
|
109
|
+
// fallthrough to legacy 2-segment path
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!manifest) {
|
|
113
|
+
writeJson(res, 503, { error: "manifest not found" });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const h264mp4 = manifest.fixtures.find((f) => f.codec === "h264" && f.format === "mp4");
|
|
117
|
+
const fileSize = h264mp4?.entries[0]?.size ?? 0;
|
|
118
|
+
const durMs = manifest.durationSeconds * 1000;
|
|
119
|
+
writeJson(res, 200, {
|
|
120
|
+
segments: [
|
|
121
|
+
{
|
|
122
|
+
url: "/streams/vod/sample-1.mp4",
|
|
123
|
+
startTimestamp: 0,
|
|
124
|
+
endTimestamp: durMs,
|
|
125
|
+
timeLen: durMs,
|
|
126
|
+
fileSize,
|
|
127
|
+
channelID: 1,
|
|
128
|
+
RecordType: 1,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
url: "/streams/vod/sample-2.mp4",
|
|
132
|
+
startTimestamp: durMs,
|
|
133
|
+
endTimestamp: durMs * 2,
|
|
134
|
+
timeLen: durMs,
|
|
135
|
+
fileSize,
|
|
136
|
+
channelID: 1,
|
|
137
|
+
RecordType: 1,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Streams HTTP(S) server:托管 @flow-player/test-streams/fixtures 下生成的全部 fixture。
|
|
2
|
+
// 与 browser-playback-proxy-server 职责分离:本服务只读本地磁盘,不转发 LAN 上游。
|
|
3
|
+
import { createReadStream, readFileSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
type IncomingMessage,
|
|
6
|
+
type Server,
|
|
7
|
+
type ServerResponse,
|
|
8
|
+
createServer as createHttpServer,
|
|
9
|
+
} from "node:http";
|
|
10
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
11
|
+
import { type AddressInfo } from "node:net";
|
|
12
|
+
import { extname, resolve, sep } from "node:path";
|
|
13
|
+
import { DEFAULT_HOST, DEFAULT_PORT } from "../config.ts";
|
|
14
|
+
import { type Manifest, loadManifest } from "../fixtures/catalog.ts";
|
|
15
|
+
import { applyCors, setRangeHeaders, writeBadRequest, writeNotFound } from "./static.ts";
|
|
16
|
+
import { handleHealth, handleList, handleVodList, resolveRoute } from "./routes.ts";
|
|
17
|
+
import { handleLiveStream, matchLivePath } from "./live.ts";
|
|
18
|
+
export type StreamsServerHandle = {
|
|
19
|
+
server: Server;
|
|
20
|
+
port: number;
|
|
21
|
+
host: string;
|
|
22
|
+
close: () => Promise<void>;
|
|
23
|
+
refreshManifest: () => Manifest | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type StreamsServerOptions = {
|
|
27
|
+
port?: number;
|
|
28
|
+
host?: string;
|
|
29
|
+
fixtureDir: string;
|
|
30
|
+
cert?: string;
|
|
31
|
+
key?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function createStreamsServer(options: StreamsServerOptions): StreamsServerHandle {
|
|
35
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
36
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
37
|
+
const fixtureDir = resolve(options.fixtureDir);
|
|
38
|
+
|
|
39
|
+
const ctx: { manifest: Manifest | null; fixtureDir: string } = {
|
|
40
|
+
manifest: loadManifest(fixtureDir),
|
|
41
|
+
fixtureDir,
|
|
42
|
+
};
|
|
43
|
+
const refreshManifest = (): Manifest | null => {
|
|
44
|
+
ctx.manifest = loadManifest(fixtureDir);
|
|
45
|
+
return ctx.manifest;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handler = (req: IncomingMessage, res: ServerResponse) => {
|
|
49
|
+
applyCors(res);
|
|
50
|
+
if (req.method === "OPTIONS") {
|
|
51
|
+
res.writeHead(204, {
|
|
52
|
+
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
|
53
|
+
"Access-Control-Allow-Headers": "Range, Content-Type, If-None-Match",
|
|
54
|
+
});
|
|
55
|
+
res.end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
59
|
+
res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" });
|
|
60
|
+
res.end("Method Not Allowed\n");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const path = decodePath(req.url ?? "/");
|
|
65
|
+
if (path.includes("..")) {
|
|
66
|
+
writeBadRequest(res, "Path traversal not allowed");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (path === "/streams/health" || path === "/streams/health/") {
|
|
70
|
+
handleHealth(res, ctx.manifest, fixtureDir);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (path === "/streams/list" || path === "/streams/list/") {
|
|
74
|
+
handleList(res, ctx.manifest);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (path === "/streams/vod-list" || path === "/streams/vod-list/") {
|
|
78
|
+
handleVodList(res, ctx.manifest, ctx.fixtureDir);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const resolved = resolveRoute(path);
|
|
82
|
+
if (resolved) {
|
|
83
|
+
serveEntry(req, res, fixtureDir, resolved);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const liveMatch = matchLivePath(path);
|
|
87
|
+
if (liveMatch) {
|
|
88
|
+
handleLiveStream(req, res, liveMatch.format, liveMatch.codec);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
writeNotFound(res);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
94
|
+
if (!res.headersSent) {
|
|
95
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
96
|
+
res.end(`Internal Error: ${message}\n`);
|
|
97
|
+
} else {
|
|
98
|
+
res.destroy(err instanceof Error ? err : new Error(message));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const server =
|
|
104
|
+
options.cert && options.key
|
|
105
|
+
? createHttpsServer(
|
|
106
|
+
{
|
|
107
|
+
cert: readFileSync(resolve(options.cert)),
|
|
108
|
+
key: readFileSync(resolve(options.key)),
|
|
109
|
+
},
|
|
110
|
+
handler,
|
|
111
|
+
)
|
|
112
|
+
: createHttpServer(handler);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
server,
|
|
116
|
+
port,
|
|
117
|
+
host,
|
|
118
|
+
close: () =>
|
|
119
|
+
new Promise<void>((resolveClose, rejectClose) => {
|
|
120
|
+
server.close((err) => (err ? rejectClose(err) : resolveClose()));
|
|
121
|
+
}),
|
|
122
|
+
refreshManifest,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function listen(server: StreamsServerHandle): Promise<StreamsServerHandle> {
|
|
127
|
+
const { promise, resolve, reject } = Promise.withResolvers<StreamsServerHandle>();
|
|
128
|
+
server.server.once("error", reject);
|
|
129
|
+
server.server.listen(server.port, server.host, () => {
|
|
130
|
+
const addr = server.server.address() as AddressInfo | null;
|
|
131
|
+
if (!addr) {
|
|
132
|
+
reject(new Error("server.address() returned null after listen"));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
server.port = addr.port;
|
|
136
|
+
resolve(server);
|
|
137
|
+
});
|
|
138
|
+
return promise;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const FIXTURE_MIME: Record<string, string> = {
|
|
142
|
+
".flv": "video/x-flv",
|
|
143
|
+
".mp4": "video/mp4",
|
|
144
|
+
".m4s": "video/iso.segment",
|
|
145
|
+
".ts": "video/mp2t",
|
|
146
|
+
".m3u8": "application/vnd.apple.mpegurl",
|
|
147
|
+
".json": "application/json; charset=utf-8",
|
|
148
|
+
".aac": "audio/aac",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
function serveEntry(
|
|
152
|
+
req: IncomingMessage,
|
|
153
|
+
res: ServerResponse,
|
|
154
|
+
fixtureDir: string,
|
|
155
|
+
relPath: string,
|
|
156
|
+
): void {
|
|
157
|
+
const abs = resolve(fixtureDir, relPath);
|
|
158
|
+
const rel = relativePosix(fixtureDir, abs);
|
|
159
|
+
if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
160
|
+
writeNotFound(res);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const mime = FIXTURE_MIME[extname(abs).toLowerCase()] ?? "application/octet-stream";
|
|
164
|
+
const rangeInfo = setRangeHeaders(req, res, abs, mime);
|
|
165
|
+
if (rangeInfo && "alreadyServed" in rangeInfo) return;
|
|
166
|
+
if (req.method === "HEAD") {
|
|
167
|
+
res.end();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const startEnd = rangeInfo && "start" in rangeInfo ? rangeInfo : null;
|
|
171
|
+
const stream = startEnd
|
|
172
|
+
? createReadStream(abs, { start: startEnd.start, end: startEnd.end })
|
|
173
|
+
: createReadStream(abs);
|
|
174
|
+
stream.on("error", (err) => res.destroy(err));
|
|
175
|
+
stream.pipe(res);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 手动实现相对路径(避免引入 path.relative 的隐式语义),只接受 fixtureDir 子树。
|
|
179
|
+
function relativePosix(root: string, abs: string): string {
|
|
180
|
+
if (abs === root) return "";
|
|
181
|
+
if (abs.startsWith(root + sep)) return abs.slice(root.length + 1);
|
|
182
|
+
if (abs.startsWith(root)) return abs.slice(root.length);
|
|
183
|
+
return abs;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function decodePath(rawTarget: string): string {
|
|
187
|
+
const stripped = rawTarget.split(/[?#]/, 1)[0] || "/";
|
|
188
|
+
try {
|
|
189
|
+
return decodeURIComponent(stripped);
|
|
190
|
+
} catch {
|
|
191
|
+
return stripped;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// 静态文件 + Range 响应 + CORS 头 + JSON/NotFound 工具。
|
|
2
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
|
+
import { createReadStream, statSync } from "node:fs";
|
|
4
|
+
export function applyCors(res: ServerResponse): void {
|
|
5
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
6
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
|
7
|
+
res.setHeader("Access-Control-Allow-Headers", "Range, Content-Type, If-None-Match");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
11
|
+
const text = JSON.stringify(body);
|
|
12
|
+
res.writeHead(status, {
|
|
13
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
14
|
+
"Content-Length": Buffer.byteLength(text),
|
|
15
|
+
"Cache-Control": "no-store",
|
|
16
|
+
});
|
|
17
|
+
res.end(text);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function writeNotFound(res: ServerResponse): void {
|
|
21
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
22
|
+
res.end("Not Found\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeBadRequest(res: ServerResponse, message: string): void {
|
|
26
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
27
|
+
res.end(`${message}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeStaticFile(res: ServerResponse, fullPath: string, mime: string): void {
|
|
31
|
+
res.setHeader("Content-Type", mime);
|
|
32
|
+
res.setHeader("Cache-Control", "no-store");
|
|
33
|
+
const stream = createReadStream(fullPath);
|
|
34
|
+
stream.on("error", (err) => res.destroy(err));
|
|
35
|
+
stream.pipe(res);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 单段 Range 处理:仅支持 bytes=start-end,不支持 multi-range。
|
|
39
|
+
// 返回 RangeInfo | null:
|
|
40
|
+
// - { alreadyServed: true }: HEAD 或 416 边界情况, caller 不要再写。
|
|
41
|
+
// - { start, end }: 有效 Range, caller 需用 createReadStream(abs, {start, end}) + pipe。
|
|
42
|
+
// - null: 非 Range 请求, caller 用全文件 stream。
|
|
43
|
+
export type RangeInfo = { alreadyServed: true } | { start: number; end: number } | null;
|
|
44
|
+
|
|
45
|
+
export function setRangeHeaders(
|
|
46
|
+
req: IncomingMessage,
|
|
47
|
+
res: ServerResponse,
|
|
48
|
+
fullPath: string,
|
|
49
|
+
mime: string,
|
|
50
|
+
): RangeInfo {
|
|
51
|
+
const stat = statSync(fullPath);
|
|
52
|
+
const total = stat.size;
|
|
53
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
54
|
+
const range = req.headers.range;
|
|
55
|
+
if (!range) {
|
|
56
|
+
res.setHeader("Content-Length", total);
|
|
57
|
+
res.setHeader("Content-Type", mime);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
61
|
+
if (!match) {
|
|
62
|
+
res.setHeader("Content-Length", total);
|
|
63
|
+
res.setHeader("Content-Type", mime);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const startRaw = match[1];
|
|
67
|
+
const endRaw = match[2];
|
|
68
|
+
let start = startRaw === "" ? Math.max(0, total - Number(endRaw || 0)) : Number(startRaw);
|
|
69
|
+
let end = endRaw === "" ? total - 1 : Number(endRaw);
|
|
70
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) {
|
|
71
|
+
res.setHeader("Content-Range", `bytes */${total}`);
|
|
72
|
+
res.writeHead(416);
|
|
73
|
+
res.end();
|
|
74
|
+
return { alreadyServed: true };
|
|
75
|
+
}
|
|
76
|
+
start = Math.max(0, start);
|
|
77
|
+
end = Math.min(total - 1, end);
|
|
78
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
|
|
79
|
+
res.setHeader("Content-Length", end - start + 1);
|
|
80
|
+
res.setHeader("Content-Type", mime);
|
|
81
|
+
res.statusCode = 206;
|
|
82
|
+
return { start, end };
|
|
83
|
+
}
|