import WebSocket from 'ws' interface ThreadfinConfig { url: string user: string pass: string } interface XepgEntry { 'tvg-id': string 'x-active': boolean [key: string]: unknown } type EpgMapping = Record interface ActivateResult { activated: string[] notFound: string[] } function getConfig(): ThreadfinConfig { const url = process.env.THREADFIN_URL const user = process.env.THREADFIN_USER const pass = process.env.THREADFIN_PASS if (!url || !user || !pass) { throw new Error('THREADFIN_URL, THREADFIN_USER, THREADFIN_PASS must be set') } return { url: url.replace(/\/$/, ''), user, pass } } async function login(): Promise { const { url, user, pass } = getConfig() const res = await fetch(`${url}/api/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cmd: 'login', username: user, password: pass }), }) if (!res.ok) throw new Error(`Threadfin login failed: ${res.status}`) const data = await res.json() if (!data.token) throw new Error('Threadfin login returned no token') return data.token } export async function activateChannels( channelIds: string[], ): Promise { const { url } = getConfig() const token = await login() const wsUrl = url.replace(/^http/, 'ws') + `/data/?Token=${token}` return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl) const timeout = setTimeout(() => { ws.close() reject(new Error('Threadfin WebSocket timed out after 30s')) }, 30_000) let currentToken = token ws.on('error', (err) => { clearTimeout(timeout) reject(err) }) ws.on('message', (raw) => { try { const msg = JSON.parse(raw.toString()) // Update token if rotated if (msg.token) currentToken = msg.token // Initial data response contains the xepg map if (msg.xepg) { const epgMapping: EpgMapping = msg.xepg const activated: string[] = [] const idSet = new Set(channelIds) const foundIds = new Set() // Find and activate matching channels for (const [key, entry] of Object.entries(epgMapping)) { const tvgId = entry['tvg-id'] if (tvgId && idSet.has(tvgId)) { entry['x-active'] = true activated.push(tvgId) foundIds.add(tvgId) } } const notFound = channelIds.filter((id) => !foundIds.has(id)) if (activated.length === 0) { clearTimeout(timeout) ws.close() resolve({ activated, notFound }) return } // Must send the ENTIRE map back const saveMsg = JSON.stringify({ cmd: 'saveEpgMapping', epgMapping, token: currentToken, }) ws.send(saveMsg, (err) => { clearTimeout(timeout) if (err) { ws.close() reject(err) return } // Give Threadfin a moment to process, then close setTimeout(() => { ws.close() resolve({ activated, notFound }) }, 1000) }) } } catch (err) { clearTimeout(timeout) ws.close() reject(err) } }) }) }