Initial commit: Distributed Stremio Media Cluster V1

Master API, Admin UI, Go media-node, Docker/Dokploy deploy configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 00:16:42 +02:00
commit a5c6fd5fe4
74 changed files with 9899 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
node_modules/
dist/
.next/
.env
.env.local
*.log
.DS_Store
coverage/
.turbo/
*.db
*.db-journal
*.tsbuildinfo
node/media-node/media-node
node/media-node/media-node.exe
apps/admin-ui/.next/
apps/master-api/dist/
packages/*/dist/

128
README.md Normal file
View file

@ -0,0 +1,128 @@
# Distributed Stremio Media Cluster
Privé gedistribueerd mediaplatform voor **Stremio**: één Master, onbeperkt Media Nodes, video rechtstreeks van node naar player.
## Architectuur
```
Stremio ──HTTPS──► Master (catalog / meta / stream)
▲
│ WSS control
Media Nodes
│
Stremio ◄──HTTPS video──┘ (via Nginx Proxy Manager)
```
Harde eisen:
- geen VPN / WireGuard / SMB / NFS tussen locaties
- geen Docker op nodes
- geen transcoding in V1
- geen raw filesystem paths publiek
- server-side playback sessions (geen simpele 1-uur signed URLs)
## Stack
| Component | Techniek |
|-----------|----------|
| Master API | TypeScript, Fastify, Prisma, PostgreSQL, Redis |
| Admin UI | Next.js |
| Media Node | Go binary + SQLite + systemd |
| Proxy | Nginx Proxy Manager (GUI only) |
## Repository
```
apps/master-api/ Master + Stremio addon
apps/admin-ui/ Admin dashboard
node/media-node/ Go media node
packages/ Gedeelde types + protocol
deploy/docker/ Docker Compose Master
deploy/node/ Installer + systemd
docs/ Deployment + NPM setup
```
## Snel starten (dev)
```bash
npx pnpm install
# Postgres + Redis
cd deploy/docker
docker compose up -d postgres redis
# Master
cd ../../apps/master-api
cp .env.example .env
# DATABASE_URL=postgresql://media:media_secret@localhost:5432/media_cluster
npx prisma migrate deploy
npx pnpm db:seed
npx pnpm dev
# Admin UI
cd ../admin-ui
npx pnpm dev
```
- API: http://localhost:3000
- UI: http://localhost:3001
- Login: `admin@localhost` / `changeme123`
## Media Node
Op Linux (productie):
```bash
cd node/media-node
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o media-node ./cmd/media-node
sudo ./media-node install
```
CLI:
```
media-node install|register|run|scan|diagnostics|config|start|stop|restart|status|version
```
## Productie
Zie:
- [docs/deployment.md](docs/deployment.md)
- [docs/dokploy.md](docs/dokploy.md) — **Master op Dokploy (aanbevolen)**
- [docs/npm-setup.md](docs/npm-setup.md)
```bash
cd deploy/docker
cp ../master/.env.example ../master/.env
docker compose up -d --build
```
Of in Dokploy: Compose-app met pad `deploy/docker/docker-compose.dokploy.yml`.
## Stremio installeren
1. Admin UI → **Instellingen** → addon token
2. URL in Stremio:
`https://master.example.com/stremio/<token>/manifest.json`
## V1 features
- Node enrollment + unieke credentials
- WebSocket control channel + heartbeat + reconnect
- Movies/Series scanner + library sync
- Filename parsing + TMDB matching (optioneel)
- Stremio catalogs / meta / streams
- Playback sessions met idle + absolute expiry
- HTTP Range streaming (direct play)
- Offline node detectie + duplicate media
- Admin: dashboard, nodes, library, streams, matching ignore/refresh
- Rate limits, security headers, redacted token logs
- Backup script + release build script
## Tests
```bash
cd apps/master-api && npx pnpm test
cd node/media-node && go test ./...
```

6
apps/admin-ui/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// standalone only needed for Docker; skip on Windows to avoid symlink EPERM
output: process.env.DOCKER_BUILD === "1" ? "standalone" : undefined,
async rewrites() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
return [
{
source: "/api/:path*",
destination: `${apiUrl}/api/:path*`,
},
];
},
};
module.exports = nextConfig;

View file

@ -0,0 +1,21 @@
{
"name": "@media-cluster/admin-ui",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001"
},
"dependencies": {
"next": "^15.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1 @@
# Keep this file so Next.js public assets directory exists

View file

@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface Dashboard {
nodesOnline: number;
nodesOffline: number;
totalStorage: string;
freeStorage: string;
movies: number;
series: number;
episodes: number;
activeStreams: number;
currentBandwidth: string;
}
function formatBytes(bytes: string): string {
const n = BigInt(bytes);
const units = ["B", "KB", "MB", "GB", "TB"];
let val = Number(n);
let i = 0;
while (val >= 1024 && i < units.length - 1) {
val /= 1024;
i++;
}
return `${val.toFixed(1)} ${units[i]}`;
}
export default function DashboardPage() {
useAuth();
const [data, setData] = useState<Dashboard | null>(null);
useEffect(() => {
fetch("/api/v1/admin/dashboard", { credentials: "include" })
.then((r) => r.json())
.then(setData)
.catch(console.error);
}, []);
return (
<>
<Nav />
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Dashboard</h1>
{data && (
<div className="grid">
<div className="card">
<h2>Nodes Online</h2>
<div className="value">{data.nodesOnline}</div>
</div>
<div className="card">
<h2>Nodes Offline</h2>
<div className="value">{data.nodesOffline}</div>
</div>
<div className="card">
<h2>Movies</h2>
<div className="value">{data.movies}</div>
</div>
<div className="card">
<h2>Series</h2>
<div className="value">{data.series}</div>
</div>
<div className="card">
<h2>Episodes</h2>
<div className="value">{data.episodes}</div>
</div>
<div className="card">
<h2>Active Streams</h2>
<div className="value">{data.activeStreams}</div>
</div>
<div className="card">
<h2>Total Storage</h2>
<div className="value" style={{ fontSize: "1.5rem" }}>{formatBytes(data.totalStorage)}</div>
</div>
<div className="card">
<h2>Free Storage</h2>
<div className="value" style={{ fontSize: "1.5rem" }}>{formatBytes(data.freeStorage)}</div>
</div>
</div>
)}
</div>
</>
);
}

View file

@ -0,0 +1,190 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f1117;
color: #e4e4e7;
min-height: 100vh;
}
a {
color: #818cf8;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.card {
background: #1a1d27;
border: 1px solid #2a2d3a;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.card h2 {
font-size: 0.875rem;
color: #71717a;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.card .value {
font-size: 2rem;
font-weight: 700;
color: #fafafa;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
nav {
background: #1a1d27;
border-bottom: 1px solid #2a2d3a;
padding: 1rem 2rem;
display: flex;
gap: 2rem;
align-items: center;
}
nav .brand {
font-weight: 700;
font-size: 1.25rem;
color: #818cf8;
}
nav a {
color: #a1a1aa;
font-size: 0.875rem;
}
nav a:hover,
nav a.active {
color: #fafafa;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid #2a2d3a;
}
th {
color: #71717a;
font-size: 0.75rem;
text-transform: uppercase;
}
.status {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.status.online { background: #166534; color: #bbf7d0; }
.status.offline { background: #374151; color: #d1d5db; }
.status.revoked { background: #991b1b; color: #fecaca; }
button, .btn {
background: #4f46e5;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
}
button:hover, .btn:hover {
background: #4338ca;
}
button.danger {
background: #dc2626;
}
button.danger:hover {
background: #b91c1c;
}
input {
background: #0f1117;
border: 1px solid #2a2d3a;
color: #fafafa;
padding: 0.5rem 0.75rem;
border-radius: 8px;
width: 100%;
margin-bottom: 0.75rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
color: #a1a1aa;
margin-bottom: 0.25rem;
}
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-box {
background: #1a1d27;
border: 1px solid #2a2d3a;
border-radius: 16px;
padding: 2rem;
width: 100%;
max-width: 400px;
}
.login-box h1 {
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
.error {
color: #f87171;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.token-display {
background: #0f1117;
border: 1px solid #2a2d3a;
padding: 1rem;
border-radius: 8px;
font-family: monospace;
word-break: break-all;
margin: 1rem 0;
}

View file

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "STH Media Cluster",
description: "Admin dashboard for distributed Stremio media cluster",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="nl">
<body>{children}</body>
</html>
);
}

View file

@ -0,0 +1,170 @@
"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface MovieFile {
id: string;
node: string;
nodeStatus: string;
resolution: string | null;
sizeBytes: string;
videoCodec: string | null;
audioCodec: string | null;
releaseName: string;
available: boolean;
}
interface Movie {
id: string;
title: string;
year: number | null;
imdbId: string | null;
tmdbId: number | null;
overview: string | null;
posterUrl: string | null;
fileCount: number;
nodeCount: number;
qualities: string[];
files: MovieFile[];
}
function formatBytes(bytes: string): string {
let val = Number(bytes);
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (val >= 1024 && i < units.length - 1) {
val /= 1024;
i++;
}
return `${val.toFixed(1)} ${units[i]}`;
}
export default function MoviesPage() {
useAuth();
const [movies, setMovies] = useState<Movie[]>([]);
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<string | null>(null);
function load(q = search) {
const qs = q ? `?search=${encodeURIComponent(q)}` : "";
fetch(`/api/v1/admin/movies${qs}`, { credentials: "include" })
.then((r) => r.json())
.then((d) => setMovies(d.movies ?? []));
}
useEffect(() => {
load();
}, []);
async function ignoreFile(fileId: string) {
if (!confirm("Dit bestand negeren?")) return;
await fetch(`/api/v1/admin/media-files/${fileId}/match`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ignore: true }),
});
load();
}
async function refreshMeta(fileId: string) {
await fetch(`/api/v1/admin/media-files/${fileId}/refresh-metadata`, {
method: "POST",
credentials: "include",
});
load();
}
return (
<>
<Nav />
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", gap: "1rem", marginBottom: "2rem" }}>
<h1 style={{ fontSize: "1.75rem" }}>Movies</h1>
<form
onSubmit={(e) => {
e.preventDefault();
load();
}}
style={{ display: "flex", gap: "0.5rem" }}
>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Zoeken..."
style={{ width: 240, marginBottom: 0 }}
/>
<button type="submit">Zoek</button>
</form>
</div>
{movies.map((m) => (
<div key={m.id} className="card">
<div
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", gap: "1rem" }}
onClick={() => setExpanded(expanded === m.id ? null : m.id)}
>
<div>
<strong>{m.title}</strong>
{m.year ? ` (${m.year})` : ""}
<div style={{ color: "#71717a", fontSize: "0.875rem", marginTop: 4 }}>
{m.qualities.join(", ") || "geen kwaliteit"} · {m.nodeCount} node(s) · {m.fileCount} bestand(en)
{m.imdbId ? ` · ${m.imdbId}` : ""}
</div>
</div>
<span style={{ color: "#71717a" }}>{expanded === m.id ? "▲" : "▼"}</span>
</div>
{expanded === m.id && (
<div style={{ marginTop: "1rem" }}>
{m.overview && (
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginBottom: "1rem" }}>{m.overview}</p>
)}
<table>
<thead>
<tr>
<th>Node</th>
<th>Release</th>
<th>Kwaliteit</th>
<th>Grootte</th>
<th>Codec</th>
<th>Status</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{m.files.map((f) => (
<tr key={f.id}>
<td>{f.node}</td>
<td style={{ maxWidth: 280, overflow: "hidden", textOverflow: "ellipsis" }}>{f.releaseName}</td>
<td>{f.resolution ?? "-"}</td>
<td>{formatBytes(f.sizeBytes)}</td>
<td>{[f.videoCodec, f.audioCodec].filter(Boolean).join(" / ") || "-"}</td>
<td>
<span className={`status ${f.available ? "online" : "offline"}`}>
{f.available ? "AVAILABLE" : "UNAVAILABLE"}
</span>
</td>
<td style={{ display: "flex", gap: 8 }}>
<button onClick={() => refreshMeta(f.id)}>Refresh</button>
<button className="danger" onClick={() => ignoreFile(f.id)}>Ignore</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
{movies.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
Geen movies gevonden
</div>
)}
</div>
</>
);
}

View file

@ -0,0 +1,213 @@
"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface Node {
id: string;
name: string;
hostname: string | null;
location: string | null;
locationType: string;
status: string;
version: string | null;
publicStreamUrl: string;
lastSeen: string | null;
totalStorage: string;
freeStorage: string;
activeStreams: number;
libraryFileCount: number;
revoked: boolean;
}
export default function NodesPage() {
useAuth();
const [nodes, setNodes] = useState<Node[]>([]);
const [showEnroll, setShowEnroll] = useState(false);
const [enrollName, setEnrollName] = useState("");
const [enrollLocation, setEnrollLocation] = useState("");
const [enrollToken, setEnrollToken] = useState("");
const [editId, setEditId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editLocation, setEditLocation] = useState("");
function loadNodes() {
fetch("/api/v1/admin/nodes", { credentials: "include" })
.then((r) => r.json())
.then((d) => setNodes(d.nodes ?? []));
}
useEffect(() => {
loadNodes();
const t = setInterval(loadNodes, 15000);
return () => clearInterval(t);
}, []);
async function createEnrollmentToken() {
const res = await fetch("/api/v1/admin/nodes/enrollment-tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name: enrollName, location: enrollLocation }),
});
const data = await res.json();
setEnrollToken(data.token);
}
async function rescanNode(id: string) {
const res = await fetch(`/api/v1/admin/nodes/${id}/rescan`, {
method: "POST",
credentials: "include",
});
if (!res.ok) {
const data = await res.json();
alert(data.error?.message ?? "Rescan mislukt");
return;
}
alert("Rescan aangevraagd");
}
async function revokeNode(id: string) {
if (!confirm("Weet je zeker dat je deze node wilt intrekken?")) return;
await fetch(`/api/v1/admin/nodes/${id}/revoke`, {
method: "POST",
credentials: "include",
});
loadNodes();
}
async function disableNode(id: string) {
if (!confirm("Node tijdelijk uitschakelen?")) return;
await fetch(`/api/v1/admin/nodes/${id}/disable`, {
method: "POST",
credentials: "include",
});
loadNodes();
}
async function saveEdit() {
if (!editId) return;
await fetch(`/api/v1/admin/nodes/${editId}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName, location: editLocation }),
});
setEditId(null);
loadNodes();
}
return (
<>
<Nav />
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "2rem" }}>
<h1 style={{ fontSize: "1.75rem" }}>Nodes</h1>
<button onClick={() => setShowEnroll(!showEnroll)}>
{showEnroll ? "Annuleren" : "Node toevoegen"}
</button>
</div>
{showEnroll && (
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Enrollment Token
</h2>
<div className="form-group">
<label>Naam</label>
<input value={enrollName} onChange={(e) => setEnrollName(e.target.value)} placeholder="Rotterdam-Node-01" />
</div>
<div className="form-group">
<label>Locatie</label>
<input value={enrollLocation} onChange={(e) => setEnrollLocation(e.target.value)} placeholder="Rotterdam" />
</div>
<button onClick={createEnrollmentToken}>Token genereren</button>
{enrollToken && <div className="token-display">{enrollToken}</div>}
</div>
)}
{editId && (
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Node bewerken
</h2>
<div className="form-group">
<label>Naam</label>
<input value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<div className="form-group">
<label>Locatie</label>
<input value={editLocation} onChange={(e) => setEditLocation(e.target.value)} />
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={saveEdit}>Opslaan</button>
<button onClick={() => setEditId(null)} style={{ background: "#374151" }}>Annuleren</button>
</div>
</div>
)}
<div className="card">
<table>
<thead>
<tr>
<th>Naam</th>
<th>Locatie</th>
<th>Status</th>
<th>Bestanden</th>
<th>Streams</th>
<th>Versie</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{nodes.map((node) => (
<tr key={node.id}>
<td>
<div>{node.name}</div>
<div style={{ fontSize: "0.75rem", color: "#71717a" }}>{node.publicStreamUrl}</div>
</td>
<td>{node.location ?? "-"}</td>
<td>
<span className={`status ${node.status.toLowerCase()}`}>{node.status}</span>
</td>
<td>{node.libraryFileCount}</td>
<td>{node.activeStreams}</td>
<td>{node.version ?? "-"}</td>
<td style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button
onClick={() => {
setEditId(node.id);
setEditName(node.name);
setEditLocation(node.location ?? "");
}}
>
Edit
</button>
<button onClick={() => rescanNode(node.id)}>Rescan</button>
{!node.revoked && (
<>
<button onClick={() => disableNode(node.id)} style={{ background: "#b45309" }}>
Disable
</button>
<button className="danger" onClick={() => revokeNode(node.id)}>
Revoke
</button>
</>
)}
</td>
</tr>
))}
{nodes.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: "center", color: "#71717a" }}>
Geen nodes gevonden
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => r.ok && router.push("/dashboard"))
.catch(() => {});
}, [router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error?.message ?? "Login failed");
}
router.push("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
}
return (
<div className="login-container">
<div className="login-box">
<h1>STH Media Cluster</h1>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>E-mail</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Wachtwoord</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit" disabled={loading} style={{ width: "100%" }}>
{loading ? "Inloggen..." : "Inloggen"}
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface Series {
id: string;
title: string;
year: number | null;
imdbId: string | null;
seasons: Array<{
seasonNumber: number;
episodes: Array<{
seasonNumber: number;
episodeNumber: number;
title: string | null;
files: Array<{ node: string; resolution: string | null; available: boolean }>;
}>;
}>;
}
export default function SeriesPage() {
useAuth();
const [series, setSeries] = useState<Series[]>([]);
const [expanded, setExpanded] = useState<string | null>(null);
useEffect(() => {
fetch("/api/v1/admin/series", { credentials: "include" })
.then((r) => r.json())
.then((d) => setSeries(d.series ?? []));
}, []);
return (
<>
<Nav />
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Series</h1>
{series.map((s) => (
<div key={s.id} className="card">
<div
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between" }}
onClick={() => setExpanded(expanded === s.id ? null : s.id)}
>
<strong>{s.title}</strong>
<span style={{ color: "#71717a" }}>
{s.seasons.length} season(s) · {expanded === s.id ? "▲" : "▼"}
</span>
</div>
{expanded === s.id && s.seasons.map((season) => (
<div key={season.seasonNumber} style={{ marginTop: "1rem", paddingLeft: "1rem" }}>
<h3 style={{ fontSize: "0.875rem", color: "#a1a1aa", marginBottom: "0.5rem" }}>
Season {season.seasonNumber}
</h3>
{season.episodes.map((ep) => (
<div key={ep.episodeNumber} style={{ fontSize: "0.875rem", padding: "0.25rem 0" }}>
S{String(ep.seasonNumber).padStart(2, "0")}E{String(ep.episodeNumber).padStart(2, "0")}
{" "}{ep.title ?? ""}
{ep.files.length > 0 && (
<span style={{ color: "#71717a" }}>
{" "}— {ep.files.map((f) => `${f.node} (${f.resolution ?? "?"})`).join(", ")}
</span>
)}
</div>
))}
</div>
))}
</div>
))}
{series.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>Geen series gevonden</div>
)}
</div>
</>
);
}

View file

@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
export default function SettingsPage() {
useAuth();
const [addonToken, setAddonToken] = useState("");
const [installUrl, setInstallUrl] = useState("");
const [label, setLabel] = useState("Stremio");
async function createAddonToken() {
const res = await fetch("/api/v1/admin/addon-tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ label }),
});
const data = await res.json();
setAddonToken(data.token);
const base = window.location.origin.replace(":3001", ":3000");
setInstallUrl(`${base}/stremio/${data.token}/manifest.json`);
}
return (
<>
<Nav />
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Instellingen</h1>
<div className="card">
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "1rem" }}>
Stremio Addon
</h2>
<p style={{ color: "#a1a1aa", marginBottom: "1rem", fontSize: "0.875rem" }}>
Genereer een addon token om de media library in Stremio te installeren.
</p>
<div className="form-group">
<label>Label</label>
<input value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<button onClick={createAddonToken}>Addon token genereren</button>
{addonToken && (
<>
<div className="token-display">{addonToken}</div>
<p style={{ fontSize: "0.875rem", color: "#a1a1aa" }}>Install URL:</p>
<div className="token-display">{installUrl}</div>
<p style={{ fontSize: "0.875rem", color: "#71717a", marginTop: "0.5rem" }}>
Plak deze URL in Stremio → Addons → Community Addons → URL invoeren
</p>
</>
)}
</div>
</div>
</>
);
}

View file

@ -0,0 +1,85 @@
"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface Stream {
id: string;
node: string;
status: string;
createdAt: string;
lastActivity: string;
absoluteExpiresAt: string;
title: string;
resolution: string | null;
}
export default function StreamsPage() {
useAuth();
const [streams, setStreams] = useState<Stream[]>([]);
function load() {
fetch("/api/v1/admin/streams", { credentials: "include" })
.then((r) => r.json())
.then((d) => setStreams(d.streams ?? []));
}
useEffect(() => {
load();
const t = setInterval(load, 10000);
return () => clearInterval(t);
}, []);
async function revoke(id: string) {
if (!confirm("Playback-sessie intrekken?")) return;
await fetch(`/api/v1/admin/streams/${id}/revoke`, {
method: "POST",
credentials: "include",
});
load();
}
return (
<>
<Nav />
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Actieve streams</h1>
<div className="card">
<table>
<thead>
<tr>
<th>Titel</th>
<th>Node</th>
<th>Kwaliteit</th>
<th>Laatste activiteit</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{streams.map((s) => (
<tr key={s.id}>
<td>{s.title}</td>
<td>{s.node}</td>
<td>{s.resolution ?? "-"}</td>
<td>{new Date(s.lastActivity).toLocaleString()}</td>
<td>
<button className="danger" onClick={() => revoke(s.id)}>
Intrekken
</button>
</td>
</tr>
))}
{streams.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: "center", color: "#71717a" }}>
Geen actieve streams
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
export function Nav() {
const pathname = usePathname();
const router = useRouter();
async function logout() {
await fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" });
router.push("/");
}
const links = [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/nodes", label: "Nodes" },
{ href: "/movies", label: "Movies" },
{ href: "/series", label: "Series" },
{ href: "/streams", label: "Streams" },
{ href: "/settings", label: "Instellingen" },
];
return (
<nav>
<span className="brand">STH Media</span>
{links.map((l) => (
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
{l.label}
</Link>
))}
<button onClick={logout} style={{ marginLeft: "auto", background: "transparent", border: "1px solid #2a2d3a" }}>
Uitloggen
</button>
</nav>
);
}
export function useAuth() {
const router = useRouter();
useEffect(() => {
fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => { if (!r.ok) router.push("/"); })
.catch(() => router.push("/"));
}, [router]);
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,21 @@
NODE_ENV=development
PORT=3000
HOST=0.0.0.0
DATABASE_URL=postgresql://media:media_secret@localhost:5432/media_cluster
REDIS_URL=redis://localhost:6379
SESSION_SECRET=dev-secret-change-in-production-min-32-chars
ADMIN_EMAIL=admin@localhost
ADMIN_PASSWORD=changeme123
PUBLIC_URL=http://localhost:3000
TMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
NODE_HEARTBEAT_INTERVAL_SECONDS=30
NODE_OFFLINE_THRESHOLD_SECONDS=90
HIDE_UNAVAILABLE_TITLES=false

View file

@ -0,0 +1,38 @@
{
"name": "@media-cluster/master-api",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",
"build": "tsc",
"start": "node dist/app.js",
"test": "tsx src/metadata/filename-parser.test.ts",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy",
"db:migrate:dev": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^10.0.2",
"@fastify/rate-limit": "^10.2.2",
"@fastify/websocket": "^11.0.2",
"@media-cluster/protocol": "workspace:*",
"@media-cluster/shared-types": "workspace:*",
"@prisma/client": "^6.3.1",
"argon2": "^0.41.1",
"dotenv": "^16.4.7",
"fastify": "^5.2.1",
"ioredis": "^5.4.2",
"nanoid": "^5.0.9",
"stremio-addon-sdk": "^1.6.10",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/ws": "^8.5.14",
"prisma": "^6.3.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,312 @@
-- CreateEnum
CREATE TYPE "NodeStatus" AS ENUM ('ONLINE', 'OFFLINE', 'DEGRADED', 'REVOKED', 'UPDATING', 'SCANNING', 'ERROR');
-- CreateEnum
CREATE TYPE "PlaybackSessionStatus" AS ENUM ('ACTIVE', 'IDLE', 'EXPIRED', 'REVOKED', 'COMPLETED');
-- CreateEnum
CREATE TYPE "LocationType" AS ENUM ('LOCAL', 'REMOTE');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "admin_sessions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "admin_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "addon_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"label" TEXT,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "addon_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "locations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"priority" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "locations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "nodes" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"hostname" TEXT,
"location_id" TEXT,
"location_type" "LocationType" NOT NULL DEFAULT 'LOCAL',
"status" "NodeStatus" NOT NULL DEFAULT 'OFFLINE',
"version" TEXT,
"architecture" TEXT,
"public_stream_url" TEXT NOT NULL,
"last_seen" TIMESTAMP(3),
"total_storage" BIGINT NOT NULL DEFAULT 0,
"free_storage" BIGINT NOT NULL DEFAULT 0,
"active_streams" INTEGER NOT NULL DEFAULT 0,
"current_bandwidth" BIGINT NOT NULL DEFAULT 0,
"library_file_count" INTEGER NOT NULL DEFAULT 0,
"last_revision" INTEGER NOT NULL DEFAULT 0,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "nodes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "node_credentials" (
"id" TEXT NOT NULL,
"node_id" TEXT NOT NULL,
"api_key_hash" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"rotated_at" TIMESTAMP(3),
CONSTRAINT "node_credentials_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "node_enrollment_tokens" (
"id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"name" TEXT,
"location_id" TEXT,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "node_enrollment_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "movies" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"original_title" TEXT,
"year" INTEGER,
"overview" TEXT,
"poster_url" TEXT,
"backdrop_url" TEXT,
"genres" TEXT[] DEFAULT ARRAY[]::TEXT[],
"runtime" INTEGER,
"imdb_id" TEXT,
"tmdb_id" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "movies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "series" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"year" INTEGER,
"overview" TEXT,
"poster_url" TEXT,
"backdrop_url" TEXT,
"genres" TEXT[] DEFAULT ARRAY[]::TEXT[],
"imdb_id" TEXT,
"tmdb_id" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "series_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "seasons" (
"id" TEXT NOT NULL,
"series_id" TEXT NOT NULL,
"season_number" INTEGER NOT NULL,
CONSTRAINT "seasons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "episodes" (
"id" TEXT NOT NULL,
"series_id" TEXT NOT NULL,
"season_id" TEXT NOT NULL,
"season_number" INTEGER NOT NULL,
"episode_number" INTEGER NOT NULL,
"title" TEXT,
"overview" TEXT,
"runtime" INTEGER,
"air_date" TIMESTAMP(3),
"imdb_id" TEXT,
"tmdb_id" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "episodes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "media_files" (
"id" TEXT NOT NULL,
"node_id" TEXT NOT NULL,
"movie_id" TEXT,
"episode_id" TEXT,
"local_file_id" TEXT NOT NULL,
"size_bytes" BIGINT NOT NULL,
"container" TEXT,
"resolution" TEXT,
"video_codec" TEXT,
"video_bitrate" INTEGER,
"hdr_type" TEXT,
"audio_codec" TEXT,
"audio_channels" TEXT,
"audio_language" TEXT,
"release_name" TEXT NOT NULL,
"available" BOOLEAN NOT NULL DEFAULT true,
"last_seen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "media_files_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "library_matches" (
"id" TEXT NOT NULL,
"movie_id" TEXT,
"series_id" TEXT,
"episode_id" TEXT,
"release_name" TEXT NOT NULL,
"ignored" BOOLEAN NOT NULL DEFAULT false,
"manual" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "library_matches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "playback_sessions" (
"id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"user_id" TEXT,
"node_id" TEXT NOT NULL,
"media_file_id" TEXT NOT NULL,
"status" "PlaybackSessionStatus" NOT NULL DEFAULT 'ACTIVE',
"idle_timeout_seconds" INTEGER NOT NULL,
"absolute_expires_at" TIMESTAMP(3) NOT NULL,
"last_activity" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "playback_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "admin_sessions_token_hash_key" ON "admin_sessions"("token_hash");
-- CreateIndex
CREATE UNIQUE INDEX "addon_tokens_token_hash_key" ON "addon_tokens"("token_hash");
-- CreateIndex
CREATE UNIQUE INDEX "locations_name_key" ON "locations"("name");
-- CreateIndex
CREATE UNIQUE INDEX "node_credentials_node_id_key" ON "node_credentials"("node_id");
-- CreateIndex
CREATE UNIQUE INDEX "node_enrollment_tokens_token_hash_key" ON "node_enrollment_tokens"("token_hash");
-- CreateIndex
CREATE UNIQUE INDEX "movies_imdb_id_key" ON "movies"("imdb_id");
-- CreateIndex
CREATE UNIQUE INDEX "movies_tmdb_id_key" ON "movies"("tmdb_id");
-- CreateIndex
CREATE UNIQUE INDEX "series_imdb_id_key" ON "series"("imdb_id");
-- CreateIndex
CREATE UNIQUE INDEX "series_tmdb_id_key" ON "series"("tmdb_id");
-- CreateIndex
CREATE UNIQUE INDEX "seasons_series_id_season_number_key" ON "seasons"("series_id", "season_number");
-- CreateIndex
CREATE UNIQUE INDEX "episodes_series_id_season_number_episode_number_key" ON "episodes"("series_id", "season_number", "episode_number");
-- CreateIndex
CREATE UNIQUE INDEX "media_files_node_id_local_file_id_key" ON "media_files"("node_id", "local_file_id");
-- CreateIndex
CREATE UNIQUE INDEX "playback_sessions_token_hash_key" ON "playback_sessions"("token_hash");
-- AddForeignKey
ALTER TABLE "admin_sessions" ADD CONSTRAINT "admin_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "addon_tokens" ADD CONSTRAINT "addon_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "nodes" ADD CONSTRAINT "nodes_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "node_credentials" ADD CONSTRAINT "node_credentials_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "seasons" ADD CONSTRAINT "seasons_series_id_fkey" FOREIGN KEY ("series_id") REFERENCES "series"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "episodes" ADD CONSTRAINT "episodes_series_id_fkey" FOREIGN KEY ("series_id") REFERENCES "series"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "episodes" ADD CONSTRAINT "episodes_season_id_fkey" FOREIGN KEY ("season_id") REFERENCES "seasons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "media_files" ADD CONSTRAINT "media_files_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "media_files" ADD CONSTRAINT "media_files_movie_id_fkey" FOREIGN KEY ("movie_id") REFERENCES "movies"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "media_files" ADD CONSTRAINT "media_files_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "episodes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "library_matches" ADD CONSTRAINT "library_matches_movie_id_fkey" FOREIGN KEY ("movie_id") REFERENCES "movies"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "library_matches" ADD CONSTRAINT "library_matches_series_id_fkey" FOREIGN KEY ("series_id") REFERENCES "series"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "library_matches" ADD CONSTRAINT "library_matches_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "episodes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "playback_sessions" ADD CONSTRAINT "playback_sessions_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "nodes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "playback_sessions" ADD CONSTRAINT "playback_sessions_media_file_id_fkey" FOREIGN KEY ("media_file_id") REFERENCES "media_files"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
# Migration lock file
provider = "postgresql"

View file

@ -0,0 +1,280 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum NodeStatus {
ONLINE
OFFLINE
DEGRADED
REVOKED
UPDATING
SCANNING
ERROR
}
enum PlaybackSessionStatus {
ACTIVE
IDLE
EXPIRED
REVOKED
COMPLETED
}
enum LocationType {
LOCAL
REMOTE
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String @map("password_hash")
name String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
addonTokens AddonToken[]
sessions AdminSession[]
@@map("users")
}
model AdminSession {
id String @id @default(uuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("admin_sessions")
}
model AddonToken {
id String @id @default(uuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
label String?
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("addon_tokens")
}
model Location {
id String @id @default(uuid())
name String @unique
description String?
priority Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
nodes Node[]
@@map("locations")
}
model Node {
id String @id @default(uuid())
name String
hostname String?
locationId String? @map("location_id")
locationType LocationType @default(LOCAL) @map("location_type")
status NodeStatus @default(OFFLINE)
version String?
architecture String?
publicStreamUrl String @map("public_stream_url")
lastSeen DateTime? @map("last_seen")
totalStorage BigInt @default(0) @map("total_storage")
freeStorage BigInt @default(0) @map("free_storage")
activeStreams Int @default(0) @map("active_streams")
currentBandwidth BigInt @default(0) @map("current_bandwidth")
libraryFileCount Int @default(0) @map("library_file_count")
lastRevision Int @default(0) @map("last_revision")
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
location Location? @relation(fields: [locationId], references: [id])
credentials NodeCredential?
mediaFiles MediaFile[]
playbackSessions PlaybackSession[]
@@map("nodes")
}
model NodeCredential {
id String @id @default(uuid())
nodeId String @unique @map("node_id")
apiKeyHash String @map("api_key_hash")
createdAt DateTime @default(now()) @map("created_at")
rotatedAt DateTime? @map("rotated_at")
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@map("node_credentials")
}
model NodeEnrollmentToken {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
name String?
locationId String? @map("location_id")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("node_enrollment_tokens")
}
model Movie {
id String @id @default(uuid())
title String
originalTitle String? @map("original_title")
year Int?
overview String?
posterUrl String? @map("poster_url")
backdropUrl String? @map("backdrop_url")
genres String[] @default([])
runtime Int?
imdbId String? @unique @map("imdb_id")
tmdbId Int? @unique @map("tmdb_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
mediaFiles MediaFile[]
matches LibraryMatch[]
@@map("movies")
}
model Series {
id String @id @default(uuid())
title String
year Int?
overview String?
posterUrl String? @map("poster_url")
backdropUrl String? @map("backdrop_url")
genres String[] @default([])
imdbId String? @unique @map("imdb_id")
tmdbId Int? @unique @map("tmdb_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
seasons Season[]
episodes Episode[]
matches LibraryMatch[]
@@map("series")
}
model Season {
id String @id @default(uuid())
seriesId String @map("series_id")
seasonNumber Int @map("season_number")
series Series @relation(fields: [seriesId], references: [id], onDelete: Cascade)
episodes Episode[]
@@unique([seriesId, seasonNumber])
@@map("seasons")
}
model Episode {
id String @id @default(uuid())
seriesId String @map("series_id")
seasonId String @map("season_id")
seasonNumber Int @map("season_number")
episodeNumber Int @map("episode_number")
title String?
overview String?
runtime Int?
airDate DateTime? @map("air_date")
imdbId String? @map("imdb_id")
tmdbId Int? @map("tmdb_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
series Series @relation(fields: [seriesId], references: [id], onDelete: Cascade)
season Season @relation(fields: [seasonId], references: [id], onDelete: Cascade)
mediaFiles MediaFile[]
matches LibraryMatch[]
@@unique([seriesId, seasonNumber, episodeNumber])
@@map("episodes")
}
model MediaFile {
id String @id @default(uuid())
nodeId String @map("node_id")
movieId String? @map("movie_id")
episodeId String? @map("episode_id")
localFileId String @map("local_file_id")
sizeBytes BigInt @map("size_bytes")
container String?
resolution String?
videoCodec String? @map("video_codec")
videoBitrate Int? @map("video_bitrate")
hdrType String? @map("hdr_type")
audioCodec String? @map("audio_codec")
audioChannels String? @map("audio_channels")
audioLanguage String? @map("audio_language")
releaseName String @map("release_name")
available Boolean @default(true)
lastSeen DateTime @default(now()) @map("last_seen")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
movie Movie? @relation(fields: [movieId], references: [id])
episode Episode? @relation(fields: [episodeId], references: [id])
playbackSessions PlaybackSession[]
@@unique([nodeId, localFileId])
@@map("media_files")
}
model LibraryMatch {
id String @id @default(uuid())
movieId String? @map("movie_id")
seriesId String? @map("series_id")
episodeId String? @map("episode_id")
releaseName String @map("release_name")
ignored Boolean @default(false)
manual Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
movie Movie? @relation(fields: [movieId], references: [id])
series Series? @relation(fields: [seriesId], references: [id])
episode Episode? @relation(fields: [episodeId], references: [id])
@@map("library_matches")
}
model PlaybackSession {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
userId String? @map("user_id")
nodeId String @map("node_id")
mediaFileId String @map("media_file_id")
status PlaybackSessionStatus @default(ACTIVE)
idleTimeoutSeconds Int @map("idle_timeout_seconds")
absoluteExpiresAt DateTime @map("absolute_expires_at")
lastActivity DateTime @default(now()) @map("last_activity")
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
node Node @relation(fields: [nodeId], references: [id])
mediaFile MediaFile @relation(fields: [mediaFileId], references: [id])
@@map("playback_sessions")
}

View file

@ -0,0 +1,35 @@
import "dotenv/config";
import { prisma } from "../src/database/client";
import { hashPassword } from "../src/security/crypto";
async function seed() {
const email = process.env.ADMIN_EMAIL ?? "admin@localhost";
const password = process.env.ADMIN_PASSWORD ?? "changeme123";
const existing = await prisma.user.findUnique({ where: { email } });
if (!existing) {
await prisma.user.create({
data: {
email,
passwordHash: await hashPassword(password),
name: "Admin",
},
});
console.log(`Created admin: ${email}`);
}
const locations = ["Main", "Remote-01"];
for (const name of locations) {
await prisma.location.upsert({
where: { name },
create: { name, priority: name === "Main" ? 10 : 0 },
update: {},
});
}
console.log("Seed completed");
}
seed()
.catch(console.error)
.finally(() => prisma.$disconnect());

View file

@ -0,0 +1,375 @@
import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client";
import { requireAdmin } from "../auth/routes";
import {
generateApiKey,
generateSecureToken,
hashToken,
} from "../security/crypto";
import { AppError } from "../security/errors";
import { nodeConnectionManager } from "../websocket/manager";
import { MetadataService } from "../metadata/service";
import type { Config } from "../config";
export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
const metadata = new MetadataService(config.TMDB_API_KEY);
app.get("/api/v1/admin/dashboard", { preHandler: requireAdmin }, async () => {
const [
nodesOnline,
nodesOffline,
movieCount,
seriesCount,
episodeCount,
activeStreams,
nodes,
] = await Promise.all([
prisma.node.count({ where: { status: "ONLINE", revoked: false } }),
prisma.node.count({ where: { status: "OFFLINE", revoked: false } }),
prisma.movie.count(),
prisma.series.count(),
prisma.episode.count(),
prisma.playbackSession.count({ where: { status: "ACTIVE", revoked: false } }),
prisma.node.findMany({
where: { revoked: false },
select: { totalStorage: true, freeStorage: true, currentBandwidth: true },
}),
]);
const totalStorage = nodes.reduce((sum, n) => sum + n.totalStorage, 0n);
const freeStorage = nodes.reduce((sum, n) => sum + n.freeStorage, 0n);
const bandwidth = nodes.reduce((sum, n) => sum + n.currentBandwidth, 0n);
return {
nodesOnline,
nodesOffline,
totalStorage: totalStorage.toString(),
freeStorage: freeStorage.toString(),
movies: movieCount,
series: seriesCount,
episodes: episodeCount,
activeStreams,
currentBandwidth: bandwidth.toString(),
};
});
app.get("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
const query = request.query as { search?: string };
const movies = await prisma.movie.findMany({
where: query.search
? { title: { contains: query.search, mode: "insensitive" } }
: undefined,
orderBy: { title: "asc" },
include: {
mediaFiles: {
include: { node: { select: { id: true, name: true, status: true } } },
},
},
take: 200,
});
return {
movies: movies.map((m) => ({
id: m.id,
title: m.title,
year: m.year,
imdbId: m.imdbId,
tmdbId: m.tmdbId,
overview: m.overview,
posterUrl: m.posterUrl,
fileCount: m.mediaFiles.length,
nodeCount: new Set(m.mediaFiles.map((f) => f.nodeId)).size,
qualities: [...new Set(m.mediaFiles.map((f) => f.resolution).filter(Boolean))],
files: m.mediaFiles.map((f) => ({
id: f.id,
nodeId: f.nodeId,
node: f.node.name,
nodeStatus: f.node.status,
resolution: f.resolution,
sizeBytes: f.sizeBytes.toString(),
videoCodec: f.videoCodec,
audioCodec: f.audioCodec,
hdrType: f.hdrType,
releaseName: f.releaseName,
available: f.available,
})),
})),
};
});
app.get("/api/v1/admin/movies/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const movie = await prisma.movie.findUnique({
where: { id },
include: {
mediaFiles: { include: { node: true } },
},
});
if (!movie) throw new AppError("NOT_FOUND", "Movie not found", 404);
return { movie };
});
app.get("/api/v1/admin/series", { preHandler: requireAdmin }, async (request) => {
const query = request.query as { search?: string };
const series = await prisma.series.findMany({
where: query.search
? { title: { contains: query.search, mode: "insensitive" } }
: undefined,
orderBy: { title: "asc" },
include: {
seasons: {
include: {
episodes: {
include: {
mediaFiles: {
include: { node: { select: { name: true, status: true } } },
},
},
orderBy: { episodeNumber: "asc" },
},
},
orderBy: { seasonNumber: "asc" },
},
},
take: 100,
});
return {
series: series.map((s) => ({
id: s.id,
title: s.title,
year: s.year,
imdbId: s.imdbId,
tmdbId: s.tmdbId,
posterUrl: s.posterUrl,
seasons: s.seasons.map((season) => ({
seasonNumber: season.seasonNumber,
episodes: season.episodes.map((ep) => ({
id: ep.id,
seasonNumber: ep.seasonNumber,
episodeNumber: ep.episodeNumber,
title: ep.title,
files: ep.mediaFiles.map((f) => ({
id: f.id,
node: f.node.name,
nodeStatus: f.node.status,
resolution: f.resolution,
available: f.available,
releaseName: f.releaseName,
})),
})),
})),
})),
};
});
app.get("/api/v1/admin/streams", { preHandler: requireAdmin }, async () => {
const sessions = await prisma.playbackSession.findMany({
where: { status: "ACTIVE", revoked: false },
include: {
node: { select: { id: true, name: true } },
mediaFile: {
include: {
movie: { select: { title: true, year: true } },
episode: {
include: { series: { select: { title: true } } },
},
},
},
},
orderBy: { lastActivity: "desc" },
take: 100,
});
return {
streams: sessions.map((s) => ({
id: s.id,
node: s.node.name,
status: s.status,
createdAt: s.createdAt,
lastActivity: s.lastActivity,
absoluteExpiresAt: s.absoluteExpiresAt,
title:
s.mediaFile.movie?.title ??
(s.mediaFile.episode
? `${s.mediaFile.episode.series?.title} S${String(s.mediaFile.episode.seasonNumber).padStart(2, "0")}E${String(s.mediaFile.episode.episodeNumber).padStart(2, "0")}`
: s.mediaFile.releaseName),
resolution: s.mediaFile.resolution,
})),
};
});
app.post("/api/v1/admin/streams/:id/revoke", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const session = await prisma.playbackSession.findUnique({ where: { id } });
if (!session) throw new AppError("NOT_FOUND", "Session not found", 404);
await prisma.playbackSession.update({
where: { id },
data: { status: "REVOKED", revoked: true },
});
nodeConnectionManager.revokePlaybackSession(session.nodeId, id);
return { ok: true };
});
app.get("/api/v1/admin/locations", { preHandler: requireAdmin }, async () => {
const locations = await prisma.location.findMany({
include: { nodes: { select: { id: true, name: true, status: true } } },
orderBy: { priority: "desc" },
});
return { locations };
});
app.post("/api/v1/admin/locations", { preHandler: requireAdmin }, async (request) => {
const body = request.body as { name?: string; description?: string; priority?: number };
if (!body.name) throw new AppError("INVALID_REQUEST", "name required");
const location = await prisma.location.create({
data: {
name: body.name,
description: body.description,
priority: body.priority ?? 0,
},
});
return { location };
});
app.post("/api/v1/admin/addon-tokens", { preHandler: requireAdmin }, async (request) => {
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const body = request.body as { label?: string };
const token = generateSecureToken(24);
await prisma.addonToken.create({
data: {
userId: user.id,
tokenHash: hashToken(token),
label: body.label,
},
});
return {
token,
installUrl: `/stremio/${token}/manifest.json`,
};
});
app.get("/api/v1/admin/addon-tokens", { preHandler: requireAdmin }, async (request) => {
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
const tokens = await prisma.addonToken.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
});
return {
tokens: tokens.map((t) => ({
id: t.id,
label: t.label,
revoked: t.revoked,
createdAt: t.createdAt,
})),
};
});
app.post("/api/v1/admin/addon-tokens/:id/revoke", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
await prisma.addonToken.update({ where: { id }, data: { revoked: true } });
return { ok: true };
});
// Manual library matching
app.post("/api/v1/admin/media-files/:id/match", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
movieId?: string;
episodeId?: string;
ignore?: boolean;
};
const file = await prisma.mediaFile.findUnique({ where: { id } });
if (!file) throw new AppError("NOT_FOUND", "Media file not found", 404);
if (body.ignore) {
await prisma.libraryMatch.create({
data: {
releaseName: file.releaseName,
ignored: true,
manual: true,
},
});
await prisma.mediaFile.update({
where: { id },
data: { movieId: null, episodeId: null, available: false },
});
return { ok: true };
}
await prisma.mediaFile.update({
where: { id },
data: {
movieId: body.movieId ?? null,
episodeId: body.episodeId ?? null,
available: true,
},
});
await prisma.libraryMatch.create({
data: {
movieId: body.movieId,
episodeId: body.episodeId,
releaseName: file.releaseName,
manual: true,
ignored: false,
},
});
return { ok: true };
});
app.post("/api/v1/admin/media-files/:id/refresh-metadata", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const file = await prisma.mediaFile.findUnique({ where: { id } });
if (!file) throw new AppError("NOT_FOUND", "Media file not found", 404);
await metadata.matchMediaFile(file.nodeId, {
localFileId: file.localFileId,
sizeBytes: Number(file.sizeBytes),
container: file.container ?? undefined,
resolution: file.resolution ?? undefined,
videoCodec: file.videoCodec ?? undefined,
audioCodec: file.audioCodec ?? undefined,
releaseName: file.releaseName,
modifiedAt: file.lastSeen.toISOString(),
mediaType: file.movieId ? "movie" : "episode",
parsedMovie: file.movieId
? { title: file.releaseName.replace(/\.[^.]+$/, "").replace(/[._]/g, " ") }
: undefined,
parsedEpisode: !file.movieId
? { title: file.releaseName, season: 1, episode: 1 }
: undefined,
});
return { ok: true };
});
app.post("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
title: string;
year?: number;
imdbId?: string;
tmdbId?: number;
};
if (!body.title) throw new AppError("INVALID_REQUEST", "title required");
const movie = await prisma.movie.create({ data: body });
return { movie };
});
}
export async function rotateNodeCredentials(nodeId: string): Promise<string> {
const apiKey = generateApiKey();
await prisma.nodeCredential.update({
where: { nodeId },
data: {
apiKeyHash: hashToken(apiKey),
rotatedAt: new Date(),
},
});
return apiKey;
}

View file

@ -0,0 +1,93 @@
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import websocket from "@fastify/websocket";
import { loadConfig } from "./config";
import { disconnectDatabase, prisma } from "./database/client";
import { registerAuthRoutes, ensureAdminUser } from "./auth/routes";
import { registerNodeRoutes } from "./nodes/routes";
import { registerStremioRoutes } from "./stremio/routes";
import { registerAdminRoutes } from "./admin/routes";
import { nodeConnectionManager } from "./websocket/manager";
import { toErrorResponse } from "./security/errors";
async function main() {
const config = loadConfig();
const app = Fastify({
logger: {
level: config.NODE_ENV === "production" ? "info" : "debug",
redact: ["req.headers.cookie", "req.headers.authorization"],
},
trustProxy: true,
});
await app.register(cors, {
origin: true,
credentials: true,
});
await app.register(cookie, {
secret: config.SESSION_SECRET,
});
await app.register(rateLimit, {
max: 300,
timeWindow: "1 minute",
});
await app.register(websocket);
app.addHook("onSend", async (request, reply, payload) => {
if (!request.url.startsWith("/stremio/")) {
reply.header("X-Content-Type-Options", "nosniff");
reply.header("Referrer-Policy", "no-referrer");
reply.header("X-Frame-Options", "DENY");
}
return payload;
});
app.setErrorHandler((error, _request, reply) => {
const { statusCode, body } = toErrorResponse(error);
reply.status(statusCode).send(body);
});
app.get("/health", async () => ({
status: "ok",
service: "master-api",
version: "1.0.0",
}));
await registerAuthRoutes(app, config);
await registerNodeRoutes(app);
await registerStremioRoutes(app, config);
await registerAdminRoutes(app, config);
app.get("/api/v1/node/connect", { websocket: true }, (socket) => {
void nodeConnectionManager.handleConnection(socket);
});
nodeConnectionManager.init(config);
await ensureAdminUser(config);
const shutdown = async () => {
console.log("Shutting down...");
nodeConnectionManager.shutdown();
await app.close();
await disconnectDatabase();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
await app.listen({ port: config.PORT, host: config.HOST });
console.log(`Master API listening on ${config.HOST}:${config.PORT}`);
}
main().catch((err) => {
console.error("Failed to start:", err);
void prisma.$disconnect();
process.exit(1);
});

View file

@ -0,0 +1,124 @@
import type { FastifyRequest, FastifyReply } from "fastify";
import { prisma } from "../database/client";
import {
generateSecureToken,
hashPassword,
hashToken,
verifyPassword,
} from "../security/crypto";
import { AppError } from "../security/errors";
import type { Config } from "../config";
const SESSION_COOKIE = "mc_admin_session";
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
export async function registerAuthRoutes(
app: import("fastify").FastifyInstance,
config: Config
) {
app.post("/api/v1/auth/login", async (request, reply) => {
const body = request.body as { email?: string; password?: string };
if (!body.email || !body.password) {
throw new AppError("INVALID_REQUEST", "Email and password required", 400);
}
const user = await prisma.user.findUnique({ where: { email: body.email } });
if (!user || !(await verifyPassword(user.passwordHash, body.password))) {
throw new AppError("INVALID_CREDENTIALS", "Invalid email or password", 401);
}
const token = generateSecureToken();
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await prisma.adminSession.create({
data: {
userId: user.id,
tokenHash: hashToken(token),
expiresAt,
},
});
reply.setCookie(SESSION_COOKIE, token, {
httpOnly: true,
secure: config.NODE_ENV === "production",
sameSite: "lax",
path: "/",
expires: expiresAt,
});
return { user: { id: user.id, email: user.email, name: user.name } };
});
app.post("/api/v1/auth/logout", async (request, reply) => {
const token = request.cookies[SESSION_COOKIE];
if (token) {
await prisma.adminSession.deleteMany({
where: { tokenHash: hashToken(token) },
});
}
reply.clearCookie(SESSION_COOKIE, { path: "/" });
return { ok: true };
});
app.get("/api/v1/auth/me", { preHandler: requireAdmin }, async (request) => {
const user = (request as AuthenticatedRequest).user;
return { user: { id: user.id, email: user.email, name: user.name } };
});
}
export interface AuthenticatedRequest extends FastifyRequest {
user: { id: string; email: string; name: string | null };
}
export async function requireAdmin(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const token = request.cookies[SESSION_COOKIE];
if (!token) {
reply.status(401).send({ error: { code: "UNAUTHORIZED", message: "Authentication required" } });
return;
}
const session = await prisma.adminSession.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
reply.status(401).send({ error: { code: "UNAUTHORIZED", message: "Session expired" } });
return;
}
(request as AuthenticatedRequest).user = {
id: session.user.id,
email: session.user.email,
name: session.user.name,
};
}
export async function ensureAdminUser(config: Config): Promise<void> {
if (!config.ADMIN_EMAIL || !config.ADMIN_PASSWORD) return;
const existing = await prisma.user.findUnique({
where: { email: config.ADMIN_EMAIL },
});
if (existing) return;
await prisma.user.create({
data: {
email: config.ADMIN_EMAIL,
passwordHash: await hashPassword(config.ADMIN_PASSWORD),
name: "Admin",
},
});
console.log(`Created admin user: ${config.ADMIN_EMAIL}`);
}
export async function validateAddonToken(token: string): Promise<string | null> {
const record = await prisma.addonToken.findUnique({
where: { tokenHash: hashToken(token) },
});
if (!record || record.revoked) return null;
return record.userId;
}

View file

@ -0,0 +1,30 @@
import { z } from "zod";
const configSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().default(3000),
HOST: z.string().default("0.0.0.0"),
DATABASE_URL: z.string(),
REDIS_URL: z.string().default("redis://localhost:6379"),
SESSION_SECRET: z.string().min(32),
ADMIN_EMAIL: z.string().email().optional(),
ADMIN_PASSWORD: z.string().min(8).optional(),
TMDB_API_KEY: z.string().optional(),
PUBLIC_URL: z.string().url().default("http://localhost:3000"),
PLAYBACK_IDLE_TIMEOUT_SECONDS: z.coerce.number().default(1200),
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: z.coerce.number().default(43200),
NODE_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
NODE_OFFLINE_THRESHOLD_SECONDS: z.coerce.number().default(90),
HIDE_UNAVAILABLE_TITLES: z.coerce.boolean().default(false),
});
export type Config = z.infer<typeof configSchema>;
export function loadConfig(): Config {
const result = configSchema.safeParse(process.env);
if (!result.success) {
console.error("Invalid configuration:", result.error.flatten().fieldErrors);
process.exit(1);
}
return result.data;
}

View file

@ -0,0 +1,8 @@
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
export async function disconnectDatabase(): Promise<void> {
await prisma.$disconnect();
}

View file

@ -0,0 +1,62 @@
import type { LibraryEvent } from "@media-cluster/shared-types";
import { prisma } from "../database/client";
import { MetadataService } from "../metadata/service";
export class LibrarySyncService {
constructor(private readonly metadata: MetadataService) {}
async processEvents(nodeId: string, events: LibraryEvent[]): Promise<number> {
let maxRevision = 0;
for (const event of events) {
maxRevision = Math.max(maxRevision, event.nodeRevision);
if (event.type === "FILE_REMOVED") {
await prisma.mediaFile.updateMany({
where: { nodeId, localFileId: event.file.localFileId },
data: { available: false },
});
continue;
}
await this.metadata.matchMediaFile(nodeId, event.file);
}
if (maxRevision > 0) {
await prisma.node.update({
where: { id: nodeId },
data: { lastRevision: maxRevision },
});
}
return maxRevision;
}
async processFullSync(
nodeId: string,
nodeRevision: number,
files: LibraryEvent["file"][]
): Promise<void> {
const localFileIds = files.map((f) => f.localFileId);
await prisma.mediaFile.updateMany({
where: {
nodeId,
localFileId: { notIn: localFileIds },
},
data: { available: false },
});
for (const file of files) {
await this.metadata.matchMediaFile(nodeId, file);
}
await prisma.node.update({
where: { id: nodeId },
data: {
lastRevision: nodeRevision,
libraryFileCount: files.length,
},
});
}
}

View file

@ -0,0 +1,36 @@
import {
detectMediaType,
parseMovieFilename,
parseSeriesFilename,
} from "./filename-parser";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function run() {
const movie = parseMovieFilename(
"The.Matrix.1999.2160p.UHD.BluRay.REMUX.HEVC.TrueHD.Atmos.mkv"
);
assert(movie, "movie parse failed");
assert(movie.title === "The Matrix", `title=${movie.title}`);
assert(movie.year === 1999, `year=${movie.year}`);
assert(movie.resolution === "2160p", `resolution=${movie.resolution}`);
const series = parseSeriesFilename("Breaking.Bad.S03E04.1080p.BluRay.mkv");
assert(series, "series parse failed");
assert(series.title === "Breaking Bad", `title=${series.title}`);
assert(series.season === 3, `season=${series.season}`);
assert(series.episode === 4, `episode=${series.episode}`);
const alt = parseSeriesFilename("Show.Name.1x02.720p.mkv");
assert(alt?.season === 1 && alt.episode === 2, "1x02 pattern failed");
assert(detectMediaType("Movie.2020.mkv") === "movie", "detect movie");
assert(detectMediaType("Show.S01E01.mkv") === "episode", "detect episode");
assert(detectMediaType("readme.txt") === null, "detect non-video");
console.log("filename-parser tests passed");
}
run();

View file

@ -0,0 +1,105 @@
import type { MediaFileInfo, ParsedMovieFilename, ParsedSeriesFilename } from "@media-cluster/shared-types";
const VIDEO_EXTENSIONS = [".mkv", ".mp4", ".m4v", ".avi", ".ts", ".m2ts"];
export function isVideoFile(filename: string): boolean {
const lower = filename.toLowerCase();
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
export function parseMovieFilename(filename: string): ParsedMovieFilename | null {
const base = filename.replace(/\.[^.]+$/, "");
const yearMatch = base.match(/[\.\s_-]((19|20)\d{2})[\.\s_-]/);
const year = yearMatch ? parseInt(yearMatch[1], 10) : undefined;
let titlePart = base;
if (yearMatch?.index !== undefined) {
titlePart = base.slice(0, yearMatch.index);
}
const title = titlePart.replace(/[\._-]+/g, " ").trim();
if (!title) return null;
const resolution = base.match(/\b(2160p|1080p|720p|480p|4K)\b/i)?.[1];
const source = base.match(/\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV|DVDRip)\b/i)?.[1];
const releaseType = base.match(/\b(REMUX|PROPER|REPACK)\b/i)?.[1];
const videoCodec = base.match(/\b(HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD)\b/i)?.[1];
const audio = base.match(/\b(TrueHD(\s*Atmos)?|DTS-HD|DTS|AAC|AC3|Atmos|EAC3)\b/i)?.[0];
return { title, year, resolution, source, releaseType, videoCodec, audio };
}
export function parseSeriesFilename(filename: string): ParsedSeriesFilename | null {
const base = filename.replace(/\.[^.]+$/, "");
const patterns = [
/(.+?)[\.\s_-]S(\d{1,2})E(\d{1,2})(?:E\d{1,2})?/i,
/(.+?)[\.\s_-](\d{1,2})x(\d{1,2})/i,
];
for (const pattern of patterns) {
const match = base.match(pattern);
if (match) {
const title = match[1].replace(/[\._-]+/g, " ").trim();
const season = parseInt(match[2], 10);
const episode = parseInt(match[3], 10);
if (!title || isNaN(season) || isNaN(episode)) continue;
const resolution = base.match(/\b(2160p|1080p|720p|480p|4K)\b/i)?.[1];
const source = base.match(/\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV)\b/i)?.[1];
const videoCodec = base.match(/\b(HEVC|H\.?264|H\.?265|x264|x265)\b/i)?.[1];
const audio = base.match(/\b(TrueHD|DTS-HD|DTS|AAC|AC3|Atmos)\b/i)?.[0];
return { title, season, episode, resolution, source, videoCodec, audio };
}
}
return null;
}
export function detectMediaType(filename: string): "movie" | "episode" | null {
if (!isVideoFile(filename)) return null;
const series = parseSeriesFilename(filename);
if (series) return "episode";
const movie = parseMovieFilename(filename);
if (movie) return "movie";
return null;
}
export function extractContainer(filename: string): string | undefined {
const ext = filename.match(/\.([^.]+)$/)?.[1];
return ext?.toLowerCase();
}
export function mediaFileInfoFromFilename(
localFileId: string,
filename: string,
sizeBytes: number,
modifiedAt: Date
): MediaFileInfo | null {
const mediaType = detectMediaType(filename);
if (!mediaType) return null;
return {
localFileId,
sizeBytes,
container: extractContainer(filename),
releaseName: filename,
modifiedAt: modifiedAt.toISOString(),
mediaType,
parsedMovie: mediaType === "movie" ? parseMovieFilename(filename) ?? undefined : undefined,
parsedEpisode: mediaType === "episode" ? parseSeriesFilename(filename) ?? undefined : undefined,
resolution:
(mediaType === "movie"
? parseMovieFilename(filename)?.resolution
: parseSeriesFilename(filename)?.resolution) ?? undefined,
videoCodec:
(mediaType === "movie"
? parseMovieFilename(filename)?.videoCodec
: parseSeriesFilename(filename)?.videoCodec) ?? undefined,
audioCodec:
(mediaType === "movie"
? parseMovieFilename(filename)?.audio
: parseSeriesFilename(filename)?.audio) ?? undefined,
};
}

View file

@ -0,0 +1,235 @@
import { prisma } from "../database/client";
import type { MediaFileInfo } from "@media-cluster/shared-types";
interface TmdbSearchResult {
results: Array<{
id: number;
title?: string;
name?: string;
release_date?: string;
first_air_date?: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
genre_ids?: number[];
}>;
}
const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500";
const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original";
export class MetadataService {
constructor(private readonly tmdbApiKey?: string) {}
async matchMediaFile(
nodeId: string,
file: MediaFileInfo
): Promise<string | null> {
if (file.mediaType === "movie" && file.parsedMovie) {
return this.matchMovie(nodeId, file);
}
if (file.mediaType === "episode" && file.parsedEpisode) {
return this.matchEpisode(nodeId, file);
}
return null;
}
private async matchMovie(nodeId: string, file: MediaFileInfo): Promise<string | null> {
const parsed = file.parsedMovie!;
let movie = await this.findExistingMovie(parsed.title, parsed.year);
if (!movie && this.tmdbApiKey) {
const tmdb = await this.searchTmdbMovie(parsed.title, parsed.year);
if (tmdb) {
movie = await prisma.movie.upsert({
where: { tmdbId: tmdb.id },
create: {
title: tmdb.title,
year: tmdb.year,
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id,
},
update: {},
});
}
}
if (!movie) {
movie = await prisma.movie.create({
data: {
title: parsed.title,
year: parsed.year,
},
});
}
await this.upsertMediaFile(nodeId, file, { movieId: movie.id });
return movie.id;
}
private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise<string | null> {
const parsed = file.parsedEpisode!;
let series = await prisma.series.findFirst({
where: { title: { equals: parsed.title, mode: "insensitive" } },
});
if (!series && this.tmdbApiKey) {
const tmdb = await this.searchTmdbSeries(parsed.title);
if (tmdb) {
series = await prisma.series.upsert({
where: { tmdbId: tmdb.id },
create: {
title: tmdb.title,
year: tmdb.year,
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
tmdbId: tmdb.id,
},
update: {},
});
}
}
if (!series) {
series = await prisma.series.create({
data: { title: parsed.title },
});
}
let season = await prisma.season.findUnique({
where: {
seriesId_seasonNumber: {
seriesId: series.id,
seasonNumber: parsed.season,
},
},
});
if (!season) {
season = await prisma.season.create({
data: {
seriesId: series.id,
seasonNumber: parsed.season,
},
});
}
let episode = await prisma.episode.findUnique({
where: {
seriesId_seasonNumber_episodeNumber: {
seriesId: series.id,
seasonNumber: parsed.season,
episodeNumber: parsed.episode,
},
},
});
if (!episode) {
episode = await prisma.episode.create({
data: {
seriesId: series.id,
seasonId: season.id,
seasonNumber: parsed.season,
episodeNumber: parsed.episode,
title: `Episode ${parsed.episode}`,
},
});
}
await this.upsertMediaFile(nodeId, file, { episodeId: episode.id });
return episode.id;
}
private async upsertMediaFile(
nodeId: string,
file: MediaFileInfo,
refs: { movieId?: string; episodeId?: string }
): Promise<void> {
await prisma.mediaFile.upsert({
where: {
nodeId_localFileId: { nodeId, localFileId: file.localFileId },
},
create: {
nodeId,
localFileId: file.localFileId,
movieId: refs.movieId,
episodeId: refs.episodeId,
sizeBytes: BigInt(file.sizeBytes),
container: file.container,
resolution: file.resolution,
videoCodec: file.videoCodec,
audioCodec: file.audioCodec,
releaseName: file.releaseName,
available: true,
lastSeen: new Date(file.modifiedAt),
},
update: {
movieId: refs.movieId,
episodeId: refs.episodeId,
sizeBytes: BigInt(file.sizeBytes),
container: file.container,
resolution: file.resolution,
videoCodec: file.videoCodec,
audioCodec: file.audioCodec,
releaseName: file.releaseName,
available: true,
lastSeen: new Date(file.modifiedAt),
},
});
}
private async findExistingMovie(title: string, year?: number) {
return prisma.movie.findFirst({
where: {
title: { equals: title, mode: "insensitive" },
...(year ? { year } : {}),
},
});
}
private async searchTmdbMovie(title: string, year?: number) {
if (!this.tmdbApiKey) return null;
const params = new URLSearchParams({
api_key: this.tmdbApiKey,
query: title,
...(year ? { year: String(year) } : {}),
});
const res = await fetch(`https://api.themoviedb.org/3/search/movie?${params}`);
if (!res.ok) return null;
const data = (await res.json()) as TmdbSearchResult;
const item = data.results[0];
if (!item) return null;
return {
id: item.id,
title: item.title ?? title,
year: item.release_date ? parseInt(item.release_date.slice(0, 4), 10) : year,
overview: item.overview,
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined,
backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined,
};
}
private async searchTmdbSeries(title: string) {
if (!this.tmdbApiKey) return null;
const params = new URLSearchParams({
api_key: this.tmdbApiKey,
query: title,
});
const res = await fetch(`https://api.themoviedb.org/3/search/tv?${params}`);
if (!res.ok) return null;
const data = (await res.json()) as TmdbSearchResult;
const item = data.results[0];
if (!item) return null;
return {
id: item.id,
title: item.name ?? title,
year: item.first_air_date ? parseInt(item.first_air_date.slice(0, 4), 10) : undefined,
overview: item.overview,
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined,
backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined,
};
}
}

View file

@ -0,0 +1,290 @@
import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client";
import {
generateApiKey,
generateEnrollmentToken,
hashToken,
} from "../security/crypto";
import { AppError } from "../security/errors";
import { requireAdmin } from "../auth/routes";
export async function registerNodeRoutes(app: FastifyInstance) {
app.post("/api/v1/nodes/enroll", async (request) => {
const body = request.body as {
token?: string;
name?: string;
location?: string;
hostname?: string;
architecture?: string;
version?: string;
publicStreamUrl?: string;
};
if (!body.token || !body.name || !body.publicStreamUrl) {
throw new AppError("INVALID_REQUEST", "token, name and publicStreamUrl required");
}
const tokenRecord = await prisma.nodeEnrollmentToken.findUnique({
where: { tokenHash: hashToken(body.token) },
});
if (!tokenRecord || tokenRecord.usedAt || tokenRecord.expiresAt < new Date()) {
throw new AppError("INVALID_TOKEN", "Enrollment token is invalid or expired", 403);
}
if (tokenRecord.name && tokenRecord.name !== body.name) {
throw new AppError("INVALID_TOKEN", "Enrollment token name mismatch", 403);
}
let locationId = tokenRecord.locationId;
if (!locationId && body.location) {
const location = await prisma.location.upsert({
where: { name: body.location },
create: { name: body.location },
update: {},
});
locationId = location.id;
}
const apiKey = generateApiKey();
const node = await prisma.$transaction(async (tx) => {
await tx.nodeEnrollmentToken.update({
where: { id: tokenRecord.id },
data: { usedAt: new Date() },
});
const created = await tx.node.create({
data: {
name: body.name!,
hostname: body.hostname,
locationId,
publicStreamUrl: body.publicStreamUrl!,
version: body.version,
architecture: body.architecture,
status: "OFFLINE",
},
});
await tx.nodeCredential.create({
data: {
nodeId: created.id,
apiKeyHash: hashToken(apiKey),
},
});
return created;
});
return {
nodeId: node.id,
apiKey,
name: node.name,
};
});
app.post(
"/api/v1/admin/nodes/enrollment-tokens",
{ preHandler: requireAdmin },
async (request) => {
const body = request.body as {
name?: string;
location?: string;
expiresInMinutes?: number;
};
const expiresInMinutes = body.expiresInMinutes ?? 30;
const token = generateEnrollmentToken();
let locationId: string | undefined;
if (body.location) {
const location = await prisma.location.upsert({
where: { name: body.location },
create: { name: body.location },
update: {},
});
locationId = location.id;
}
await prisma.nodeEnrollmentToken.create({
data: {
tokenHash: hashToken(token),
name: body.name,
locationId,
expiresAt: new Date(Date.now() + expiresInMinutes * 60 * 1000),
},
});
return { token, expiresInMinutes };
}
);
app.get("/api/v1/admin/nodes", { preHandler: requireAdmin }, async () => {
const nodes = await prisma.node.findMany({
include: { location: true },
orderBy: { name: "asc" },
});
return {
nodes: nodes.map((n) => serializeNode(n)),
};
});
app.get("/api/v1/admin/nodes/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({
where: { id },
include: { location: true },
});
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
return { node: serializeNode(node) };
});
app.patch("/api/v1/admin/nodes/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const body = request.body as {
name?: string;
location?: string;
locationType?: "LOCAL" | "REMOTE";
publicStreamUrl?: string;
};
let locationId: string | undefined | null = undefined;
if (body.location !== undefined) {
if (body.location === "") {
locationId = null;
} else {
const location = await prisma.location.upsert({
where: { name: body.location },
create: { name: body.location },
update: {},
});
locationId = location.id;
}
}
const node = await prisma.node.update({
where: { id },
data: {
...(body.name ? { name: body.name } : {}),
...(locationId !== undefined ? { locationId } : {}),
...(body.locationType ? { locationType: body.locationType } : {}),
...(body.publicStreamUrl ? { publicStreamUrl: body.publicStreamUrl } : {}),
},
include: { location: true },
});
return { node: serializeNode(node) };
});
app.post("/api/v1/admin/nodes/:id/disable", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
await prisma.node.update({
where: { id },
data: { status: "OFFLINE" },
});
await prisma.mediaFile.updateMany({
where: { nodeId: id },
data: { available: false },
});
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.disconnect(id);
return { ok: true };
});
app.post("/api/v1/admin/nodes/:id/rotate-credentials", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const apiKey = generateApiKey();
await prisma.nodeCredential.update({
where: { nodeId: id },
data: { apiKeyHash: hashToken(apiKey), rotatedAt: new Date() },
});
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.disconnect(id);
return { apiKey, warning: "Node must be reconfigured with the new API key" };
});
app.post("/api/v1/admin/nodes/:id/rescan", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.findUnique({ where: { id } });
if (!node) throw new AppError("NOT_FOUND", "Node not found", 404);
const { nodeConnectionManager } = await import("../websocket/manager");
const sent = nodeConnectionManager.sendRescan(id, true);
if (!sent) throw new AppError("NODE_OFFLINE", "Node is not connected", 503);
return { ok: true };
});
app.post("/api/v1/admin/nodes/:id/revoke", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
const node = await prisma.node.update({
where: { id },
data: { revoked: true, status: "REVOKED" },
});
const { nodeConnectionManager } = await import("../websocket/manager");
nodeConnectionManager.disconnect(id);
await prisma.mediaFile.updateMany({
where: { nodeId: id },
data: { available: false },
});
await prisma.playbackSession.updateMany({
where: { nodeId: id, status: "ACTIVE" },
data: { status: "REVOKED", revoked: true },
});
return { node: { id: node.id, status: node.status } };
});
}
function serializeNode(n: {
id: string;
name: string;
hostname: string | null;
location: { name: string } | null;
locationType: string;
status: string;
version: string | null;
architecture: string | null;
publicStreamUrl: string;
lastSeen: Date | null;
totalStorage: bigint;
freeStorage: bigint;
activeStreams: number;
currentBandwidth: bigint;
libraryFileCount: number;
revoked: boolean;
}) {
return {
id: n.id,
name: n.name,
hostname: n.hostname,
location: n.location?.name ?? null,
locationType: n.locationType,
status: n.status,
version: n.version,
architecture: n.architecture,
publicStreamUrl: n.publicStreamUrl,
lastSeen: n.lastSeen,
totalStorage: n.totalStorage.toString(),
freeStorage: n.freeStorage.toString(),
activeStreams: n.activeStreams,
currentBandwidth: n.currentBandwidth.toString(),
libraryFileCount: n.libraryFileCount,
revoked: n.revoked,
};
}
export async function authenticateNode(
nodeId: string,
apiKey: string
): Promise<boolean> {
const credential = await prisma.nodeCredential.findUnique({
where: { nodeId },
include: { node: true },
});
if (!credential || credential.node.revoked) return false;
return credential.apiKeyHash === hashToken(apiKey);
}

View file

@ -0,0 +1,109 @@
import { prisma } from "../database/client";
import {
generateSecureToken,
generateSessionId,
hashToken,
} from "../security/crypto";
import { AppError } from "../security/errors";
import { nodeConnectionManager } from "../websocket/manager";
import type { Config } from "../config";
export class PlaybackService {
constructor(private readonly config: Config) {}
async createSession(mediaFileId: string, userId?: string) {
const mediaFile = await prisma.mediaFile.findUnique({
where: { id: mediaFileId },
include: { node: true },
});
if (!mediaFile || !mediaFile.available) {
throw new AppError("MEDIA_UNAVAILABLE", "Media file is not available", 404);
}
if (mediaFile.node.revoked || mediaFile.node.status !== "ONLINE") {
throw new AppError("NODE_OFFLINE", "Selected media node is unavailable", 503);
}
if (!nodeConnectionManager.isOnline(mediaFile.nodeId)) {
throw new AppError("NODE_OFFLINE", "Selected media node is unavailable", 503);
}
const token = generateSecureToken(32);
const sessionId = generateSessionId();
const tokenHash = hashToken(token);
const now = new Date();
const absoluteExpiresAt = new Date(
now.getTime() + this.config.PLAYBACK_ABSOLUTE_LIFETIME_SECONDS * 1000
);
await prisma.playbackSession.create({
data: {
id: sessionId,
tokenHash,
userId,
nodeId: mediaFile.nodeId,
mediaFileId: mediaFile.id,
idleTimeoutSeconds: this.config.PLAYBACK_IDLE_TIMEOUT_SECONDS,
absoluteExpiresAt,
lastActivity: now,
status: "ACTIVE",
},
});
const pushed = nodeConnectionManager.createPlaybackSession(mediaFile.nodeId, {
sessionId,
tokenHash,
localFileId: mediaFile.localFileId,
idleTimeoutSeconds: this.config.PLAYBACK_IDLE_TIMEOUT_SECONDS,
absoluteExpiresAt: absoluteExpiresAt.toISOString(),
});
if (!pushed) {
await prisma.playbackSession.update({
where: { id: sessionId },
data: { status: "REVOKED", revoked: true },
});
throw new AppError("NODE_OFFLINE", "Failed to register session on node", 503);
}
const streamUrl = `${mediaFile.node.publicStreamUrl.replace(/\/$/, "")}/play/${token}`;
return {
sessionId,
streamUrl,
token,
nodeId: mediaFile.nodeId,
mediaFileId: mediaFile.id,
};
}
async revokeSession(sessionId: string): Promise<void> {
const session = await prisma.playbackSession.findUnique({
where: { id: sessionId },
});
if (!session) return;
await prisma.playbackSession.update({
where: { id: sessionId },
data: { status: "REVOKED", revoked: true },
});
nodeConnectionManager.revokePlaybackSession(session.nodeId, sessionId);
}
formatStreamLabel(mediaFile: {
resolution?: string | null;
releaseName: string;
hdrType?: string | null;
audioCodec?: string | null;
videoCodec?: string | null;
}): string {
const parts = ["STH Media"];
if (mediaFile.resolution) parts.push(mediaFile.resolution);
if (mediaFile.videoCodec) parts.push(mediaFile.videoCodec);
if (mediaFile.hdrType) parts.push(mediaFile.hdrType);
if (mediaFile.audioCodec) parts.push(mediaFile.audioCodec);
return parts.join(" • ");
}
}

View file

@ -0,0 +1,39 @@
import { createHash, randomBytes, randomUUID } from "crypto";
import * as argon2 from "argon2";
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function generateSecureToken(bytes = 32): string {
return randomBytes(bytes).toString("base64url");
}
export function generateEnrollmentToken(): string {
const part = () => randomBytes(2).toString("hex").toUpperCase().slice(0, 4);
return `ENR-${part()}-${part()}-${part()}`;
}
export function generateApiKey(): string {
return `ndk_${randomBytes(32).toString("base64url")}`;
}
export function generateSessionId(): string {
return randomUUID();
}
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(
hash: string,
password: string
): Promise<boolean> {
return argon2.verify(hash, password);
}
export function redactToken(token: string): string {
if (token.length <= 8) return "[REDACTED]";
return `${token.slice(0, 4)}...[REDACTED]`;
}

View file

@ -0,0 +1,25 @@
export class AppError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly statusCode = 400
) {
super(message);
this.name = "AppError";
}
}
export function toErrorResponse(error: unknown) {
if (error instanceof AppError) {
return {
statusCode: error.statusCode,
body: { error: { code: error.code, message: error.message } },
};
}
console.error("Unhandled error:", error);
return {
statusCode: 500,
body: { error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" } },
};
}

View file

@ -0,0 +1,363 @@
import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client";
import { validateAddonToken } from "../auth/routes";
import { PlaybackService } from "../playback/service";
import type { Config } from "../config";
const MANIFEST = {
id: "com.sthmedia.cluster",
version: "1.0.0",
name: "STH Media Cluster",
description: "Private distributed media library",
catalogs: [
{ type: "movie", id: "movies", name: "Movies", extra: [{ name: "search", isRequired: false }] },
{ type: "series", id: "series", name: "Series", extra: [{ name: "search", isRequired: false }] },
{ type: "movie", id: "recent-movies", name: "Recently Added Movies" },
{ type: "series", id: "recent-series", name: "Recently Added Series" },
{ type: "movie", id: "4k-movies", name: "4K Movies" },
],
resources: ["catalog", "meta", "stream"],
types: ["movie", "series"],
idPrefixes: ["tt", "sth"],
};
export async function registerStremioRoutes(app: FastifyInstance, config: Config) {
const playback = new PlaybackService(config);
app.options("/stremio/*", async (_request, reply) => {
reply
.header("Access-Control-Allow-Origin", "*")
.header("Access-Control-Allow-Methods", "GET, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type")
.status(204)
.send();
});
app.get("/stremio/:token/manifest.json", async (request, reply) => {
const { token } = request.params as { token: string };
const userId = await validateAddonToken(token);
if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" });
}
reply.header("Access-Control-Allow-Origin", "*");
return MANIFEST;
});
app.get("/stremio/:token/catalog/:type/:id/*.json", async (request, reply) => {
const { token, type, id } = request.params as { token: string; type: string; id: string };
const userId = await validateAddonToken(token);
if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" });
}
reply.header("Access-Control-Allow-Origin", "*");
const extra = (request.params as { "*": string })["*"] ?? "";
const search = parseExtraSearch(extra);
if (type === "movie") {
return { metas: await getMovieCatalog(id, config, search) };
}
if (type === "series") {
return { metas: await getSeriesCatalog(id, config, search) };
}
return { metas: [] };
});
app.get("/stremio/:token/catalog/:type/:id.json", async (request, reply) => {
const { token, type, id } = request.params as { token: string; type: string; id: string };
const userId = await validateAddonToken(token);
if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" });
}
reply.header("Access-Control-Allow-Origin", "*");
if (type === "movie") {
return { metas: await getMovieCatalog(id, config) };
}
if (type === "series") {
return { metas: await getSeriesCatalog(id, config) };
}
return { metas: [] };
});
app.get("/stremio/:token/meta/:type/:id.json", async (request, reply) => {
const { token, type, id } = request.params as { token: string; type: string; id: string };
const userId = await validateAddonToken(token);
if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" });
}
reply.header("Access-Control-Allow-Origin", "*");
if (type === "movie") {
const meta = await getMovieMeta(id);
return { meta: meta ?? { id, type: "movie", name: "Unknown" } };
}
if (type === "series") {
const meta = await getSeriesMeta(id);
return { meta: meta ?? { id, type: "series", name: "Unknown" } };
}
return { meta: { id, type, name: "Unknown" } };
});
app.get("/stremio/:token/stream/:type/:id.json", async (request, reply) => {
const { token, type, id } = request.params as { token: string; type: string; id: string };
const userId = await validateAddonToken(token);
if (!userId) {
return reply.status(401).send({ error: "Invalid addon token" });
}
reply.header("Access-Control-Allow-Origin", "*");
const streams = await getStreams(type, id, userId, playback);
return { streams };
});
}
function parseExtraSearch(extra: string): string | undefined {
const decoded = decodeURIComponent(extra.replace(/\.json$/, ""));
const parts = decoded.split("&");
for (const part of parts) {
const [key, ...rest] = part.split("=");
if (key === "search") return rest.join("=");
}
return undefined;
}
async function getMovieCatalog(catalogId: string, config: Config, search?: string) {
const where: Record<string, unknown> = {};
if (config.HIDE_UNAVAILABLE_TITLES) {
where.mediaFiles = { some: { available: true } };
}
if (search) {
where.title = { contains: search, mode: "insensitive" };
}
let movies = await prisma.movie.findMany({
where,
orderBy: { title: "asc" },
take: 100,
include: { mediaFiles: { where: { available: true } } },
});
if (catalogId === "recent-movies" && !search) {
movies = await prisma.movie.findMany({
where: { mediaFiles: { some: { available: true } } },
orderBy: { createdAt: "desc" },
take: 50,
include: { mediaFiles: { where: { available: true } } },
});
} else if (catalogId === "4k-movies") {
movies = await prisma.movie.findMany({
where: {
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
mediaFiles: {
some: {
available: true,
OR: [
{ resolution: { contains: "2160" } },
{ resolution: { contains: "4K", mode: "insensitive" } },
],
},
},
},
orderBy: { title: "asc" },
take: 100,
include: { mediaFiles: { where: { available: true } } },
});
}
return movies
.filter((m) => m.mediaFiles.length > 0 || !config.HIDE_UNAVAILABLE_TITLES)
.map((m) => ({
id: m.imdbId ?? `sth:movie:${m.id}`,
type: "movie" as const,
name: m.title,
poster: m.posterUrl ?? undefined,
background: m.backdropUrl ?? undefined,
description: m.overview ?? undefined,
releaseInfo: m.year ? String(m.year) : undefined,
genres: m.genres,
}));
}
async function getSeriesCatalog(catalogId: string, config: Config, search?: string) {
let series = await prisma.series.findMany({
where: {
...(search ? { title: { contains: search, mode: "insensitive" } } : {}),
...(config.HIDE_UNAVAILABLE_TITLES
? {
seasons: {
some: {
episodes: { some: { mediaFiles: { some: { available: true } } } },
},
},
}
: {}),
},
orderBy: { title: "asc" },
take: 100,
});
if (catalogId === "recent-series" && !search) {
series = await prisma.series.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
}
return series.map((s) => ({
id: s.imdbId ?? `sth:series:${s.id}`,
type: "series" as const,
name: s.title,
poster: s.posterUrl ?? undefined,
background: s.backdropUrl ?? undefined,
description: s.overview ?? undefined,
releaseInfo: s.year ? String(s.year) : undefined,
genres: s.genres,
}));
}
async function getMovieMeta(id: string) {
const movie = await prisma.movie.findFirst({
where: {
OR: [{ imdbId: id }, { id: id.replace("sth:movie:", "") }],
},
});
if (!movie) return null;
return {
id: movie.imdbId ?? `sth:movie:${movie.id}`,
type: "movie" as const,
name: movie.title,
poster: movie.posterUrl ?? undefined,
background: movie.backdropUrl ?? undefined,
description: movie.overview ?? undefined,
releaseInfo: movie.year ? String(movie.year) : undefined,
runtime: movie.runtime ? `${movie.runtime} min` : undefined,
genres: movie.genres,
imdbRating: undefined,
};
}
async function getSeriesMeta(id: string) {
const series = await prisma.series.findFirst({
where: {
OR: [{ imdbId: id }, { id: id.replace("sth:series:", "") }],
},
include: {
seasons: {
include: { episodes: { orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }] } },
orderBy: { seasonNumber: "asc" },
},
},
});
if (!series) return null;
const videos = series.seasons.flatMap((season) =>
season.episodes.map((ep) => ({
id: `${series.imdbId ?? `sth:series:${series.id}`}:${ep.seasonNumber}:${ep.episodeNumber}`,
title: ep.title ?? `Episode ${ep.episodeNumber}`,
season: ep.seasonNumber,
episode: ep.episodeNumber,
overview: ep.overview ?? undefined,
released: ep.airDate?.toISOString().slice(0, 10),
}))
);
return {
id: series.imdbId ?? `sth:series:${series.id}`,
type: "series" as const,
name: series.title,
poster: series.posterUrl ?? undefined,
background: series.backdropUrl ?? undefined,
description: series.overview ?? undefined,
releaseInfo: series.year ? String(series.year) : undefined,
genres: series.genres,
videos,
};
}
async function getStreams(
type: string,
id: string,
userId: string,
playback: PlaybackService
) {
if (type === "movie") {
const movie = await prisma.movie.findFirst({
where: { OR: [{ imdbId: id }, { id: id.replace("sth:movie:", "") }] },
include: {
mediaFiles: {
where: { available: true },
include: { node: true },
},
},
});
if (!movie) return [];
const streams = [];
for (const file of movie.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try {
const session = await playback.createSession(file.id, userId);
streams.push({
name: playback.formatStreamLabel(file),
title: playback.formatStreamLabel(file),
url: session.streamUrl,
behaviorHints: { bingeGroup: movie.id, notWebReady: true },
});
} catch {
// skip unavailable nodes
}
}
return streams;
}
if (type === "series") {
const match = id.match(/^(.+):(\d+):(\d+)$/);
if (!match) return [];
const [, seriesId, seasonStr, episodeStr] = match;
const seasonNumber = parseInt(seasonStr, 10);
const episodeNumber = parseInt(episodeStr, 10);
const series = await prisma.series.findFirst({
where: {
OR: [{ imdbId: seriesId }, { id: seriesId.replace("sth:series:", "") }],
},
});
if (!series) return [];
const episode = await prisma.episode.findUnique({
where: {
seriesId_seasonNumber_episodeNumber: {
seriesId: series.id,
seasonNumber,
episodeNumber,
},
},
include: {
mediaFiles: {
where: { available: true },
include: { node: true },
},
},
});
if (!episode) return [];
const streams = [];
for (const file of episode.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try {
const session = await playback.createSession(file.id, userId);
streams.push({
name: playback.formatStreamLabel(file),
title: playback.formatStreamLabel(file),
url: session.streamUrl,
behaviorHints: { bingeGroup: `${series.id}-S${seasonNumber}`, notWebReady: true },
});
} catch {
// skip
}
}
return streams;
}
return [];
}

View file

@ -0,0 +1,317 @@
import type { WebSocket } from "ws";
import type { ControlMessage, ControlMessageType } from "@media-cluster/protocol";
import { createMessage } from "@media-cluster/protocol";
import type { CreatePlaybackSessionPayload } from "@media-cluster/shared-types";
import { prisma } from "../database/client";
import { authenticateNode } from "../nodes/routes";
import { LibrarySyncService } from "../media/sync";
import { MetadataService } from "../metadata/service";
import type { Config } from "../config";
import type {
FullLibrarySyncPayload,
HeartbeatPayload,
HelloPayload,
LibraryEventPayload,
PlaybackSessionEndedPayload,
} from "@media-cluster/protocol";
interface NodeConnection {
nodeId: string;
socket: WebSocket;
lastHeartbeat: Date;
}
class NodeConnectionManager {
private connections = new Map<string, NodeConnection>();
private librarySync: LibrarySyncService | null = null;
private config: Config | null = null;
private offlineCheckInterval: ReturnType<typeof setInterval> | null = null;
init(config: Config): void {
this.config = config;
const metadata = new MetadataService(config.TMDB_API_KEY);
this.librarySync = new LibrarySyncService(metadata);
this.offlineCheckInterval = setInterval(() => {
void this.checkOfflineNodes();
}, 30_000);
}
async handleConnection(socket: WebSocket): Promise<void> {
let nodeId: string | null = null;
let authenticated = false;
const authTimeout = setTimeout(() => {
if (!authenticated) socket.close(4001, "Authentication timeout");
}, 30_000);
socket.on("message", async (data) => {
try {
const message = JSON.parse(data.toString()) as ControlMessage;
await this.handleMessage(socket, message, (id) => {
nodeId = id;
authenticated = true;
clearTimeout(authTimeout);
});
} catch (err) {
console.error("WebSocket message error:", err);
this.send(socket, createMessage("ERROR", { message: "Invalid message" }));
}
});
socket.on("close", () => {
if (nodeId) {
this.connections.delete(nodeId);
void prisma.node.update({
where: { id: nodeId },
data: { status: "OFFLINE" },
});
void prisma.mediaFile.updateMany({
where: { nodeId },
data: { available: false },
});
console.log(`Node disconnected: ${nodeId}`);
}
});
}
private async handleMessage(
socket: WebSocket,
message: ControlMessage,
onAuth: (nodeId: string) => void
): Promise<void> {
if (message.protocolVersion !== 1) {
this.send(
socket,
createMessage("ERROR", {
code: "UNSUPPORTED_PROTOCOL",
message: `Unsupported protocol version: ${message.protocolVersion}`,
})
);
socket.close(4002, "Unsupported protocol");
return;
}
switch (message.type) {
case "HELLO":
await this.handleHello(socket, message.payload as HelloPayload, onAuth);
break;
case "HEARTBEAT":
await this.handleHeartbeat(message.payload as HeartbeatPayload);
this.send(socket, createMessage("HEARTBEAT_ACK", {}));
break;
case "LIBRARY_EVENT":
await this.handleLibraryEvent(
message.payload as LibraryEventPayload,
socket
);
break;
case "FULL_LIBRARY_SYNC":
await this.handleFullSync(message.payload as FullLibrarySyncPayload, socket);
break;
case "PLAYBACK_SESSION_ENDED":
await this.handlePlaybackEnded(message.payload as PlaybackSessionEndedPayload);
break;
default:
this.send(socket, createMessage("ERROR", { message: `Unknown type: ${message.type}` }));
}
}
private async handleHello(
socket: WebSocket,
payload: HelloPayload,
onAuth: (nodeId: string) => void
): Promise<void> {
const valid = await authenticateNode(payload.nodeId, payload.apiKey);
if (!valid) {
this.send(
socket,
createMessage("HELLO_ACK", {
accepted: false,
reason: "Invalid credentials",
heartbeatIntervalSeconds: 30,
fullScanIntervalSeconds: 21600,
})
);
socket.close(4003, "Authentication failed");
return;
}
this.connections.set(payload.nodeId, {
nodeId: payload.nodeId,
socket,
lastHeartbeat: new Date(),
});
onAuth(payload.nodeId);
await prisma.node.update({
where: { id: payload.nodeId },
data: {
status: "ONLINE",
lastSeen: new Date(),
version: payload.softwareVersion,
architecture: payload.architecture,
hostname: payload.hostname,
publicStreamUrl: payload.publicStreamUrl,
},
});
// Restore availability for previously indexed files until full sync arrives
await prisma.mediaFile.updateMany({
where: { nodeId: payload.nodeId },
data: { available: true },
});
console.log(`Node connected: ${payload.nodeId} (${payload.hostname})`);
this.send(
socket,
createMessage("HELLO_ACK", {
accepted: true,
heartbeatIntervalSeconds: this.config?.NODE_HEARTBEAT_INTERVAL_SECONDS ?? 30,
fullScanIntervalSeconds: 21600,
})
);
}
private async handleHeartbeat(payload: HeartbeatPayload): Promise<void> {
const conn = this.connections.get(payload.nodeId);
if (conn) conn.lastHeartbeat = new Date();
await prisma.node.update({
where: { id: payload.nodeId },
data: {
status: "ONLINE",
lastSeen: new Date(),
totalStorage: BigInt(payload.storageTotalBytes),
freeStorage: BigInt(payload.storageFreeBytes),
activeStreams: payload.activeStreams,
currentBandwidth: BigInt(payload.currentBytesPerSec),
libraryFileCount: payload.libraryFileCount,
version: payload.softwareVersion,
},
});
}
private async handleLibraryEvent(
payload: LibraryEventPayload,
socket: WebSocket
): Promise<void> {
const conn = [...this.connections.values()].find((c) => c.socket === socket);
if (!conn || !this.librarySync) return;
const node = await prisma.node.findUnique({ where: { id: conn.nodeId } });
if (!node) return;
const expectedRevision = node.lastRevision + 1;
const minRevision = Math.min(...payload.events.map((e) => e.nodeRevision));
if (payload.events.length > 0 && minRevision > expectedRevision) {
this.send(socket, createMessage("FULL_LIBRARY_SYNC", { full: true }));
return;
}
await this.librarySync.processEvents(conn.nodeId, payload.events);
this.send(socket, createMessage("LIBRARY_EVENT_ACK", { accepted: true }));
}
private async handleFullSync(
payload: FullLibrarySyncPayload,
socket: WebSocket
): Promise<void> {
const conn = [...this.connections.values()].find((c) => c.socket === socket);
if (!conn || !this.librarySync) return;
await this.librarySync.processFullSync(
conn.nodeId,
payload.nodeRevision,
payload.files
);
this.send(socket, createMessage("FULL_LIBRARY_SYNC_ACK", { accepted: true }));
}
private async handlePlaybackEnded(payload: PlaybackSessionEndedPayload): Promise<void> {
await prisma.playbackSession.updateMany({
where: { id: payload.sessionId },
data: { status: "COMPLETED" },
});
}
sendToNode(nodeId: string, type: ControlMessageType, payload: unknown): boolean {
const conn = this.connections.get(nodeId);
if (!conn || conn.socket.readyState !== 1) return false;
this.send(conn.socket, createMessage(type, payload));
return true;
}
sendRescan(nodeId: string, full: boolean): boolean {
return this.sendToNode(nodeId, "RESCAN", { full });
}
createPlaybackSession(nodeId: string, payload: CreatePlaybackSessionPayload): boolean {
return this.sendToNode(nodeId, "CREATE_PLAYBACK_SESSION", payload);
}
revokePlaybackSession(nodeId: string, sessionId: string): boolean {
return this.sendToNode(nodeId, "REVOKE_PLAYBACK_SESSION", { sessionId });
}
disconnect(nodeId: string): void {
const conn = this.connections.get(nodeId);
if (conn) {
conn.socket.close(4004, "Revoked");
this.connections.delete(nodeId);
}
}
isOnline(nodeId: string): boolean {
const conn = this.connections.get(nodeId);
return !!conn && conn.socket.readyState === 1;
}
private send(socket: WebSocket, message: ControlMessage): void {
if (socket.readyState === 1) {
socket.send(JSON.stringify(message));
}
}
private async checkOfflineNodes(): Promise<void> {
if (!this.config) return;
const threshold = new Date(
Date.now() - this.config.NODE_OFFLINE_THRESHOLD_SECONDS * 1000
);
const stale = await prisma.node.findMany({
where: {
status: "ONLINE",
lastSeen: { lt: threshold },
revoked: false,
},
select: { id: true },
});
for (const node of stale) {
if (this.connections.has(node.id)) {
this.disconnect(node.id);
}
await prisma.node.update({
where: { id: node.id },
data: { status: "OFFLINE" },
});
await prisma.mediaFile.updateMany({
where: { nodeId: node.id },
data: { available: false },
});
}
}
shutdown(): void {
if (this.offlineCheckInterval) clearInterval(this.offlineCheckInterval);
for (const conn of this.connections.values()) {
conn.socket.close(1001, "Server shutting down");
}
this.connections.clear();
}
}
export const nodeConnectionManager = new NodeConnectionManager();

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,27 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-workspace.yaml ./
COPY apps/admin-ui/package.json ./apps/admin-ui/
RUN pnpm install --no-frozen-lockfile
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/admin-ui/node_modules ./apps/admin-ui/node_modules
COPY apps/admin-ui ./apps/admin-ui
ENV NEXT_TELEMETRY_DISABLED=1
ENV DOCKER_BUILD=1
WORKDIR /app/apps/admin-ui
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/apps/admin-ui/.next/standalone ./
COPY --from=builder /app/apps/admin-ui/.next/static ./apps/admin-ui/.next/static
COPY --from=builder /app/apps/admin-ui/public ./apps/admin-ui/public
EXPOSE 3000
CMD ["node", "apps/admin-ui/server.js"]

View file

@ -0,0 +1,34 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-workspace.yaml ./
COPY apps/master-api/package.json ./apps/master-api/
COPY packages/shared-types/package.json ./packages/shared-types/
COPY packages/protocol/package.json ./packages/protocol/
RUN pnpm install --no-frozen-lockfile
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps ./apps
COPY --from=deps /app/packages ./packages
COPY package.json pnpm-workspace.yaml ./
COPY packages ./packages
COPY apps/master-api ./apps/master-api
RUN pnpm --filter @media-cluster/shared-types build \
&& pnpm --filter @media-cluster/protocol build
WORKDIR /app/apps/master-api
RUN pnpm exec prisma generate && pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache openssl wget
COPY --from=builder /app/apps/master-api/dist ./dist
COPY --from=builder /app/apps/master-api/package.json ./package.json
COPY --from=builder /app/apps/master-api/prisma ./prisma
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/app.js"]

View file

@ -0,0 +1,88 @@
# Docker Compose tuned for Dokploy
# Deploy as: Compose application → Git provider → compose path:
# deploy/docker/docker-compose.dokploy.yml
#
# Set environment variables in the Dokploy UI (Environment tab).
# Dokploy writes them to .env next to this compose file.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-media}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-media_cluster}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-media} -d $${POSTGRES_DB:-media_cluster}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- media-cluster
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- media-cluster
master-api:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.master-api
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3000
DATABASE_URL: postgresql://${POSTGRES_USER:-media}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-media_cluster}
REDIS_URL: redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
expose:
- "3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
networks:
- media-cluster
admin-ui:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.admin-ui
restart: unless-stopped
environment:
# Server-side rewrite target (Docker network)
NEXT_PUBLIC_API_URL: http://master-api:3000
depends_on:
- master-api
expose:
- "3000"
networks:
- media-cluster
volumes:
postgres_data:
redis_data:
networks:
media-cluster:
driver: bridge

View file

@ -0,0 +1,62 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: media
POSTGRES_PASSWORD: media_secret
POSTGRES_DB: media_cluster
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U media -d media_cluster"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
master-api:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.master-api
restart: unless-stopped
env_file:
- ../master/.env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "3000:3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
admin-ui:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.admin-ui
restart: unless-stopped
environment:
NEXT_PUBLIC_API_URL: http://master-api:3000
depends_on:
- master-api
ports:
- "3001:3000"
volumes:
postgres_data:
redis_data:

View file

@ -0,0 +1,31 @@
# Environment for Dokploy (paste into Environment tab)
# Generate SESSION_SECRET: openssl rand -base64 48
NODE_ENV=production
PORT=3000
HOST=0.0.0.0
POSTGRES_USER=media
POSTGRES_PASSWORD=CHANGE_ME_STRONG_DB_PASSWORD
POSTGRES_DB=media_cluster
# Built automatically in compose, but keep explicit for clarity:
DATABASE_URL=postgresql://media:CHANGE_ME_STRONG_DB_PASSWORD@postgres:5432/media_cluster
REDIS_URL=redis://redis:6379
SESSION_SECRET=CHANGE_ME_MIN_32_CHARS_RANDOM_SECRET_HERE_PLEASE
ADMIN_EMAIL=admin@jouwdomein.nl
ADMIN_PASSWORD=CHANGE_ME_STRONG_ADMIN_PASSWORD
# Publieke Master URL (Dokploy domain of NPM)
PUBLIC_URL=https://master.jouwdomein.nl
# Optioneel – betere metadata matching
TMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
NODE_HEARTBEAT_INTERVAL_SECONDS=30
NODE_OFFLINE_THRESHOLD_SECONDS=90
HIDE_UNAVAILABLE_TITLES=false

View file

@ -0,0 +1,23 @@
# Master API environment variables
NODE_ENV=development
PORT=3000
HOST=0.0.0.0
DATABASE_URL=postgresql://media:media_secret@postgres:5432/media_cluster
REDIS_URL=redis://redis:6379
SESSION_SECRET=change-this-to-a-random-string-at-least-32-chars-long
ADMIN_EMAIL=admin@localhost
ADMIN_PASSWORD=changeme123
PUBLIC_URL=https://master.media.example.com
# Optional TMDB API key for metadata matching
TMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
NODE_HEARTBEAT_INTERVAL_SECONDS=30
NODE_OFFLINE_THRESHOLD_SECONDS=90
HIDE_UNAVAILABLE_TITLES=false

21
deploy/master/backup.sh Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
# Backup Master PostgreSQL + config
# Usage: ./backup.sh [output-dir]
OUT_DIR="${1:-/opt/media-master/backups}"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DEST="${OUT_DIR}/${STAMP}"
mkdir -p "$DEST"
echo "Backing up PostgreSQL..."
docker compose -f "$(dirname "$0")/../docker/docker-compose.yml" exec -T postgres \
pg_dump -U media media_cluster | gzip > "${DEST}/postgres.sql.gz"
if [[ -f "$(dirname "$0")/../master/.env" ]]; then
cp "$(dirname "$0")/../master/.env" "${DEST}/master.env"
fi
echo "Backup written to ${DEST}"

21
deploy/node/build.sh Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../node/media-node"
VERSION="${1:-1.0.0}"
OUTPUT_DIR="../../dist"
mkdir -p "$OUTPUT_DIR"
echo "Building media-node v${VERSION}..."
for ARCH in amd64 arm64; do
echo " linux/${ARCH}..."
GOOS=linux GOARCH=$ARCH CGO_ENABLED=0 go build \
-ldflags "-s -w -X main.version=${VERSION}" \
-o "${OUTPUT_DIR}/media-node-linux-${ARCH}" \
./cmd/media-node
done
echo "Done. Binaries in ${OUTPUT_DIR}/"

View file

@ -0,0 +1,22 @@
node:
name: Rotterdam-Node-01
master:
url: https://master.media.example.com
stream:
listen: 0.0.0.0:8080
public_url: https://node01.media.example.com
media:
movies:
- /mnt/disk1/movies
series:
- /mnt/disk3/series
scanner:
full_scan_interval: 6h
security:
session_idle_timeout: 20m
max_concurrent_per_ip: 8

63
deploy/node/install.sh Normal file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${MEDIA_NODE_VERSION:-1.0.0}"
ARCH="${MEDIA_NODE_ARCH:-$(uname -m)}"
INSTALL_DIR="/usr/local/bin"
CONFIG_DIR="/etc/media-node"
DATA_DIR="/var/lib/media-node"
SERVICE_USER="media-node"
case "$ARCH" in
x86_64) GOARCH="amd64" ;;
aarch64) GOARCH="arm64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
BINARY="media-node-linux-${GOARCH}"
DOWNLOAD_URL="${MEDIA_NODE_DOWNLOAD_URL:-https://releases.example.com/media-node/${VERSION}/${BINARY}}"
echo "=== STH Media Node Installer ==="
echo "Version: $VERSION"
echo "Architecture: $GOARCH"
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root (sudo)"
exit 1
fi
# Create service user
if ! id "$SERVICE_USER" &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
echo "Created user: $SERVICE_USER"
fi
# Create directories
mkdir -p "$CONFIG_DIR/credentials" "$DATA_DIR"
chmod 750 "$CONFIG_DIR" "$DATA_DIR"
chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR"
chown -R root:"$SERVICE_USER" "$CONFIG_DIR"
chmod 750 "$CONFIG_DIR/credentials"
# Download binary (or use local if building)
if [[ -f "./${BINARY}" ]]; then
cp "./${BINARY}" "$INSTALL_DIR/media-node"
else
echo "Downloading from $DOWNLOAD_URL..."
curl -fsSL "$DOWNLOAD_URL" -o "$INSTALL_DIR/media-node"
fi
chmod 755 "$INSTALL_DIR/media-node"
# Install systemd service
cp deploy/node/media-node.service /etc/systemd/system/media-node.service
systemctl daemon-reload
# Run interactive setup
"$INSTALL_DIR/media-node" install
systemctl enable media-node
systemctl start media-node
echo ""
echo "Media Node installed and started!"
echo "Check status: systemctl status media-node"

View file

@ -0,0 +1,25 @@
[Unit]
Description=STH Media Node
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=media-node
Group=media-node
ExecStart=/usr/local/bin/media-node run
Restart=always
RestartSec=5
Environment=MEDIA_NODE_CONFIG=/etc/media-node/config.yaml
Environment=MEDIA_NODE_DATA=/var/lib/media-node
Environment=MEDIA_NODE_CREDENTIALS=/etc/media-node/credentials/credentials.json
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/media-node
PrivateTmp=true
[Install]
WantedBy=multi-user.target

71
docs/deployment.md Normal file
View file

@ -0,0 +1,71 @@
# Deployment guide
## Master (Ubuntu + Docker)
```bash
sudo mkdir -p /opt/media-master
# Kopieer de repo naar /opt/media-master
cd /opt/media-master
cp deploy/master/.env.example deploy/master/.env
nano deploy/master/.env # SESSION_SECRET, ADMIN_*, PUBLIC_URL, TMDB_API_KEY
cd deploy/docker
docker compose up -d --build
```
Services:
- `master-api` → `:3000`
- `admin-ui` → `:3001`
- `postgres`, `redis`
Backup:
```bash
bash deploy/master/backup.sh /opt/media-master/backups
```
## Media Node (Ubuntu, geen Docker)
```bash
# Build op CI/build host:
cd node/media-node
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o media-node-linux-amd64 ./cmd/media-node
# Op de node:
sudo cp media-node-linux-amd64 /usr/local/bin/media-node
sudo chmod +x /usr/local/bin/media-node
sudo media-node install
```
Installer vraagt om:
1. Master URL
2. Node name
3. Public stream URL
4. Movies directory
5. Series directory
6. Enrollment token (uit Admin UI)
Daarna:
```bash
sudo systemctl status media-node
media-node diagnostics
```
## Stremio
1. Admin UI → Instellingen → Addon token genereren
2. In Stremio: Community Addons → URL
`https://master.media.example.com/stremio/<token>/manifest.json`
## Checklist productie
- [ ] Sterke `SESSION_SECRET` (≥ 32 chars)
- [ ] Sterk admin-wachtwoord
- [ ] HTTPS via NPM op Master + alle Nodes
- [ ] Node firewall: alleen NPM → `:8080`
- [ ] TMDB API key (optioneel, betere metadata)
- [ ] Backups van PostgreSQL gepland

137
docs/dokploy.md Normal file
View file

@ -0,0 +1,137 @@
# Master deployen met Dokploy
Ja — **Dokploy is een uitstekende optie** voor de Master. De Media Nodes blijven losse Go-binaries op andere servers (niet in Dokploy).
```
Dokploy (Master stack)
├── master-api
├── admin-ui
├── postgres
└── redis
Media Nodes (apart, systemd)
└── media-node binary → outbound WSS naar Master
```
## Wat Dokploy wel / niet doet
| Onderdeel | Dokploy? |
|-----------|----------|
| Master API + Admin UI + Postgres + Redis | Ja |
| HTTPS / domeinen (Traefik) | Ja (aanbevolen) |
| Media Nodes | Nee — aparte servers |
| Stremio zelf | Nee — client-app |
Als je al **Nginx Proxy Manager** voor Master wilt blijven gebruiken: laat Dokploy de containers draaien zonder publiek domein, en proxy NPM naar de host-poorten. Meestal is **Dokploy Traefik** eenvoudiger voor de Master.
## Voorbereiding
1. Zet deze repo op GitHub/GitLab/Gitea (privé mag).
2. Zorg dat Dokploy bij die repo kan (deploy key / token).
3. Genereer secrets:
```bash
openssl rand -base64 48 # SESSION_SECRET
openssl rand -base64 24 # POSTGRES_PASSWORD
```
## Stappen in Dokploy
### 1. Nieuwe Compose-app
- **Project** → Create Service → **Compose**
- **Source**: Git repository
- **Compose path**: `deploy/docker/docker-compose.dokploy.yml`
- Branch: `main` (of jouw branch)
### 2. Environment variables
Tab **Environment** — plak en vul in (zie ook `deploy/dokploy/.env.example`):
```env
NODE_ENV=production
POSTGRES_USER=media
POSTGRES_PASSWORD=<sterk-wachtwoord>
POSTGRES_DB=media_cluster
DATABASE_URL=postgresql://media:<sterk-wachtwoord>@postgres:5432/media_cluster
REDIS_URL=redis://redis:6379
SESSION_SECRET=<min-32-chars>
ADMIN_EMAIL=admin@jouwdomein.nl
ADMIN_PASSWORD=<sterk-wachtwoord>
PUBLIC_URL=https://master.jouwdomein.nl
TMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
NODE_HEARTBEAT_INTERVAL_SECONDS=30
NODE_OFFLINE_THRESHOLD_SECONDS=90
HIDE_UNAVAILABLE_TITLES=false
```
Belangrijk: `DATABASE_URL` wachtwoord moet **exact** gelijk zijn aan `POSTGRES_PASSWORD`.
### 3. Domeinen (Dokploy Traefik)
Koppel domeinen aan de juiste service/poort:
| Domein | Service | Poort | Opmerking |
|--------|---------|-------|-----------|
| `master.jouwdomein.nl` | `master-api` | `3000` | **WebSockets aan** |
| `admin.jouwdomein.nl` | `admin-ui` | `3000` | Admin UI |
WebSockets zijn verplicht: nodes verbinden via
`wss://master.jouwdomein.nl/api/v1/node/connect`.
DNS A/AAAA records naar je Dokploy-server.
### 4. Deploy
Klik **Deploy**. Eerste build kan enkele minuten duren (TypeScript + Next.js).
Controleer:
```text
https://master.jouwdomein.nl/health
→ {"status":"ok","service":"master-api",...}
```
Login Admin UI:
```text
https://admin.jouwdomein.nl
```
## Admin UI → API
De Admin UI praat via server-side rewrites naar `http://master-api:3000` (Docker-netwerk).
Browsers roepen alleen `/api/...` op het admin-domein aan — dat is correct.
## Na succesvolle deploy
1. Admin UI → **Instellingen** → Stremio addon token genereren
2. Admin UI → **Nodes** → enrollment token
3. Op een Media Node: `sudo media-node install` met
Master URL = `https://master.jouwdomein.nl`
## Alternatief: Dokploy + jouw NPM
Als Master achter NPM moet blijven:
1. In Dokploy: expose `master-api` als host-poort `3000` en `admin-ui` als `3001` (pas compose aan met `ports:`).
2. In NPM:
- `master.…` → `http://dokploy-host:3000` (Websockets aan)
- `admin.…` → `http://dokploy-host:3001`
## Troubleshooting
| Probleem | Oplossing |
|----------|-----------|
| Build faalt (context) | Compose path moet `deploy/docker/docker-compose.dokploy.yml` zijn; context is repo-root |
| Env leeg in container | Variables in Dokploy UI + `env_file: .env` (staat al in de compose) |
| Node kan niet verbinden | Domein op `master-api`, WebSockets enabled, `PUBLIC_URL` HTTPS |
| Admin login faalt | Check `ADMIN_EMAIL` / `ADMIN_PASSWORD`; herdeploy na env-wijziging |
| DB auth error | `DATABASE_URL` password ≠ `POSTGRES_PASSWORD` |
## Volume / backups
Postgres data staat in Docker volume `postgres_data`.
Backup periodiek (Dokploy volume backup of `pg_dump` via `deploy/master/backup.sh`).

64
docs/npm-setup.md Normal file
View file

@ -0,0 +1,64 @@
# Nginx Proxy Manager – Media Cluster
Alle TLS en reverse proxy-instellingen gaan via de **NPM Web GUI**.
Bewerk nooit handmatig `/etc/nginx/` of `/data/nginx/`.
## Master proxy host
| Veld | Waarde |
|------|--------|
| Domain | `master.media.example.com` |
| Scheme | `http` |
| Forward Host | IP of hostname van de Master Docker-host |
| Forward Port | `3000` |
| Websockets | **Aan** (node control channel) |
| Block Common Exploits | Aan |
| SSL | Let's Encrypt + Force SSL + HTTP/2 |
### Advanced (optioneel)
```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
```
## Media Node proxy host
Per node een apart Proxy Host:
| Veld | Waarde |
|------|--------|
| Domain | `node01.media.example.com` |
| Scheme | `http` |
| Forward Host | LAN-IP van de media-node |
| Forward Port | `8080` |
| Block Common Exploits | Aan |
| SSL | Let's Encrypt + Force SSL + HTTP/2 |
### Advanced (verplicht voor streaming)
```nginx
proxy_buffering off;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
send_timeout 86400s;
# Voorkom logging van playback-tokens
access_log off;
```
## Firewall op de Media Node
Alleen NPM mag poort 8080 bereiken:
```bash
# Voorbeeld: NPM = 192.168.1.5, Node = 192.168.1.50
sudo ufw allow from 192.168.1.5 to any port 8080 proto tcp
sudo ufw deny 8080/tcp
```
Expose **geen** WAN-poort rechtstreeks naar de media-node.

View file

@ -0,0 +1,453 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/sthmedia/media-node/internal/config"
"github.com/sthmedia/media-node/internal/control"
"github.com/sthmedia/media-node/internal/database"
"github.com/sthmedia/media-node/internal/scanner"
"github.com/sthmedia/media-node/internal/streaming"
)
var version = "1.0.0"
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "install":
runInstall()
case "uninstall":
runUninstall()
case "register":
runRegister()
case "start":
runSystemctl("start")
case "stop":
runSystemctl("stop")
case "restart":
runSystemctl("restart")
case "status":
runSystemctl("status")
case "scan":
runScan()
case "config":
runConfig()
case "diagnostics":
runDiagnostics()
case "version":
fmt.Printf("media-node %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH)
case "run":
runService()
default:
runService()
}
}
func printUsage() {
fmt.Println(`media-node - Distributed Stremio Media Node
Usage:
media-node install Interactive installer + systemd
media-node uninstall Remove systemd service
media-node register Register with Master using enrollment token
media-node start|stop|restart|status
media-node run Run the node service (foreground)
media-node scan Trigger a full media scan
media-node config Show current configuration
media-node diagnostics Run connectivity diagnostics
media-node version Show version`)
}
func runService() {
configPath := envOr("MEDIA_NODE_CONFIG", config.DefaultConfigPath)
dataDir := envOr("MEDIA_NODE_DATA", config.DefaultDataDir)
credsPath := envOr("MEDIA_NODE_CREDENTIALS", config.DefaultCredentialsPath)
cfg, err := config.Load(configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
creds, err := loadCredentials(credsPath)
if err != nil {
log.Fatalf("Failed to load credentials: %v", err)
}
store, err := database.Open(dataDir)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer store.Close()
streamer := streaming.New(store, cfg.Stream.Listen, cfg.Security.MaxConcurrentPerIP)
var ctrlClient *control.Client
sc := scanner.New(cfg, store, func(events []scanner.LibraryEvent) {
if ctrlClient != nil {
ctrlClient.SendLibraryEvents(events)
}
})
ctrlClient = control.NewClient(cfg, creds, store, sc, streamer, version)
streamer.SetSessionEndHandler(func(sessionID, reason string) {
ctrlClient.NotifySessionEnded(sessionID, reason)
})
go sc.Start()
go ctrlClient.Run()
go func() {
if err := streamer.ListenAndServe(); err != nil {
log.Fatalf("Stream server error: %v", err)
}
}()
log.Printf("media-node %s started as %s", version, cfg.Node.Name)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Println("Shutting down...")
ctrlClient.Stop()
}
func runInstall() {
fmt.Println("=== STH Media Node Installer ===")
fmt.Println()
masterURL := prompt("Master URL (https://master.media.example.com): ")
nodeName := prompt("Node name: ")
publicURL := prompt("Public stream URL (https://node01.media.example.com): ")
moviesDir := prompt("Movies directory: ")
seriesDir := prompt("Series directory (optional): ")
token := prompt("Enrollment token: ")
cfg := &config.Config{
Node: config.NodeConfig{Name: nodeName},
Master: config.MasterConfig{URL: strings.TrimRight(masterURL, "/")},
Stream: config.StreamConfig{
Listen: "0.0.0.0:8080",
PublicURL: strings.TrimRight(publicURL, "/"),
},
Media: config.MediaConfig{
Movies: nonEmpty(moviesDir),
Series: nonEmpty(seriesDir),
},
Scanner: config.ScannerConfig{FullScanInterval: "6h"},
Security: config.SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 8},
}
configDir := "/etc/media-node"
dataDir := "/var/lib/media-node"
credsDir := filepath.Join(configDir, "credentials")
if err := os.MkdirAll(credsDir, 0o750); err != nil {
fmt.Printf("Warning: system paths unavailable (%v), using ./config\n", err)
configDir = "./config"
dataDir = "./data"
credsDir = filepath.Join(configDir, "credentials")
_ = os.MkdirAll(credsDir, 0o750)
_ = os.MkdirAll(dataDir, 0o750)
} else {
_ = os.MkdirAll(dataDir, 0o750)
}
configPath := filepath.Join(configDir, "config.yaml")
if err := config.Save(configPath, cfg); err != nil {
log.Fatalf("Failed to save config: %v", err)
}
fmt.Printf("Config saved to %s\n", configPath)
creds, err := enroll(cfg.Master.URL, token, nodeName, cfg.Stream.PublicURL)
if err != nil {
log.Fatalf("Enrollment failed: %v", err)
}
credsPath := filepath.Join(credsDir, "credentials.json")
data, _ := json.MarshalIndent(creds, "", " ")
if err := os.WriteFile(credsPath, data, 0o600); err != nil {
log.Fatalf("Failed to save credentials: %v", err)
}
fmt.Printf("Credentials saved to %s\n", credsPath)
if runtime.GOOS == "linux" && os.Geteuid() == 0 {
installSystemd()
fmt.Println("\nInstallation complete. Service started.")
fmt.Println(" systemctl status media-node")
} else {
fmt.Println("\nInstallation complete! Run: media-node run")
}
}
func installSystemd() {
exe, err := os.Executable()
if err != nil {
log.Printf("Could not determine binary path: %v", err)
return
}
target := "/usr/local/bin/media-node"
if exe != target {
in, err := os.ReadFile(exe)
if err == nil {
_ = os.WriteFile(target, in, 0o755)
}
}
_ = exec.Command("useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", "media-node").Run()
_ = exec.Command("chown", "-R", "media-node:media-node", "/var/lib/media-node").Run()
_ = exec.Command("chown", "-R", "root:media-node", "/etc/media-node").Run()
unit := `[Unit]
Description=STH Media Node
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=media-node
Group=media-node
ExecStart=/usr/local/bin/media-node run
Restart=always
RestartSec=5
Environment=MEDIA_NODE_CONFIG=/etc/media-node/config.yaml
Environment=MEDIA_NODE_DATA=/var/lib/media-node
Environment=MEDIA_NODE_CREDENTIALS=/etc/media-node/credentials/credentials.json
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/media-node
PrivateTmp=true
[Install]
WantedBy=multi-user.target
`
_ = os.WriteFile("/etc/systemd/system/media-node.service", []byte(unit), 0o644)
_ = exec.Command("systemctl", "daemon-reload").Run()
_ = exec.Command("systemctl", "enable", "--now", "media-node").Run()
}
func runSystemctl(action string) {
if runtime.GOOS != "linux" {
fmt.Println("systemctl commands are only available on Linux")
return
}
cmd := exec.Command("systemctl", action, "media-node")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
func runRegister() {
configPath := envOr("MEDIA_NODE_CONFIG", config.DefaultConfigPath)
credsDir := envOr("MEDIA_NODE_CREDENTIALS_DIR", "/etc/media-node/credentials")
cfg, err := config.Load(configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
token := prompt("Enrollment token: ")
creds, err := enroll(cfg.Master.URL, token, cfg.Node.Name, cfg.Stream.PublicURL)
if err != nil {
log.Fatalf("Enrollment failed: %v", err)
}
_ = os.MkdirAll(credsDir, 0o750)
credsPath := filepath.Join(credsDir, "credentials.json")
data, _ := json.MarshalIndent(creds, "", " ")
if err := os.WriteFile(credsPath, data, 0o600); err != nil {
log.Fatalf("Failed to save credentials: %v", err)
}
fmt.Println("Registration successful!")
}
func runScan() {
configPath := envOr("MEDIA_NODE_CONFIG", config.DefaultConfigPath)
dataDir := envOr("MEDIA_NODE_DATA", config.DefaultDataDir)
cfg, err := config.Load(configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
store, err := database.Open(dataDir)
if err != nil {
log.Fatalf("Failed to open database: %v", err)
}
defer store.Close()
sc := scanner.New(cfg, store, func(events []scanner.LibraryEvent) {
fmt.Printf("Event batch: %d\n", len(events))
})
sc.FullScan()
fmt.Println("Scan complete")
}
func runConfig() {
configPath := envOr("MEDIA_NODE_CONFIG", config.DefaultConfigPath)
cfg, err := config.Load(configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
data, _ := json.MarshalIndent(cfg, "", " ")
fmt.Println(string(data))
}
func runDiagnostics() {
configPath := envOr("MEDIA_NODE_CONFIG", config.DefaultConfigPath)
cfg, err := config.Load(configPath)
if err != nil {
fmt.Printf("✗ Config: %v\n", err)
return
}
fmt.Printf("✓ Config loaded from %s\n", configPath)
u := strings.TrimPrefix(strings.TrimPrefix(cfg.Master.URL, "https://"), "http://")
host := strings.Split(u, "/")[0]
if _, err := net.LookupHost(strings.Split(host, ":")[0]); err != nil {
fmt.Printf("✗ Master DNS: %v\n", err)
} else {
fmt.Printf("✓ Master DNS: %s\n", host)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(cfg.Master.URL + "/health")
if err != nil {
fmt.Printf("✗ Master HTTPS: %v\n", err)
} else {
resp.Body.Close()
fmt.Printf("✓ Master HTTPS: %s (HTTP %d)\n", cfg.Master.URL, resp.StatusCode)
}
for _, dir := range append(cfg.Media.Movies, cfg.Media.Series...) {
if dir == "" {
continue
}
info, err := os.Stat(dir)
if err != nil {
fmt.Printf("✗ Media directory %s: %v\n", dir, err)
} else if !info.IsDir() {
fmt.Printf("✗ Media path not a directory: %s\n", dir)
} else {
fmt.Printf("✓ Media directory accessible: %s\n", dir)
}
}
ln, err := net.Listen("tcp", cfg.Stream.Listen)
if err != nil {
fmt.Printf("✗ Stream listen %s: %v\n", cfg.Stream.Listen, err)
} else {
_ = ln.Close()
fmt.Printf("✓ Stream listen port available: %s\n", cfg.Stream.Listen)
}
dataDir := envOr("MEDIA_NODE_DATA", config.DefaultDataDir)
if store, err := database.Open(dataDir); err != nil {
fmt.Printf("✗ Node database: %v\n", err)
} else {
_ = store.Close()
fmt.Printf("✓ Node database: %s\n", filepath.Join(dataDir, "node.db"))
}
fmt.Printf("✓ System clock: %s\n", time.Now().UTC().Format(time.RFC3339))
fmt.Printf("✓ Version: %s (%s/%s)\n", version, runtime.GOOS, runtime.GOARCH)
}
func runUninstall() {
if runtime.GOOS == "linux" && os.Geteuid() == 0 {
_ = exec.Command("systemctl", "stop", "media-node").Run()
_ = exec.Command("systemctl", "disable", "media-node").Run()
_ = os.Remove("/etc/systemd/system/media-node.service")
_ = exec.Command("systemctl", "daemon-reload").Run()
fmt.Println("Service removed. Config/data left at /etc/media-node and /var/lib/media-node")
return
}
fmt.Println("To uninstall:")
fmt.Println(" sudo systemctl stop media-node")
fmt.Println(" sudo systemctl disable media-node")
fmt.Println(" sudo rm /etc/systemd/system/media-node.service")
fmt.Println(" sudo rm -rf /etc/media-node /var/lib/media-node")
}
func enroll(masterURL, token, name, publicURL string) (*config.Credentials, error) {
hostname, _ := os.Hostname()
body := map[string]string{
"token": token,
"name": name,
"hostname": hostname,
"architecture": runtime.GOARCH,
"version": version,
"publicStreamUrl": publicURL,
}
data, _ := json.Marshal(body)
resp, err := http.Post(masterURL+"/api/v1/nodes/enroll", "application/json", bytes.NewReader(data))
if err != nil {
return nil, err
}
defer resp.Body.Close()
respData, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("enrollment failed (HTTP %d): %s", resp.StatusCode, string(respData))
}
var result config.Credentials
if err := json.Unmarshal(respData, &result); err != nil {
return nil, err
}
return &result, nil
}
func loadCredentials(path string) (*config.Credentials, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var creds config.Credentials
if err := json.Unmarshal(data, &creds); err != nil {
return nil, err
}
return &creds, nil
}
func prompt(label string) string {
fmt.Print(label)
var val string
_, _ = fmt.Scanln(&val)
return strings.TrimSpace(val)
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func nonEmpty(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
return []string{s}
}

11
node/media-node/go.mod Normal file
View file

@ -0,0 +1,11 @@
module github.com/sthmedia/media-node
go 1.22
require (
github.com/fsnotify/fsnotify v1.8.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.34.5
)

View file

@ -0,0 +1,99 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
const (
DefaultConfigPath = "/etc/media-node/config.yaml"
DefaultDataDir = "/var/lib/media-node"
DefaultCredentialsPath = "/etc/media-node/credentials/credentials.json"
DefaultListen = "0.0.0.0:8080"
)
type Config struct {
Node NodeConfig `yaml:"node"`
Master MasterConfig `yaml:"master"`
Stream StreamConfig `yaml:"stream"`
Media MediaConfig `yaml:"media"`
Scanner ScannerConfig `yaml:"scanner"`
Security SecurityConfig `yaml:"security"`
}
type NodeConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
}
type MasterConfig struct {
URL string `yaml:"url"`
}
type StreamConfig struct {
Listen string `yaml:"listen"`
PublicURL string `yaml:"public_url"`
}
type MediaConfig struct {
Movies []string `yaml:"movies"`
Series []string `yaml:"series"`
}
type ScannerConfig struct {
FullScanInterval string `yaml:"full_scan_interval"`
}
type SecurityConfig struct {
SessionIdleTimeout string `yaml:"session_idle_timeout"`
MaxConcurrentPerIP int `yaml:"max_concurrent_per_ip"`
}
type Credentials struct {
NodeID string `json:"nodeId"`
APIKey string `json:"apiKey"`
Name string `json:"name"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if cfg.Stream.Listen == "" {
cfg.Stream.Listen = DefaultListen
}
if cfg.Scanner.FullScanInterval == "" {
cfg.Scanner.FullScanInterval = "6h"
}
if cfg.Security.SessionIdleTimeout == "" {
cfg.Security.SessionIdleTimeout = "20m"
}
if cfg.Security.MaxConcurrentPerIP == 0 {
cfg.Security.MaxConcurrentPerIP = 8
}
return &cfg, nil
}
func (c *Config) FullScanDuration() time.Duration {
d, err := time.ParseDuration(c.Scanner.FullScanInterval)
if err != nil {
return 6 * time.Hour
}
return d
}
func Save(path string, cfg *Config) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}

View file

@ -0,0 +1,332 @@
package control
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"runtime"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/sthmedia/media-node/internal/config"
"github.com/sthmedia/media-node/internal/database"
"github.com/sthmedia/media-node/internal/health"
"github.com/sthmedia/media-node/internal/scanner"
"github.com/sthmedia/media-node/internal/streaming"
)
const protocolVersion = 1
type Client struct {
cfg *config.Config
creds *config.Credentials
store *database.Store
scanner *scanner.Scanner
streamer *streaming.Server
version string
conn *websocket.Conn
mu sync.Mutex
connected bool
startTime time.Time
stopCh chan struct{}
}
func NewClient(
cfg *config.Config,
creds *config.Credentials,
store *database.Store,
sc *scanner.Scanner,
streamer *streaming.Server,
version string,
) *Client {
return &Client{
cfg: cfg,
creds: creds,
store: store,
scanner: sc,
streamer: streamer,
version: version,
startTime: time.Now(),
stopCh: make(chan struct{}),
}
}
func (c *Client) Run() {
backoff := time.Second
maxBackoff := 60 * time.Second
for {
select {
case <-c.stopCh:
return
default:
}
if err := c.connect(); err != nil {
log.Printf("Master connection failed: %v", err)
}
jitter := time.Duration(rand.Intn(1000)) * time.Millisecond
select {
case <-c.stopCh:
return
case <-time.After(backoff + jitter):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
func (c *Client) Stop() {
select {
case <-c.stopCh:
default:
close(c.stopCh)
}
c.mu.Lock()
if c.conn != nil {
_ = c.conn.Close()
}
c.mu.Unlock()
}
func (c *Client) connect() error {
wsURL := toWebSocketURL(c.cfg.Master.URL) + "/api/v1/node/connect"
dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second}
conn, _, err := dialer.Dial(wsURL, http.Header{})
if err != nil {
return fmt.Errorf("dial: %w", err)
}
c.mu.Lock()
c.conn = conn
c.connected = true
c.mu.Unlock()
defer func() {
c.mu.Lock()
c.connected = false
c.conn = nil
c.mu.Unlock()
_ = conn.Close()
}()
if err := c.sendHello(); err != nil {
return err
}
done := make(chan struct{})
go func() {
c.readLoop()
close(done)
}()
go c.heartbeatLoop(done)
<-done
return fmt.Errorf("connection closed")
}
func (c *Client) sendHello() error {
hostname, _ := os.Hostname()
msg := controlMessage("HELLO", map[string]interface{}{
"nodeId": c.creds.NodeID,
"apiKey": c.creds.APIKey,
"softwareVersion": c.version,
"architecture": runtime.GOARCH,
"hostname": hostname,
"publicStreamUrl": c.cfg.Stream.PublicURL,
})
return c.write(msg)
}
func (c *Client) heartbeatLoop(done <-chan struct{}) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if err := c.sendHeartbeat(); err != nil {
log.Printf("heartbeat failed: %v", err)
return
}
}
}
}
func (c *Client) sendHeartbeat() error {
paths := append(append([]string{}, c.cfg.Media.Movies...), c.cfg.Media.Series...)
total, free := health.DiskUsage(paths)
fileCount, _ := c.store.FileCount()
msg := controlMessage("HEARTBEAT", map[string]interface{}{
"nodeId": c.creds.NodeID,
"softwareVersion": c.version,
"uptimeSeconds": int(time.Since(c.startTime).Seconds()),
"cpuUsagePercent": 0,
"memoryUsagePercent": health.MemoryUsagePercent(),
"storageTotalBytes": total,
"storageFreeBytes": free,
"activeStreams": c.streamer.ActiveStreams(),
"currentBytesPerSec": c.streamer.BytesPerSecond(),
"libraryFileCount": fileCount,
"scanStatus": c.scanner.Status(),
"errors": []string{},
})
return c.write(msg)
}
func (c *Client) readLoop() {
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
log.Printf("WebSocket read error: %v", err)
return
}
var msg struct {
ProtocolVersion int `json:"protocolVersion"`
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
c.handleMessage(msg.Type, msg.Payload)
}
}
func (c *Client) handleMessage(msgType string, payload json.RawMessage) {
switch msgType {
case "HELLO_ACK":
var ack struct {
Accepted bool `json:"accepted"`
Reason string `json:"reason"`
}
_ = json.Unmarshal(payload, &ack)
if !ack.Accepted {
log.Printf("Master rejected connection: %s", ack.Reason)
return
}
log.Println("Connected to Master")
c.sendFullSync()
case "CREATE_PLAYBACK_SESSION":
var p struct {
SessionID string `json:"sessionId"`
TokenHash string `json:"tokenHash"`
LocalFileID string `json:"localFileId"`
IdleTimeoutSeconds int `json:"idleTimeoutSeconds"`
AbsoluteExpiresAt string `json:"absoluteExpiresAt"`
}
if json.Unmarshal(payload, &p) == nil {
expires, _ := time.Parse(time.RFC3339, p.AbsoluteExpiresAt)
_ = c.streamer.CreateSession(database.PlaybackSession{
SessionID: p.SessionID,
TokenHash: p.TokenHash,
LocalFileID: p.LocalFileID,
IdleTimeoutSeconds: p.IdleTimeoutSeconds,
AbsoluteExpiresAt: expires,
LastActivity: time.Now().UTC(),
})
log.Printf("Playback session created: %s", p.SessionID)
}
case "REVOKE_PLAYBACK_SESSION":
var p struct {
SessionID string `json:"sessionId"`
}
if json.Unmarshal(payload, &p) == nil {
_ = c.streamer.RevokeSession(p.SessionID)
log.Printf("Playback session revoked: %s", p.SessionID)
}
case "RESCAN":
go c.scanner.FullScan()
case "FULL_LIBRARY_SYNC":
c.sendFullSync()
case "CONFIG_UPDATE":
log.Println("CONFIG_UPDATE received (apply on next restart)")
case "ERROR":
log.Printf("Master error: %s", string(payload))
}
}
func (c *Client) SendLibraryEvents(events []scanner.LibraryEvent) {
if len(events) == 0 {
return
}
msg := controlMessage("LIBRARY_EVENT", map[string]interface{}{
"events": events,
})
if err := c.write(msg); err != nil {
log.Printf("failed to send library events: %v", err)
}
}
func (c *Client) sendFullSync() {
files := c.scanner.AllMediaFiles()
rev, _ := c.store.MaxRevision()
msg := controlMessage("FULL_LIBRARY_SYNC", map[string]interface{}{
"nodeRevision": rev,
"files": files,
})
if err := c.write(msg); err != nil {
log.Printf("full library sync failed: %v", err)
return
}
log.Printf("Full library sync sent (%d files)", len(files))
}
func (c *Client) NotifySessionEnded(sessionID, reason string) {
msg := controlMessage("PLAYBACK_SESSION_ENDED", map[string]interface{}{
"sessionId": sessionID,
"reason": reason,
})
_ = c.write(msg)
}
func (c *Client) write(msg interface{}) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
data, err := json.Marshal(msg)
if err != nil {
return err
}
_ = c.conn.SetWriteDeadline(time.Now().Add(15 * time.Second))
return c.conn.WriteMessage(websocket.TextMessage, data)
}
func controlMessage(msgType string, payload interface{}) map[string]interface{} {
return map[string]interface{}{
"protocolVersion": protocolVersion,
"type": msgType,
"messageId": uuid.New().String(),
"timestamp": time.Now().UTC().Format(time.RFC3339),
"payload": payload,
}
}
func toWebSocketURL(url string) string {
url = strings.TrimRight(url, "/")
if strings.HasPrefix(url, "https://") {
return "wss://" + strings.TrimPrefix(url, "https://")
}
if strings.HasPrefix(url, "http://") {
return "ws://" + strings.TrimPrefix(url, "http://")
}
if strings.HasPrefix(url, "wss://") || strings.HasPrefix(url, "ws://") {
return url
}
return "wss://" + url
}

View file

@ -0,0 +1,256 @@
package database
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
type Store struct {
db *sql.DB
}
type LocalFile struct {
LocalFileID string
Path string
SizeBytes int64
ModifiedAt time.Time
MediaType string
ReleaseName string
Revision int64
}
type PlaybackSession struct {
SessionID string
TokenHash string
LocalFileID string
IdleTimeoutSeconds int
AbsoluteExpiresAt time.Time
LastActivity time.Time
}
func Open(dataDir string) (*Store, error) {
if err := os.MkdirAll(dataDir, 0o750); err != nil {
return nil, err
}
dbPath := filepath.Join(dataDir, "node.db")
db, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS local_files (
local_file_id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
size_bytes INTEGER NOT NULL,
modified_at TEXT NOT NULL,
media_type TEXT NOT NULL,
release_name TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS playback_sessions (
session_id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
local_file_id TEXT NOT NULL,
idle_timeout_seconds INTEGER NOT NULL,
absolute_expires_at TEXT NOT NULL,
last_activity TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS node_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON playback_sessions(token_hash);
`)
return err
}
func (s *Store) UpsertFile(f LocalFile) error {
_, err := s.db.Exec(`
INSERT INTO local_files (local_file_id, path, size_bytes, modified_at, media_type, release_name, revision)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(local_file_id) DO UPDATE SET
path=excluded.path,
size_bytes=excluded.size_bytes,
modified_at=excluded.modified_at,
media_type=excluded.media_type,
release_name=excluded.release_name,
revision=excluded.revision
`, f.LocalFileID, f.Path, f.SizeBytes, f.ModifiedAt.UTC().Format(time.RFC3339),
f.MediaType, f.ReleaseName, f.Revision)
return err
}
func (s *Store) DeleteFile(localFileID string) error {
_, err := s.db.Exec(`DELETE FROM local_files WHERE local_file_id = ?`, localFileID)
return err
}
func (s *Store) GetFile(localFileID string) (*LocalFile, error) {
row := s.db.QueryRow(`
SELECT local_file_id, path, size_bytes, modified_at, media_type, release_name, revision
FROM local_files WHERE local_file_id = ?
`, localFileID)
return scanFile(row)
}
func (s *Store) GetFileByPath(path string) (*LocalFile, error) {
row := s.db.QueryRow(`
SELECT local_file_id, path, size_bytes, modified_at, media_type, release_name, revision
FROM local_files WHERE path = ?
`, path)
return scanFile(row)
}
func (s *Store) AllFiles() ([]LocalFile, error) {
rows, err := s.db.Query(`
SELECT local_file_id, path, size_bytes, modified_at, media_type, release_name, revision
FROM local_files ORDER BY release_name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var files []LocalFile
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return nil, err
}
files = append(files, *f)
}
return files, rows.Err()
}
func (s *Store) FileCount() (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM local_files`).Scan(&count)
return count, err
}
func (s *Store) NextRevision() (int64, error) {
var rev int64
err := s.db.QueryRow(`SELECT COALESCE(MAX(revision), 0) + 1 FROM local_files`).Scan(&rev)
return rev, err
}
func (s *Store) MaxRevision() (int64, error) {
var rev int64
err := s.db.QueryRow(`SELECT COALESCE(MAX(revision), 0) FROM local_files`).Scan(&rev)
return rev, err
}
func (s *Store) SaveSession(sess PlaybackSession) error {
_, err := s.db.Exec(`
INSERT OR REPLACE INTO playback_sessions
(session_id, token_hash, local_file_id, idle_timeout_seconds, absolute_expires_at, last_activity)
VALUES (?, ?, ?, ?, ?, ?)
`, sess.SessionID, sess.TokenHash, sess.LocalFileID, sess.IdleTimeoutSeconds,
sess.AbsoluteExpiresAt.UTC().Format(time.RFC3339), sess.LastActivity.UTC().Format(time.RFC3339))
return err
}
func (s *Store) GetSessionByTokenHash(tokenHash string) (*PlaybackSession, error) {
row := s.db.QueryRow(`
SELECT session_id, token_hash, local_file_id, idle_timeout_seconds, absolute_expires_at, last_activity
FROM playback_sessions WHERE token_hash = ?
`, tokenHash)
return scanSession(row)
}
func (s *Store) DeleteSession(sessionID string) error {
_, err := s.db.Exec(`DELETE FROM playback_sessions WHERE session_id = ?`, sessionID)
return err
}
func (s *Store) TouchSession(sessionID string) error {
_, err := s.db.Exec(`UPDATE playback_sessions SET last_activity = ? WHERE session_id = ?`,
time.Now().UTC().Format(time.RFC3339), sessionID)
return err
}
func (s *Store) ActiveSessions() ([]PlaybackSession, error) {
rows, err := s.db.Query(`
SELECT session_id, token_hash, local_file_id, idle_timeout_seconds, absolute_expires_at, last_activity
FROM playback_sessions
`)
if err != nil {
return nil, err
}
defer rows.Close()
var sessions []PlaybackSession
for rows.Next() {
sess, err := scanSession(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, *sess)
}
return sessions, rows.Err()
}
func (s *Store) SetState(key, value string) error {
_, err := s.db.Exec(`
INSERT INTO node_state (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value
`, key, value)
return err
}
func (s *Store) GetState(key string) (string, error) {
var value string
err := s.db.QueryRow(`SELECT value FROM node_state WHERE key = ?`, key).Scan(&value)
if err == sql.ErrNoRows {
return "", nil
}
return value, err
}
func FileID(path string) string {
sum := sha256.Sum256([]byte(path))
return fmt.Sprintf("f-%s", hex.EncodeToString(sum[:])[:12])
}
type scannable interface {
Scan(dest ...any) error
}
func scanFile(row scannable) (*LocalFile, error) {
var f LocalFile
var modStr string
if err := row.Scan(&f.LocalFileID, &f.Path, &f.SizeBytes, &modStr, &f.MediaType, &f.ReleaseName, &f.Revision); err != nil {
return nil, err
}
f.ModifiedAt, _ = time.Parse(time.RFC3339, modStr)
return &f, nil
}
func scanSession(row scannable) (*PlaybackSession, error) {
var s PlaybackSession
var absStr, lastStr string
if err := row.Scan(&s.SessionID, &s.TokenHash, &s.LocalFileID, &s.IdleTimeoutSeconds, &absStr, &lastStr); err != nil {
return nil, err
}
s.AbsoluteExpiresAt, _ = time.Parse(time.RFC3339, absStr)
s.LastActivity, _ = time.Parse(time.RFC3339, lastStr)
return &s, nil
}

View file

@ -0,0 +1,15 @@
//go:build !windows
package health
import "syscall"
func diskStat(path string) (total, free uint64) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return 0, 0
}
total = st.Blocks * uint64(st.Bsize)
free = st.Bavail * uint64(st.Bsize)
return total, free
}

View file

@ -0,0 +1,7 @@
//go:build windows
package health
func diskStat(_ string) (total, free uint64) {
return 0, 0
}

View file

@ -0,0 +1,46 @@
package health
import (
"path/filepath"
"runtime"
)
type Snapshot struct {
UptimeSeconds int64
CPUUsagePercent float64
MemoryUsagePercent float64
StorageTotalBytes uint64
StorageFreeBytes uint64
LibraryFileCount int
ActiveStreams int
CurrentBytesPerSec int64
ScanStatus string
Errors []string
}
func MemoryUsagePercent() float64 {
var m runtime.MemStats
runtime.ReadMemStats(&m)
if m.Sys == 0 {
return 0
}
return float64(m.Alloc) / float64(m.Sys) * 100
}
func DiskUsage(paths []string) (total, free uint64) {
seen := map[string]bool{}
for _, p := range paths {
if p == "" {
continue
}
key := filepath.Clean(p)
if seen[key] {
continue
}
seen[key] = true
t, f := diskStat(p)
total += t
free += f
}
return total, free
}

View file

@ -0,0 +1,98 @@
package scanner
import (
"regexp"
"strings"
)
var (
yearRe = regexp.MustCompile(`[\.\s_-]((19|20)\d{2})[\.\s_-]`)
resolutionRe = regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K)\b`)
sourceRe = regexp.MustCompile(`(?i)\b(UHD\s*BluRay|BluRay|WEB-DL|WEBRip|HDTV|DVDRip)\b`)
releaseRe = regexp.MustCompile(`(?i)\b(REMUX|PROPER|REPACK)\b`)
videoRe = regexp.MustCompile(`(?i)\b(HEVC|H\.?264|H\.?265|x264|x265|AV1|XviD)\b`)
audioRe = regexp.MustCompile(`(?i)\b(TrueHD(\s*Atmos)?|DTS-HD|DTS|AAC|AC3|Atmos|EAC3)\b`)
seriesRe1 = regexp.MustCompile(`(?i)(.+?)[\.\s_-]S(\d{1,2})E(\d{1,2})`)
seriesRe2 = regexp.MustCompile(`(?i)(.+?)[\.\s_-](\d{1,2})x(\d{1,2})`)
)
func parseMovie(base string) map[string]interface{} {
yearMatch := yearRe.FindStringSubmatchIndex(base)
var titlePart string
var year int
if len(yearMatch) >= 4 {
titlePart = base[:yearMatch[0]]
year = atoi(base[yearMatch[2]:yearMatch[3]])
} else {
titlePart = base
}
title := cleanTitle(titlePart)
if title == "" {
return nil
}
result := map[string]interface{}{"title": title}
if year > 0 {
result["year"] = year
}
if m := resolutionRe.FindStringSubmatch(base); len(m) > 1 {
result["resolution"] = m[1]
}
if m := sourceRe.FindStringSubmatch(base); len(m) > 1 {
result["source"] = m[1]
}
if m := releaseRe.FindStringSubmatch(base); len(m) > 1 {
result["releaseType"] = m[1]
}
if m := videoRe.FindStringSubmatch(base); len(m) > 1 {
result["videoCodec"] = m[1]
}
if m := audioRe.FindStringSubmatch(base); len(m) > 0 {
result["audio"] = m[0]
}
return result
}
func parseSeries(base string) map[string]interface{} {
for _, re := range []*regexp.Regexp{seriesRe1, seriesRe2} {
m := re.FindStringSubmatch(base)
if len(m) >= 4 {
title := cleanTitle(m[1])
season := atoi(m[2])
episode := atoi(m[3])
if title == "" || season <= 0 || episode <= 0 {
continue
}
result := map[string]interface{}{
"title": title,
"season": season,
"episode": episode,
}
if rm := resolutionRe.FindStringSubmatch(base); len(rm) > 1 {
result["resolution"] = rm[1]
}
if vm := videoRe.FindStringSubmatch(base); len(vm) > 1 {
result["videoCodec"] = vm[1]
}
if am := audioRe.FindStringSubmatch(base); len(am) > 0 {
result["audio"] = am[0]
}
return result
}
}
return nil
}
func cleanTitle(s string) string {
return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(s, ".", " "), "_", " "))
}
func atoi(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}

View file

@ -0,0 +1,36 @@
package scanner
import "testing"
func TestParseMovie(t *testing.T) {
p := parseMovie("The.Matrix.1999.2160p.UHD.BluRay.REMUX.HEVC.TrueHD.Atmos")
if p == nil {
t.Fatal("expected parse result")
}
if p["title"] != "The Matrix" {
t.Fatalf("title=%v", p["title"])
}
if p["year"] != 1999 {
t.Fatalf("year=%v", p["year"])
}
}
func TestParseSeries(t *testing.T) {
p := parseSeries("Breaking.Bad.S03E04.1080p.BluRay")
if p == nil {
t.Fatal("expected parse result")
}
if p["title"] != "Breaking Bad" {
t.Fatalf("title=%v", p["title"])
}
if p["season"] != 3 || p["episode"] != 4 {
t.Fatalf("season/episode=%v/%v", p["season"], p["episode"])
}
}
func TestParseSeriesAlt(t *testing.T) {
p := parseSeries("Show.Name.1x02.720p")
if p == nil || p["season"] != 1 || p["episode"] != 2 {
t.Fatalf("unexpected: %#v", p)
}
}

View file

@ -0,0 +1,298 @@
package scanner
import (
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/sthmedia/media-node/internal/config"
"github.com/sthmedia/media-node/internal/database"
)
var videoExtensions = map[string]bool{
".mkv": true, ".mp4": true, ".m4v": true,
".avi": true, ".ts": true, ".m2ts": true,
}
type MediaFileInfo struct {
LocalFileID string `json:"localFileId"`
SizeBytes int64 `json:"sizeBytes"`
Container string `json:"container,omitempty"`
Resolution string `json:"resolution,omitempty"`
VideoCodec string `json:"videoCodec,omitempty"`
AudioCodec string `json:"audioCodec,omitempty"`
ReleaseName string `json:"releaseName"`
ModifiedAt string `json:"modifiedAt"`
MediaType string `json:"mediaType"`
ParsedMovie map[string]interface{} `json:"parsedMovie,omitempty"`
ParsedEpisode map[string]interface{} `json:"parsedEpisode,omitempty"`
}
type LibraryEvent struct {
Type string `json:"type"`
NodeRevision int64 `json:"nodeRevision"`
File MediaFileInfo `json:"file"`
}
type EventHandler func(events []LibraryEvent)
type Scanner struct {
cfg *config.Config
store *database.Store
onEvents EventHandler
mu sync.Mutex
scanning bool
status string
}
func New(cfg *config.Config, store *database.Store, onEvents EventHandler) *Scanner {
return &Scanner{cfg: cfg, store: store, onEvents: onEvents, status: "idle"}
}
func (s *Scanner) Status() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.status
}
func (s *Scanner) Start() {
go s.runFullScanLoop()
go s.watchFilesystem()
}
func (s *Scanner) runFullScanLoop() {
s.FullScan()
ticker := time.NewTicker(s.cfg.FullScanDuration())
for range ticker.C {
s.FullScan()
}
}
func (s *Scanner) FullScan() {
s.mu.Lock()
if s.scanning {
s.mu.Unlock()
return
}
s.scanning = true
s.status = "scanning"
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.scanning = false
s.status = "idle"
s.mu.Unlock()
}()
log.Println("Starting full media scan...")
found := make(map[string]bool)
dirs := append(s.cfg.Media.Movies, s.cfg.Media.Series...)
for _, dir := range dirs {
if dir == "" {
continue
}
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !isVideo(path) {
return nil
}
found[path] = true
s.indexFile(path, info, "FILE_UPDATED")
return nil
})
}
existing, _ := s.store.AllFiles()
for _, f := range existing {
if !found[f.Path] {
rev, _ := s.store.NextRevision()
event := LibraryEvent{
Type: "FILE_REMOVED",
NodeRevision: rev,
File: fileToInfo(f),
}
_ = s.store.DeleteFile(f.LocalFileID)
s.onEvents([]LibraryEvent{event})
}
}
log.Println("Full scan completed")
}
func (s *Scanner) watchFilesystem() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Printf("fsnotify unavailable: %v", err)
return
}
defer watcher.Close()
dirs := append(s.cfg.Media.Movies, s.cfg.Media.Series...)
for _, dir := range dirs {
if dir == "" {
continue
}
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err == nil && info.IsDir() {
_ = watcher.Add(path)
}
return nil
})
}
debounce := time.NewTimer(0)
<-debounce.C
pending := make(map[string]struct{})
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Create == fsnotify.Create ||
event.Op&fsnotify.Write == fsnotify.Write ||
event.Op&fsnotify.Rename == fsnotify.Rename {
if isVideo(event.Name) {
pending[event.Name] = struct{}{}
}
}
if event.Op&fsnotify.Remove == fsnotify.Remove {
s.handleRemove(event.Name)
}
if !debounce.Stop() {
<-debounce.C
}
debounce.Reset(2 * time.Second)
case <-debounce.C:
for path := range pending {
info, err := os.Stat(path)
if err == nil && !info.IsDir() {
s.indexFile(path, info, "FILE_ADDED")
}
}
pending = make(map[string]struct{})
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Printf("watch error: %v", err)
}
}
}
func (s *Scanner) indexFile(path string, info os.FileInfo, eventType string) {
mediaType := detectMediaType(filepath.Base(path))
if mediaType == "" {
return
}
localFileID := database.FileID(path)
rev, _ := s.store.NextRevision()
lf := database.LocalFile{
LocalFileID: localFileID,
Path: path,
SizeBytes: info.Size(),
ModifiedAt: info.ModTime(),
MediaType: mediaType,
ReleaseName: filepath.Base(path),
Revision: rev,
}
_ = s.store.UpsertFile(lf)
info2 := buildMediaInfo(lf)
event := LibraryEvent{
Type: eventType,
NodeRevision: rev,
File: info2,
}
s.onEvents([]LibraryEvent{event})
}
func (s *Scanner) handleRemove(path string) {
localFileID := database.FileID(path)
f, err := s.store.GetFile(localFileID)
if err != nil {
return
}
rev, _ := s.store.NextRevision()
event := LibraryEvent{
Type: "FILE_REMOVED",
NodeRevision: rev,
File: fileToInfo(*f),
}
_ = s.store.DeleteFile(localFileID)
s.onEvents([]LibraryEvent{event})
}
func (s *Scanner) AllMediaFiles() []MediaFileInfo {
files, _ := s.store.AllFiles()
result := make([]MediaFileInfo, len(files))
for i, f := range files {
result[i] = buildMediaInfo(f)
}
return result
}
func isVideo(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return videoExtensions[ext]
}
func detectMediaType(filename string) string {
base := strings.TrimSuffix(filename, filepath.Ext(filename))
if parseSeries(base) != nil {
return "episode"
}
if parseMovie(base) != nil {
return "movie"
}
return ""
}
func buildMediaInfo(f database.LocalFile) MediaFileInfo {
info := MediaFileInfo{
LocalFileID: f.LocalFileID,
SizeBytes: f.SizeBytes,
Container: strings.TrimPrefix(filepath.Ext(f.ReleaseName), "."),
ReleaseName: f.ReleaseName,
ModifiedAt: f.ModifiedAt.UTC().Format(time.RFC3339),
MediaType: f.MediaType,
}
base := strings.TrimSuffix(f.ReleaseName, filepath.Ext(f.ReleaseName))
if f.MediaType == "movie" {
if p := parseMovie(base); p != nil {
info.ParsedMovie = p
info.Resolution = strVal(p, "resolution")
info.VideoCodec = strVal(p, "videoCodec")
info.AudioCodec = strVal(p, "audio")
}
} else {
if p := parseSeries(base); p != nil {
info.ParsedEpisode = p
info.Resolution = strVal(p, "resolution")
info.VideoCodec = strVal(p, "videoCodec")
info.AudioCodec = strVal(p, "audio")
}
}
return info
}
func fileToInfo(f database.LocalFile) MediaFileInfo {
return buildMediaInfo(f)
}
func strVal(m map[string]interface{}, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}

View file

@ -0,0 +1,380 @@
package streaming
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/sthmedia/media-node/internal/database"
)
type Server struct {
store *database.Store
listen string
maxPerIP int
mu sync.RWMutex
activeCount int
bytesWindow int64
lastWindow time.Time
bytesPerSecond int64
ipConns map[string]int
onSessionEnd func(sessionID, reason string)
}
func New(store *database.Store, listen string, maxPerIP int) *Server {
if maxPerIP <= 0 {
maxPerIP = 8
}
s := &Server{
store: store,
listen: listen,
maxPerIP: maxPerIP,
lastWindow: time.Now(),
ipConns: make(map[string]int),
}
go s.cleanupLoop()
go s.bandwidthLoop()
return s
}
func (s *Server) SetSessionEndHandler(fn func(sessionID, reason string)) {
s.onSessionEnd = fn
}
func (s *Server) ListenAndServe() error {
mux := http.NewServeMux()
mux.HandleFunc("/play/", s.handlePlay)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.NotFound(w, r)
})
server := &http.Server{
Addr: s.listen,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Printf("Stream server listening on %s", s.listen)
return server.ListenAndServe()
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if host != "127.0.0.1" && host != "::1" && host != "localhost" {
// Prefer internal-only health; still allow LAN NPM if needed via 200 for GET from private ranges
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() && !ip.IsPrivate() {
http.NotFound(w, r)
return
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r)
return
}
token := strings.TrimPrefix(r.URL.Path, "/play/")
if token == "" || strings.Contains(token, "/") {
http.NotFound(w, r)
return
}
clientIP := clientIP(r)
if !s.acquireIP(clientIP) {
log.Printf("GET /play/[REDACTED] rejected: too many connections from %s", clientIP)
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
defer s.releaseIP(clientIP)
tokenHash := hashToken(token)
sess, err := s.store.GetSessionByTokenHash(tokenHash)
if err != nil {
log.Printf("GET /play/[REDACTED] invalid session from %s", clientIP)
http.NotFound(w, r)
return
}
now := time.Now().UTC()
if now.After(sess.AbsoluteExpiresAt) {
_ = s.store.DeleteSession(sess.SessionID)
http.NotFound(w, r)
return
}
idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second)
if now.After(idleDeadline) {
_ = s.store.DeleteSession(sess.SessionID)
if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "idle_timeout")
}
http.NotFound(w, r)
return
}
file, err := s.store.GetFile(sess.LocalFileID)
if err != nil {
http.NotFound(w, r)
return
}
f, err := os.Open(file.Path)
if err != nil {
log.Printf("GET /play/[REDACTED] file open failed session=%s", sess.SessionID)
http.NotFound(w, r)
return
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
http.NotFound(w, r)
return
}
fileSize := stat.Size()
_ = s.store.TouchSession(sess.SessionID)
s.mu.Lock()
s.activeCount++
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.activeCount--
s.mu.Unlock()
}()
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Content-Type", contentType(file.Path))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
rangeHeader := r.Header.Get("Range")
if rangeHeader == "" {
w.Header().Set("Content-Length", strconv.FormatInt(fileSize, 10))
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
s.streamRange(w, f, 0, fileSize-1)
return
}
start, end, err := parseRange(rangeHeader, fileSize)
if err != nil {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", fileSize))
http.Error(w, "Invalid Range", http.StatusRequestedRangeNotSatisfiable)
return
}
contentLength := end - start + 1
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize))
w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
w.WriteHeader(http.StatusPartialContent)
if r.Method == http.MethodHead {
return
}
s.streamRange(w, f, start, end)
}
func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64) {
if _, err := f.Seek(start, io.SeekStart); err != nil {
return
}
remaining := end - start + 1
buf := make([]byte, 256*1024)
flusher, canFlush := w.(http.Flusher)
for remaining > 0 {
toRead := int64(len(buf))
if toRead > remaining {
toRead = remaining
}
n, err := f.Read(buf[:toRead])
if n > 0 {
atomic.AddInt64(&s.bytesWindow, int64(n))
if _, werr := w.Write(buf[:n]); werr != nil {
return
}
if canFlush {
flusher.Flush()
}
remaining -= int64(n)
}
if err != nil {
return
}
}
}
func (s *Server) ActiveStreams() int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.activeCount
}
func (s *Server) BytesPerSecond() int64 {
return atomic.LoadInt64(&s.bytesPerSecond)
}
func (s *Server) CurrentBandwidth() int64 {
return s.BytesPerSecond()
}
func (s *Server) CreateSession(sess database.PlaybackSession) error {
return s.store.SaveSession(sess)
}
func (s *Server) RevokeSession(sessionID string) error {
return s.store.DeleteSession(sessionID)
}
func (s *Server) acquireIP(ip string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.ipConns[ip] >= s.maxPerIP {
return false
}
s.ipConns[ip]++
return true
}
func (s *Server) releaseIP(ip string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.ipConns[ip] <= 1 {
delete(s.ipConns, ip)
return
}
s.ipConns[ip]--
}
func (s *Server) bandwidthLoop() {
ticker := time.NewTicker(time.Second)
for range ticker.C {
n := atomic.SwapInt64(&s.bytesWindow, 0)
atomic.StoreInt64(&s.bytesPerSecond, n)
}
}
func (s *Server) cleanupLoop() {
ticker := time.NewTicker(60 * time.Second)
for range ticker.C {
sessions, _ := s.store.ActiveSessions()
now := time.Now().UTC()
for _, sess := range sessions {
if now.After(sess.AbsoluteExpiresAt) {
_ = s.store.DeleteSession(sess.SessionID)
continue
}
idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second)
if now.After(idleDeadline) {
_ = s.store.DeleteSession(sess.SessionID)
if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "idle_timeout")
}
}
}
}
}
func parseRange(rangeHeader string, size int64) (int64, int64, error) {
if !strings.HasPrefix(rangeHeader, "bytes=") {
return 0, 0, fmt.Errorf("invalid range")
}
parts := strings.Split(strings.TrimPrefix(rangeHeader, "bytes="), "-")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid range")
}
var start, end int64
if parts[0] == "" {
suffix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, 0, err
}
start = size - suffix
if start < 0 {
start = 0
}
end = size - 1
} else {
var err error
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, 0, err
}
if parts[1] == "" {
end = size - 1
} else {
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, 0, err
}
}
}
if start > end || start >= size {
return 0, 0, fmt.Errorf("invalid range")
}
if end >= size {
end = size - 1
}
return start, end, nil
}
func contentType(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".mkv":
return "video/x-matroska"
case ".mp4", ".m4v":
return "video/mp4"
case ".avi":
return "video/x-msvideo"
case ".ts", ".m2ts":
return "video/mp2t"
default:
return "application/octet-stream"
}
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "media-cluster",
"version": "1.0.0",
"private": true,
"description": "Distributed Stremio Media Cluster",
"scripts": {
"dev": "pnpm --parallel --filter @media-cluster/master-api --filter @media-cluster/admin-ui dev",
"build": "pnpm -r build",
"master:dev": "pnpm --filter @media-cluster/master-api dev",
"admin:dev": "pnpm --filter @media-cluster/admin-ui dev",
"db:migrate": "pnpm --filter @media-cluster/master-api db:migrate",
"db:generate": "pnpm --filter @media-cluster/master-api db:generate",
"db:seed": "pnpm --filter @media-cluster/master-api db:seed"
},
"engines": {
"node": ">=20"
}
}

View file

@ -0,0 +1,18 @@
{
"name": "@media-cluster/protocol",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"dependencies": {
"@media-cluster/shared-types": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,86 @@
import { randomUUID } from "crypto";
import type {
CreatePlaybackSessionPayload,
LibraryEvent,
NodeHeartbeat,
} from "@media-cluster/shared-types";
import { PROTOCOL_VERSION } from "@media-cluster/shared-types";
export { PROTOCOL_VERSION };
export type ControlMessageType =
| "HELLO"
| "HELLO_ACK"
| "HEARTBEAT"
| "HEARTBEAT_ACK"
| "LIBRARY_EVENT"
| "LIBRARY_EVENT_ACK"
| "FULL_LIBRARY_SYNC"
| "FULL_LIBRARY_SYNC_ACK"
| "CREATE_PLAYBACK_SESSION"
| "REVOKE_PLAYBACK_SESSION"
| "RESCAN"
| "CONFIG_UPDATE"
| "PLAYBACK_SESSION_ENDED"
| "ERROR";
export interface ControlMessage<T = unknown> {
protocolVersion: number;
type: ControlMessageType;
messageId: string;
timestamp: string;
payload: T;
}
export interface HelloPayload {
nodeId: string;
apiKey: string;
softwareVersion: string;
architecture: string;
hostname: string;
publicStreamUrl: string;
}
export interface HelloAckPayload {
accepted: boolean;
reason?: string;
heartbeatIntervalSeconds: number;
fullScanIntervalSeconds: number;
}
export interface HeartbeatPayload extends NodeHeartbeat {}
export interface LibraryEventPayload {
events: LibraryEvent[];
}
export interface FullLibrarySyncPayload {
nodeRevision: number;
files: LibraryEvent["file"][];
}
export interface RescanPayload {
full: boolean;
}
export interface PlaybackSessionEndedPayload {
sessionId: string;
reason: string;
}
export function createMessage<T>(
type: ControlMessageType,
payload: T,
messageId?: string
): ControlMessage<T> {
return {
protocolVersion: PROTOCOL_VERSION,
type,
messageId: messageId ?? randomUUID(),
timestamp: new Date().toISOString(),
payload,
};
}
export type CreatePlaybackSessionMessage = ControlMessage<CreatePlaybackSessionPayload>;
export type RevokePlaybackSessionMessage = ControlMessage<{ sessionId: string }>;

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"declaration": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

View file

@ -0,0 +1,14 @@
{
"name": "@media-cluster/shared-types",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,105 @@
export const PROTOCOL_VERSION = 1;
export type NodeStatus =
| "ONLINE"
| "OFFLINE"
| "DEGRADED"
| "REVOKED"
| "UPDATING"
| "SCANNING"
| "ERROR";
export type PlaybackSessionStatus =
| "ACTIVE"
| "IDLE"
| "EXPIRED"
| "REVOKED"
| "COMPLETED";
export type LibraryEventType = "FILE_ADDED" | "FILE_UPDATED" | "FILE_REMOVED";
export interface ApiError {
error: {
code: string;
message: string;
};
}
export interface NodeHeartbeat {
nodeId: string;
softwareVersion: string;
uptimeSeconds: number;
cpuUsagePercent: number;
memoryUsagePercent: number;
storageTotalBytes: number;
storageFreeBytes: number;
activeStreams: number;
currentBytesPerSec: number;
libraryFileCount: number;
scanStatus: string;
errors: string[];
}
export interface ParsedMovieFilename {
title: string;
year?: number;
resolution?: string;
source?: string;
releaseType?: string;
videoCodec?: string;
audio?: string;
}
export interface ParsedSeriesFilename {
title: string;
season: number;
episode: number;
resolution?: string;
source?: string;
videoCodec?: string;
audio?: string;
}
export interface MediaFileInfo {
localFileId: string;
sizeBytes: number;
container?: string;
resolution?: string;
videoCodec?: string;
audioCodec?: string;
releaseName: string;
modifiedAt: string;
mediaType: "movie" | "episode";
parsedMovie?: ParsedMovieFilename;
parsedEpisode?: ParsedSeriesFilename;
}
export interface LibraryEvent {
type: LibraryEventType;
nodeRevision: number;
file: MediaFileInfo;
}
export interface CreatePlaybackSessionPayload {
sessionId: string;
tokenHash: string;
localFileId: string;
idleTimeoutSeconds: number;
absoluteExpiresAt: string;
}
export interface EnrollmentRequest {
token: string;
name: string;
location?: string;
hostname: string;
architecture: string;
version: string;
publicStreamUrl: string;
}
export interface EnrollmentResponse {
nodeId: string;
apiKey: string;
name: string;
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"declaration": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

2764
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

10
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,10 @@
packages:
- "apps/*"
- "packages/*"
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
argon2: true
esbuild: true
prisma: true
sharp: true