From aace210b182e59d620a8d926384205b6559867e2 Mon Sep 17 00:00:00 2001 From: zhushenwudi <55681140@163.com> Date: Sat, 3 Oct 2026 23:06:55 +0800 Subject: [PATCH] optimize transfer logic --- src/main/index.ts | 2 +- src/main/ipc/index.ts | 2 +- src/main/services/musicServer.ts | 143 ++++++++++++++++++++++----- src/main/services/transcode.ts | 56 ++++++++--- src/renderer/src/pages/Transfer.tsx | 146 +++++++++++++++++++++------- 5 files changed, 278 insertions(+), 71 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index ff59094..67e64c1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -99,7 +99,7 @@ if (!gotLock) { unregisterMediaShortcuts() destroyTray() stopFileServer() - stopMusicServer() + stopMusicServer('app-quit') stopDataServer() }) } diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 8962aba..c058084 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -107,7 +107,7 @@ export function registerIpc(): void { return true }) ipcMain.handle(IPC.MUSIC_SERVER_STOP, () => { - stopMusicServer() + stopMusicServer('renderer-stop') return true }) ipcMain.handle(IPC.MUSIC_SERVER_SEND, (_e, cmd: string, body: string) => diff --git a/src/main/services/musicServer.ts b/src/main/services/musicServer.ts index 2890a26..6b07bf2 100644 --- a/src/main/services/musicServer.ts +++ b/src/main/services/musicServer.ts @@ -20,12 +20,23 @@ import { cleanIosWavFile } from './wifiTransfer' export type MusicServerEvent = | { type: 'connected' } - | { type: 'disconnected' } + | { type: 'disconnected'; code?: number; reason?: string; wasClean?: boolean } | { type: 'versionMismatch'; remoteVer: string } | { type: 'system'; system: string } | { type: 'portUnavailable'; reason: string } | { type: 'cmd'; cmd: string; body: string } +function brief(body: string, max = 160): string { + const flat = body.replace(/\s+/g, ' ') + if (flat.length <= max) return flat + return `${flat.slice(0, max)}…(len=${body.length})` +} + +function logTransfer(message: string, extra?: unknown): void { + if (extra === undefined) console.log(`[transfer] ${message}`) + else console.log(`[transfer] ${message}`, extra) +} + /** 握手时确保本地 HTTP 文件服务可用(手机靠此端口拉歌) */ async function ensureFileServerPort(): Promise<{ port: number; error?: string }> { const status = getFileServerStatus() @@ -52,48 +63,93 @@ let wss: WebSocketServer | null = null let socket: WebSocket | null = null let phoneSystem = '' let lastReadyList: { musicUId: string; baseUrl: string; musicPath: string }[] = [] +/** 最近一次业务消息(不含 ping),用于判断 1006 是否因空闲被掐 */ +let lastActivity = Date.now() export function startMusicServer(onEvent: (e: MusicServerEvent) => void): void { - stopMusicServer() + stopMusicServer('restart') // monkey 并行实例避开开发中的 4388 const port = process.env.LLMP_MONKEY === '1' ? PORTS.MUSIC_WS + 100 : PORTS.MUSIC_WS wss = new WebSocketServer({ port, host: '0.0.0.0' }) - wss.on('error', (err) => console.error('[musicServer]', err.message)) + logTransfer(`listen 0.0.0.0:${port}`) + wss.on('error', (err) => console.error('[transfer] server error', err)) - wss.on('connection', (ws) => { + wss.on('connection', (ws, req) => { + const peer = req.socket.remoteAddress || '?' + if (socket && socket !== ws && socket.readyState === WebSocket.OPEN) { + logTransfer(`new connection from ${peer}, closing previous socket`) + try { + socket.close(4000, 'replaced') + } catch (e) { + console.error('[transfer] close previous socket failed', e) + } + } socket = ws let verified = false phoneSystem = '' lastReadyList = [] + lastActivity = Date.now() + logTransfer(`connected ${peer}`) onEvent({ type: 'connected' }) + // 传歌时指令通道会空等 HTTP 下载,路由器/手机会按空闲掐掉 TCP(关闭码 1006) + const heartbeat = setInterval(() => { + if (ws.readyState !== WebSocket.OPEN) return + try { + ws.ping() + } catch (e) { + console.error('[transfer] ping failed', peer, e) + } + }, 10000) + ws.on('pong', () => { + logTransfer(`pong ${peer} idle=${Date.now() - lastActivity}ms`) + }) + ws.on('message', (raw) => { + lastActivity = Date.now() + const text = raw.toString() let command try { - command = decodeFtpCmd(raw.toString()) - } catch { + command = decodeFtpCmd(text) + } catch (e) { + console.error('[transfer] bad message', brief(text), e) return } + logTransfer(`← ${command.cmd} ${brief(command.body)}`) switch (command.cmd) { case MusicCmd.VERSION: { const compatible = isVersionCompatible(command.body) verified = true - ws.send(encodeFtpCmd(MusicCmd.VERSION, TRANS_VER)) + try { + ws.send(encodeFtpCmd(MusicCmd.VERSION, TRANS_VER)) + logTransfer(`→ ${MusicCmd.VERSION} ${TRANS_VER} compatible=${compatible}`) + } catch (e) { + console.error('[transfer] send version failed', e) + } if (!compatible) onEvent({ type: 'versionMismatch', remoteVer: command.body }) break } case MusicCmd.SYSTEM: { if (!verified) { + console.error('[transfer] system before version handshake') onEvent({ type: 'versionMismatch', remoteVer: '?' }) return } phoneSystem = (command.body || '').toLowerCase() onEvent({ type: 'system', system: command.body }) // 下发 HTTP 文件服务端口(未运行则尝试按配置自动启动) - void ensureFileServerPort().then(({ port, error }) => { - if (ws.readyState !== WebSocket.OPEN) return - ws.send(encodeFtpCmd(MusicCmd.PORT, port)) - if (error || port <= 0) { + void ensureFileServerPort().then(({ port: filePort, error }) => { + if (ws.readyState !== WebSocket.OPEN) { + console.error('[transfer] skip port reply, socket not open', ws.readyState) + return + } + try { + ws.send(encodeFtpCmd(MusicCmd.PORT, filePort)) + logTransfer(`→ ${MusicCmd.PORT} ${filePort}${error ? ` error=${error}` : ''}`) + } catch (e) { + console.error('[transfer] send port failed', e) + } + if (error || filePort <= 0) { onEvent({ type: 'portUnavailable', reason: error || '本地文件服务端口无效' @@ -105,48 +161,89 @@ export function startMusicServer(onEvent: (e: MusicServerEvent) => void): void { default: if (command.cmd === MusicCmd.DOWNLOAD_SUCCESS && phoneSystem === 'ios') { const hit = lastReadyList.find((m) => m.musicUId === command.body) - if (hit) cleanIosWavFile(hit) + if (hit) { + try { + cleanIosWavFile(hit) + } catch (e) { + console.error('[transfer] clean ios wav failed', command.body, e) + } + } } onEvent({ type: 'cmd', cmd: command.cmd, body: command.body }) } }) - ws.on('close', () => { + ws.on('error', (err) => { + console.error('[transfer] socket error', peer, err) + }) + + ws.on('close', (code, reason) => { + clearInterval(heartbeat) + const why = reason.toString() || '(empty)' + const current = socket === ws + const idleMs = Date.now() - lastActivity + logTransfer( + `close code=${code} reason=${why} current=${current} peer=${peer} idleMs=${idleMs}` + ) + if (!current) return socket = null phoneSystem = '' lastReadyList = [] - onEvent({ type: 'disconnected' }) + onEvent({ + type: 'disconnected', + code, + reason: why, + wasClean: code === 1000 || code === 1001 + }) }) }) } export function sendMusicCmd(cmd: string, body: string): boolean { - if (!socket || socket.readyState !== WebSocket.OPEN) return false + if (!socket || socket.readyState !== WebSocket.OPEN) { + console.error( + `[transfer] send dropped, socket not open cmd=${cmd} state=${socket?.readyState ?? 'null'} body=${brief(body)}` + ) + return false + } if (cmd === MusicCmd.READY) { try { const list = JSON.parse(body) as { musicUId: string; baseUrl: string; musicPath: string }[] lastReadyList = Array.isArray(list) ? list : [] - } catch { + } catch (e) { + console.error('[transfer] ready body is not a list', e) lastReadyList = [] } } - socket.send(encodeFtpCmd(cmd, body)) - return true + try { + socket.send(encodeFtpCmd(cmd, body)) + lastActivity = Date.now() + logTransfer(`→ ${cmd} ${brief(body)}`) + return true + } catch (e) { + console.error(`[transfer] send failed cmd=${cmd}`, e) + return false + } } -export function stopMusicServer(): void { +export function stopMusicServer(reason = 'stop'): void { + logTransfer(`stop server reason=${reason} hasSocket=${!!socket} hasServer=${!!wss}`) if (socket) { try { - socket.close() - } catch { - /* ignore */ + socket.close(1001, reason) + } catch (e) { + console.error('[transfer] socket.close failed', e) } socket = null } if (wss) { - wss.close() + try { + wss.close() + } catch (e) { + console.error('[transfer] server.close failed', e) + } wss = null } phoneSystem = '' diff --git a/src/main/services/transcode.ts b/src/main/services/transcode.ts index a84cf4a..27e3785 100644 --- a/src/main/services/transcode.ts +++ b/src/main/services/transcode.ts @@ -1,4 +1,5 @@ import { spawn, ChildProcess } from 'child_process' +import { existsSync, unlinkSync } from 'fs' import ffmpegPath from 'ffmpeg-static' /** @@ -7,6 +8,8 @@ import ffmpegPath from 'ffmpeg-static' */ let current: ChildProcess | null = null +/** 终止当前这次转码(杀进程并删掉未完成的 wav) */ +let cancelCurrent: (() => void) | null = null export interface ConvertOptions { input: string @@ -14,6 +17,14 @@ export interface ConvertOptions { onProgress?: (percent: number) => void } +function removePartial(output: string): void { + try { + if (existsSync(output)) unlinkSync(output) + } catch { + /* ignore */ + } +} + export function convertFlacToWav(opts: ConvertOptions): Promise { return new Promise((resolve, reject) => { if (!ffmpegPath) { @@ -21,9 +32,29 @@ export function convertFlacToWav(opts: ConvertOptions): Promise { return } stopConvert() - current = spawn(ffmpegPath, ['-y', '-i', opts.input, opts.output]) + let aborted = false + let settled = false + const child = spawn(ffmpegPath, ['-y', '-i', opts.input, opts.output]) + current = child + const settle = (err?: Error): void => { + if (settled) return + settled = true + if (current === child) current = null + if (cancelCurrent === cancel) cancelCurrent = null + if (err) reject(err) + else resolve() + } + const cancel = (): void => { + aborted = true + try { + child.kill('SIGKILL') + } catch { + /* already exited */ + } + } + cancelCurrent = cancel - current.stderr?.on('data', (chunk: Buffer) => { + child.stderr?.on('data', (chunk: Buffer) => { const text = chunk.toString() // 粗略解析进度(ffmpeg 输出 time=xx:xx:xx) const match = text.match(/time=(\d+):(\d+):(\d+)/) @@ -33,18 +64,21 @@ export function convertFlacToWav(opts: ConvertOptions): Promise { } }) - current.on('error', reject) - current.on('close', (code) => { - current = null - if (code === 0) resolve() - else reject(new Error(`ffmpeg 退出码 ${code}`)) + child.on('error', (err) => { + if (aborted) removePartial(opts.output) + settle(aborted ? new Error('转码已取消') : err) + }) + child.on('close', (code) => { + if (code === 0 && !aborted) { + settle() + return + } + removePartial(opts.output) + settle(new Error(aborted ? '转码已取消' : `ffmpeg 退出码 ${code}`)) }) }) } export function stopConvert(): void { - if (current) { - current.kill('SIGKILL') - current = null - } + cancelCurrent?.() } diff --git a/src/renderer/src/pages/Transfer.tsx b/src/renderer/src/pages/Transfer.tsx index 8a7606a..1bccd24 100644 --- a/src/renderer/src/pages/Transfer.tsx +++ b/src/renderer/src/pages/Transfer.tsx @@ -87,25 +87,8 @@ function parseMissingMusicIds(body: string): string[] { } } -/** 拼本地文件绝对路径(兼容 Windows,供已有 convert.start 使用) */ -function mediaAbsPath(root: string, baseUrl: string, musicPath: string): string { - const parts = [root, baseUrl, musicPath] - .map((p) => p.replace(/[/\\]+/g, '/').replace(/^\/+|\/+$/g, '')) - .filter(Boolean) - const joined = parts.join('/') - return root.includes('\\') ? joined.replace(/\//g, '\\') : joined -} - -/** iOS:用已有 convert API 在 flac 旁生成 wav(不依赖 preload 新增命名空间) */ -async function ensureIosWavWithConvert(list: DownloadMusic[]): Promise { - const http = await window.api.http.status() - if (!http?.root) throw new Error('本地曲库根目录未知,请先启动本地文件服务') - for (const m of list) { - if (!/\.flac$/i.test(m.musicPath)) continue - const src = mediaAbsPath(http.root, m.baseUrl, m.musicPath) - const dest = src.replace(/\.flac$/i, '.wav') - await window.api.convert.start(src, dest) - } +function isFlac(musicPath: string): boolean { + return /\.flac$/i.test(musicPath) } export default function Transfer(): JSX.Element { @@ -116,6 +99,8 @@ export default function Transfer(): JSX.Element { const setIpIndex = useLanStore((s) => s.setIpIndex) const loadLan = useLanStore((s) => s.load) const [connected, setConnected] = useState(false) + /** 本轮传输是否已结束(socket 可能仍开着) */ + const [transferDone, setTransferDone] = useState(false) const [phoneSystem, setPhoneSystem] = useState('') const [selected, setSelected] = useState>(new Set()) const [overwrite, setOverwrite] = useState(false) @@ -130,6 +115,21 @@ export default function Transfer(): JSX.Element { const finalListRef = useRef([]) const overwriteRef = useRef(false) const phoneSystemRef = useRef('') + /** 断开 / 离开 / 手机停止时递增,进行中的转码与下发看到代际变化后退出 */ + const transferGenRef = useRef(0) + /** 是否仍有传歌任务(含手机还在拉文件) */ + const transferringRef = useRef(false) + const abortTransferRef = useRef<(notifyPhone: boolean) => void>(() => {}) + abortTransferRef.current = (notifyPhone: boolean) => { + const active = transferringRef.current + console.warn( + `[transfer] abort ${JSON.stringify({ notifyPhone, active, gen: transferGenRef.current })}` + ) + transferGenRef.current += 1 + transferringRef.current = false + void window.api.convert.stop() + if (notifyPhone && active) void window.api.musicServer.send(MusicCmd.STOP, '') + } const albumById = useMemo(() => { const map = new Map() for (const a of albums) map.set(a.albumUId, a) @@ -203,13 +203,24 @@ export default function Transfer(): JSX.Element { cmd?: string body?: string reason?: string + code?: number + wasClean?: boolean } switch (ev.type) { case 'connected': setConnected(true) + setTransferDone(false) setConnStatus('设备已连接,正在握手…') break case 'disconnected': + console.warn( + `[transfer] disconnected ${JSON.stringify({ + code: ev.code, + reason: ev.reason, + wasClean: ev.wasClean + })}` + ) + abortTransferRef.current(false) setConnected(false) setPhoneSystem('') setConnStatus('等待手机扫码连接…') @@ -224,6 +235,7 @@ export default function Transfer(): JSX.Element { break case 'system': setPhoneSystem(ev.system || '') + setTransferDone(false) setConnStatus(`已连接 ${ev.system} 设备,可开始传输`) break case 'portUnavailable': @@ -238,8 +250,9 @@ export default function Transfer(): JSX.Element { return () => { disposed = true + abortTransferRef.current(true) off() - window.api.musicServer.stop() + void window.api.musicServer.stop() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -248,14 +261,61 @@ export default function Transfer(): JSX.Element { window.api.store.set('transMusic', [...ids]) } - const sendDownloadCmds = (list: DownloadMusic[]): void => { - list.forEach((m, i) => { - const isLast = i === list.length - 1 - window.api.musicServer.send( - MusicCmd.DOWNLOAD, - `${m.musicUId}${DOWNLOAD_BODY_SEPARATOR}${isLast}` + const sendOneDownload = (m: DownloadMusic, isLast: boolean): Promise => + window.api.musicServer.send( + MusicCmd.DOWNLOAD, + `${m.musicUId}${DOWNLOAD_BODY_SEPARATOR}${isLast}` + ) + + /** + * iOS:转码队列与传输队列分开。 + * 非 flac 立刻进入传输;每首 flac 转完(或已有 wav)就 push 一条 DOWNLOAD, + * 手机开始拉这一首时,下一首继续转。isLast 标在最后一条下发上。 + */ + const pipelineIosTransfer = async (list: DownloadMusic[], gen: number): Promise => { + const okReady = await window.api.musicServer.send(MusicCmd.READY, JSON.stringify(list)) + if (gen !== transferGenRef.current) return + if (!okReady) { + setJobNotice('发送就绪指令失败,请重新连接手机') + setProgressMode('idle') + return + } + + const flacs = list.filter((m) => isFlac(m.musicPath)) + const readyNow = list.filter((m) => !isFlac(m.musicPath)) + let sent = 0 + const push = async (m: DownloadMusic): Promise => { + sent += 1 + return sendOneDownload(m, sent === list.length) + } + + for (const m of readyNow) { + if (gen !== transferGenRef.current) return + if (!(await push(m))) { + setJobNotice('发送下载指令失败,请重新连接手机') + setProgressMode('idle') + return + } + } + + for (let i = 0; i < flacs.length; i++) { + if (gen !== transferGenRef.current) return + const m = flacs[i] + setJobNotice( + sent > 0 + ? `iOS:转换 ${i + 1}/${flacs.length},已开始传输 ${sent} 首…` + : `iOS:转换 ${i + 1}/${flacs.length}…` ) - }) + await window.api.wifi.ensureIosWav([{ baseUrl: m.baseUrl, musicPath: m.musicPath }]) + if (gen !== transferGenRef.current) return + if (!(await push(m))) { + setJobNotice('发送下载指令失败,请重新连接手机') + setProgressMode('idle') + return + } + } + + if (gen === transferGenRef.current) setJobNotice('正在传输…') } const handlePhoneCmd = async (cmd: string, body: string): Promise => { @@ -283,18 +343,21 @@ export default function Transfer(): JSX.Element { return } finalListRef.current = list + transferringRef.current = true setProgressMode('wifi') setProgress({ current: 0, total: list.length }) - // iOS 需先把 flac 转成 wav,手机按 .wav URL 下载(用已有 convert.start,避免 preload 未热更新) if (phoneSystemRef.current.toLowerCase() === 'ios') { - setJobNotice('iOS:正在转换 flac→wav…') + const gen = transferGenRef.current try { - await ensureIosWavWithConvert(list) + await pipelineIosTransfer(list, gen) } catch (e) { + console.error('[transfer] ios pipeline failed', e) + if (gen !== transferGenRef.current) return + window.api.musicServer.send(MusicCmd.STOP, '') setJobNotice(`iOS 转码失败:${(e as Error).message || '未知错误'}`) setProgressMode('idle') - return } + break } const okReady = await window.api.musicServer.send(MusicCmd.READY, JSON.stringify(list)) @@ -303,8 +366,9 @@ export default function Transfer(): JSX.Element { setProgressMode('idle') return } - - sendDownloadCmds(list) + for (let i = 0; i < list.length; i++) { + void sendOneDownload(list[i], i === list.length - 1) + } setJobNotice('正在传输…') break } @@ -316,8 +380,16 @@ export default function Transfer(): JSX.Element { setProgress((p) => ({ ...p, current: Math.min(p.current + 1, p.total) })) break case MusicCmd.FINISH: - case MusicCmd.STOP: + abortTransferRef.current(false) + setTransferDone(true) setJobNotice('传输完成') + setConnStatus('传输完成,连接仍保持,可再次传输') + setProgressMode('idle') + setProgress({ current: 0, total: 0 }) + break + case MusicCmd.STOP: + abortTransferRef.current(false) + setJobNotice('传输已停止') setProgressMode('idle') setProgress({ current: 0, total: 0 }) break @@ -353,7 +425,9 @@ export default function Transfer(): JSX.Element { setProgress({ current: 0, total: 0 }) return } + setTransferDone(false) setJobNotice('已发送歌单,等待手机确认…') + setConnStatus('正在传输…') } const exportSongs = async (platform: 'android' | 'ios'): Promise => { @@ -478,7 +552,9 @@ export default function Transfer(): JSX.Element { {connected ? : }
-

{connected ? '设备已连接' : '扫码连接手机'}

+

+ {connected ? (transferDone ? '传输完成' : '设备已连接') : '扫码连接手机'} +

{connStatus}