diff --git a/zefie_wtvp_minisrv/app.js b/zefie_wtvp_minisrv/app.js index 02f21cd4..eaeb5821 100644 --- a/zefie_wtvp_minisrv/app.js +++ b/zefie_wtvp_minisrv/app.js @@ -156,8 +156,8 @@ function configureService(service_name, service_obj, initial = false) { else ports.push(service_obj.port); } - // Exclude PNM services - if (service_obj.protocol_handler === 'pnm') { + // Exclude PNM and MMS services (they manage their own TCP sockets) + if (service_obj.protocol_handler === 'pnm' || service_obj.protocol_handler === 'mms') { return true; } @@ -457,7 +457,7 @@ async function handleCGI(executable, cgi_file, socket, request_headers, vault, s env.SERVER_PORT = request_data.port; env.SERVER_ADDR = request_data.host; env.SERVER_NAME = request_data.host; - if (minisrv_config.services[socket.service_name] && minisrv_config.services[socket.service_name].hide_minisrv_version) { + if ((minisrv_config.services[socket.service_name] && minisrv_config.services[socket.service_name].hide_minisrv_version) || minisrv_config.config.hide_server_version) { env.SERVER_SOFTWARE = "NodeJS; minisrv"; } else { // Full version @@ -2172,8 +2172,9 @@ function reloadConfig() { // SERVER START const git_commit = getGitRevision() -let z_title = "zefie's wtv minisrv v" + require('./package.json').version; -const z_cgiver = "minisrv/" + require('./package.json').version; +const pkgjson = require('./package.json'); +let z_title = "zefie's wtv minisrv v" + pkgjson.version; +const z_cgiver = "minisrv/" + pkgjson.version; if (git_commit) z_title += " (git " + git_commit + ")"; console.log("**** Welcome to " + z_title + " ****"); console.log("**** Detected nodejs v" + process.versions.node + " ****") @@ -2333,6 +2334,14 @@ Object.keys(minisrv_config.services).forEach((service_name) => { throw ("Could not bind PNM protocol handler to port " + service.port + " on " + minisrv_config.config.bind_ip + ": " + e.toString()); } } + if (service.protocol_handler === 'mms') { + try { + handlerModules['wtvmms'].listen(service.port, minisrv_config.config.bind_ip); + protocolServers.push(handlerModules['wtvmms']); + } catch (e) { + throw ("Could not bind MMS protocol handler to port " + service.port + " on " + minisrv_config.config.bind_ip + ": " + e.toString()); + } + } }); const protocolHandledPorts = new Set(); @@ -2343,6 +2352,9 @@ Object.keys(minisrv_config.services).forEach((service_name) => { if (service.protocol_handler === 'pnm') { protocolHandledPorts.add([service_name, service.protocol_handler, parseInt(service.port)]); } + if (service.protocol_handler === 'mms') { + protocolHandledPorts.add([service_name, service.protocol_handler, parseInt(service.port)]); + } // Any other future special protocols would go here, and should be added to the `protocolHandledPorts` set to avoid conflicts with the main socket listener // We ignore unknown protocols and treat it like the flag doesn't exist. }); diff --git a/zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/asxgen/catchall.js b/zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/asxgen/catchall.js new file mode 100644 index 00000000..e065c7a2 --- /dev/null +++ b/zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/asxgen/catchall.js @@ -0,0 +1,141 @@ +const minisrv_service_file = true; + +if (!minisrv_config.services['mms']) { + throw ("ERROR: mms service not defined in config!"); +} + + +const mmsVaults = []; +// Check mms service vault for .wma, .wmv, and .asf files +if (minisrv_config.config.ServiceVaults) { + Object.keys(minisrv_config.config.ServiceVaults).forEach(function (k) { + const service_vault = wtvshared.getAbsolutePath(minisrv_config.config.ServiceVaults[k]); + mmsVaults.push(service_vault + "/mms"); + }) +} else { + throw ("ERROR: No Service Vaults defined!"); +} + +// Detect subdirectory structure of this catchall.js file and strip it from requests +// e.g., if at /ServiceVault/wtv-music/asxgen/catchall.js, extract "ragen" +// if at /ServiceVault/wtv-music/asx/gen/catchall.js, extract "asx/gen" +let subDirPath = ''; +const currentDir = path.dirname(__filename); +const serviceVaultIdx = currentDir.indexOf('ServiceVault'); +console.log("DEBUG: currentDir =", currentDir, "serviceVaultIdx =", serviceVaultIdx); +if (serviceVaultIdx !== -1) { + const afterVault = currentDir.substring(serviceVaultIdx + 12); // 12 = length of 'ServiceVault' + console.log("DEBUG: afterVault =", afterVault); + const parts = afterVault.split(path.sep).filter(p => p); + console.log("DEBUG: parts =", parts); + if (parts.length > 1) { + // parts[0] is the service name (e.g., 'wtv-music'), parts[1+] are the subdirs + const subdirs = parts.slice(1); + subDirPath = '/' + subdirs.join('/'); + } +} + +const url_path = request_headers.request_url.split('?')[0]; +const pathParts = url_path.split('/').filter(p => p); +const serviceName = pathParts.length > 0 ? pathParts[0] : ''; +let remainingPath = '/' + pathParts.slice(1).join('/'); +const hadTrailingSlash = request_headers.request_url.endsWith('/'); + +let strippedSubDir = ''; // Store what was stripped for link rebuilding +// Strip the subdirectory structure from the request path +if (subDirPath) { + if (remainingPath.startsWith(subDirPath + '/')) { + // Has something after the subdirectory, e.g., /ragen/classicrom + strippedSubDir = subDirPath; + remainingPath = remainingPath.substring(subDirPath.length); + } else if (remainingPath === subDirPath || remainingPath === subDirPath + '/') { + // Just the subdirectory itself, e.g., /ragen or /ragen/ + strippedSubDir = subDirPath; + remainingPath = '/'; + } +} + + +// Restore trailing slash if original URL had one +if (hadTrailingSlash && !remainingPath.endsWith('/')) { + remainingPath += '/'; +} + +const filename = remainingPath.endsWith('/') ? '' : remainingPath.split('/').pop().replace('.asx', ''); +const directory = remainingPath.endsWith('/') ? remainingPath.replace(/\/$/, '') : remainingPath.substring(0, remainingPath.lastIndexOf('/')); + +let fileFound = false; +const extensions = ['.asf', '.wma', '.wmv']; +let resolvedPath = null; + +// Check if request is for a directory listing (no filename or ends with /) +if (!filename || (request_headers.request_url.endsWith('/') && minisrv_config.services['mms'].allow_indexing !== false)) { + const listingDir = filename ? directory : directory || '/'; + const allFiles = []; + + for (const mmsVault of mmsVaults) { + const targetDir = path.join(mmsVault, listingDir); + if (fs.existsSync(targetDir) && fs.statSync(targetDir).isDirectory()) { + const files = fs.readdirSync(targetDir); + files.forEach(file => { + const fullPath = path.join(targetDir, file); + if (fs.statSync(fullPath).isFile() && (file.endsWith('.wma') || file.endsWith('.wmv') || file.endsWith('.asf'))) { + const baseFileName = file.substring(0, file.lastIndexOf('.')); + allFiles.push(baseFileName + '.asx'); + } else if (fs.statSync(fullPath).isDirectory()) { + allFiles.push(file + '/'); + } + }); + } + } + + if (allFiles.length > 0) { + headers = `200 OK +Content-type: text/html`; + data = ` + + +Windows Media on this Service + + + +
+
+

Windows Media on this Service

`; + } else { + headers = `404 Not Found +Content-type: text/html`; + data = `

No files found

`; + } +} else { + // Original file search logic + for (const mmsVault of mmsVaults) { + for (const ext of extensions) { + const filePath = path.join(mmsVault, directory, filename + ext); + if (fs.existsSync(filePath)) { + fileFound = true; + resolvedPath = filePath; + break; + } + } + if (fileFound) break; + } + + if (!fileFound) { + headers = `404 Not Found +Content-type: text/html`; + } else { + const filePath = path.join(directory || '/', filename + path.extname(resolvedPath)); + const mmsURL = `mms://${minisrv_config.config.service_ip}:${minisrv_config.services['mms'].port}${filePath.replace(/\\/g, '/')}`; + const title = (request_headers.query['wtv-title']) ? request_headers.query['wtv-title'] : minisrv_config.config.service_name+" media"; + headers = `200 OK +Content-type: video/x-ms-asf` + data = ` + ${title} media + + ${title} + + +`; + } +} \ No newline at end of file diff --git a/zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/ragen/catchall.js b/zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/ramgen/catchall.js similarity index 100% rename from zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/ragen/catchall.js rename to zefie_wtvp_minisrv/includes/ServiceVault/wtv-music/ramgen/catchall.js diff --git a/zefie_wtvp_minisrv/includes/classes/WTVMMS.js b/zefie_wtvp_minisrv/includes/classes/WTVMMS.js new file mode 100644 index 00000000..b4580eaa --- /dev/null +++ b/zefie_wtvp_minisrv/includes/classes/WTVMMS.js @@ -0,0 +1,1921 @@ +// Pure JS implementation of Microsoft MMS (MMST - MMS over TCP) streaming protocol. +// Used by WebTV Plus (LC2+) to stream WMA and ASF audio content over mms:// URLs. +// Port 1755 (standard MMS port). Commands over TCP; data over TCP or UDP. +// +// Protocol overview: +// - Command packets use the 48-byte MMST control header that starts with 0xb00bface. +// - Header/media packets use the 8-byte MMST data preheader: +// seq(4) + packetId(1) + flags(1) + totalLen(2) +// - The server replies with command packets for connect/transport/open/header/start, +// and sends ASF header/media bytes as MMST data packets paced by file bitrate. + +'use strict'; + +const net = require('net'); +const dgram = require('dgram'); +const fs = require('fs'); +const path = require('path'); + +const MMS_MAGIC = 0xB00BFACE; +const MMS_PROTOCOL = 0x20534D4D; // "MMS " little-endian +const MMS_COMMAND_HEADER_SIZE = 48; +const MMS_DIRECTION_TO_CLIENT = 0x0004; + +// MMS command IDs (client -> server) +const MMS_CLIENT_CONNECT = 0x01; +const MMS_CLIENT_TRANSPORT = 0x02; +const MMS_CLIENT_OPEN = 0x05; +const MMS_CLIENT_START = 0x07; +const MMS_CLIENT_STOP = 0x09; +const MMS_CLIENT_CLOSE = 0x0D; +const MMS_CLIENT_HEADER_REQ = 0x15; +const MMS_CLIENT_TIMING_REQ = 0x18; +const MMS_CLIENT_KEEPALIVE = 0x1B; +const MMS_CLIENT_STREAM_SELECT = 0x33; + +// MMS command IDs (server -> client) +const MMS_SERVER_CLIENT_ACCEPTED = 0x01; +const MMS_SERVER_PROTOCOL_ACCEPTED = 0x02; +const MMS_SERVER_PROTOCOL_FAILED = 0x03; +const MMS_SERVER_MEDIA_FOLLOWS = 0x05; +const MMS_SERVER_FILE_DETAILS = 0x06; +const MMS_SERVER_HEADER_ACCEPTED = 0x11; +const MMS_SERVER_TIMING_REPLY = 0x15; +const MMS_SERVER_KEEPALIVE = 0x1B; +const MMS_SERVER_STREAM_STOPPED = 0x1E; +const MMS_SERVER_STREAM_ACCEPTED = 0x21; + +const MMS_HEADER_PACKET_ID = 0x05; +const MMS_MEDIA_PACKET_ID = 0x04; +const MMS_HEADER_FLAG_MORE = 0x04; +const MMS_HEADER_FLAG_FINAL = 0x0C; + +const MMS_DISABLE_PACKET_PAIR = 0xf0f0f0ef +const MMS_USE_PACKET_PAIR = 0xf0f0f0f0 + +const FILE_ATTRIBUTE_MMS_CANSEEK = 0x01000000; + +// Default ASF data packet size (bytes); real files embed this in the ASF header. +// 5001 is the standard Windows Media Encoder default. +const DEFAULT_ASF_PACKET_SIZE = 5001; + +// Minimum pacing interval (ms) – prevents spinning too fast on low-bitrate files +const MIN_PACING_INTERVAL_MS = 10; + +class WTVMMS { + minisrv_config = null; + service_name = null; + service_config = null; + server_config = null; + server = null; + udpServer = null; + udpServerReady = false; + wtvshared = null; + sessions = new Map(); + + constructor(minisrv_config, service_name, wtvshared) { + this.minisrv_config = minisrv_config; + this.service_name = service_name; + this.service_config = minisrv_config.services[service_name] || {}; + this.server_config = this.service_config; + this.wtvshared = wtvshared; + this.version = require(`${wtvshared.path.dirname(require.main.filename)}/package.json`).version.replace(/^v/, ''); + this.server = net.createServer((socket) => this.handleConnection(socket)); + if (this.service_config.hide_minisrv_version || minisrv_config.config.hide_minisrv_version) { + this.serverName = "minisrv"; + this.minorVersion = 1; + this.majorVersion = 0.0; + } else { + this.serverName = `minisrv/${this.version}`; + this.majorVersion = this.version.split('.').slice(0)[0]; + this.minorVersion = this.version.split('.').slice(1).join('.'); + } + } + + listen(port, host = '0.0.0.0') { + this.server.listen(port, host); + + const configuredUdpPort = Number(this.server_config?.udp_port); + const udpPort = Number.isFinite(configuredUdpPort) && configuredUdpPort > 0 + ? Math.floor(configuredUdpPort) + : 1755; + this.udpServer = dgram.createSocket('udp4'); + this.udpServerReady = false; + this.udpServer.on('error', (err) => { + console.error('[WTVMMS] udp socket error', err.message); + }); + this.udpServer.on('message', (msg, rinfo) => { + this._handleUdpDatagram(msg, rinfo); + }); + this.udpServer.bind(udpPort, host, () => { + this.udpServerReady = true; + this.debugLog('udp listen', host + ':' + udpPort); + }); + + return this.server; + } + + close() { + if (this.server) this.server.close(); + if (this.udpServer) { + try { this.udpServer.close(); } catch (_) {} + this.udpServer = null; + this.udpServerReady = false; + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + getDebugEnabled() { + return this.service_config.debug; + } + + debugLog(...args) { + if (this.getDebugEnabled()) console.log('[WTVMMS]', ...args); + } + + // Resolve a requested media path across all configured ServiceVaults. + resolveMediaPath(requestedMedia) { + if (!requestedMedia) return null; + const serviceVaultDir = this.service_config.servicevault_dir || this.service_name; + const vaults = this.minisrv_config.config?.ServiceVaults || []; + const variants = this._mediaVariants(requestedMedia); + + for (const vault of vaults) { + const base = this.wtvshared.getAbsolutePath(serviceVaultDir, vault); + for (const variant of variants) { + const candidate = this.wtvshared.makeSafePath(base, variant); + if (candidate && fs.existsSync(candidate) && fs.lstatSync(candidate).isFile()) { + this.debugLog('media resolved', variant, '->', candidate); + return candidate; + } + } + } + return null; + } + + _mediaVariants(media) { + const p = media.replace(/\\/g, '/').replace(/^\/+/, ''); + const ext = path.posix.extname(p).toLowerCase(); + const stem = ext ? p.slice(0, -ext.length) : p; + const list = [p]; + // Accept both .wma and .asf for the same stem + if (ext === '.wma') list.push(stem + '.asf'); + if (ext === '.asf') list.push(stem + '.wma'); + if (!ext) { list.push(stem + '.wma'); list.push(stem + '.asf'); } + return [...new Set(list)]; + } + + // ------------------------------------------------------------------------- + // Connection handling + // ------------------------------------------------------------------------- + + handleConnection(socket) { + socket.setNoDelay(true); + + const session = { + id: `${socket.remoteAddress}:${socket.remotePort}`, + socket, + clientEndpointIp: this._normalizeIp(socket.remoteAddress), + rxBuf: Buffer.alloc(0), + mediaPath: null, + streaming: false, + paused: false, + pacingTimer: null, + asfFd: null, + requestedTransport: '', + clientPort: 0, + clientPortCandidates: [], + udpPeerLearnedPort: 0, + tcpDataEnabled: true, + udpDataEnabled: false, + requestedUdp: false, + isCubddTransport: false, + asfPacketSize: DEFAULT_ASF_PACKET_SIZE, + asfDataOffset: 0, + asfFileSize: 0, + asfPacketCount: 0, + asfDurationSec: 0, + bitrateBps: 0, + commandSeq: 0, + openFileId: 1, + clientId: (Math.random() * 0x7FFFFFFF + 1) >>> 0, + openIncarnation: 0, + headerIncarnation: MMS_HEADER_PACKET_ID, + mediaIncarnation: MMS_MEDIA_PACKET_ID, + headerPacketId: MMS_HEADER_PACKET_ID, + mediaPacketId: MMS_MEDIA_PACKET_ID, + readBlockIncarnation: 0, + dataSequence: 0, + lastDataSequenceTx: 0, + dataAfMode: 'auto', + bytesTx: 0, + headerPacketsTx: 0, + mediaPacketsTx: 0, + burstPacketsRemaining: 0, + burstMultiplier: 1, + waitingForDrain: false, + drainHandler: null, + packetIntervalMs: MIN_PACING_INTERVAL_MS, + nextPacketDueAt: 0, + effectivePacketSize: 0, + seekGeneration: 0, + packetQueue: [], + eosTimer: null, + eosPingTimer: null, + lastKeepaliveTxMs: 0, + resendPacketCache: new Map(), + resendPacketOrder: [], + recentPacketSizes: [], + smoothedPacketSize: 0, + pacingSamples: 0, + }; + + this.sessions.set(socket, session); + this.debugLog('client connected', session.id); + + socket.on('data', (data) => { + try { + session.rxBuf = Buffer.concat([session.rxBuf, data]); + this._processIncoming(socket, session); + } catch (e) { + console.error('[WTVMMS] handleData error', e); + } + }); + + socket.on('close', () => { + this._stopStream(session); + if (session.asfFd !== null) { try { fs.closeSync(session.asfFd); } catch(_){} session.asfFd = null; } + this.sessions.delete(socket); + this.debugLog('client disconnected', session.id, 'tx=' + session.bytesTx); + }); + + socket.on('error', (err) => { + this._stopStream(session); + if (session.asfFd !== null) { try { fs.closeSync(session.asfFd); } catch(_){} session.asfFd = null; } + this.sessions.delete(socket); + this.debugLog('socket error', session.id, err.message); + }); + } + + // ------------------------------------------------------------------------- + // Incoming packet parser + // ------------------------------------------------------------------------- + + _processIncoming(socket, session) { + while (session.rxBuf.length > 0) { + const buf = session.rxBuf; + + if (buf[0] === 0x4E || buf[0] === 0x47) { + const lineEnd = buf.indexOf('\r\n'); + if (lineEnd < 0 && buf.length < 512) break; + const line = buf.slice(0, lineEnd > 0 ? lineEnd + 2 : buf.length).toString('latin1'); + this.debugLog('text connect line', session.id, line.trim()); + session.rxBuf = buf.slice(lineEnd > 0 ? lineEnd + 2 : buf.length); + continue; + } + + if (buf.length < 8) break; + + if (buf.length >= 12 && buf.readUInt32LE(4) === MMS_MAGIC) { + const totalLength = buf.readUInt32LE(8) + 16; + if (totalLength < MMS_COMMAND_HEADER_SIZE || totalLength > 1048576) { + this.debugLog('invalid command length, dropping connection', session.id, totalLength); + socket.destroy(); + return; + } + if (buf.length < totalLength) break; + + const packet = buf.slice(0, totalLength); + session.rxBuf = buf.slice(totalLength); + this._handleCommandPacket(socket, session, packet); + continue; + } + + const packetLength = buf.readUInt16LE(6); + if (packetLength >= 8 && buf.length >= packetLength) { + this.debugLog('discarding unexpected client data packet', session.id, 'len=' + packetLength); + session.rxBuf = buf.slice(packetLength); + continue; + } + + this.debugLog('bad magic, resyncing', session.id, buf.slice(0, 8).toString('hex')); + session.rxBuf = buf.slice(1); + } + } + + // ------------------------------------------------------------------------- + // Command packet dispatch + // ------------------------------------------------------------------------- + + _handleCommandPacket(socket, session, packet) { + if (packet.length < MMS_COMMAND_HEADER_SIZE) return; + + const cmdId = packet.readUInt16LE(36); + const direction = packet.readUInt16LE(38); + const prefix1 = packet.readUInt32LE(40); + const prefix2 = packet.readUInt32LE(44); + const payload = packet.slice(MMS_COMMAND_HEADER_SIZE); + + this.debugLog( + 'rx cmd', + session.id, + '0x' + cmdId.toString(16), + 'dir=0x' + direction.toString(16), + 'p1=0x' + prefix1.toString(16), + 'p2=0x' + prefix2.toString(16), + 'len=' + packet.length, + 'from=' + socket.remoteAddress + ':' + socket.remotePort + ); + + switch (cmdId) { + case MMS_CLIENT_CONNECT: + this._handleConnect(socket, session, payload); + break; + case MMS_CLIENT_TRANSPORT: + this._handleTransport(socket, session, payload); + break; + case MMS_CLIENT_OPEN: + session.openIncarnation = prefix1; // playIncarnation from OpenFile + this._handleOpen(socket, session, payload); + break; + case MMS_CLIENT_START: + this._handleStart(socket, session, prefix1, payload); + break; + case MMS_CLIENT_STOP: + this._handleStop(socket, session, prefix2); // prefix2 = stopPlayIncarnation + break; + case MMS_CLIENT_HEADER_REQ: + this._handleHeaderRequest(socket, session, payload); + break; + case MMS_CLIENT_TIMING_REQ: + this._sendCommand(socket, session, MMS_SERVER_TIMING_REPLY, 0, 0xF0F0F0EF, this._buildFunnelInfoPayload(session)); + break; + case MMS_CLIENT_KEEPALIVE: + this._handleClientKeepalive(socket, session, prefix1, prefix2); + break; + case MMS_CLIENT_STREAM_SELECT: + this._sendCommand(socket, session, MMS_SERVER_STREAM_ACCEPTED, 0, 0, Buffer.alloc(0)); + break; + case MMS_CLIENT_CLOSE: + socket.end(); + break; + default: + this.debugLog('unhandled cmd', session.id, '0x' + cmdId.toString(16)); + } + } + + // ------------------------------------------------------------------------- + // Protocol handshake handlers + // ------------------------------------------------------------------------- + + _handleConnect(socket, session, payload) { + const clientStr = payload.length > 4 + ? this._decodeUtf16CString(payload.slice(4)) + : ''; + session.clientPlayer = clientStr; + session.isLegacyClient = /^NSPlayer\/6\./i.test(clientStr); + // Preserve ffplay/VLC/MPC compatibility: keep MMST data flags at 0 unless legacy mode is required. + session.dataAfMode = this._resolveDataAfMode(session); + this.debugLog('connect', session.id, clientStr || '(no player string)'); + console.log(' * [MMS]', `[${session.id}]`, 'Client connect:', clientStr || '(unknown player)'); + if (session.isLegacyClient) { + this._sendCommandBatch(socket, session, [ + { + cmdId: MMS_SERVER_CLIENT_ACCEPTED, + prefix1: 0, + prefix2: 0xF0F0F0EF, + payloadBuf: this._buildConnectAcceptedPayload(session) + } + ]); + return; + } + + this._sendCommand(socket, session, MMS_SERVER_CLIENT_ACCEPTED, 0, 0xF0F0F0EF, this._buildConnectAcceptedPayload(session)); + } + + _handleTransport(socket, session, payload) { + // Transport payload has an 8-byte prefix before the UTF-16 string. + // If slice(8) yields a valid UTF-16 string, use it; otherwise try slice(4) as fallback. + const tryBuf8 = payload.length > 8 ? payload.slice(8) : Buffer.alloc(0); + const tryBuf4 = payload.length > 4 ? payload.slice(4) : Buffer.alloc(0); + const probe8 = this._decodeUtf16CString(tryBuf8); + const transportBuf = probe8.length > 0 ? tryBuf8 : tryBuf4; + const transportUtf16 = probe8.length > 0 ? probe8 : this._decodeUtf16CString(tryBuf4); + const transportAnsi = transportBuf.toString('latin1').replace(/\0+/g, '').trim(); + const transportStr = this._selectTransportString(transportUtf16, transportAnsi); + + const requested = this._parseTransportRequest(transportStr); + this.debugLog('transport parsed', session.id, JSON.stringify(requested)); + if (requested) { + session.requestedTransport = requested.protocol || session.requestedTransport; + session.requestedClientIp = requested.ip; + session.clientPort = this._sanitizeUdpPort(requested.port); + session.clientPortCandidates = Array.isArray(requested.ports) + ? requested.ports.map((p) => this._sanitizeUdpPort(p)).filter((p) => p > 0) + : (session.clientPort > 0 ? [session.clientPort] : []); + if (session.clientPort > 0 && session.clientPortCandidates.indexOf(session.clientPort) < 0) { + session.clientPortCandidates.unshift(session.clientPort); + } + session.udpPeerLearnedPort = 0; + // Do not trust IP inside transport request; use connected peer IP. + session.clientEndpointIp = this._normalizeIp(socket.remoteAddress); + this.debugLog( + 'transport endpoint', + session.id, + 'requestedTransport=' + session.requestedTransport, + 'requestedIp=' + requested.ip, + 'peerIp=' + session.clientEndpointIp, + 'port=' + requested.port, + 'candidates=' + session.clientPortCandidates.join(',') + ); + } + + const transportToken = String(session.requestedTransport || '').toUpperCase(); + + // CUBDD is WebTV's proprietary UDP transport — treat it as UDP-capable but track separately + // so CUBDD-specific hacks don't bleed into generic MMSU/UDP handling. + const isCubdd = transportToken === 'CUBDD' + || /\bCUBDD\b/i.test(transportStr) + || /\bCUBDD\b/i.test(transportUtf16) + || /\bCUBDD\b/i.test(transportAnsi); + + const requestedUdp = isCubdd + || transportToken === 'UDP' + || transportToken === 'MMSU' + || /\b(?:UDP|MMSU)\b/i.test(transportStr) + || /\b(?:UDP|MMSU)\b/i.test(transportUtf16) + || /\b(?:UDP|MMSU)\b/i.test(transportAnsi); + + session.isCubddTransport = isCubdd; + session.requestedUdp = requestedUdp; + this._refreshUdpTransportState(session); + + // If client asked for UDP but we can't enable it (e.g. invalid/out-of-range port), + // fall back to TCP so we don't leave both transport flags false. + if (requestedUdp && !session.udpDataEnabled) { + this.debugLog('transport udp fallback to tcp', session.id, + 'reason=udpDataEnabled=false clientPort=' + session.clientPort); + session.tcpDataEnabled = true; + } else { + session.tcpDataEnabled = !requestedUdp; + } + this.debugLog('transport', session.id, + 'token=' + (transportToken || '(unknown)'), + 'tcpDataEnabled=' + session.tcpDataEnabled, + 'udpDataEnabled=' + session.udpDataEnabled, + 'udpPort=' + (session.clientPort || 0), + 'udpPeer=' + (session.clientEndpointIp || '(none)'), + 'udpReady=' + this.udpServerReady); + this._sendCommand(socket, session, MMS_SERVER_PROTOCOL_ACCEPTED, 0, 0, this._buildTransportAcceptedPayload()); + } + + _handleOpen(socket, session, payload) { + let requestedUrl = ''; + if (payload.length > 8) { + requestedUrl = payload.slice(8).toString('utf16le').split('\0')[0].trim(); + } + this.debugLog('open file', session.id, requestedUrl); + + let mediaStem = requestedUrl + .replace(/^mms:\/\/[^/]+/i, '') + .replace(/^\/+/, ''); + + session.mediaPath = this.resolveMediaPath(mediaStem); + console.log(' * [MMS]', `[${session.id}]`, 'Open:', requestedUrl, '->', session.mediaPath || '(not found)'); + + if (!session.mediaPath) { + this._sendCommand(socket, session, MMS_SERVER_FILE_DETAILS, 0x80070002, session.openIncarnation, Buffer.alloc(0)); + socket.end(); + return; + } + + this._parseAsfHeader(session); + + this._sendCommand(socket, session, MMS_SERVER_FILE_DETAILS, 0, session.openIncarnation, this._buildFileDetailsPayload(session)); + } + + _handleHeaderRequest(socket, session, payload) { + if (!session.mediaPath) return; + let requestedInc = 0; + // _handleCommandPacket passes the reduced ReadBlock payload that starts at offset, + // so playIncarnation is 32 bytes into that buffer. + if (payload.length >= 36) { + requestedInc = payload.readUInt32LE(32) >>> 0; + } + session.readBlockIncarnation = requestedInc; + // Echo the requested playIncarnation for ReportReadBlock/header data packets. + // If missing/invalid, retain previous value or fall back to legacy packet id. + if (requestedInc >= 1 && requestedInc <= 0xFE) { + session.headerIncarnation = requestedInc; + } else if (!session.headerIncarnation) { + session.headerIncarnation = MMS_HEADER_PACKET_ID; + } + session.headerPacketId = (session.headerIncarnation & 0xFF) || MMS_HEADER_PACKET_ID; + session.headerPacketsTx = 0; + this.debugLog( + 'header request', + session.id, + 'requestedInc=0x' + requestedInc.toString(16), + 'headerIncarnation=0x' + session.headerIncarnation.toString(16), + 'headerPacketId=0x' + session.headerPacketId.toString(16) + ); + + // ReportReadBlock: hr=0, playIncarnation=from ReadBlock, playSequence=0 + const readBlockPayload = Buffer.alloc(4); + readBlockPayload.writeUInt32LE(0, 0); // playSequence + this._sendCommand(socket, session, MMS_SERVER_HEADER_ACCEPTED, 0, session.headerIncarnation, readBlockPayload); + + this._refreshUdpTransportState(session); + + if (!session.tcpDataEnabled && !session.udpDataEnabled) { + this.debugLog('no data transport available', session.id, 'closing connection'); + socket.end(); + return; + } + + const asfHeaderBuf = this._readAsfHeader(session); + if (asfHeaderBuf && asfHeaderBuf.length > 0) { + this._sendHeaderPackets(socket, session, asfHeaderBuf); + } + } + + _handleStart(socket, session, openFileId, payload) { + if (!session.mediaPath) { + this.debugLog('start requested but no media path', session.id); + return; + } + + const startReq = this._parseStartPlaying(openFileId, payload); + if (startReq === null) { + this.debugLog('start payload too short', session.id, 'len=' + payload.length); + return; + } + + this._clearPendingEos(session); + session.mediaIncarnation = startReq.playIncarnation >>> 0; + session.mediaPacketId = this._resolveMediaPacketId(session); + + const seek = this._resolveStartSeek(session, startReq); + if (seek) { + session._nextReadPos = seek.readPos; + session._nextPacketNumber = seek.packetNumber; + } + + // Increment generation counter to invalidate pending packets from old seek + session.seekGeneration = (session.seekGeneration + 1) >>> 0; + session.packetQueue = []; + + if (session.streaming) { + this._stopStream(session); + } + + // Reset Data packet AFFlags sequence for each new StartPlaying sequence. + session.dataSequence = 0; + + this.debugLog( + 'start stream', + session.id, + 'mediaIncarnation=0x' + session.mediaIncarnation.toString(16), + 'position=' + (startReq.position === null ? 'unspecified' : startReq.position), + 'asfOffset=' + startReq.asfOffset, + 'locationId=' + startReq.locationId, + seek ? ('seekPacket=' + seek.packetNumber) : 'seekPacket=0' + ); + + // ReportStartedPlaying: hr=0, playIncarnation=from StartPlaying, + // tigerFileId=openFileId, unused1=0, unused2=12 bytes zeros + session.mediaPacketsTx = seek ? seek.packetNumber : 0; + const startedPayload = Buffer.alloc(20); + startedPayload.writeUInt32LE(session.openFileId >>> 0, 0); // tigerFileId + // unused1 [4] = 0, unused2 [8-19] = 0 (already zeroed) + this._sendCommand(socket, session, MMS_SERVER_MEDIA_FOLLOWS, 0, session.mediaIncarnation, startedPayload); + + this._refreshUdpTransportState(session); + + if (!session.tcpDataEnabled && !session.udpDataEnabled) { + this.debugLog('no data transport available', session.id, 'cannot start media'); + return; + } + + this._startStream(socket, session); + } + + _handleStop(socket, session, stopIncarnation) { + this.debugLog('stop stream', session.id); + this._stopStream(session); + // Echo back the playIncarnation from StopPlaying (prefix2 of that message) + const inc = stopIncarnation >>> 0; + this._sendCommand(socket, session, MMS_SERVER_STREAM_STOPPED, 0, inc, Buffer.alloc(0)); + } + + // ------------------------------------------------------------------------- + // ASF header parsing (minimal – just packet size and bitrate) + // ------------------------------------------------------------------------- + + _parseAsfHeader(session) { + if (!session.mediaPath) return; + + try { + const stat = fs.statSync(session.mediaPath); + session.asfFileSize = stat.size; + + // Read first 64 KB to find ASF header objects + const readLen = Math.min(65536, stat.size); + const buf = Buffer.alloc(readLen); + const fd = fs.openSync(session.mediaPath, 'r'); + fs.readSync(fd, buf, 0, readLen, 0); + fs.closeSync(fd); + + // ASF Header Object GUID: {75B22630-668E-11CF-A6D9-00AA0062CE6C} + const ASF_HEADER_GUID = Buffer.from([ + 0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11, + 0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C + ]); + + if (!buf.slice(0, 16).equals(ASF_HEADER_GUID)) { + this.debugLog('not a valid ASF file (bad header GUID)', session.id); + session.asfDataOffset = 0; + return; + } + + // ASF Header Object: GUID(16) + Size(8) + NumHeaders(4) + Reserved(2) + const headerSize = Number(buf.readBigUInt64LE(16)); // total header size including GUID+size fields + + // Walk sub-objects inside the header + // ASF File Properties Object GUID: {8CABDCA1-A947-11CF-8EE4-00C00C205365} + const ASF_FILE_PROPS_GUID = Buffer.from([ + 0xA1, 0xDC, 0xAB, 0x8C, 0x47, 0xA9, 0xCF, 0x11, + 0x8E, 0xE4, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65 + ]); + + // ASF Stream Properties Object GUID: {B7DC0791-A9B7-11CF-8EE6-00C00C205365} + const ASF_STREAM_PROPS_GUID = Buffer.from([ + 0x91, 0x07, 0xDC, 0xB7, 0xB7, 0xA9, 0xCF, 0x11, + 0x8E, 0xE6, 0x00, 0xC0, 0x0C, 0x20, 0x53, 0x65 + ]); + + // ASF Data Object GUID: {75B22636-668E-11CF-A6D9-00AA0062CE6C} + const ASF_DATA_GUID = Buffer.from([ + 0x36, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11, + 0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C + ]); + + let offset = 30; // skip Header Object's GUID(16)+Size(8)+NumHeaders(4)+Reserved(2) + let maxBitrate = 0; + let packetSize = 0; + let dataPacketCount = 0; + let durationSec = 0; + + while (offset + 24 <= readLen) { + const objGuid = buf.slice(offset, offset + 16); + const objSize = Number(buf.readBigUInt64LE(offset + 16)); + if (objSize < 24) break; + + if (objGuid.equals(ASF_FILE_PROPS_GUID) && objSize >= 104) { + // ASF File Properties layout after the 24-byte object header: + // FileId(16), FileSize(8), CreationDate(8), DataPacketsCount(8), + // PlayDuration(8), SendDuration(8), Preroll(8), Flags(4), + // MinDataPacketSize(4), MaxDataPacketSize(4), MaxBitrate(4) + const playDuration100ns = Number(buf.readBigUInt64LE(offset + 64)); + const prerollMs = Number(buf.readBigUInt64LE(offset + 80)); + dataPacketCount = Number(buf.readBigUInt64LE(offset + 56)); + packetSize = buf.readUInt32LE(offset + 96); + maxBitrate = buf.readUInt32LE(offset + 100); + const rawDurationSec = playDuration100ns > 0 ? (playDuration100ns / 10000000.0) : 0; + durationSec = Math.max(0, rawDurationSec - (prerollMs / 1000.0)); + } + + if (objGuid.equals(ASF_DATA_GUID)) { + session.asfDataOffset = offset; + break; + } + + offset += objSize; + } + + // If we didn't hit the Data GUID in the read window, the data starts right after the header + if (session.asfDataOffset === 0 && headerSize > 0 && headerSize < session.asfFileSize) { + session.asfDataOffset = headerSize; + } + + if (packetSize > 0) session.asfPacketSize = packetSize; + if (maxBitrate > 0) session.bitrateBps = maxBitrate; + if (dataPacketCount > 0) session.asfPacketCount = dataPacketCount; + if (durationSec > 0) session.asfDurationSec = durationSec; + + this.debugLog('ASF parsed', session.id, + 'packetSize=' + session.asfPacketSize, + 'bitrate=' + session.bitrateBps + 'bps', + 'packetCount=' + session.asfPacketCount, + 'durationSec=' + session.asfDurationSec, + 'dataOffset=0x' + session.asfDataOffset.toString(16)); + + } catch (e) { + console.error('[WTVMMS] ASF parse error', session.id, e.message); + session.asfDataOffset = 0; + } + } + + // Read the raw ASF header bytes (everything before the first Data Object) + _readAsfHeader(session) { + if (!session.mediaPath || session.asfDataOffset === 0) return null; + try { + const headerLength = Math.min(session.asfFileSize, session.asfDataOffset + 50); + const buf = Buffer.alloc(headerLength); + const fd = fs.openSync(session.mediaPath, 'r'); + fs.readSync(fd, buf, 0, headerLength, 0); + fs.closeSync(fd); + return buf; + } catch (e) { + console.error('[WTVMMS] ASF header read error', e.message); + return null; + } + } + + // ------------------------------------------------------------------------- + // Paced streaming + // ------------------------------------------------------------------------- + + + _computePacketIntervalMs(session) { + let intervalMs = MIN_PACING_INTERVAL_MS; + const packetSizeForPacing = (session.effectivePacketSize > 0) + ? session.effectivePacketSize + : session.asfPacketSize; + if (session.bitrateBps > 0 && packetSizeForPacing > 0) { + intervalMs = Math.max( + MIN_PACING_INTERVAL_MS, + (packetSizeForPacing * 8 / session.bitrateBps) * 1000 + ); + } + + const defaultMultiplier = (session.isLegacyClient && !session.udpDataEnabled) ? 0.94 : 1.0; + const configuredMultiplier = Number(this.service_config.pacing_multiplier); + const pacingMultiplier = Number.isFinite(configuredMultiplier) && configuredMultiplier > 0 + ? configuredMultiplier + : defaultMultiplier; + + return Math.max(MIN_PACING_INTERVAL_MS, Math.floor(intervalMs * pacingMultiplier)); + } + + _startStream(socket, session) { + session.streaming = true; + session.paused = false; + session.waitingForDrain = false; + session.effectivePacketSize = 0; + session.recentPacketSizes = []; + session.smoothedPacketSize = 0; + session.pacingSamples = 0; + session.resendPacketCache.clear(); + session.resendPacketOrder = []; + session.streamStartWallMs = Date.now(); + session.streamStartPacketIndex = session.mediaPacketsTx || 0; + + const intervalMs = this._computePacketIntervalMs(session); + session.packetIntervalMs = intervalMs; + session.nextPacketDueAt = Date.now(); + + let burstPrestartMs = typeof this.service_config.burst_prestart_ms === 'number' + ? this.service_config.burst_prestart_ms + : 0; + let burstMultiplier = typeof this.service_config.burst_multiplier === 'number' + ? this.service_config.burst_multiplier + : 1; + + const burstEnabled = this.service_config.enable_burst === true; + if (!burstEnabled) { + burstPrestartMs = 0; + burstMultiplier = 1; + } + const burstPacketCount = burstPrestartMs > 0 ? Math.ceil(burstPrestartMs / intervalMs) : 0; + + session.burstPacketsRemaining = Math.max(0, burstPacketCount - 1); + session.burstMultiplier = burstMultiplier > 1 ? burstMultiplier : 1; + + this.debugLog('stream pacing', session.id, + 'interval=' + intervalMs + 'ms', + 'burstPackets=' + session.burstPacketsRemaining, + 'burstMultiplier=' + session.burstMultiplier); + + try { + session.asfFd = fs.openSync(session.mediaPath, 'r'); + } catch (e) { + console.error('[WTVMMS] failed to open media for streaming', e.message); + session.streaming = false; + return; + } + + const dataStart = session.asfDataOffset + 50; + if (typeof session._nextReadPos === 'number' && Number.isFinite(session._nextReadPos)) { + session._readPos = Math.max(dataStart, Math.min(session._nextReadPos, session.asfFileSize)); + } else { + // Position read head at the start of the ASF Data Object payload. + // The Data Object itself has a 50-byte preamble before the first packet: + // GUID(16) + Size(8) + FileId(16) + TotalDataPackets(8) + Reserved(2) = 50 bytes + session._readPos = dataStart; + } + delete session._nextReadPos; + delete session._nextPacketNumber; + + // Record which audio timestamp (ms) we started streaming from. + // Used by _estimateEosDelayMs to know how much audio remains. + const startByteOffset = Math.max(0, session._readPos - dataStart); + const startPacketIdx = session.asfPacketSize > 0 + ? Math.floor(startByteOffset / session.asfPacketSize) + : 0; + session.streamStartAudioMs = (session.asfPacketCount > 0 && session.asfDurationSec > 0) + ? Math.floor((startPacketIdx / session.asfPacketCount) * session.asfDurationSec * 1000) + : 0; + + this._sendNextPacket(socket, session); + } + + _stopStream(session) { + session.streaming = false; + if (session.pacingTimer) { + clearTimeout(session.pacingTimer); + session.pacingTimer = null; + } + if (session.drainHandler && session.socket && !session.socket.destroyed) { + session.socket.off('drain', session.drainHandler); + } + session.drainHandler = null; + session.waitingForDrain = false; + if (session.asfFd !== null) { + try { fs.closeSync(session.asfFd); } catch(_){} + session.asfFd = null; + } + session.packetQueue = []; + session.resendPacketCache.clear(); + session.resendPacketOrder = []; + this._clearPendingEos(session); + } + + _clearPendingEos(session) { + if (session.eosTimer) { + clearTimeout(session.eosTimer); + session.eosTimer = null; + } + if (session.eosPingTimer) { + clearInterval(session.eosPingTimer); + session.eosPingTimer = null; + } + } + + _estimateEosDelayMs(socket, session) { + if (!session.isLegacyClient) { + return 0; + } + + const cfgTail = Number(this.service_config.webtv_eos_tail_ms); + const cfgMin = Number(this.service_config.webtv_eos_min_delay_ms); + const cfgMax = Number(this.service_config.webtv_eos_max_delay_ms); + + const tailMs = Number.isFinite(cfgTail) ? Math.max(0, Math.floor(cfgTail)) : 5000; + const minMs = Number.isFinite(cfgMin) ? Math.max(0, Math.floor(cfgMin)) : 600; + + const totalDurationMs = Math.floor((session.asfDurationSec || 0) * 1000); + // Default maxMs to the full file duration + tail + 3s safety headroom. + // This ensures we never clamp a long file to an arbitrary fixed ceiling. + const defaultMaxMs = Math.max(10000, totalDurationMs + tailMs + 3000); + const maxMs = Number.isFinite(cfgMax) ? Math.max(minMs, Math.floor(cfgMax)) : defaultMaxMs; + + // The client plays audio in real-time at 1x speed starting from wherever + // the stream began (seeked or from the top). After wallElapsedMs of real + // time the client has consumed that many ms of audio from the seek point. + // Remaining = (total duration from seek point) - wallElapsed. + const seekOffsetMs = session.streamStartAudioMs || 0; + const streamDurationMs = totalDurationMs - seekOffsetMs; // audio from seek → end + const wallElapsedMs = session.streamStartWallMs + ? (Date.now() - session.streamStartWallMs) + : 0; + const remainingMs = Math.max(0, streamDurationMs - wallElapsedMs); + + const delayMs = Math.max(minMs, Math.min(maxMs, remainingMs + tailMs)); + + this.debugLog('eos delay calc', session.id, + 'totalDurationMs=' + totalDurationMs, + 'seekOffsetMs=' + seekOffsetMs, + 'wallElapsedMs=' + wallElapsedMs, + 'remainingMs=' + remainingMs, + 'delayMs=' + delayMs); + + return delayMs; + } + + _waitForDrain(socket, session) { + if (session.waitingForDrain || socket.destroyed) return; + + session.waitingForDrain = true; + session.drainHandler = () => { + session.waitingForDrain = false; + session.drainHandler = null; + if (!session.streaming || socket.destroyed) return; + this.debugLog('socket drain', session.id, 'writableLength=' + socket.writableLength); + this._scheduleNextPacket(session, socket); + }; + socket.once('drain', session.drainHandler); + } + + _scheduleNextPacket(session, socket) { + if (!session.streaming || session.paused) return; + + const writeOk = this._flushQueuedPackets(socket, session); + if (!writeOk) { + this._waitForDrain(socket, session); + return; + } + + let intervalMs = session.packetIntervalMs || this._computePacketIntervalMs(session); + + if (session.burstPacketsRemaining > 0 && session.burstMultiplier > 1) { + intervalMs = Math.max( + MIN_PACING_INTERVAL_MS, + Math.floor(intervalMs / session.burstMultiplier) + ); + session.burstPacketsRemaining--; + } + + const now = Date.now(); + session.nextPacketDueAt = (session.nextPacketDueAt || now) + intervalMs; + const delayMs = Math.max(MIN_PACING_INTERVAL_MS, session.nextPacketDueAt - now); + + if (session.mediaPacketsTx < 4 || (session.mediaPacketsTx % 64) === 0) { + this.debugLog('pace next', session.id, + 'in=' + delayMs + 'ms', + 'packet=' + session.mediaPacketsTx, + 'readPos=0x' + session._readPos.toString(16)); + } + + session.pacingTimer = setTimeout(() => { + session.pacingTimer = null; + this._sendNextPacket(socket, session); + }, delayMs); + } + + _sendNextPacket(socket, session) { + if (!session.streaming || socket.destroyed) { + this._stopStream(session); + return; + } + if (session.waitingForDrain) { + return; + } + + const pktSize = session.asfPacketSize; + const buf = Buffer.alloc(pktSize); + + let bytesRead = 0; + try { + bytesRead = fs.readSync(session.asfFd, buf, 0, pktSize, session._readPos); + } catch (e) { + this.debugLog('read error', session.id, e.message); + this._endStream(socket, session); + return; + } + + if (bytesRead === 0) { + // End of file + this._endStream(socket, session); + return; + } + + session._readPos += bytesRead; + + const packetBuf = bytesRead < pktSize ? buf.slice(0, bytesRead) : buf; + const strippedBuf = this._stripAsfPacketPadding(packetBuf, session); + + const txSize = strippedBuf.length; + + // Track transmitted packet sizes for adaptive pacing + if (txSize > 0) { + session.recentPacketSizes.push(txSize); + if (session.recentPacketSizes.length > 20) { + session.recentPacketSizes.shift(); + } + + // Compute smoothed (median) packet size from recent samples + const sorted = [...session.recentPacketSizes].sort((a, b) => a - b); + session.smoothedPacketSize = sorted[Math.floor(sorted.length / 2)]; + session.pacingSamples = session.recentPacketSizes.length; + + // Only adjust pacing interval if the transmitted size differs significantly from expected + // (e.g., due to padding stripping). Use smoothed size to avoid jitter. + const paceSize = session.effectivePacketSize > 0 ? session.effectivePacketSize : session.asfPacketSize; + if (Math.abs(txSize - paceSize) > 10) { + session.effectivePacketSize = txSize; + const oldInterval = session.packetIntervalMs; + session.packetIntervalMs = this._computePacketIntervalMs(session); + if (session.mediaPacketsTx <= 4 || (session.mediaPacketsTx % 64) === 0) { + this.debugLog('pace adjust', session.id, + 'txSize=' + txSize, + 'old=' + oldInterval + 'ms', + 'new=' + session.packetIntervalMs + 'ms', + 'smoothed=' + session.smoothedPacketSize); + } + session.nextPacketDueAt = Date.now(); + } + } + + const afFlags = this._nextDataFlags(session); + const writeOk = this._sendDataPacket(socket, session, session.mediaPacketId, afFlags, strippedBuf, false); + if (!writeOk) { + this._waitForDrain(socket, session); + return; + } + this._scheduleNextPacket(session, socket); + } + + _endStream(socket, session) { + const eosDelayMs = this._estimateEosDelayMs(socket, session); + this.debugLog('end of stream', session.id, 'eosDelayMs=' + eosDelayMs); + + this._stopStream(session); + + if (eosDelayMs <= 0) { + this._sendCommand(socket, session, MMS_SERVER_STREAM_STOPPED, 0, session.mediaIncarnation || 0, Buffer.alloc(0)); + return; + } + + const cfgPingIntervalMs = Number(this.service_config.webtv_eos_ping_interval_ms); + const eosPingIntervalMs = Number.isFinite(cfgPingIntervalMs) + ? Math.max(0, Math.floor(cfgPingIntervalMs)) + : 3000; + + if (eosPingIntervalMs > 0) { + session.eosPingTimer = setInterval(() => { + if (socket.destroyed || session.streaming || !session.eosTimer) { + if (session.eosPingTimer) { + clearInterval(session.eosPingTimer); + session.eosPingTimer = null; + } + return; + } + // LinkMacToViewerPing (MID=0x0004001B) over MMST command channel. + this._sendCommand(socket, session, MMS_SERVER_KEEPALIVE, 0, 0, Buffer.alloc(0)); + this.debugLog('eos keepalive ping', session.id, 'intervalMs=' + eosPingIntervalMs); + }, eosPingIntervalMs); + } + + session.eosTimer = setTimeout(() => { + session.eosTimer = null; + if (session.eosPingTimer) { + clearInterval(session.eosPingTimer); + session.eosPingTimer = null; + } + if (!socket.destroyed && !session.streaming) { + this._sendCommand(socket, session, MMS_SERVER_STREAM_STOPPED, 0, session.mediaIncarnation || 0, Buffer.alloc(0)); + } + }, eosDelayMs); + } + + // Parse one fixed-size ASF data packet, remove trailing padding, and return + // the compact payload. Per MMS spec §3.2.5.11.1, Padding Data SHOULD be + // stripped before MMST encapsulation and the Padding Length field MUST be + // updated to reflect the removal. + _stripAsfPacketPadding(buf, session) { + if (!buf || buf.length < 6) return buf; + + // Locate Length Type Flags byte, skipping optional error correction header. + let ltFlagsOffset = 0; + const byte0 = buf[0]; + if (byte0 & 0x80) { + // Error Correction Present (bit 7 = 1). + // Bits 5-6 = EC Length Type; 00 means bits 0-3 carry the data length. + const ecLenType = (byte0 >> 5) & 0x03; + const ecDataLen = ecLenType === 0 ? (byte0 & 0x0F) : 0; + ltFlagsOffset = 1 + ecDataLen; + } + + if (ltFlagsOffset + 2 > buf.length) return buf; + + const ltFlags = buf[ltFlagsOffset]; + const padLenType = (ltFlags >> 3) & 0x03; // bits 3-4 + const seqType = (ltFlags >> 1) & 0x03; // bits 1-2 + const pktLenType = (ltFlags >> 5) & 0x03; // bits 5-6 + + if (padLenType === 0) return buf; // no Padding Length field → nothing to strip + + // Byte widths for each type value (0=absent,1=BYTE,2=WORD,3=DWORD) + const typeSize = [0, 1, 2, 4]; + + // After LTFlags(1) + PropFlags(1) come the optional variable-width fields. + let fieldOffset = ltFlagsOffset + 2; + fieldOffset += typeSize[pktLenType]; // optional Packet Length + fieldOffset += typeSize[seqType]; // optional Sequence + + const padLenSize = typeSize[padLenType]; + if (fieldOffset + padLenSize > buf.length) return buf; + + // Read the declared padding length. + let paddingLen; + if (padLenSize === 1) paddingLen = buf.readUInt8(fieldOffset); + else if (padLenSize === 2) paddingLen = buf.readUInt16LE(fieldOffset); + else paddingLen = buf.readUInt32LE(fieldOffset); + + if (paddingLen === 0) return buf; // already no padding + + const configuredMinStrip = Number(this.service_config.asf_padding_strip_min_bytes); + const minStripBytes = Number.isFinite(configuredMinStrip) && configuredMinStrip >= 0 + ? Math.floor(configuredMinStrip) + : 8; + if (paddingLen < minStripBytes) { + return buf; + } + + const newLen = buf.length - paddingLen; + // Sanity: compacted packet must still hold the header fields we parsed. + if (newLen < fieldOffset + padLenSize + 6) { + this.debugLog('asf strip sanity fail', session && session.id, + 'bufLen=' + buf.length, 'paddingLen=' + paddingLen); + return buf; + } + + // Copy the packet, zero the Padding Length field, and trim trailing padding. + const out = Buffer.from(buf.buffer, buf.byteOffset, buf.length); + if (padLenSize === 1) out.writeUInt8(0, fieldOffset); + else if (padLenSize === 2) out.writeUInt16LE(0, fieldOffset); + else out.writeUInt32LE(0, fieldOffset); + + return out.slice(0, newLen); + } + + // ------------------------------------------------------------------------- + // Packet builders + // ------------------------------------------------------------------------- + + _buildCommandPacket(session, cmdId, prefix1, prefix2, payloadBuf) { + const payload = payloadBuf || Buffer.alloc(0); + const paddedPayloadLength = (payload.length + 7) & ~7; + const payloadBlocks = paddedPayloadLength >> 3; + const totalLength = MMS_COMMAND_HEADER_SIZE + paddedPayloadLength; + const packet = Buffer.alloc(totalLength); + + packet.writeUInt32LE(1, 0); + packet.writeUInt32LE(MMS_MAGIC, 4); + packet.writeUInt32LE(totalLength - 16, 8); + packet.writeUInt32LE(MMS_PROTOCOL, 12); + packet.writeUInt32LE(payloadBlocks + 6, 16); + packet.writeUInt16LE(session.commandSeq++ & 0xFFFF, 20); + packet.writeUInt16LE(0, 22); + packet.writeUInt32LE(0, 24); + packet.writeUInt32LE(0, 28); + packet.writeUInt32LE(payloadBlocks + 2, 32); + packet.writeUInt32LE((MMS_DIRECTION_TO_CLIENT << 16) | (cmdId & 0xffff), 36); + packet.writeUInt32LE(prefix1 >>> 0, 40); + packet.writeUInt32LE(prefix2 >>> 0, 44); + payload.copy(packet, MMS_COMMAND_HEADER_SIZE); + + return packet; + } + + _sendCommand(socket, session, cmdId, prefix1, prefix2, payloadBuf) { + if (socket.destroyed) return; + + const pkt = this._buildCommandPacket(session, cmdId, prefix1, prefix2, payloadBuf); + this.debugLog('tx cmd', session.id, '0x' + cmdId.toString(16), 'len=' + pkt.length), 'to='+socket.remoteAddress+":"+socket.remotePort; + socket.write(pkt); + session.bytesTx += pkt.length; + if (cmdId === MMS_SERVER_KEEPALIVE) { + session.lastKeepaliveTxMs = Date.now(); + } + } + + _handleClientKeepalive(socket, session, prefix1, prefix2) { + // During delayed EOS we already transmit periodic keepalives; if we also + // answer every client keepalive immediately, both sides can get into a + // noisy ping-pong loop. Throttle replies while EOS wait is active. + if (session.eosTimer) { + const cfgPingIntervalMs = Number(this.service_config.webtv_eos_ping_interval_ms); + const eosPingIntervalMs = Number.isFinite(cfgPingIntervalMs) + ? Math.max(1000, Math.floor(cfgPingIntervalMs)) + : 3000; + const minReplyGapMs = Math.max(1000, eosPingIntervalMs - 500); + const now = Date.now(); + const sinceLastTx = now - (session.lastKeepaliveTxMs || 0); + + if (sinceLastTx < minReplyGapMs) { + this.debugLog('keepalive throttled', session.id, + 'sinceLastTxMs=' + sinceLastTx, + 'minReplyGapMs=' + minReplyGapMs, + 'p1=0x' + (prefix1 >>> 0).toString(16), + 'p2=0x' + (prefix2 >>> 0).toString(16)); + return; + } + } + + this._sendCommand(socket, session, MMS_SERVER_KEEPALIVE, 0, 0, Buffer.alloc(0)); + } + + _sendCommandBatch(socket, session, commands) { + if (socket.destroyed || !Array.isArray(commands) || commands.length === 0) return; + + const packets = []; + for (const command of commands) { + const pkt = this._buildCommandPacket( + session, + command.cmdId, + command.prefix1, + command.prefix2, + command.payloadBuf + ); + this.debugLog('tx cmd', session.id, '0x' + command.cmdId.toString(16), 'len=' + pkt.length); + packets.push(pkt); + session.bytesTx += pkt.length; + } + + socket.write(Buffer.concat(packets)); + } + + _sendHeaderPackets(socket, session, headerBuf) { + const configuredChunkSize = Number(this.service_config.header_chunk_size); + const maxPayload = Math.max( + 256, + Math.min( + 0xFFFF - 8, + Number.isFinite(configuredChunkSize) && configuredChunkSize > 0 ? Math.floor(configuredChunkSize) : 1400 + ) + ); + let offset = 0; + let count = 0; + + while (offset < headerBuf.length && !socket.destroyed) { + const remaining = headerBuf.length - offset; + const chunkLength = Math.min(maxPayload, remaining); + const flags = offset + chunkLength < headerBuf.length ? MMS_HEADER_FLAG_MORE : MMS_HEADER_FLAG_FINAL; + this._sendDataPacket(socket, session, session.headerPacketId, flags, headerBuf.slice(offset, offset + chunkLength), true); + offset += chunkLength; + count++; + } + + this.debugLog('header sent', session.id, + 'packets=' + count, + 'bytes=' + headerBuf.length, + 'headerPacketId=0x' + session.headerPacketId.toString(16)); + } + + _sendDataPacket(socket, session, packetId, flags, payloadBuf, isHeaderPacket = false) { + if (socket.destroyed) return false; + + const payload = payloadBuf || Buffer.alloc(0); + const totalLength = 8 + payload.length; + const pkt = Buffer.alloc(totalLength); + + // LocationId: independent per-type sequence starting at 0. + // For header payloads: 0, 1, 2, ... per header stream. + // For data payloads: ASF data packet number starting at 0. + const isHeader = isHeaderPacket; + const locationId = isHeader ? session.headerPacketsTx : session.mediaPacketsTx; + + pkt.writeUInt32LE(locationId, 0); + pkt.writeUInt8(packetId & 0xff, 4); + pkt.writeUInt8(flags & 0xff, 5); + pkt.writeUInt16LE(totalLength, 6); + payload.copy(pkt, 8); + + if (isHeader) session.headerPacketsTx++; + else if (packetId === session.mediaPacketId) session.mediaPacketsTx++; + + if (!isHeader && packetId === session.mediaPacketId) { + const resendSeq = session.dataAfMode === 'sequence' + ? (session.lastDataSequenceTx >>> 0) + : (locationId >>> 0); + this._cacheResendPacket(session, resendSeq, pkt); + } + + const shouldLog = isHeader + ? true + : session.mediaPacketsTx <= 4 || (session.mediaPacketsTx % 64) === 0; + + if (shouldLog) { + const previewLength = Math.min(8, payload.length); + const preview = previewLength > 0 ? payload.subarray(0, previewLength).toString('hex') : ''; + this.debugLog('tx data', + (session.udpDataEnabled ? 'UDP' : (session.tcpDataEnabled) ? 'TCP' : '???'), + 'locationId=' + locationId, + 'packetId=0x' + packetId.toString(16), + 'flags=0x' + flags.toString(16), + 'packetSizeField=' + totalLength, + 'payload=' + payload.length, + preview ? 'preview=' + preview : ''); + } + + if (session.udpDataEnabled) { + this._sendUdpDataPacket(session, pkt, packetId, locationId, flags, payload.length); + return true; + } + + if (isHeader) { + const writeOk = socket.write(pkt); + if (!writeOk && shouldLog) { + this.debugLog('socket buffered', session.id, + 'packetId=0x' + packetId.toString(16), + 'writableLength=' + socket.writableLength); + } + session.bytesTx += pkt.length; + return writeOk; + } + + session.packetQueue.push({ + buffer: pkt, + generation: session.seekGeneration, + }); + return true; + } + + _sendUdpDataPacket(session, packetBuf, packetId, locationId, flags, payloadLen) { + if (!this.udpServer || !session || !session.clientEndpointIp || !session.clientPort) { + this.debugLog('udp send skipped', session && session.id, + 'packetId=0x' + packetId.toString(16), + 'peer=' + ((session && session.clientEndpointIp) || '(none)'), + 'port=' + ((session && session.clientPort) || 0)); + return; + } + + const configuredProbePackets = Number(this.service_config.udp_probe_alt_ports_packets); + const probeAltPortsPackets = Number.isFinite(configuredProbePackets) + ? Math.max(0, Math.floor(configuredProbePackets)) + : 96; + + const primaryPort = session.clientPort >>> 0; + const targetPorts = [primaryPort]; + + if (!session.udpPeerLearnedPort && probeAltPortsPackets > 0 && session.mediaPacketsTx <= probeAltPortsPackets) { + const alternates = Array.isArray(session.clientPortCandidates) + ? session.clientPortCandidates + : []; + for (const candidate of alternates) { + const p = this._sanitizeUdpPort(candidate); + if (p > 0 && targetPorts.indexOf(p) < 0) { + targetPorts.push(p); + } + } + } + + const host = session.clientEndpointIp; + for (const port of targetPorts) { + this.udpServer.send(packetBuf, 0, packetBuf.length, port, host, (err) => { + if (err) { + this.debugLog('udp send error', session.id, + 'packetId=0x' + packetId.toString(16), + 'port=' + port, + err.message); + } + }); + } + + session.bytesTx += packetBuf.length; + } + + _cacheResendPacket(session, sequenceNumber, packetBuf) { + if (!session || !Buffer.isBuffer(packetBuf)) return; + + const configuredCacheSize = Number(this.service_config.udp_retransmit_cache_size); + const maxCacheSize = Number.isFinite(configuredCacheSize) + ? Math.max(128, Math.floor(configuredCacheSize)) + : 4096; + + const seq = sequenceNumber >>> 0; + if (!session.resendPacketCache.has(seq)) { + session.resendPacketOrder.push(seq); + } + + session.resendPacketCache.set(seq, { + packet: Buffer.from(packetBuf), + ts: Date.now(), + }); + + while (session.resendPacketOrder.length > maxCacheSize) { + const dropSeq = session.resendPacketOrder.shift(); + session.resendPacketCache.delete(dropSeq); + } + } + + _lookupResendPacket(session, sequenceNumber) { + if (!session) return null; + + const seq = sequenceNumber >>> 0; + const direct = session.resendPacketCache.get(seq); + if (direct && Buffer.isBuffer(direct.packet)) { + return direct.packet; + } + + // Some clients may only preserve the low 8 bits from AFFlags. + const low8 = seq & 0xFF; + for (let i = session.resendPacketOrder.length - 1; i >= 0; i--) { + const candidateSeq = session.resendPacketOrder[i] >>> 0; + if ((candidateSeq & 0xFF) !== low8) continue; + const candidate = session.resendPacketCache.get(candidateSeq); + if (candidate && Buffer.isBuffer(candidate.packet)) { + return candidate.packet; + } + } + + return null; + } + + _handleUdpDatagram(msg, rinfo) { + if (!Buffer.isBuffer(msg) || msg.length === 0) { + return; + } + + this._learnUdpPeerPort(rinfo); + + if (msg.length < 12) { + this.debugLog('udp rx', rinfo.address + ':' + rinfo.port, 'len=' + msg.length); + return; + } + + const resendReq = this._parseRequestPacketListResend(msg); + if (!resendReq) { + this.debugLog('udp rx', rinfo.address + ':' + rinfo.port, 'len=' + msg.length); + return; + } + + const peerIp = this._normalizeIp(rinfo.address); + let targetSession = null; + + for (const session of this.sessions.values()) { + if (!session || !session.udpDataEnabled || !session.requestedUdp) continue; + if (this._normalizeIp(session.clientEndpointIp) !== peerIp) continue; + if ((session.clientId >>> 0) !== resendReq.clientId) continue; + if (((session.openFileId || 0) & 0xFFFF) !== resendReq.sourceId) continue; + + // If we already learned a client media port, require it to match. + if (session.clientPort > 0 && session.clientPort !== rinfo.port) { + continue; + } + + targetSession = session; + break; + } + + if (!targetSession) { + this.debugLog('udp resend no session', + 'from=' + peerIp + ':' + rinfo.port, + 'clientId=0x' + resendReq.clientId.toString(16), + 'sourceId=0x' + resendReq.sourceId.toString(16), + 'count=' + resendReq.packetList.length); + return; + } + + let resent = 0; + let missing = 0; + for (const sequenceNumber of resendReq.packetList) { + const packet = this._lookupResendPacket(targetSession, sequenceNumber); + if (!packet) { + missing++; + continue; + } + + this.udpServer.send(packet, 0, packet.length, targetSession.clientPort >>> 0, targetSession.clientEndpointIp, (err) => { + if (err) { + this.debugLog('udp resend send error', targetSession.id, + 'seq=' + (sequenceNumber >>> 0), + err.message); + } + }); + targetSession.bytesTx += packet.length; + resent++; + } + + this.debugLog('udp resend', targetSession.id, + 'requested=' + resendReq.packetList.length, + 'resent=' + resent, + 'missing=' + missing, + 'from=' + peerIp + ':' + rinfo.port); + } + + _parseRequestPacketListResend(msg) { + if (!Buffer.isBuffer(msg) || msg.length < 12) return null; + + const MMS_RETRANSMIT_SIGNATURE = 0xBEEFF00D; + let littleEndian = true; + if (msg.readUInt32LE(0) !== MMS_RETRANSMIT_SIGNATURE) { + if (msg.readUInt32BE(0) !== MMS_RETRANSMIT_SIGNATURE) { + return null; + } + littleEndian = false; + } + + const readU32 = (offset) => littleEndian ? msg.readUInt32LE(offset) : msg.readUInt32BE(offset); + const readU16 = (offset) => littleEndian ? msg.readUInt16LE(offset) : msg.readUInt16BE(offset); + + const clientId = readU32(4) >>> 0; + const sourceId = readU16(8) >>> 0; + const numPackets = readU16(10) >>> 0; + if (numPackets < 1 || numPackets > 32) { + return null; + } + + const expectedLen = 12 + (numPackets * 4); + if (msg.length < expectedLen) { + return null; + } + + const packetList = []; + let offset = 12; + for (let i = 0; i < numPackets; i++) { + packetList.push(readU32(offset) >>> 0); + offset += 4; + } + + return { + clientId, + sourceId, + packetList, + }; + } + + _flushQueuedPackets(socket, session) { + if (socket.destroyed || session.packetQueue.length === 0) { + return true; + } + + const currentGen = session.seekGeneration; + let writeOk = true; + + while (session.packetQueue.length > 0) { + const queuedPacket = session.packetQueue[0]; + if (queuedPacket.generation !== currentGen) { + session.packetQueue.shift(); + continue; + } + + writeOk = socket.write(queuedPacket.buffer); + session.bytesTx += queuedPacket.buffer.length; + session.packetQueue.shift(); + if (!writeOk) return false; + } + + return true; + } + + // ------------------------------------------------------------------------- + // Individual response payload builders + // ------------------------------------------------------------------------- + + _utf16leBuffer(str) { + return Buffer.from((str || '') + '\0', 'utf16le'); + } + + _decodeUtf16CString(buf) { + if (!buf || buf.length === 0) return ''; + + let end = buf.length; + for (let offset = 0; offset + 1 < buf.length; offset += 2) { + if (buf.readUInt16LE(offset) === 0) { + end = offset; + break; + } + } + + return buf.slice(0, end).toString('utf16le').trim(); + } + + _normalizeIp(remoteAddress) { + if (!remoteAddress) return ''; + return remoteAddress.startsWith('::ffff:') ? remoteAddress.slice(7) : remoteAddress; + } + + _selectTransportString(utf16Str, ansiStr) { + const a = (utf16Str || '').trim(); + const b = (ansiStr || '').trim(); + const score = (s) => { + let v = 0; + if (!s) return v; + if (s.includes('\\')) v += 2; + if (/\b(?:CUBDD|MMSU|UDP|TCP)\b/i.test(s)) v += 2; + if (/\\\d+/.test(s)) v += 1; + return v; + }; + return score(a) >= score(b) ? a : b; + } + + _parseTransportRequest(transportStr) { + if (!transportStr) return null; + const ipMatch = transportStr.match(/\\([^\\]+)/); + const ip = ipMatch ? ipMatch[1] : ''; + const protocol = this._inferTransportToken(transportStr) || ''; + + let port = 0; + const candidatePorts = []; + if (protocol) { + const tokenPortRegex = new RegExp('\\\\' + protocol + '(?:\\\\(\\d+))+', 'ig'); + let tokenMatch; + while ((tokenMatch = tokenPortRegex.exec(transportStr)) !== null) { + const numericParts = tokenMatch[0].match(/\\(\\d+)/g) || []; + for (const numericPart of numericParts) { + const parsed = parseInt(numericPart.slice(1), 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + candidatePorts.push(parsed); + } + } + } + } + + if (candidatePorts.length === 0) { + const fallbackPortRegex = /\\(?:CUBDD|MMSU|UDP|TCP)\\(\d+)/ig; + let fallbackMatch; + while ((fallbackMatch = fallbackPortRegex.exec(transportStr)) !== null) { + const parsed = parseInt(fallbackMatch[1], 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + candidatePorts.push(parsed); + } + } + } + + // Prefer the first valid candidate as initial target, then probe alternates. + if (candidatePorts.length > 0) { + port = candidatePorts[0]; + } + + if (!ip && !protocol && !port) return null; + return { + ip, + protocol, + port, + ports: [...new Set(candidatePorts)], + }; + } + + _learnUdpPeerPort(rinfo) { + if (!rinfo || !Number.isInteger(rinfo.port)) return; + const peerIp = this._normalizeIp(rinfo.address); + const learnedPort = this._sanitizeUdpPort(rinfo.port); + if (!peerIp || learnedPort <= 0) return; + + const matchingSessions = []; + for (const session of this.sessions.values()) { + if (!session || !session.requestedUdp) continue; + if (this._normalizeIp(session.clientEndpointIp) !== peerIp) continue; + matchingSessions.push(session); + } + + if (matchingSessions.length !== 1) { + return; + } + + const session = matchingSessions[0]; + if (session.udpPeerLearnedPort === learnedPort && session.clientPort === learnedPort) { + return; + } + + const previousPort = session.clientPort; + session.udpPeerLearnedPort = learnedPort; + session.clientPort = learnedPort; + if (!Array.isArray(session.clientPortCandidates)) { + session.clientPortCandidates = []; + } + if (session.clientPortCandidates.indexOf(learnedPort) < 0) { + session.clientPortCandidates.unshift(learnedPort); + } + this._refreshUdpTransportState(session); + + this.debugLog('udp peer learned', session.id, + 'oldPort=' + (previousPort || 0), + 'newPort=' + learnedPort, + 'ip=' + peerIp); + } + + _sanitizeUdpPort(port) { + const parsed = Number(port); + return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : 0; + } + + _refreshUdpTransportState(session) { + if (!session) return; + const udpEnabledByConfig = this.service_config.enable_udp !== false; + session.clientPort = this._sanitizeUdpPort(session.clientPort); + session.udpDataEnabled = !!session.requestedUdp + && udpEnabledByConfig + && !!this.udpServer + && !!this.udpServerReady + && session.clientPort > 0 + && !!session.clientEndpointIp; + } + + _inferTransportToken(transportStr) { + if (!transportStr) return ''; + const upper = String(transportStr).toUpperCase(); + // Prioritize UDP-like transports over TCP when both appear. + if (upper.includes('\\CUBDD\\')) return 'CUBDD'; + if (upper.includes('\\MMSU\\')) return 'MMSU'; + if (upper.includes('\\UDP\\')) return 'UDP'; + if (upper.includes('\\TCP\\')) return 'TCP'; + return ''; + } + + _resolveDataAfMode(session) { + const mode = String(this.service_config?.data_af_mode || 'auto').toLowerCase(); + if (mode === 'zero' || mode === 'sequence') return mode; + return session.isLegacyClient ? 'sequence' : 'zero'; + } + + _resolveMediaPacketId(session) { + const mode = String(this.service_config?.media_packet_id_mode || 'auto').toLowerCase(); + if (mode === 'fixed') return MMS_MEDIA_PACKET_ID; + if (mode === 'incarnation') return session.mediaIncarnation & 0xFF; + return session.isLegacyClient + ? (session.mediaIncarnation & 0xFF) + : MMS_MEDIA_PACKET_ID; + } + + _nextDataFlags(session) { + if (session.dataAfMode === 'sequence') { + const sequence = session.dataSequence >>> 0; + const flags = sequence & 0xFF; + session.lastDataSequenceTx = sequence; + session.dataSequence = (sequence + 1) >>> 0; + return flags; + } + session.lastDataSequenceTx = session.mediaPacketsTx >>> 0; + return 0x00; + } + + _parseStartPlaying(openFileId, payload) { + // _handleCommandPacket passes the reduced StartPlaying payload that begins at position(8). + if (!payload || payload.length < 24) return null; + + const rawPosition = payload.readDoubleLE(0); + const asfOffset = payload.readUInt32LE(8) >>> 0; + const locationId = payload.readUInt32LE(12) >>> 0; + const frameOffset = payload.readUInt32LE(16) >>> 0; + const playIncarnation = payload.readUInt32LE(20) >>> 0; + const position = Number.isFinite(rawPosition) && rawPosition >= 0 && rawPosition < 1e300 + ? rawPosition + : null; + + return { + openFileId: openFileId >>> 0, + position, + asfOffset, + locationId, + frameOffset, + playIncarnation, + }; + } + + _resolveStartSeek(session, startReq) { + if (!startReq || !session.mediaPath) return null; + + const dataStart = session.asfDataOffset + 50; + const maxPacketSize = Math.max(1, session.asfPacketSize >>> 0); + const maxMediaBytes = Math.max(0, session.asfFileSize - dataStart); + const maxPacketFromBytes = maxMediaBytes > 0 + ? Math.max(0, Math.floor((maxMediaBytes - 1) / maxPacketSize)) + : 0; + const maxPacketFromHeader = session.asfPacketCount > 0 + ? Math.max(0, (session.asfPacketCount >>> 0) - 1) + : maxPacketFromBytes; + const maxPacketNumber = Math.max(maxPacketFromBytes, maxPacketFromHeader); + + const clampPacketNumber = (value) => { + const n = Number.isFinite(value) ? Math.floor(value) : 0; + return Math.max(0, Math.min(n, maxPacketNumber)); + }; + + let packetNumber = 0; + let readPos = dataStart; + let seekSource = 'default'; + + if (startReq.locationId !== 0 && startReq.locationId !== 0xFFFFFFFF) { + packetNumber = clampPacketNumber(startReq.locationId >>> 0); + readPos = dataStart + (packetNumber * maxPacketSize); + seekSource = 'locationId'; + this.debugLog('seek resolve', session.id, seekSource, 'packet=' + packetNumber, 'readPos=0x' + readPos.toString(16)); + return { + packetNumber, + readPos: Math.min(readPos, session.asfFileSize), + }; + } + + if (startReq.asfOffset !== 0 && startReq.asfOffset !== 0xFFFFFFFF) { + const requested = Math.max(dataStart, startReq.asfOffset >>> 0); + const relative = Math.max(0, requested - dataStart); + packetNumber = clampPacketNumber(Math.floor(relative / maxPacketSize)); + readPos = dataStart + (packetNumber * maxPacketSize); + seekSource = 'asfOffset'; + this.debugLog('seek resolve', session.id, seekSource, 'packet=' + packetNumber, 'readPos=0x' + readPos.toString(16)); + return { + packetNumber, + readPos: Math.min(readPos, session.asfFileSize), + }; + } + + if (Number.isFinite(startReq.position) && startReq.position > 0 && startReq.position < 1e300) { + if (session.asfPacketCount > 0 && session.asfDurationSec > 0) { + const ratio = Math.max(0, Math.min(1, startReq.position / session.asfDurationSec)); + packetNumber = clampPacketNumber(Math.floor(ratio * session.asfPacketCount)); + readPos = dataStart + (packetNumber * maxPacketSize); + seekSource = 'position-duration'; + } else if (session.bitrateBps > 0) { + const bytePos = Math.floor((startReq.position * session.bitrateBps) / 8); + const clamped = Math.max(0, Math.min(bytePos, maxMediaBytes)); + packetNumber = clampPacketNumber(Math.floor(clamped / maxPacketSize)); + readPos = dataStart + (packetNumber * maxPacketSize); + seekSource = 'position-bitrate'; + } + } + + this.debugLog('seek resolve', session.id, seekSource, 'packet=' + packetNumber, 'readPos=0x' + readPos.toString(16)); + + return { + packetNumber, + readPos: Math.min(readPos, session.asfFileSize), + }; + } + + _buildConnectAcceptedPayload(session) { + // LinkMacToViewerReportConnectedEX + const isLegacyClient = /^NSPlayer\/6\./i.test(session?.clientPlayer || ''); + const serverVersion = this._utf16leBuffer(this.majorVersion + '.' + this.minorVersion); + const playerMinVersion = this._utf16leBuffer(''); // no minimum version + const updatePlayerUrl = this._utf16leBuffer(''); + const AuthenPackage = this._utf16leBuffer(''); + const configUdp = (this.service_config.enable_udp === false) ? false : true; + const failure = (!configUdp && session.requestedUdp) || (configUdp && session.requestedUdp) || (!session.requestedUdp && !session.requestedTcp); + + const header = Buffer.alloc(48); + header.writeUInt32LE(header.length / 8, 0); // chunkLength in 8-byte blocks + header.writeUInt32LE(0x00040001, 4); // MessageID + header.writeUInt32LE((failure ? 0xC00D0005 : 0x00000000), 8); // HRESULT (StatusCode) (NS_E_NOCONNECTION if failure = true, 0 = false) + header.writeUInt32LE(MMS_DISABLE_PACKET_PAIR, 12); // playIncaration (packet-pair support) + header.writeUInt32LE(0x0004000B, 16); // MacToViewerProtocolRevision + header.writeUInt32LE(0x0003001C, 20); // ViewerToMacProtocolRevision + header.writeDoubleLE(1.0, 8); // blockGroupPlayTime = 1.0 + header.writeUInt32LE(0x00000001, 16); // blockGroupBlocks + header.writeUInt32LE(0x00000001, 20); // nMaxOpenFiles + header.writeUInt32LE(0x00008000, 24); // nBlockMaxBytes + header.writeUInt32LE(0x00989680, 28); // maxBitRate + header.writeUInt32LE(serverVersion.length / 2, 32); // cbServerVersionInfo (Unicode chars) + header.writeUInt32LE(playerMinVersion.length / 2, 36); // cbVersionInfo + header.writeUInt32LE(updatePlayerUrl.length / 2, 40); // cbVersionUrl + header.writeUInt32LE(AuthenPackage.length / 2, 44); // cbAuthenPackage + + return Buffer.concat([header, serverVersion, playerMinVersion, updatePlayerUrl, AuthenPackage]); + } + + _buildTransportAcceptedPayload() { + // packetPayloadSize(4) + funnelName (UTF-16LE, null-terminated) + const funnelName = Buffer.from('Funnel Of The Gods\0', 'utf16le'); + const buf = Buffer.alloc(4 + funnelName.length); + // packetPayloadSize = 0 (already zero from alloc) + funnelName.copy(buf, 4); + return buf; + } + + _buildFunnelInfoPayload(session) { + // transportMask(4) + nBlockFragments(4) + fragmentBytes(4) + nCubs(4) + // + failedCubs(4) + nDisks(4) + decluster(4) + cubddDatagramSize(4) + const buf = Buffer.alloc(32); + buf.writeUInt32LE(0x00000008, 0); // transportMask + buf.writeUInt32LE(0x00000001, 4); // nBlockFragments + buf.writeUInt32LE(0x00010000, 8); // fragmentBytes + buf.writeUInt32LE(session.clientId >>> 0, 12); // nCubs (unique client identifier) + buf.writeUInt32LE(0x00000000, 16); // failedCubs + buf.writeUInt32LE(0x00000001, 20); // nDisks + buf.writeUInt32LE(0x00000000, 24); // decluster + buf.writeUInt32LE(0x00000000, 28); // cubddDatagramSize + return buf; + } + + _buildFileDetailsPayload(session) { + // Full LinkMacToViewerReportOpenFile payload (100 bytes) + const buf = Buffer.alloc(100); + const headerBuf = this._readAsfHeader(session) || Buffer.alloc(0); + const mediaDataStart = session.asfDataOffset + 50; + const mediaBytes = Math.max(0, session.asfFileSize - mediaDataStart); + const packetCount = session.asfPacketCount > 0 + ? session.asfPacketCount + : (session.asfPacketSize > 0 ? Math.ceil(mediaBytes / session.asfPacketSize) : 0); + const durationSeconds = session.asfDurationSec > 0 + ? Math.ceil(session.asfDurationSec) + : (session.bitrateBps > 0 + ? Math.ceil((mediaBytes * 8) / session.bitrateBps) + : 0); + + buf.writeUInt32LE(session.openFileId >>> 0, 0); // openFileId + // padding [4] = 0, fileName [8] = 0 (already zeroed) + buf.writeUInt32LE(FILE_ATTRIBUTE_MMS_CANSEEK, 12); // fileAttributes = FILE_ATTRIBUTE_MMS_CANSEEK + buf.writeDoubleLE(durationSeconds, 16); // fileDuration (8-byte double) + buf.writeUInt32LE(durationSeconds >>> 0, 24); // fileBlocks (integer seconds) + // unused1 [28-43] = 0 (already zeroed) + buf.writeUInt32LE(session.asfPacketSize >>> 0, 44); // filePacketSize + buf.writeUInt32LE(packetCount >>> 0, 48); // filePacketCount low 32 bits + // filePacketCount high 32 bits [52-55] = 0 + buf.writeUInt32LE(session.bitrateBps >>> 0, 56); // fileBitRate + buf.writeUInt32LE(headerBuf.length >>> 0, 60); // fileHeaderSize + // unused2 [64-99] = 0 (already zeroed) + return buf; + } + + _buildOpenFileErrorPayload() { + return Buffer.alloc(0); + } +} + +module.exports = WTVMMS; diff --git a/zefie_wtvp_minisrv/includes/config.json b/zefie_wtvp_minisrv/includes/config.json index bd67b6d1..df260909 100644 --- a/zefie_wtvp_minisrv/includes/config.json +++ b/zefie_wtvp_minisrv/includes/config.json @@ -422,19 +422,30 @@ "key": "%ServiceDeps%/https/selfsigned_key.pem" } }, + "mms": { + "port": 1755, + "udp_port": 1755, + "enable_udp": true, + "protocol_handler": "mms", + "pacing_multiplier": 0.9, + "enable_burst": true, + "burst_multiplier": 4.0, + "burst_prestart_ms": 15000, + "allow_indexing": true, + "debug": false, + "handler_module": "WTVMMS" + }, "pnm": { "port": 7070, - "flags": "0x00000001", - "allow_double_slash": true, "protocol_handler": "pnm", "udp_bind_port_min": 57361, "udp_bind_port_max": 57391, "descriptor_after_hello_ms": 85, "burst_prestart_ms": 5000, - "debug": false, "allow_indexing": true, + "debug": false, "handler_module": "WTVPNM" - } + } }, "favorites": { "folder_templates": {