Settings key opens VMC-menu, CHANNEL P+/P- zaps live TV, and Dokploy persists android-tv APK volume.
212 lines
6.9 KiB
TypeScript
212 lines
6.9 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import type { Config } from "../config";
|
|
import { requireAdmin } from "../auth/routes";
|
|
import { AppError } from "../security/errors";
|
|
import {
|
|
TOKEN_TRAY_MAX_BYTES,
|
|
deleteTokenTrayInstaller,
|
|
getTokenTrayMeta,
|
|
resolveInstallRoot,
|
|
saveTokenTrayUpload,
|
|
tokenTrayPaths,
|
|
} from "./token-tray-store";
|
|
import {
|
|
ANDROID_TV_APK_MAX_BYTES,
|
|
androidTvApkPaths,
|
|
checkAndroidTvUpdate,
|
|
deleteAndroidTvApk,
|
|
getAndroidTvApkMeta,
|
|
saveAndroidTvApkUpload,
|
|
type AndroidTvApkFlavor,
|
|
} from "./android-tv-apk-store";
|
|
|
|
function sendInstallScript(
|
|
reply: {
|
|
header: (k: string, v: string) => unknown;
|
|
status: (c: number) => { send: (b: string) => unknown };
|
|
send: (b: string) => unknown;
|
|
},
|
|
dir: string,
|
|
filename: string,
|
|
publicUrl: string
|
|
) {
|
|
const file = path.join(dir, filename);
|
|
if (!fs.existsSync(file)) {
|
|
return reply
|
|
.status(404)
|
|
.send("Install script not found — redeploy Master with install assets.");
|
|
}
|
|
let script = fs.readFileSync(file, "utf8");
|
|
script = script.replaceAll("__MASTER_PUBLIC_URL__", publicUrl.replace(/\/$/, ""));
|
|
reply.header("Content-Type", "text/x-shellscript; charset=utf-8");
|
|
reply.header("Content-Disposition", `inline; filename="${filename}"`);
|
|
return reply.send(script);
|
|
}
|
|
|
|
export async function registerInstallRoutes(app: FastifyInstance, config: Config) {
|
|
const dir = resolveInstallRoot();
|
|
const publicUrl = config.PUBLIC_URL;
|
|
|
|
app.get("/install/unraid.sh", async (_request, reply) => {
|
|
return sendInstallScript(reply, dir, "unraid.sh", publicUrl);
|
|
});
|
|
|
|
app.get("/install/synology.sh", async (_request, reply) => {
|
|
return sendInstallScript(reply, dir, "synology.sh", publicUrl);
|
|
});
|
|
|
|
app.get("/install/upgrade-node.sh", async (_request, reply) => {
|
|
return sendInstallScript(reply, dir, "upgrade-node.sh", publicUrl);
|
|
});
|
|
|
|
for (const bin of ["media-node-linux-amd64", "media-node-linux-arm64"] as const) {
|
|
app.get(`/install/${bin}`, async (_request, reply) => {
|
|
const file = path.join(dir, bin);
|
|
if (!fs.existsSync(file)) {
|
|
return reply.status(404).send(`${bin} not available on this Master build`);
|
|
}
|
|
reply.header("Content-Type", "application/octet-stream");
|
|
reply.header("Content-Disposition", `attachment; filename="${bin}"`);
|
|
return reply.send(fs.createReadStream(file));
|
|
});
|
|
}
|
|
|
|
app.get("/install/token-tray.json", async (_request, reply) => {
|
|
reply.header("Content-Type", "application/json; charset=utf-8");
|
|
reply.header("Cache-Control", "no-store");
|
|
return reply.send(getTokenTrayMeta(publicUrl));
|
|
});
|
|
|
|
app.get("/install/token-tray-setup.exe", async (_request, reply) => {
|
|
const { exe } = tokenTrayPaths();
|
|
if (!fs.existsSync(exe)) {
|
|
return reply
|
|
.status(404)
|
|
.send(
|
|
"Token Tray installer not found — upload via Admin → /agent (of pack:win + publish)."
|
|
);
|
|
}
|
|
reply.header("Content-Type", "application/octet-stream");
|
|
reply.header(
|
|
"Content-Disposition",
|
|
'attachment; filename="Media-Cluster-Token-Tray-Setup.exe"'
|
|
);
|
|
reply.header("Cache-Control", "no-store");
|
|
return reply.send(fs.createReadStream(exe));
|
|
});
|
|
|
|
app.get("/api/v1/admin/install/token-tray", { preHandler: requireAdmin }, async () => {
|
|
return getTokenTrayMeta(publicUrl);
|
|
});
|
|
|
|
app.post(
|
|
"/api/v1/admin/install/token-tray",
|
|
{
|
|
preHandler: requireAdmin,
|
|
bodyLimit: TOKEN_TRAY_MAX_BYTES + 1024 * 1024,
|
|
},
|
|
async (request) => {
|
|
const data = await request.file();
|
|
if (!data) {
|
|
throw new AppError(
|
|
"INVALID_REQUEST",
|
|
"Geen bestand — stuur multipart field 'file' (.exe)",
|
|
400
|
|
);
|
|
}
|
|
|
|
let version: string | null = null;
|
|
const versionField = data.fields?.version;
|
|
if (versionField && !Array.isArray(versionField) && "value" in versionField) {
|
|
version = String((versionField as { value: unknown }).value ?? "").trim() || null;
|
|
}
|
|
const q = (request.query as { version?: string }).version;
|
|
if (!version && typeof q === "string" && q.trim()) version = q.trim();
|
|
|
|
return saveTokenTrayUpload({
|
|
stream: data.file,
|
|
filename: data.filename,
|
|
version,
|
|
publicUrl,
|
|
});
|
|
}
|
|
);
|
|
|
|
app.delete("/api/v1/admin/install/token-tray", { preHandler: requireAdmin }, async () => {
|
|
return deleteTokenTrayInstaller(publicUrl);
|
|
});
|
|
|
|
// ——— Android TV / VMC Mediabox OTA APK ———
|
|
app.get("/api/v1/admin/install/android-tv", { preHandler: requireAdmin }, async () => {
|
|
return getAndroidTvApkMeta();
|
|
});
|
|
|
|
app.post(
|
|
"/api/v1/admin/install/android-tv",
|
|
{
|
|
preHandler: requireAdmin,
|
|
bodyLimit: ANDROID_TV_APK_MAX_BYTES + 1024 * 1024,
|
|
},
|
|
async (request) => {
|
|
const data = await request.file();
|
|
if (!data) {
|
|
throw new AppError(
|
|
"INVALID_REQUEST",
|
|
"Geen bestand — stuur multipart field 'file' (.apk)",
|
|
400
|
|
);
|
|
}
|
|
return saveAndroidTvApkUpload({
|
|
stream: data.file,
|
|
filename: data.filename,
|
|
fields: data.fields as Record<string, unknown>,
|
|
});
|
|
}
|
|
);
|
|
|
|
app.delete("/api/v1/admin/install/android-tv", { preHandler: requireAdmin }, async () => {
|
|
return deleteAndroidTvApk();
|
|
});
|
|
}
|
|
|
|
/** Client OTA check + download — registered from viewer routes (needs auth). */
|
|
export function registerAndroidTvClientUpdateRoutes(
|
|
app: FastifyInstance,
|
|
authFromBearer: (authorization: string | undefined) => Promise<unknown>
|
|
) {
|
|
app.get("/api/v1/client/app-update", async (request) => {
|
|
await authFromBearer(request.headers.authorization);
|
|
const q = request.query as {
|
|
platform?: string;
|
|
versionCode?: string;
|
|
flavor?: string;
|
|
};
|
|
const flavor: AndroidTvApkFlavor =
|
|
q.flavor === "store" || q.platform === "android_tv_store"
|
|
? "store"
|
|
: "appliance";
|
|
const current = Math.max(0, Number(q.versionCode) || 0);
|
|
return checkAndroidTvUpdate({ flavor, currentVersionCode: current });
|
|
});
|
|
|
|
app.get("/api/v1/client/app-update/download", async (request, reply) => {
|
|
await authFromBearer(request.headers.authorization);
|
|
const { apk } = androidTvApkPaths();
|
|
const meta = getAndroidTvApkMeta();
|
|
if (!meta.available || !fs.existsSync(apk)) {
|
|
throw new AppError("NOT_FOUND", "Geen update-APK beschikbaar", 404);
|
|
}
|
|
const name =
|
|
meta.versionName != null
|
|
? `vmc-appliance-${meta.versionName}.apk`
|
|
: "vmc-appliance.apk";
|
|
reply.header("Content-Type", "application/vnd.android.package-archive");
|
|
reply.header("Content-Disposition", `attachment; filename="${name}"`);
|
|
reply.header("Cache-Control", "no-store");
|
|
reply.header("X-VMC-Version-Code", String(meta.versionCode ?? ""));
|
|
reply.header("X-VMC-Version-Name", String(meta.versionName ?? ""));
|
|
return reply.send(fs.createReadStream(apk));
|
|
});
|
|
}
|