129 lines
3.3 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|
|
}
|