This commit is contained in:
eric
2026-03-25 12:29:03 -05:00
parent fce2c49b62
commit cc2f30b6e2
180 changed files with 20523 additions and 0 deletions

31
lib/api.ts Normal file
View File

@@ -0,0 +1,31 @@
import type { BindPayload } from './types'
function joinUrl(base: string, path: string): string {
const b = base.replace(/\/$/, '')
const p = path.startsWith('/') ? path : `/${path}`
return `${b}${p}`
}
export async function apiFetch(
bind: BindPayload | null,
path: string,
init?: RequestInit,
): Promise<Response> {
if (!bind) {
return Promise.reject(new Error('尚未绑定 PC请先在「配对」扫码'))
}
const headers = new Headers(init?.headers)
if (bind.token?.trim()) {
headers.set('Authorization', `Bearer ${bind.token.trim()}`)
}
return fetch(joinUrl(bind.baseUrl, path), { ...init, headers })
}
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(bind, path, init)
if (!res.ok) {
const t = await res.text()
throw new Error(t || `HTTP ${res.status}`)
}
return res.json() as Promise<T>
}

36
lib/easyTierVpn.ts Normal file
View File

@@ -0,0 +1,36 @@
import { requireOptionalNativeModule } from 'expo-modules-core'
export type EasyTierConfigInput = {
networkName: string
networkSecret: string
peerUrls: string[]
}
type NativeModule = {
isAvailable: () => boolean
saveAndPrepareTunnel: (configJson: string) => Promise<Record<string, unknown>>
checkVpnPermissionGranted: () => Promise<Record<string, unknown>>
}
const Native = requireOptionalNativeModule<NativeModule>('ExpoEasyTierVpn')
export function isExpoEasyTierVpnAvailable(): boolean {
try {
return Native?.isAvailable?.() === true
} catch {
return false
}
}
export async function saveAndPrepareTunnel(config: EasyTierConfigInput): Promise<Record<string, unknown>> {
if (!Native?.saveAndPrepareTunnel) {
return { ok: false, reason: 'native_module_missing' }
}
return Native.saveAndPrepareTunnel(JSON.stringify(config))
}
export async function checkVpnPermissionGranted(): Promise<boolean> {
if (!Native?.checkVpnPermissionGranted) return false
const r = await Native.checkVpnPermissionGranted()
return r?.granted === true
}

88
lib/pcTypes.ts Normal file
View File

@@ -0,0 +1,88 @@
/** 与 Electron NetworkDashboard / SystemMetrics 对齐的数据结构 */
export type SiteInfo = {
host: string
reachable: boolean
latency_ms: number
error: string | null
resolved_ip: string | null
country: string | null
country_code: string | null
geo_error: string | null
}
export type SpeedBlock = {
label: string
download_mbps: number | null
download_error: string | null
download_bytes_sampled: number
upload_mbps: number | null
upload_error: string | null
upload_target: string | null
}
export type NetworkPayload = {
google: SiteInfo
douyin: SiteInfo
routes: { different: boolean | null; note: string }
speed: { overseas: SpeedBlock; domestic: SpeedBlock }
meta?: { at: number; download_cap_bytes: number; upload_bytes: number }
error?: string
}
export type SystemMetricsPayload = {
ts?: number
cpu_percent?: number | null
cpu_count_logical?: number
memory?: { percent?: number; used?: number; total?: number; available?: number }
swap?: { percent?: number; used?: number; total?: number }
disks?: Array<{
mountpoint?: string
percent?: number
free?: number
total?: number
}>
network?: { up_mbps?: number | null; down_mbps?: number | null }
ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }>
error?: string
}
export type ClientOverview = {
youtubeChannelCount?: number
youtubeChannels?: { name: string; keyPreview: string; douyinPreview?: string }[]
relayPairingPolicy?: string
relayValidationErrors?: string[]
relayNotes?: string[]
wechat?: {
configured?: boolean
nickName?: string
wcId?: string
online?: boolean
proxyError?: string | null
}
}
export type RelayLivePayload = {
youtubeProcess?: string
processStatus?: string
processOnline?: boolean
relayRows?: Array<{
channelId?: string | null
channelName?: string
anchor?: string
douyinHint?: string
streamTitle?: string
youtubeKeySuffix?: string
durationSec?: number | null
processOnline?: boolean
}>
}
export type YoutubeIniModel = {
keys: string[]
activeIndex: number
options: Record<string, string>
header: string
}
export type GpuCaps = { nvidia_detected: boolean; nvenc_available: boolean }

17
lib/scriptUtils.ts Normal file
View File

@@ -0,0 +1,17 @@
export function pm2NameFromScript(script: string): string {
const base = String(script).split(/[/\\]/).pop() || ''
const i = base.lastIndexOf('.')
return i > 0 ? base.slice(0, i) : base
}
export function isYoutubeScript(script: string): boolean {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube')
}
export function isTiktokScript(script: string): boolean {
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok')
}
export function isObsScript(script: string): boolean {
return pm2NameFromScript(script).startsWith('obs')
}

39
lib/storage.ts Normal file
View File

@@ -0,0 +1,39 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { BindPayload } from './types'
const KEY = 'bind_payload_v1'
export async function loadBindPayload(): Promise<BindPayload | null> {
try {
const raw = await AsyncStorage.getItem(KEY)
if (!raw) return null
const j = JSON.parse(raw) as BindPayload
if (j.v !== 1 || !j.baseUrl) return null
return j
} catch {
return null
}
}
export async function saveBindPayload(p: BindPayload): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(p))
}
export async function clearBindPayload(): Promise<void> {
await AsyncStorage.removeItem(KEY)
}
export const STORAGE_MULTI_CHANNEL_PRO = 'recording_multi_channel_pro'
export async function getMultiChannelPro(): Promise<boolean> {
try {
const v = await AsyncStorage.getItem(STORAGE_MULTI_CHANNEL_PRO)
return v === '1'
} catch {
return false
}
}
export async function setMultiChannelPro(on: boolean): Promise<void> {
await AsyncStorage.setItem(STORAGE_MULTI_CHANNEL_PRO, on ? '1' : '0')
}

54
lib/types.ts Normal file
View File

@@ -0,0 +1,54 @@
export type EasyTierConfig = {
networkName: string
networkSecret: string
peerUrls: string[]
}
export const DEFAULT_EASYTIER_PEER_URLS = ['tcp://public.easytier.top:11010']
function normalizePeerUrls(value: unknown): string[] {
const seen = new Set<string>()
const next = Array.isArray(value) ? value.map(String) : []
const normalized = next
.map((item) => item.trim())
.filter((item) => {
if (!item || seen.has(item)) return false
seen.add(item)
return true
})
return normalized.length > 0 ? normalized : [...DEFAULT_EASYTIER_PEER_URLS]
}
/** 与 Electron `remote:get-bind-payload` 输出一致,供扫码导入 */
export type BindPayload = {
v: 1
baseUrl: string
port: number
token: string
easytier: EasyTierConfig
}
export function parseBindPayload(raw: string): BindPayload | null {
try {
const j = JSON.parse(raw) as Record<string, unknown>
if (j.v !== 1) return null
const et = j.easytier as Record<string, unknown> | undefined
if (!et || typeof j.baseUrl !== 'string' || typeof j.token !== 'string') return null
const networkName = String(et.networkName ?? '').trim()
const networkSecret = String(et.networkSecret ?? '').trim()
if (!networkName || !networkSecret) return null
return {
v: 1,
baseUrl: String(j.baseUrl).replace(/\/$/, ''),
port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001,
token: String(j.token),
easytier: {
networkName,
networkSecret,
peerUrls: normalizePeerUrls(et.peerUrls),
},
}
} catch {
return null
}
}

71
lib/youtubeConfigUtils.ts Normal file
View File

@@ -0,0 +1,71 @@
/** 与 electron youtubeConfigUtils 对齐(远程控制仅用于展示/解析) */
export function parseYoutubeStreamKeySuffix(iniContent: string): string {
for (const line of iniContent.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.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
}
}
}
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 [...new Set(out)].slice(0, 16)
}
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 [...new Set(out)].slice(0, 12)
}
export function douyinRoomAndHref(douyinUrl: string | undefined, urlIni: string): { room: string; href: string } {
const combined = `${urlIni}\n${douyinUrl ?? ''}`
const urls = extractDouyinLiveUrls(combined)
const hints = extractDouyinRoomHints(combined)
const room = hints[0] || '—'
const href = urls[0] || (hints[0] ? `https://live.douyin.com/${hints[0]}` : '')
return { room, href }
}
export function proChannelPm2Process(channelId: string): string {
return `youtube2__${channelId}`
}