API support for channel change

This commit is contained in:
david 2025-04-01 16:29:56 -07:00
parent a83a5fa605
commit 358f9c8784
5 changed files with 217 additions and 17 deletions

6
dvb_channel.conf Normal file
View File

@ -0,0 +1,6 @@
ION:521028615:8VSB:49:52:3
KATU:533028615:8VSB:49:52:3
KOIN-HD:539028615:8VSB:49:52:3
KGW:545028615:8VSB:49:52:3
KBLN-DT:575028615:8VSB:49:52:1
TBN HD:581028615:8VSB:49:52:3

View File

@ -5,9 +5,13 @@
"ws": "^8.18.0"
},
"scripts": {
"build": "npx tsc --skipLibCheck --outDir dist src/server.ts",
"build:scss": "npx sass src/static/css:dist/static/css",
"copy:html": "cp src/static/index.html dist/static",
"build:js:fe": "npx tsc src/static/js/*.ts --outDir dist/static/js",
"build:js:be": "npx tsc --skipLibCheck src/*.ts --outDir dist",
"build": "npm run build:js:fe && npm run build:js:be && npm run build:scss && npm run copy:html",
"start": "npm run build && node dist/server.js",
"debug": "npx ts-node src/server.ts"
"dev": "npm run build && npx ts-node src/server.ts"
},
"devDependencies": {
"@types/node": "^22.10.2",
@ -15,4 +19,4 @@
"ts-node": "^10.9.2",
"typescript": "^5.8.2"
}
}
}

View File

@ -8,20 +8,45 @@ export default class HttpServer {
private httpServer: http.Server;
private port: number;
private root: string;
public constructor(port: number, root : string) {
public constructor(port: number, root: string, tune: (ch: string, adp?: number) => void) {
this.port = port;
this.root = root;
this.httpServer = http.createServer((req, res) => {
const filePath = path.join(root, req.url === "/" ? "index.html" : req.url || "");
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end("Not Found");
} else {
res.writeHead(200);
res.end(data);
let status: number = 404;
let body: any;
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = path.normalize(url.pathname);
if (req.method === 'GET') {
const filePath = path.join(root, path.extname(pathname) === '' ? pathname + "/index.html" : pathname);
try {
body = fs.readFileSync(filePath);
status = 200;
}
});
catch (err) {
body = "Invalid File"
}
}
else if (req.method === 'PUT' && pathname.startsWith('/api/')) {
const query = pathname.split('/');
const api = query[2];
switch (api) {
case "channel":
const channel = query[3];
const adapter = parseInt(url.searchParams.get('adapter'));
tune(channel, adapter);
break;
default:
body = "Invalid API Endpoint"
break;
}
}
else {
body = "Invalid Request"
}
res.writeHead(status);
res.end(body);
});
}
public start() {
@ -34,5 +59,7 @@ export default class HttpServer {
}

View File

@ -1,15 +1,51 @@
import HttpServer from './http';
import TVWebSocket from './ws';
import Zap, { IZap } from './zap';
const HTTP_PORT = process.env.HTTP_PORT ? parseInt(process.env.HTTP_PORT,10) : 8080;
import * as readline from 'readline';
const HTTP_PORT = process.env.HTTP_PORT ? parseInt(process.env.HTTP_PORT, 10) : 8080;
const WS_PORT = process.env.WS_PORT ? parseInt(process.env.WS_PORT, 10) : 3001;
const STATIC_ROOT = process.cwd() + "/dist/static";;
const STATIC_ROOT = process.cwd() + "/dist/static";
const TV_DEV_0 = process.env.TV_DEV_0 ?? '/dev/dvb/adapter0/dvr0'
const TV_DEV_1 = process.env.TV_DEV_1 ? '/dev/dvb/adapter0/dvr1' : null;
const httpServer = new HttpServer(HTTP_PORT, STATIC_ROOT);
const zap = new Zap();
const tune = (reqChannel: string, reqAdapter?: number) => {
const adapter = reqAdapter === 0 || reqAdapter === 1 ? reqAdapter : 0;
zap.zapTo(reqChannel, adapter).then((zap: IZap) => {
console.log(`Tuned ${zap.adapter} to ${zap.channel}`)
}).catch((err: Error) => {
console.error(err.message);
});
}
const httpServer = new HttpServer(HTTP_PORT, STATIC_ROOT, tune);
const tvWebSocket = new TVWebSocket(WS_PORT);
httpServer.start();
process.stdin.setEncoding("utf8");
process.stdin.resume();
console.log("Enter Channel Name:");
process.stdin.on("data", async (data: string) => {
const input = data.trim();
console.log(`Received: "${input}"`);
await zap.zapTo(input).then((zap: IZap) => {
console.log(`Tuned ${zap.adapter} to ${zap.channel}`)
}).catch((err: Error) => {
console.error(err.message);
});
});

127
src/zap.ts Normal file
View File

@ -0,0 +1,127 @@
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
import * as fs from "fs";
export interface IZap {
process: ChildProcessWithoutNullStreams | null,
channel: string,
adapter: 0 | 1
}
export default class Zap {
private zap0: IZap;
private zap1: IZap;
private channelNameList: string[];
private fileName: string;
public constructor(fileName = "dvb_channel.conf", channel = "ION") {
const zap0: IZap = {
process: null,
channel,
adapter: 0
}
const zap1: IZap = {
process: null,
channel,
adapter: 1
}
this.zap0 = zap0;
this.zap1 = zap1;
const file = fs.readFileSync('./' + fileName, "utf-8");
const lines = file.split("\n").map(line => line.trim()).filter(line => line && !line.startsWith("#")).map(line => line.split(":")[0]);
this.channelNameList = lines;
this.fileName = fileName;
}
private nextChannel(channel: string) {
const size = this.channelNameList.length;
const currentIndex = this.channelNameList.indexOf(channel);
if (currentIndex >= 0) {
return this.channelNameList[this.mod(currentIndex + 1, size)];
}
else {
return channel;
}
}
private mod(n: number, m: number) {
return ((n % m) + m) % m;
}
private previousChannel(channel: string) {
const size = this.channelNameList.length;
const currentIndex = this.channelNameList.indexOf(channel);
if (currentIndex >= 0) {
return this.channelNameList[this.mod(currentIndex - 1, size)];
}
else {
return channel;
}
}
private cleanup(proc: ChildProcessWithoutNullStreams): Promise<void> {
proc.kill('SIGHUP');
return new Promise((resolve, reject) => {
proc.once('exit', () =>
resolve()
)
proc.on("error", (err:Error) =>
reject(err)
);
})
}
public async zapTo(channel: string, adapter: 0 | 1 = 0): Promise<IZap> {
let zap = adapter == 0 ? this.zap0 : this.zap1;
let verifiedChannel: string;
if (channel == '+') {
verifiedChannel = this.nextChannel(zap.channel);
}
else if (channel == '-') {
verifiedChannel = this.previousChannel(zap.channel);
}
else {
if (!this.channelNameList.includes(channel)) {
return Promise.reject(new Error("Invalid Channel name"));
}
else {
verifiedChannel = channel;
}
}
const cmd = 'dvbv5-zap'
const args: string[] = ["-r", "-C", "US", "-I", "ZAP", "-c", this.fileName, "-a", adapter.toString(), verifiedChannel];
if (zap.process != null) {
await this.cleanup(zap.process).catch(err => Promise.reject(err));
}
zap.process = spawn(cmd, args);
return new Promise((resolve, reject) => {
let lockTimer: NodeJS.Timeout;
zap.process.stderr.on("data", (data) => {
const output = data.toString();
if (/Lock/.test(output)) {
clearTimeout(lockTimer);
zap.channel = verifiedChannel;
resolve(zap);
}
if (/Not locked/.test(output)) {
clearTimeout(lockTimer);
zap.process.kill("SIGHUP");
}
});
zap.process.on("exit", (code) => {
reject(new Error("Unexpected exit of Zap"));
});
lockTimer = setTimeout(() => {
reject(new Error("Failed to Zap in time"));
}, 5000);
})
}
}