diff --git a/zefie_wtvp_minisrv/includes/classes/WTVPNM.js b/zefie_wtvp_minisrv/includes/classes/WTVPNM.js index 6c945a92..9ca73121 100644 --- a/zefie_wtvp_minisrv/includes/classes/WTVPNM.js +++ b/zefie_wtvp_minisrv/includes/classes/WTVPNM.js @@ -2,6 +2,7 @@ const net = require('net'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const dgram = require('dgram'); const { WTVShared } = require('./WTVShared.js'); class WTVPNM { @@ -67,12 +68,6 @@ class WTVPNM { mediaPath: null, notFoundSent: false, pnaFields: null, - tcpTimer: null, - tcpSeq: 0, - ipId: 0, - tcpStartTimer: null, - mediaData: null, - mediaOffset: 0, bytesRx: 0, bytesTx: 0, // Control-stream command accumulator. TCP is byte-oriented so @@ -179,9 +174,12 @@ class WTVPNM { } if (session.requestedMedia && !session.mediaPath) { + console.log(' * PNM RealServer Warning: requested media not found', session.requestedMedia); this.sendNotFound(socket, session.requestedMedia); session.notFoundSent = true; return; + } else { + console.log(' * PNM RealServer Request from', session.id, 'for media', session.mediaPath); } } @@ -210,7 +208,7 @@ class WTVPNM { } else { this.debugLog('client hash MISMATCH', session.id, 'expected', expectedResp); } - if (session.clientUdpPort && this.service_config.auto_stream !== false) { + if (session.clientUdpPort) { this.startUdpStream(socket, session); } } else { @@ -501,24 +499,6 @@ class WTVPNM { } } - scheduleDescriptorSequence(socket, session) { - this.clearDescriptorTimer(session); - let delayMs = null; - let reason = 'webtv-sequence'; - - if (this.service_config.descriptor_on_first_pna === true) { - delayMs = (typeof this.service_config.descriptor_after_hello_ms === 'number') - ? this.service_config.descriptor_after_hello_ms - : (this.service_config.descriptor_fallback_ms || 20); - } - - if (delayMs === null) return; - this.debugLog('descriptor scheduled', session.id, reason, `${delayMs}ms`); - session.descriptorTimer = setTimeout(() => { - this.sendDescriptorAndStartStream(socket, session, reason); - }, delayMs); - } - sendDescriptorAndStartStream(socket, session, reason) { if (!socket || !session || session.descriptorSent) return; this.clearDescriptorTimer(session); @@ -631,14 +611,22 @@ class WTVPNM { const startDelayMs = 72; const redundantSeqs = [0, 1]; + // Pre-start burst: send the first N ms of audio at double rate to + // pre-fill the client buffer before settling into normal pacing. + const burstPrestartMs = typeof this.service_config.burst_prestart_ms === 'number' + ? this.service_config.burst_prestart_ms + : 3000; + const burstFrameCount = burstPrestartMs > 0 ? Math.ceil(burstPrestartMs / intervalMs) : 0; + session.burstFramesSent = 0; + this.debugLog('udp stream start', session.id, `frames=${session.mediaFrames?.length || 0}`, `avgBitRate=${session.avgBitRate || 'unknown'}bps`, `bodyLen=${bodyLen}`, `interval=${intervalMs.toFixed(2)}ms`, + `burstFrames=${burstFrameCount}`, `target=${socket.remoteAddress}:${session.clientUdpPort}`); - const dgram = require('dgram'); session.udpSocket = dgram.createSocket('udp4'); session.udpSocket.on('error', (err) => { this.debugLog('udp socket error', session.id, err.message); @@ -678,20 +666,18 @@ class WTVPNM { session._startDataInterval = () => { if (session.udpTimer) return; - session.udpTimer = setInterval(() => { + const tick = () => { + session.udpTimer = null; if (socket.destroyed || !session.udpSocket) { this.stopUdpStream(session); return; } + // Don't re-arm while paused; resumeUdpStream calls _startDataInterval. if (session.paused) return; const frames = session.mediaFrames; if (!frames || session.mediaFrameIdx >= frames.length) { // End of media: stop sending once all RA frames are out. this.debugLog('udp stream complete', session.id, `sent=${seq}`); - if (session.udpTimer) { - clearInterval(session.udpTimer); - session.udpTimer = null; - } // Signal end-of-stream to the client on TCP. wtv2.pcap // shows the native RealServer sending a single 0x45 byte // ~0.5s after the last UDP packet; the client then FINs. @@ -716,7 +702,14 @@ class WTVPNM { sendPacket(seq, frame); seq++; session.mediaFrameIdx++; - }, intervalMs); + session.burstFramesSent++; + // Use half the interval during the pre-start burst window, then + // drop to normal pacing once burstFrameCount frames have been sent. + const delay = session.burstFramesSent < burstFrameCount ? intervalMs / 2 : intervalMs; + session.udpTimer = setTimeout(tick, delay); + }; + const initialDelay = session.burstFramesSent < burstFrameCount ? intervalMs / 2 : intervalMs; + session.udpTimer = setTimeout(tick, initialDelay); }; session.udpStartTimer = setTimeout(() => { @@ -760,18 +753,12 @@ class WTVPNM { return out; } - buildTunnelFrame(session) { - // Send raw PNA media data directly on TCP (no PPP/IP/UDP wrapping). - return this.buildMediaPayload(session); - } - buildMediaPayload(session, pSeq, pFrame) { const seq = pSeq !== undefined ? pSeq : (session ? session.udpSeq || 0 : 0); if (session && pSeq === undefined) session.udpSeq = seq + 1; // Pick the frame: caller can pass one explicitly (interval / burst / - // seek path) or, for the legacy tunnel path, we fall back to indexing - // by seq against mediaFrames as before. + // seek path) or fall back to indexing by seq against mediaFrames. let frame = pFrame; if (frame === undefined) { frame = session?.mediaFrames?.[seq]; @@ -826,83 +813,6 @@ class WTVPNM { return out; } - buildIPv4UdpPacket(session, udpPayload) { - const srcIp = this.parseIPv4(this.service_config.stream_src_ip || '10.0.0.2'); - const dstIp = this.parseIPv4(this.service_config.stream_dst_ip || '10.0.0.3'); - const srcPort = this.service_config.stream_udp_src_port || 0xb385; - const dstPort = this.service_config.stream_udp_dst_port || 6970; - - const ipHeader = Buffer.alloc(20); - const udpHeader = Buffer.alloc(8); - const totalLen = 20 + 8 + udpPayload.length; - const ipId = (session.ipId++ & 0xffff); - - ipHeader[0] = 0x45; - ipHeader[1] = 0x00; - ipHeader.writeUInt16BE(totalLen, 2); - ipHeader.writeUInt16BE(ipId, 4); - ipHeader.writeUInt16BE(0x4000, 6); - ipHeader[8] = 0x40; - ipHeader[9] = 0x11; - srcIp.copy(ipHeader, 12); - dstIp.copy(ipHeader, 16); - ipHeader.writeUInt16BE(this.ipv4HeaderChecksum(ipHeader), 10); - - udpHeader.writeUInt16BE(srcPort & 0xffff, 0); - udpHeader.writeUInt16BE(dstPort & 0xffff, 2); - udpHeader.writeUInt16BE(8 + udpPayload.length, 4); - udpHeader.writeUInt16BE(0, 6); - - return Buffer.concat([ipHeader, udpHeader, udpPayload]); - } - - buildPppIpFrame(ipPacket) { - const protocol = Buffer.from([0x21]); - const raw = Buffer.concat([protocol, ipPacket]); - return this.pppEscape(raw); - } - - pppEscape(buffer) { - const escaped = []; - for (let i = 0; i < buffer.length; i++) { - const b = buffer[i]; - if (b === 0x7d || b === 0x7e || b < 0x20) { - escaped.push(0x7d, b ^ 0x20); - } else { - escaped.push(b); - } - } - return Buffer.from(escaped); - } - - parseIPv4(ipStr) { - const parts = String(ipStr).split('.').map((v) => parseInt(v, 10)); - if (parts.length !== 4 || parts.some((v) => Number.isNaN(v) || v < 0 || v > 255)) { - return Buffer.from([10, 0, 0, 2]); - } - return Buffer.from(parts); - } - - ipv4HeaderChecksum(header) { - let sum = 0; - for (let i = 0; i < 20; i += 2) { - if (i === 10) continue; - sum += header.readUInt16BE(i); - while (sum > 0xffff) sum = (sum & 0xffff) + (sum >>> 16); - } - return (~sum) & 0xffff; - } - - // The server-challenge wire format has two observed variants: - // - 16-bit: small values like 0x03f1, 0x047d, 0x011e..0x0138. Seen in - // every WebTV capture (wtv.pcap, wtv2.pcap, wtv_multi.pcap). WebTV's - // PNM client REFUSES to send its hash response when the upper 16 bits - // are non-zero. - // - 32-bit Unix timestamp: seen in multi_auth.pcap (a newer RealServer - // build talking to modern RealPlayer). - // Detection is by User-Agent (set in handleData from the raw request data) - // since WebTV clients advertise the same cook/sipr caps as modern RP. - buildPnaHello(session = null) { // The client advertises its local `time()` value in tag 0 of the // PNA request, XORed with 0x67E32B93. The hello-parser in @@ -960,7 +870,6 @@ class WTVPNM { } buildDescriptorPacket(session = null) { - const fs = require('fs'); const outChunks = []; // 4F headers: Rule Tags / Properties (based on capture to appease client parser) @@ -1120,20 +1029,6 @@ class WTVPNM { } } - // Optional codec adaptation - if (tag === 'MDPR' && this.service_config.adapt_codec_from_caps === true) { - const caps = (session && Array.isArray(session.capabilities)) ? session.capabilities : []; - if (caps.includes('cook')) { - // Replace 'slae' codec with 'cook' if requested - const at = chunkData.indexOf(Buffer.from('slae', 'ascii')); - if (at >= 0) { - const newChunk = Buffer.from(chunkData); - Buffer.from('cook', 'ascii').copy(newChunk, at); - chunkData = newChunk; - } - } - } - // Wrap in [0x72] [size_16] const wrap = Buffer.alloc(3); wrap[0] = 0x72; @@ -1156,9 +1051,7 @@ class WTVPNM { } // Include the session token as tag 0x23 [size_16 = 64] - const token = (this.service_config.dynamic_session_token === true) - ? this.buildSessionToken(session) - : '8e475de1df1ddc5c58c5ecef20e64d26073fe6f98fc0077dd0eb4429e0d8c375'; + const token = this.buildSessionToken(session); const tokenBuf = Buffer.alloc(3 + 64); tokenBuf[0] = 0x23; diff --git a/zefie_wtvp_minisrv/includes/config.json b/zefie_wtvp_minisrv/includes/config.json index 53241836..404f2f14 100644 --- a/zefie_wtvp_minisrv/includes/config.json +++ b/zefie_wtvp_minisrv/includes/config.json @@ -399,20 +399,9 @@ "flags": "0x00000001", "allow_double_slash": true, "protocol_handler": "pnm", - "send_keepalive": false, - "keepalive_zero_ack": true, - "hello_split_ms": 8, - "descriptor_on_first_pna": true, "descriptor_after_hello_ms": 85, - "descriptor_fallback_ms": 85, - "descriptor_idle_fallback_ms": 85, - "tcp_start_delay_ms": 72, - "tcp_initial_burst": 1, - "tcp_interval_ms": 232, - "dynamic_session_token": true, - "adapt_codec_from_caps": false, - "auto_stream": true, - "debug": true + "burst_prestart_ms": 5000, + "debug": false } }, "favorites": { diff --git a/zefie_wtvp_minisrv/package-lock.json b/zefie_wtvp_minisrv/package-lock.json index 38ab657b..2835a13e 100644 --- a/zefie_wtvp_minisrv/package-lock.json +++ b/zefie_wtvp_minisrv/package-lock.json @@ -44,9 +44,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -952,9 +952,9 @@ } }, "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", "license": "MIT", "engines": { "node": ">=12.0" @@ -1051,9 +1051,9 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.0.tgz", + "integrity": "sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -1111,9 +1111,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1949,9 +1949,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -2158,9 +2158,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2602,9 +2602,9 @@ } }, "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", "license": "MIT", "engines": { "node": ">= 0.4.0" @@ -2880,9 +2880,9 @@ "license": "ISC" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "funding": [ { "type": "opencollective", @@ -3144,9 +3144,9 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.17.2", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.2.tgz", - "integrity": "sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg==", + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz", + "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", @@ -3374,13 +3374,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4"