Add one-command Unraid media-node install via Master.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
72a323e451
commit
3a7151e3ae
12 changed files with 435 additions and 9 deletions
|
|
@ -20,6 +20,8 @@ interface Node {
|
||||||
revoked: boolean;
|
revoked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MASTER_URL = "https://master.vonas.nl";
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
useAuth();
|
useAuth();
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
|
|
@ -27,6 +29,10 @@ export default function NodesPage() {
|
||||||
const [enrollName, setEnrollName] = useState("");
|
const [enrollName, setEnrollName] = useState("");
|
||||||
const [enrollLocation, setEnrollLocation] = useState("");
|
const [enrollLocation, setEnrollLocation] = useState("");
|
||||||
const [enrollToken, setEnrollToken] = useState("");
|
const [enrollToken, setEnrollToken] = useState("");
|
||||||
|
const [publicUrl, setPublicUrl] = useState("https://node01.vonas.nl");
|
||||||
|
const [moviesPath, setMoviesPath] = useState("/mnt/user/movies");
|
||||||
|
const [seriesPath, setSeriesPath] = useState("/mnt/user/series");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editLocation, setEditLocation] = useState("");
|
const [editLocation, setEditLocation] = useState("");
|
||||||
|
|
@ -54,6 +60,23 @@ export default function NodesPage() {
|
||||||
setEnrollToken(data.token);
|
setEnrollToken(data.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installCommand() {
|
||||||
|
const series = seriesPath.trim()
|
||||||
|
? ` \\\n --series='${seriesPath.trim()}'`
|
||||||
|
: "";
|
||||||
|
return `curl -fsSL ${MASTER_URL}/install/unraid.sh | bash -s -- \\
|
||||||
|
--token='${enrollToken}' \\
|
||||||
|
--name='${enrollName || "Unraid-Node-01"}' \\
|
||||||
|
--public-url='${publicUrl.trim()}' \\
|
||||||
|
--movies='${moviesPath.trim()}'${series}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInstall() {
|
||||||
|
await navigator.clipboard.writeText(installCommand());
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
async function rescanNode(id: string) {
|
async function rescanNode(id: string) {
|
||||||
const res = await fetch(`/api/v1/admin/nodes/${id}/rescan`, {
|
const res = await fetch(`/api/v1/admin/nodes/${id}/rescan`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -111,18 +134,50 @@ export default function NodesPage() {
|
||||||
{showEnroll && (
|
{showEnroll && (
|
||||||
<div className="card" style={{ marginBottom: "2rem" }}>
|
<div className="card" style={{ marginBottom: "2rem" }}>
|
||||||
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
|
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
|
||||||
Enrollment Token
|
Unraid-node toevoegen
|
||||||
</h2>
|
</h2>
|
||||||
|
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.9rem" }}>
|
||||||
|
Genereer een token, vul de paden in, kopieer het commando en plak het in de Unraid-terminal.
|
||||||
|
Geen Docker Compose of image build nodig.
|
||||||
|
</p>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Naam</label>
|
<label>Naam</label>
|
||||||
<input value={enrollName} onChange={(e) => setEnrollName(e.target.value)} placeholder="Rotterdam-Node-01" />
|
<input value={enrollName} onChange={(e) => setEnrollName(e.target.value)} placeholder="Unraid-Node-01" />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Locatie</label>
|
<label>Locatie</label>
|
||||||
<input value={enrollLocation} onChange={(e) => setEnrollLocation(e.target.value)} placeholder="Rotterdam" />
|
<input value={enrollLocation} onChange={(e) => setEnrollLocation(e.target.value)} placeholder="Thuis" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Publieke node-URL (NPM)</label>
|
||||||
|
<input value={publicUrl} onChange={(e) => setPublicUrl(e.target.value)} placeholder="https://node01.vonas.nl" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Unraid films-pad</label>
|
||||||
|
<input value={moviesPath} onChange={(e) => setMoviesPath(e.target.value)} placeholder="/mnt/user/movies" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Unraid series-pad (optioneel)</label>
|
||||||
|
<input value={seriesPath} onChange={(e) => setSeriesPath(e.target.value)} placeholder="/mnt/user/series" />
|
||||||
</div>
|
</div>
|
||||||
<button onClick={createEnrollmentToken}>Token genereren</button>
|
<button onClick={createEnrollmentToken}>Token genereren</button>
|
||||||
{enrollToken && <div className="token-display">{enrollToken}</div>}
|
{enrollToken && (
|
||||||
|
<>
|
||||||
|
<div className="token-display" style={{ marginTop: "1rem" }}>{enrollToken}</div>
|
||||||
|
<h2 style={{ fontSize: "0.95rem", color: "#fafafa", textTransform: "none", margin: "1.25rem 0 0.5rem" }}>
|
||||||
|
Unraid-terminal
|
||||||
|
</h2>
|
||||||
|
<pre className="token-display" style={{ whiteSpace: "pre-wrap", fontSize: "0.8rem" }}>
|
||||||
|
{installCommand()}
|
||||||
|
</pre>
|
||||||
|
<button onClick={copyInstall} style={{ marginTop: "0.75rem" }}>
|
||||||
|
{copied ? "Gekopieerd" : "Kopieer install-commando"}
|
||||||
|
</button>
|
||||||
|
<p style={{ color: "#71717a", fontSize: "0.8rem", marginTop: "0.75rem" }}>
|
||||||
|
NPM: {publicUrl || "node-URL"} → Unraid LAN-IP:8080, Force SSL, buffering uit.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { registerAuthRoutes, ensureAdminUser } from "./auth/routes";
|
||||||
import { registerNodeRoutes } from "./nodes/routes";
|
import { registerNodeRoutes } from "./nodes/routes";
|
||||||
import { registerStremioRoutes } from "./stremio/routes";
|
import { registerStremioRoutes } from "./stremio/routes";
|
||||||
import { registerAdminRoutes } from "./admin/routes";
|
import { registerAdminRoutes } from "./admin/routes";
|
||||||
|
import { registerInstallRoutes } from "./install/routes";
|
||||||
import { nodeConnectionManager } from "./websocket/manager";
|
import { nodeConnectionManager } from "./websocket/manager";
|
||||||
import { toErrorResponse } from "./security/errors";
|
import { toErrorResponse } from "./security/errors";
|
||||||
|
|
||||||
|
|
@ -63,6 +64,7 @@ async function main() {
|
||||||
await registerNodeRoutes(app);
|
await registerNodeRoutes(app);
|
||||||
await registerStremioRoutes(app, config);
|
await registerStremioRoutes(app, config);
|
||||||
await registerAdminRoutes(app, config);
|
await registerAdminRoutes(app, config);
|
||||||
|
await registerInstallRoutes(app, config);
|
||||||
|
|
||||||
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
|
||||||
void nodeConnectionManager.handleConnection(socket);
|
void nodeConnectionManager.handleConnection(socket);
|
||||||
|
|
|
||||||
44
apps/master-api/src/install/routes.ts
Normal file
44
apps/master-api/src/install/routes.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import type { Config } from "../config";
|
||||||
|
|
||||||
|
function installDir(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.cwd(), "install"),
|
||||||
|
path.join(__dirname, "..", "..", "install"),
|
||||||
|
"/app/install",
|
||||||
|
];
|
||||||
|
for (const dir of candidates) {
|
||||||
|
if (fs.existsSync(path.join(dir, "unraid.sh"))) return dir;
|
||||||
|
}
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerInstallRoutes(app: FastifyInstance, config: Config) {
|
||||||
|
const dir = installDir();
|
||||||
|
|
||||||
|
app.get("/install/unraid.sh", async (_request, reply) => {
|
||||||
|
const file = path.join(dir, "unraid.sh");
|
||||||
|
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__", config.PUBLIC_URL.replace(/\/$/, ""));
|
||||||
|
reply.header("Content-Type", "text/x-shellscript; charset=utf-8");
|
||||||
|
reply.header("Content-Disposition", 'inline; filename="unraid.sh"');
|
||||||
|
return reply.send(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,14 @@ FROM node:20-alpine AS base
|
||||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM golang:1.22-alpine AS node-build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
COPY node/media-node/ ./
|
||||||
|
RUN go mod tidy \
|
||||||
|
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.0.0" -o /media-node-linux-amd64 ./cmd/media-node \
|
||||||
|
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=1.0.0" -o /media-node-linux-arm64 ./cmd/media-node
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY package.json pnpm-workspace.yaml ./
|
COPY package.json pnpm-workspace.yaml ./
|
||||||
COPY apps/master-api ./apps/master-api
|
COPY apps/master-api ./apps/master-api
|
||||||
|
|
@ -31,6 +39,9 @@ COPY --from=builder /out/dist ./dist
|
||||||
COPY --from=builder /out/prisma ./prisma
|
COPY --from=builder /out/prisma ./prisma
|
||||||
COPY --from=builder /out/package.json ./package.json
|
COPY --from=builder /out/package.json ./package.json
|
||||||
COPY --from=builder /out/local-packages ./local-packages
|
COPY --from=builder /out/local-packages ./local-packages
|
||||||
|
COPY --from=node-build /media-node-linux-amd64 /app/install/media-node-linux-amd64
|
||||||
|
COPY --from=node-build /media-node-linux-arm64 /app/install/media-node-linux-arm64
|
||||||
|
COPY deploy/node/install-unraid.sh /app/install/unraid.sh
|
||||||
|
|
||||||
# Remove workspace protocol deps before npm install
|
# Remove workspace protocol deps before npm install
|
||||||
RUN node -e "\
|
RUN node -e "\
|
||||||
|
|
|
||||||
19
deploy/docker/Dockerfile.media-node
Normal file
19
deploy/docker/Dockerfile.media-node
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Media Node — static Go binary for Unraid / Docker
|
||||||
|
FROM golang:1.22-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
COPY node/media-node/ ./
|
||||||
|
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=1.0.0" -o /media-node ./cmd/media-node
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
# uid 99 / gid 100 = Unraid nobody:users
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata \
|
||||||
|
&& mkdir -p /etc/media-node/credentials /var/lib/media-node \
|
||||||
|
&& chown -R 99:100 /etc/media-node /var/lib/media-node
|
||||||
|
COPY --from=build /media-node /usr/local/bin/media-node
|
||||||
|
USER 99:100
|
||||||
|
ENV MEDIA_NODE_CONFIG=/etc/media-node/config.yaml \
|
||||||
|
MEDIA_NODE_DATA=/var/lib/media-node \
|
||||||
|
MEDIA_NODE_CREDENTIALS=/etc/media-node/credentials/credentials.json
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/usr/local/bin/media-node", "run"]
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "$0")/../node/media-node"
|
cd "$(dirname "$0")/../../node/media-node"
|
||||||
|
|
||||||
VERSION="${1:-1.0.0}"
|
VERSION="${1:-1.0.0}"
|
||||||
OUTPUT_DIR="../../dist"
|
OUTPUT_DIR="../../dist"
|
||||||
|
|
|
||||||
33
deploy/node/docker-compose.unraid.yml
Normal file
33
deploy/node/docker-compose.unraid.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Voorbeeld voor Unraid (Docker Compose / Compose Manager)
|
||||||
|
# Pas paden, token en public URL aan vóór starten.
|
||||||
|
#
|
||||||
|
# Build vanaf repo-root:
|
||||||
|
# docker compose -f deploy/node/docker-compose.unraid.yml build
|
||||||
|
# docker compose -f deploy/node/docker-compose.unraid.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
media-node:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: deploy/docker/Dockerfile.media-node
|
||||||
|
image: media-node:1.0.0
|
||||||
|
container_name: media-node
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
MEDIA_NODE_NAME: "Unraid-Node-01"
|
||||||
|
MEDIA_NODE_MASTER_URL: "https://master.vonas.nl"
|
||||||
|
MEDIA_NODE_PUBLIC_URL: "https://node01.vonas.nl"
|
||||||
|
# Alleen nodig bij eerste start — daarna credentials-volume bewaren en token weghalen
|
||||||
|
MEDIA_NODE_ENROLLMENT_TOKEN: "PLAK_TOKEN_HIER"
|
||||||
|
# Paden BÍNnen de container (zie volumes hieronder)
|
||||||
|
MEDIA_NODE_MOVIES: "/media/movies"
|
||||||
|
MEDIA_NODE_SERIES: "/media/series"
|
||||||
|
TZ: "Europe/Amsterdam"
|
||||||
|
volumes:
|
||||||
|
- /mnt/user/appdata/media-node/config:/etc/media-node
|
||||||
|
- /mnt/user/appdata/media-node/data:/var/lib/media-node
|
||||||
|
# Pas aan naar jouw Unraid shares (read-only)
|
||||||
|
- /mnt/user/movies:/media/movies:ro
|
||||||
|
- /mnt/user/series:/media/series:ro
|
||||||
165
deploy/node/install-unraid.sh
Normal file
165
deploy/node/install-unraid.sh
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Unraid media-node installer — geen image build, alleen download + docker run
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MASTER_URL="${MASTER_URL:-__MASTER_PUBLIC_URL__}"
|
||||||
|
TOKEN=""
|
||||||
|
NAME=""
|
||||||
|
PUBLIC_URL=""
|
||||||
|
MOVIES=""
|
||||||
|
SERIES=""
|
||||||
|
PORT="${PORT:-8080}"
|
||||||
|
APPDATA="${APPDATA:-/mnt/user/appdata/media-node}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Gebruik:
|
||||||
|
curl -fsSL ${MASTER_URL}/install/unraid.sh | bash -s -- \\
|
||||||
|
--token=TOKEN --name=Unraid-01 \\
|
||||||
|
--public-url=https://node01.example.com \\
|
||||||
|
--movies=/mnt/user/movies [--series=/mnt/user/series]
|
||||||
|
|
||||||
|
Opties:
|
||||||
|
--token= Enrollment token uit Admin → Nodes
|
||||||
|
--name= Node-naam
|
||||||
|
--public-url= Publieke HTTPS-URL via NPM (poort 8080)
|
||||||
|
--movies= Unraid-pad naar films (share)
|
||||||
|
--series= Unraid-pad naar series (optioneel)
|
||||||
|
--port= Host-poort (default 8080)
|
||||||
|
--appdata= Appdata-map (default /mnt/user/appdata/media-node)
|
||||||
|
--master= Master-URL (default: ${MASTER_URL})
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--token=*) TOKEN="${arg#*=}" ;;
|
||||||
|
--name=*) NAME="${arg#*=}" ;;
|
||||||
|
--public-url=*) PUBLIC_URL="${arg#*=}" ;;
|
||||||
|
--movies=*) MOVIES="${arg#*=}" ;;
|
||||||
|
--series=*) SERIES="${arg#*=}" ;;
|
||||||
|
--port=*) PORT="${arg#*=}" ;;
|
||||||
|
--appdata=*) APPDATA="${arg#*=}" ;;
|
||||||
|
--master=*) MASTER_URL="${arg#*=}" ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "Onbekende optie: $arg"; usage; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Docker niet gevonden. Op Unraid: Docker service aanzetten."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
prompt() {
|
||||||
|
local label="$1" var="$2" current="$3" flag="$4"
|
||||||
|
if [ -n "$current" ]; then
|
||||||
|
eval "$var=\"\$current\""
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ -t 0 ]; then
|
||||||
|
local val=""
|
||||||
|
printf "%s" "$label"
|
||||||
|
read -r val
|
||||||
|
eval "$var=\"\$val\""
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "Ontbrekende optie: $flag (bij curl|bash alle flags meegeven)."
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== STH Media Node — Unraid install ==="
|
||||||
|
echo "Master: $MASTER_URL"
|
||||||
|
echo
|
||||||
|
|
||||||
|
prompt "Enrollment token: " TOKEN "$TOKEN" "--token"
|
||||||
|
prompt "Node-naam [Unraid-Node-01]: " NAME "${NAME}" "--name"
|
||||||
|
if [ -z "$NAME" ]; then NAME="Unraid-Node-01"; fi
|
||||||
|
prompt "Publieke URL (https://node01....): " PUBLIC_URL "$PUBLIC_URL" "--public-url"
|
||||||
|
prompt "Films-pad [/mnt/user/movies]: " MOVIES "${MOVIES}" "--movies"
|
||||||
|
if [ -z "$MOVIES" ] && [ -t 0 ]; then MOVIES="/mnt/user/movies"; fi
|
||||||
|
if [ -z "$MOVIES" ]; then
|
||||||
|
echo "Ontbrekende optie: --movies"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -t 0 ] && [ -z "$SERIES" ]; then
|
||||||
|
printf "Series-pad (leeg = overslaan): "
|
||||||
|
read -r SERIES || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOKEN="$(echo -n "$TOKEN" | tr -d '\r\n')"
|
||||||
|
NAME="$(echo -n "$NAME" | tr -d '\r\n')"
|
||||||
|
PUBLIC_URL="$(echo -n "$PUBLIC_URL" | sed 's:/*$::')"
|
||||||
|
MOVIES="$(echo -n "$MOVIES" | tr -d '\r\n')"
|
||||||
|
SERIES="$(echo -n "$SERIES" | tr -d '\r\n')"
|
||||||
|
MASTER_URL="$(echo -n "$MASTER_URL" | sed 's:/*$::')"
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ] || [ -z "$NAME" ] || [ -z "$PUBLIC_URL" ] || [ -z "$MOVIES" ]; then
|
||||||
|
echo "Token, naam, public-url en movies zijn verplicht."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$MOVIES" ]; then
|
||||||
|
echo "Waarschuwing: films-pad bestaat niet: $MOVIES"
|
||||||
|
fi
|
||||||
|
if [ -n "$SERIES" ] && [ ! -d "$SERIES" ]; then
|
||||||
|
echo "Waarschuwing: series-pad bestaat niet: $SERIES"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64|amd64) BIN_NAME="media-node-linux-amd64" ;;
|
||||||
|
aarch64|arm64) BIN_NAME="media-node-linux-arm64" ;;
|
||||||
|
*) echo "Niet-ondersteunde architectuur: $ARCH"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$APPDATA/config/credentials" "$APPDATA/data" "$APPDATA/bin"
|
||||||
|
chown -R 99:100 "$APPDATA" 2>/dev/null || true
|
||||||
|
|
||||||
|
BIN_PATH="$APPDATA/bin/media-node"
|
||||||
|
echo "Binary downloaden ($BIN_NAME)..."
|
||||||
|
curl -fsSL "$MASTER_URL/install/$BIN_NAME" -o "$BIN_PATH"
|
||||||
|
chmod +x "$BIN_PATH"
|
||||||
|
|
||||||
|
# Oude container weg
|
||||||
|
docker rm -f media-node >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Alpine met gemounte static binary — geen custom image build
|
||||||
|
VOLUMES=(
|
||||||
|
-v "$APPDATA/config:/etc/media-node"
|
||||||
|
-v "$APPDATA/data:/var/lib/media-node"
|
||||||
|
-v "$BIN_PATH:/usr/local/bin/media-node:ro"
|
||||||
|
-v /etc/ssl/certs:/etc/ssl/certs:ro
|
||||||
|
-v "$MOVIES:/media/movies:ro"
|
||||||
|
)
|
||||||
|
ENV_SERIES=""
|
||||||
|
if [ -n "$SERIES" ]; then
|
||||||
|
VOLUMES+=(-v "$SERIES:/media/series:ro")
|
||||||
|
ENV_SERIES="/media/series"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Container starten..."
|
||||||
|
docker run -d \
|
||||||
|
--name media-node \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p "${PORT}:8080" \
|
||||||
|
-e "MEDIA_NODE_NAME=$NAME" \
|
||||||
|
-e "MEDIA_NODE_MASTER_URL=$MASTER_URL" \
|
||||||
|
-e "MEDIA_NODE_PUBLIC_URL=$PUBLIC_URL" \
|
||||||
|
-e "MEDIA_NODE_MOVIES=/media/movies" \
|
||||||
|
-e "MEDIA_NODE_SERIES=$ENV_SERIES" \
|
||||||
|
-e "MEDIA_NODE_ENROLLMENT_TOKEN=$TOKEN" \
|
||||||
|
-e "TZ=${TZ:-Europe/Amsterdam}" \
|
||||||
|
--user 99:100 \
|
||||||
|
"${VOLUMES[@]}" \
|
||||||
|
--entrypoint /usr/local/bin/media-node \
|
||||||
|
alpine:3.20 \
|
||||||
|
run
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Klaar. Logs: docker logs -f media-node"
|
||||||
|
echo "Status: in Admin → Nodes (moet online worden)"
|
||||||
|
echo "NPM: $PUBLIC_URL → Unraid-LAN-IP:$PORT (buffering uit, Force SSL)"
|
||||||
|
echo
|
||||||
|
echo "Na succes: token staat opgeslagen in appdata — herstart zonder nieuwe token is ok."
|
||||||
|
|
@ -139,8 +139,8 @@ Browsers roepen alleen `/api/...` op het admin-domein aan — dat is correct.
|
||||||
## Na succesvolle deploy
|
## Na succesvolle deploy
|
||||||
|
|
||||||
1. Admin UI → **Instellingen** → Stremio addon token genereren
|
1. Admin UI → **Instellingen** → Stremio addon token genereren
|
||||||
2. Admin UI → **Nodes** → enrollment token
|
2. Admin UI → **Nodes** → token + **Kopieer install-commando** (Unraid: één curl|bash)
|
||||||
3. Op een Media Node: `sudo media-node install` met
|
3. Op Unraid: zie [unraid-node.md](./unraid-node.md). Op Linux: `sudo media-node install` met
|
||||||
Master URL = `https://master.jouwdomein.nl`
|
Master URL = `https://master.jouwdomein.nl`
|
||||||
|
|
||||||
## Alternatief: Dokploy + jouw NPM
|
## Alternatief: Dokploy + jouw NPM
|
||||||
|
|
|
||||||
13
docs/unraid-node.md
Normal file
13
docs/unraid-node.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Media Node op Unraid (eenvoudig)
|
||||||
|
|
||||||
|
Na Master-redeploy (met install-assets) heb je **geen** Compose of image build nodig.
|
||||||
|
|
||||||
|
## Stappen
|
||||||
|
|
||||||
|
1. **NPM**: Proxy Host voor bijv. `node01.vonas.nl` → Unraid LAN-IP `:8080`, Force SSL, buffering uit (zie `npm-setup.md`).
|
||||||
|
2. **Admin** → Nodes → **Node toevoegen** → token + paden → **Kopieer install-commando**.
|
||||||
|
3. Plak het commando in de **Unraid-terminal** (of SSH).
|
||||||
|
|
||||||
|
Het script downloadt de binary van de Master en start één Alpine-container.
|
||||||
|
|
||||||
|
Controle: `docker logs -f media-node` en in Admin of de node **online** is.
|
||||||
|
|
@ -82,6 +82,8 @@ func runService() {
|
||||||
dataDir := envOr("MEDIA_NODE_DATA", config.DefaultDataDir)
|
dataDir := envOr("MEDIA_NODE_DATA", config.DefaultDataDir)
|
||||||
credsPath := envOr("MEDIA_NODE_CREDENTIALS", config.DefaultCredentialsPath)
|
credsPath := envOr("MEDIA_NODE_CREDENTIALS", config.DefaultCredentialsPath)
|
||||||
|
|
||||||
|
ensureDirs(filepath.Dir(configPath), dataDir, filepath.Dir(credsPath))
|
||||||
|
|
||||||
cfg, err := config.Load(configPath)
|
cfg, err := config.Load(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
|
@ -89,7 +91,22 @@ func runService() {
|
||||||
|
|
||||||
creds, err := loadCredentials(credsPath)
|
creds, err := loadCredentials(credsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load credentials: %v", err)
|
token := strings.TrimSpace(os.Getenv("MEDIA_NODE_ENROLLMENT_TOKEN"))
|
||||||
|
if token == "" {
|
||||||
|
log.Fatalf("Failed to load credentials: %v (set MEDIA_NODE_ENROLLMENT_TOKEN for first boot)", err)
|
||||||
|
}
|
||||||
|
log.Printf("No credentials at %s — enrolling with Master...", credsPath)
|
||||||
|
creds, err = enroll(cfg.Master.URL, token, cfg.Node.Name, cfg.Stream.PublicURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Enrollment failed: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(creds, "", " ")
|
||||||
|
if writeErr := os.WriteFile(credsPath, data, 0o600); writeErr != nil {
|
||||||
|
log.Fatalf("Failed to save credentials: %v", writeErr)
|
||||||
|
}
|
||||||
|
log.Printf("Enrolled as node %s — credentials saved", creds.NodeID)
|
||||||
|
cfg.Node.ID = creds.NodeID
|
||||||
|
_ = config.Save(configPath, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := database.Open(dataDir)
|
store, err := database.Open(dataDir)
|
||||||
|
|
@ -451,3 +468,12 @@ func nonEmpty(s string) []string {
|
||||||
}
|
}
|
||||||
return []string{s}
|
return []string{s}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureDirs(paths ...string) {
|
||||||
|
for _, p := range paths {
|
||||||
|
if p == "" || p == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(p, 0o750)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package config
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -61,12 +62,52 @@ type Credentials struct {
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
cfg, envErr := FromEnv()
|
||||||
|
if envErr != nil {
|
||||||
|
return nil, fmt.Errorf("read config: %w (and env incomplete: %v)", err, envErr)
|
||||||
|
}
|
||||||
|
if saveErr := Save(path, cfg); saveErr != nil {
|
||||||
|
// Still usable in-memory when the path is not writable.
|
||||||
|
return applyDefaults(cfg), nil
|
||||||
|
}
|
||||||
|
return applyDefaults(cfg), nil
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("read config: %w", err)
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
}
|
}
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parse config: %w", err)
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
}
|
}
|
||||||
|
return applyDefaults(&cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromEnv builds config from MEDIA_NODE_* environment variables (Docker/Unraid).
|
||||||
|
func FromEnv() (*Config, error) {
|
||||||
|
name := strings.TrimSpace(os.Getenv("MEDIA_NODE_NAME"))
|
||||||
|
master := strings.TrimRight(strings.TrimSpace(os.Getenv("MEDIA_NODE_MASTER_URL")), "/")
|
||||||
|
publicURL := strings.TrimRight(strings.TrimSpace(os.Getenv("MEDIA_NODE_PUBLIC_URL")), "/")
|
||||||
|
if name == "" || master == "" || publicURL == "" {
|
||||||
|
return nil, fmt.Errorf("MEDIA_NODE_NAME, MEDIA_NODE_MASTER_URL and MEDIA_NODE_PUBLIC_URL are required")
|
||||||
|
}
|
||||||
|
listen := strings.TrimSpace(os.Getenv("MEDIA_NODE_LISTEN"))
|
||||||
|
if listen == "" {
|
||||||
|
listen = DefaultListen
|
||||||
|
}
|
||||||
|
return &Config{
|
||||||
|
Node: NodeConfig{Name: name},
|
||||||
|
Master: MasterConfig{URL: master},
|
||||||
|
Stream: StreamConfig{Listen: listen, PublicURL: publicURL},
|
||||||
|
Media: MediaConfig{
|
||||||
|
Movies: splitPaths(os.Getenv("MEDIA_NODE_MOVIES")),
|
||||||
|
Series: splitPaths(os.Getenv("MEDIA_NODE_SERIES")),
|
||||||
|
},
|
||||||
|
Scanner: ScannerConfig{FullScanInterval: "6h"},
|
||||||
|
Security: SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 8},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDefaults(cfg *Config) *Config {
|
||||||
if cfg.Stream.Listen == "" {
|
if cfg.Stream.Listen == "" {
|
||||||
cfg.Stream.Listen = DefaultListen
|
cfg.Stream.Listen = DefaultListen
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +120,24 @@ func Load(path string) (*Config, error) {
|
||||||
if cfg.Security.MaxConcurrentPerIP == 0 {
|
if cfg.Security.MaxConcurrentPerIP == 0 {
|
||||||
cfg.Security.MaxConcurrentPerIP = 8
|
cfg.Security.MaxConcurrentPerIP = 8
|
||||||
}
|
}
|
||||||
return &cfg, nil
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPaths(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == '\n'
|
||||||
|
}) {
|
||||||
|
p := strings.TrimSpace(part)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) FullScanDuration() time.Duration {
|
func (c *Config) FullScanDuration() time.Duration {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue