stremio/apps/master-api/src/install/routes.ts
Jos Vooges | STH 1ef0c4339f Add VMC Mediabox OTA (admin upload + box update check) and appliance remote fixes.
Settings key opens VMC-menu, CHANNEL P+/P- zaps live TV, and Dokploy persists android-tv APK volume.
2026-09-26 03:43:27 +02:00

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));
});
}