's'
This commit is contained in:
47
d2ypp2/web-console/lib/androidScrcpyWs.ts
Normal file
47
d2ypp2/web-console/lib/androidScrcpyWs.ts
Normal 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()}`;
|
||||
}
|
||||
73
d2ypp2/web-console/lib/fetchJson.ts
Normal file
73
d2ypp2/web-console/lib/fetchJson.ts
Normal 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);
|
||||
}
|
||||
31
d2ypp2/web-console/lib/panelUrls.ts
Normal file
31
d2ypp2/web-console/lib/panelUrls.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
169
d2ypp2/web-console/lib/scrcpyH264Decoder.ts
Normal file
169
d2ypp2/web-console/lib/scrcpyH264Decoder.ts
Normal 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";
|
||||
}
|
||||
146
d2ypp2/web-console/lib/youtubeConfigUtils.ts
Normal file
146
d2ypp2/web-console/lib/youtubeConfigUtils.ts
Normal 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"}
|
||||
`;
|
||||
}
|
||||
116
d2ypp2/web-console/lib/youtubeProChannels.ts
Normal file
116
d2ypp2/web-console/lib/youtubeProChannels.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user