jefflix-website/lib/threadfin.ts

129 lines
3.3 KiB
TypeScript

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<string, XepgEntry>
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<string> {
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<ActivateResult> {
const { url } = getConfig()
const token = await login()
const wsUrl = url.replace(/^http/, 'ws') + `/data/?Token=${token}`
return new Promise<ActivateResult>((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<string>()
// 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)
}
})
})
}