This commit is contained in:
eric
2026-05-16 19:24:30 -05:00
parent 19beec12a5
commit 75a0ca4e31
260 changed files with 51345 additions and 1 deletions

View File

@@ -0,0 +1,47 @@
/**
* 将 HTTP API 根地址转为 WebSocket 根(用于 /android/scrcpy/ws)。
* next dev:HTTP 可走 middleware rewrite,但 WebSocket 升级通常不会转发,故在常见 dev 端口直连 uvicorn(与 middleware API_PROXY_TARGET 默认一致)。
*/
const DEFAULT_DEV_API_PORT = "8001";
export function httpApiBaseToWsBase(apiBase: string): string {
if (typeof window === "undefined") return `ws://127.0.0.1:${DEFAULT_DEV_API_PORT}`;
const base = (apiBase || "").trim();
if (base) {
try {
const u = new URL(base, window.location.href);
const proto = u.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${u.host}`;
} catch {
/* ignore */
}
}
const pub =
typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL
? String(process.env.NEXT_PUBLIC_API_URL).trim()
: "";
if (pub) {
try {
const u = new URL(pub, window.location.href);
const proto = u.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${u.host}`;
} catch {
/* ignore */
}
}
const port = window.location.port;
if (port === "3000" || port === "3001") {
const h = window.location.hostname || "127.0.0.1";
return `ws://${h}:${DEFAULT_DEV_API_PORT}`;
}
const { protocol, host } = window.location;
const proto = protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${host}`;
}
export function androidScrcpyWsUrl(apiBase: string, serial: string, maxSize = 1920): string {
const root = httpApiBaseToWsBase(apiBase).replace(/\/$/, "");
const q = new URLSearchParams({ serial, max_size: String(maxSize) });
return `${root}/android/scrcpy/ws?${q.toString()}`;
}

View File

@@ -0,0 +1,73 @@
type JsonErrorPayload = {
error?: string;
message?: string;
detail?: string;
};
const MAX_ERROR_TEXT = 240;
function summarizeResponseText(raw: string): string {
const compact = raw.replace(/\s+/g, " ").trim();
if (!compact) return "";
if (compact.length <= MAX_ERROR_TEXT) return compact;
return `${compact.slice(0, MAX_ERROR_TEXT - 3)}...`;
}
function defaultHttpMessage(response: Response): string {
const suffix = response.statusText?.trim() ? ` ${response.statusText.trim()}` : "";
return `HTTP ${response.status}${suffix}`;
}
function extractErrorMessage(
response: Response,
raw: string,
payload: JsonErrorPayload | null,
): string {
const candidate =
(typeof payload?.error === "string" && payload.error.trim()) ||
(typeof payload?.message === "string" && payload.message.trim()) ||
(typeof payload?.detail === "string" && payload.detail.trim()) ||
summarizeResponseText(raw);
return candidate || defaultHttpMessage(response);
}
export async function fetchJsonResponse<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
const raw = await response.text();
const trimmed = raw.trim();
let payload: (T & JsonErrorPayload) | null = null;
if (trimmed) {
try {
payload = JSON.parse(trimmed) as T & JsonErrorPayload;
} catch {
if (!response.ok) {
throw new Error(extractErrorMessage(response, trimmed, null));
}
throw new Error(`Invalid JSON response from ${url}`);
}
}
if (!response.ok) {
throw new Error(extractErrorMessage(response, trimmed, payload));
}
if (payload !== null) {
return payload as T;
}
return {} as T;
}
export async function readErrorMessage(response: Response): Promise<string> {
const raw = (await response.text()).trim();
let payload: JsonErrorPayload | null = null;
if (raw) {
try {
payload = JSON.parse(raw) as JsonErrorPayload;
} catch {
payload = null;
}
}
return extractErrorMessage(response, raw, payload);
}

View File

@@ -0,0 +1,31 @@
/**
* 将服务状态里常见的 0.0.0.0 / 127.0.0.1 等替换为当前浏览器访问的主机名,
* 避免在局域网用 live.local 打开页面时 iframe/新窗口指向不可达地址。
*/
export function normalizeBrowserReachableHttpUrl(url: string): string {
const trimmed = url.trim();
if (typeof window === "undefined" || !trimmed) return trimmed;
try {
const u = new URL(trimmed, window.location.origin);
const h = u.hostname;
const pageHost = window.location.hostname;
if (h === "0.0.0.0" || h === "::" || h === "[::]") {
u.hostname = pageHost || h;
return u.toString();
}
if ((h === "127.0.0.1" || h === "localhost") && pageHost && pageHost !== "127.0.0.1" && pageHost !== "localhost") {
u.hostname = pageHost;
return u.toString();
}
if (h.endsWith(".local") && u.port === "8001") {
return `${u.pathname || "/"}${u.search}${u.hash}`;
}
if (h.endsWith(".local") && pageHost && pageHost !== h) {
u.hostname = pageHost;
return u.toString();
}
return trimmed;
} catch {
return trimmed;
}
}

View File

@@ -0,0 +1,169 @@
/**
* 解析 scrcpy raw_stream(AVCC:4 字节大端长度 + NAL)并用 WebCodecs 画到 Canvas。
* 与官方 scrcpy / escrcpy 设备端编码一致;浏览器需 Chromium 系且支持 VideoDecoder(H.264)。
*/
function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
const o = new Uint8Array(a.length + b.length);
o.set(a, 0);
o.set(b, a.length);
return o;
}
function readU32be(b: Uint8Array, o: number): number {
return (b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3];
}
function buildAvcC(sps: Uint8Array, pps: Uint8Array): Uint8Array {
const spsBody = sps.subarray(1);
const ppsBody = pps.subarray(1);
const out = new Uint8Array(11 + spsBody.length + 3 + ppsBody.length);
let p = 0;
out[p++] = 1;
out[p++] = sps[1];
out[p++] = sps[2];
out[p++] = sps[3];
out[p++] = 0xfc | 3;
out[p++] = 0xe0 | 1;
out[p++] = (spsBody.length >> 8) & 0xff;
out[p++] = spsBody.length & 0xff;
out.set(spsBody, p);
p += spsBody.length;
out[p++] = 1;
out[p++] = (ppsBody.length >> 8) & 0xff;
out[p++] = ppsBody.length & 0xff;
out.set(ppsBody, p);
return out;
}
function codecStringFromSps(sps: Uint8Array): string {
const h = (n: number) => n.toString(16).padStart(2, "0").toUpperCase();
return `avc1.${h(sps[1])}${h(sps[2])}${h(sps[3])}`;
}
export class ScrcpyH264Decoder {
private buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
private decoder: VideoDecoder | null = null;
private configured = false;
private configuring = false;
private ts = 0;
private sps: Uint8Array | null = null;
private pps: Uint8Array | null = null;
constructor(
private readonly canvas: HTMLCanvasElement,
private readonly onDecodeError: (msg: string) => void,
) {}
reset(): void {
this.buf = new Uint8Array(0);
this.sps = null;
this.pps = null;
this.configured = false;
this.configuring = false;
this.ts = 0;
if (this.decoder) {
try {
this.decoder.close();
} catch {
/* ignore */
}
this.decoder = null;
}
}
push(chunk: ArrayBuffer): void {
const add = new Uint8Array(chunk.byteLength);
add.set(new Uint8Array(chunk));
this.buf = concatBuffers(this.buf, add);
this.drainAvcc();
}
private drainAvcc(): void {
const b = this.buf;
let o = 0;
while (o + 4 <= b.length) {
const len = readU32be(b, o);
if (len <= 0 || len > 6 * 1024 * 1024) {
this.buf = new Uint8Array(b.subarray(o + 1));
o = 0;
continue;
}
if (o + 4 + len > b.length) break;
const nal = b.subarray(o + 4, o + 4 + len);
o += 4 + len;
this.handleNal(nal);
}
if (o > 0) this.buf = new Uint8Array(b.subarray(o));
}
private handleNal(nal: Uint8Array): void {
if (nal.length < 2) return;
const t = nal[0] & 0x1f;
if (t === 7) this.sps = new Uint8Array(nal);
else if (t === 8) this.pps = new Uint8Array(nal);
if (this.sps && this.pps && !this.configured && !this.configuring) {
void this.configure();
}
if (!this.configured || !this.decoder) return;
if (t !== 1 && t !== 5 && t !== 7 && t !== 8) return;
if (t === 7 || t === 8) return;
const key = t === 5;
const data = new Uint8Array(4 + nal.length);
new DataView(data.buffer).setUint32(0, nal.length, false);
data.set(nal, 4);
try {
const chunk = new EncodedVideoChunk({
type: key ? "key" : "delta",
timestamp: this.ts,
data,
});
this.ts += 33_333;
this.decoder.decode(chunk);
} catch (e) {
this.onDecodeError((e as Error).message || String(e));
}
}
private async configure(): Promise<void> {
if (!this.sps || !this.pps || this.configured || this.configuring) return;
this.configuring = true;
try {
const avcc = buildAvcC(this.sps, this.pps);
const codec = codecStringFromSps(this.sps);
const dec = new VideoDecoder({
output: (frame) => {
const ctx = this.canvas.getContext("2d");
if (!ctx) {
frame.close();
return;
}
if (this.canvas.width !== frame.displayWidth || this.canvas.height !== frame.displayHeight) {
this.canvas.width = frame.displayWidth;
this.canvas.height = frame.displayHeight;
}
ctx.drawImage(frame, 0, 0);
frame.close();
},
error: (e) => this.onDecodeError(String((e as DOMException).message || e)),
});
await dec.configure({
codec,
description: avcc,
optimizeForLatency: true,
} as VideoDecoderConfig);
this.decoder = dec;
this.configured = true;
} catch (e) {
this.onDecodeError((e as Error).message || String(e));
} finally {
this.configuring = false;
}
}
}
export function webCodecsH264Supported(): boolean {
return typeof VideoDecoder !== "undefined" && typeof VideoFrame !== "undefined";
}

View File

@@ -0,0 +1,146 @@
/** 对齐 d2yexeapp electron `youtubeConfigUtils.ts` 的浏览器侧解析(无 OAuth) */
export function parseYoutubeStreamKeySuffix(iniOrKeyLine: string): string {
for (const line of iniOrKeyLine.split(/\r?\n/)) {
const t = line.trim();
if (t.startsWith("#") || !t) continue;
const m = t.match(/^(?:key|stream_key)\s*=\s*(.+)$/i);
if (m) {
const v = m[1].trim().split(/[#;]/)[0].trim();
if (v && !v.toLowerCase().startsWith("rtmp")) return v.length > 8 ? v.slice(-8) : v;
if (v.includes("live2/")) {
const parts = v.split("/");
const last = parts[parts.length - 1] || "";
return last.length > 8 ? last.slice(-8) : last;
}
}
}
const one = iniOrKeyLine.trim();
if (one && !one.includes("\n") && !one.toLowerCase().startsWith("rtmp"))
return one.length > 8 ? one.slice(-8) : one;
return "";
}
export function extractDouyinLiveUrls(urlIni: string): string[] {
const out: string[] = [];
for (const line of urlIni.split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue;
const full = t.match(/(https?:\/\/live\.douyin\.com\/[a-zA-Z0-9_-]+)/i);
if (full) {
out.push(full[1].replace(/^http:\/\//i, "https://"));
continue;
}
const rel = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i);
if (rel) {
out.push(`https://live.douyin.com/${rel[1]}`);
continue;
}
const n = t.match(/(\d{8,})/);
if (n) out.push(`https://live.douyin.com/${n[1]}`);
}
return Array.from(new Set(out)).slice(0, 16);
}
/** 从进程日志中解析「当前」抖音房间号(取文本中最后一次出现的 live.douyin.com/xxx) */
export function extractDouyinCurrentRoomFromLog(logText: string): string | null {
if (!logText) return null;
let last: string | null = null;
for (const line of logText.split(/\r?\n/)) {
const m = line.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i);
if (m) last = m[1];
const m2 = line.match(/https?:\/\/[^/\s]*douyin\.com\/(\d{8,})/i);
if (m2) last = m2[1];
}
return last;
}
/** URL_config 文本中提取抖音房间线索(与 d2y electron 一致) */
export function extractDouyinRoomHints(urlIni: string): string[] {
const out: string[] = [];
for (const line of urlIni.split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue;
const m = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i);
if (m) {
out.push(m[1]);
continue;
}
const n = t.match(/(\d{8,})/);
out.push(n ? n[1] : t.slice(0, 96));
}
return Array.from(new Set(out)).slice(0, 12);
}
export type YoutubeIniFields = {
key: string;
rtmps: boolean;
bitrate: string;
fastAudio: boolean;
};
export function parseYoutubeIniFields(content: string): YoutubeIniFields {
let key = "";
let rtmps = true;
let bitrate = "";
let fastAudio = false;
const lines = content.split(/\r?\n/);
let inYoutube = false;
for (const line of lines) {
const t = line.trim();
if (/^\[youtube\]/i.test(t)) {
inYoutube = true;
continue;
}
if (/^\[/.test(t)) {
inYoutube = false;
continue;
}
if (!inYoutube) continue;
if (!t || t.startsWith("#") || t.startsWith(";")) continue;
const eq = t.indexOf("=");
if (eq < 0) continue;
const k = t.slice(0, eq).trim().toLowerCase();
const v = t
.slice(eq + 1)
.trim()
.split(/[#;]/)[0]
.trim();
if (k === "key" || k === "stream_key") key = v;
else if (k === "rtmps") rtmps = !/^(0|false|否)/i.test(v);
else if (k === "bitrate") bitrate = v;
else if (k === "fast_audio") fastAudio = /^(1|true|yes|是|开|on)/i.test(v);
}
return { key, rtmps, bitrate, fastAudio };
}
/** 与 web.py `managed_url_config_relpath` 一致(每进程独立,避免 YouTube / TikTok 共写同一文件) */
export function managedUrlConfigRelPathForPm2(pm2: string): string {
return `URL_config.${pm2.replace(/[^a-zA-Z0-9_.-]/g, "_")}.ini`;
}
/** 与 web.py `managed_youtube_ini_relpath` 一致 */
export function managedYoutubeIniRelPathForPm2(pm2: string): string {
return `youtube.${pm2.replace(/[^a-zA-Z0-9_.-]/g, "_")}.ini`;
}
export function buildYoutubeIniFromFields(f: YoutubeIniFields): string {
const bitrateLine = f.bitrate.trim()
? `bitrate = ${f.bitrate.trim()}`
: "# bitrate = 4000";
return `[youtube]
# YouTube 推流 key(工作室 → 直播 → 串流密钥)
key = ${f.key.trim()}
# 使用 RTMPS;填 否 则 RTMP
rtmps = ${f.rtmps ? "是" : "否"}
# 视频比特率 kbps(可选)
${bitrateLine}
# 轻量音频链,减轻 CPU:1 / 是
fast_audio = ${f.fastAudio ? "1" : "0"}
`;
}

View File

@@ -0,0 +1,116 @@
export type YoutubeProChannel = {
id: string;
name: string;
/** 备忘尾码(旧版) */
streamKeySuffix?: string;
/** 完整串流密钥或 rtmp(s) URL */
streamKey?: string;
/** 本线路 URL_config 草稿(多路本地编排) */
urlLines?: string;
/** 建议 PM2 名,如 youtube2__xxx */
pm2Name?: string;
notes?: string;
};
const STORAGE_KEY = "live-hub-youtube-pro-v2";
export function parseYoutubeProChannelList(raw: unknown): YoutubeProChannel[] {
if (!Array.isArray(raw)) return [];
return raw
.map((x) => migrateRow(x as Record<string, unknown>))
.filter((c): c is YoutubeProChannel => c !== null);
}
function migrateRow(x: Record<string, unknown>): YoutubeProChannel | null {
if (!x || typeof x !== "object") return null;
const id = String(x.id || "").trim();
if (!id) return null;
const name = String(x.name || "").trim() || id;
return {
id,
name,
streamKeySuffix: typeof x.streamKeySuffix === "string" ? x.streamKeySuffix : undefined,
streamKey: typeof x.streamKey === "string" ? x.streamKey : undefined,
urlLines: typeof x.urlLines === "string" ? x.urlLines : undefined,
pm2Name: typeof x.pm2Name === "string" ? x.pm2Name : undefined,
notes: typeof x.notes === "string" ? x.notes : undefined,
};
}
function safeParse(raw: string | null): YoutubeProChannel[] {
if (!raw) return [];
try {
const v = JSON.parse(raw) as unknown;
if (!Array.isArray(v)) return [];
return v.map((x) => migrateRow(x as Record<string, unknown>)).filter((c): c is YoutubeProChannel => c !== null);
} catch {
return [];
}
}
export function loadYoutubeProChannels(): YoutubeProChannel[] {
if (typeof window === "undefined") return [];
let raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
raw = window.localStorage.getItem("live-hub-youtube-pro-v1");
if (raw) {
const legacy = safeParse(raw);
if (legacy.length) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(legacy));
return legacy;
}
}
}
return safeParse(raw);
}
export function saveYoutubeProChannels(channels: YoutubeProChannel[]): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(channels));
}
const YOUTUBE_PRO_SYNC_PATHS = ["/hub/youtube_pro_channels", "/youtube_pro_channels"] as const;
/** 从服务器拉多频道列表(依次尝试 /hub 与根路径,兼容反代未转发 /hub 的情况) */
export async function fetchYoutubeProChannelsRemote(
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>,
): Promise<YoutubeProChannel[]> {
for (const path of YOUTUBE_PRO_SYNC_PATHS) {
try {
const data = await fetchJson<{ channels?: unknown }>(path);
const fromServer = parseYoutubeProChannelList(data.channels);
if (fromServer.length) return fromServer;
} catch {
/* try next */
}
}
return [];
}
export async function pushYoutubeProChannelsRemote(
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>,
channels: YoutubeProChannel[],
): Promise<void> {
let last: Error | null = null;
for (const path of YOUTUBE_PRO_SYNC_PATHS) {
try {
await fetchJson(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channels }),
});
return;
} catch (e) {
last = e instanceof Error ? e : new Error(String(e));
}
}
throw last ?? new Error("youtube_pro_channels sync failed");
}
export function newChannelId(): string {
return `ch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
}
export function defaultPm2NameForChannel(channelId: string): string {
return `youtube2__${channelId}`;
}