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:
commit
a5c6fd5fe4
74 changed files with 9899 additions and 0 deletions
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
128
README.md
Normal 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
6
apps/admin-ui/next-env.d.ts
vendored
Normal 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.
|
||||
16
apps/admin-ui/next.config.js
Normal file
16
apps/admin-ui/next.config.js
Normal 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;
|
||||
21
apps/admin-ui/package.json
Normal file
21
apps/admin-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
apps/admin-ui/public/.gitkeep
Normal file
1
apps/admin-ui/public/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Keep this file so Next.js public assets directory exists
|
||||
85
apps/admin-ui/src/app/dashboard/page.tsx
Normal file
85
apps/admin-ui/src/app/dashboard/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
apps/admin-ui/src/app/globals.css
Normal file
190
apps/admin-ui/src/app/globals.css
Normal 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;
|
||||
}
|
||||
15
apps/admin-ui/src/app/layout.tsx
Normal file
15
apps/admin-ui/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
apps/admin-ui/src/app/movies/page.tsx
Normal file
170
apps/admin-ui/src/app/movies/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
213
apps/admin-ui/src/app/nodes/page.tsx
Normal file
213
apps/admin-ui/src/app/nodes/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
apps/admin-ui/src/app/page.tsx
Normal file
73
apps/admin-ui/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/admin-ui/src/app/series/page.tsx
Normal file
75
apps/admin-ui/src/app/series/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
apps/admin-ui/src/app/settings/page.tsx
Normal file
57
apps/admin-ui/src/app/settings/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
apps/admin-ui/src/app/streams/page.tsx
Normal file
85
apps/admin-ui/src/app/streams/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
apps/admin-ui/src/components/Nav.tsx
Normal file
47
apps/admin-ui/src/components/Nav.tsx
Normal 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]);
|
||||
}
|
||||
21
apps/admin-ui/tsconfig.json
Normal file
21
apps/admin-ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
21
apps/master-api/.env.example
Normal file
21
apps/master-api/.env.example
Normal 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
|
||||
38
apps/master-api/package.json
Normal file
38
apps/master-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
2
apps/master-api/prisma/migrations/migration_lock.toml
Normal file
2
apps/master-api/prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Migration lock file
|
||||
provider = "postgresql"
|
||||
280
apps/master-api/prisma/schema.prisma
Normal file
280
apps/master-api/prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
35
apps/master-api/prisma/seed.ts
Normal file
35
apps/master-api/prisma/seed.ts
Normal 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());
|
||||
375
apps/master-api/src/admin/routes.ts
Normal file
375
apps/master-api/src/admin/routes.ts
Normal 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;
|
||||
}
|
||||
93
apps/master-api/src/app.ts
Normal file
93
apps/master-api/src/app.ts
Normal 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);
|
||||
});
|
||||
124
apps/master-api/src/auth/routes.ts
Normal file
124
apps/master-api/src/auth/routes.ts
Normal 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;
|
||||
}
|
||||
30
apps/master-api/src/config.ts
Normal file
30
apps/master-api/src/config.ts
Normal 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;
|
||||
}
|
||||
8
apps/master-api/src/database/client.ts
Normal file
8
apps/master-api/src/database/client.ts
Normal 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();
|
||||
}
|
||||
62
apps/master-api/src/media/sync.ts
Normal file
62
apps/master-api/src/media/sync.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
36
apps/master-api/src/metadata/filename-parser.test.ts
Normal file
36
apps/master-api/src/metadata/filename-parser.test.ts
Normal 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();
|
||||
105
apps/master-api/src/metadata/filename-parser.ts
Normal file
105
apps/master-api/src/metadata/filename-parser.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
235
apps/master-api/src/metadata/service.ts
Normal file
235
apps/master-api/src/metadata/service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
290
apps/master-api/src/nodes/routes.ts
Normal file
290
apps/master-api/src/nodes/routes.ts
Normal 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);
|
||||
}
|
||||
109
apps/master-api/src/playback/service.ts
Normal file
109
apps/master-api/src/playback/service.ts
Normal 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(" • ");
|
||||
}
|
||||
}
|
||||
39
apps/master-api/src/security/crypto.ts
Normal file
39
apps/master-api/src/security/crypto.ts
Normal 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]`;
|
||||
}
|
||||
25
apps/master-api/src/security/errors.ts
Normal file
25
apps/master-api/src/security/errors.ts
Normal 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" } },
|
||||
};
|
||||
}
|
||||
363
apps/master-api/src/stremio/routes.ts
Normal file
363
apps/master-api/src/stremio/routes.ts
Normal 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 [];
|
||||
}
|
||||
317
apps/master-api/src/websocket/manager.ts
Normal file
317
apps/master-api/src/websocket/manager.ts
Normal 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();
|
||||
17
apps/master-api/tsconfig.json
Normal file
17
apps/master-api/tsconfig.json
Normal 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"]
|
||||
}
|
||||
27
deploy/docker/Dockerfile.admin-ui
Normal file
27
deploy/docker/Dockerfile.admin-ui
Normal 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"]
|
||||
34
deploy/docker/Dockerfile.master-api
Normal file
34
deploy/docker/Dockerfile.master-api
Normal 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"]
|
||||
88
deploy/docker/docker-compose.dokploy.yml
Normal file
88
deploy/docker/docker-compose.dokploy.yml
Normal 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
|
||||
62
deploy/docker/docker-compose.yml
Normal file
62
deploy/docker/docker-compose.yml
Normal 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:
|
||||
31
deploy/dokploy/.env.example
Normal file
31
deploy/dokploy/.env.example
Normal 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
|
||||
23
deploy/master/.env.example
Normal file
23
deploy/master/.env.example
Normal 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
21
deploy/master/backup.sh
Normal 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
21
deploy/node/build.sh
Normal 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}/"
|
||||
22
deploy/node/config.example.yaml
Normal file
22
deploy/node/config.example.yaml
Normal 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
63
deploy/node/install.sh
Normal 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"
|
||||
25
deploy/node/media-node.service
Normal file
25
deploy/node/media-node.service
Normal 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
71
docs/deployment.md
Normal 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
137
docs/dokploy.md
Normal 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
64
docs/npm-setup.md
Normal 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.
|
||||
453
node/media-node/cmd/media-node/main.go
Normal file
453
node/media-node/cmd/media-node/main.go
Normal 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
11
node/media-node/go.mod
Normal 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
|
||||
)
|
||||
99
node/media-node/internal/config/config.go
Normal file
99
node/media-node/internal/config/config.go
Normal 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)
|
||||
}
|
||||
332
node/media-node/internal/control/client.go
Normal file
332
node/media-node/internal/control/client.go
Normal 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
|
||||
}
|
||||
256
node/media-node/internal/database/store.go
Normal file
256
node/media-node/internal/database/store.go
Normal 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
|
||||
}
|
||||
15
node/media-node/internal/health/disk_unix.go
Normal file
15
node/media-node/internal/health/disk_unix.go
Normal 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
|
||||
}
|
||||
7
node/media-node/internal/health/disk_windows.go
Normal file
7
node/media-node/internal/health/disk_windows.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//go:build windows
|
||||
|
||||
package health
|
||||
|
||||
func diskStat(_ string) (total, free uint64) {
|
||||
return 0, 0
|
||||
}
|
||||
46
node/media-node/internal/health/health.go
Normal file
46
node/media-node/internal/health/health.go
Normal 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
|
||||
}
|
||||
98
node/media-node/internal/scanner/parser.go
Normal file
98
node/media-node/internal/scanner/parser.go
Normal 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
|
||||
}
|
||||
36
node/media-node/internal/scanner/parser_test.go
Normal file
36
node/media-node/internal/scanner/parser_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
298
node/media-node/internal/scanner/scanner.go
Normal file
298
node/media-node/internal/scanner/scanner.go
Normal 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 ""
|
||||
}
|
||||
380
node/media-node/internal/streaming/server.go
Normal file
380
node/media-node/internal/streaming/server.go
Normal 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
18
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
packages/protocol/package.json
Normal file
18
packages/protocol/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
86
packages/protocol/src/index.ts
Normal file
86
packages/protocol/src/index.ts
Normal 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 }>;
|
||||
14
packages/protocol/tsconfig.json
Normal file
14
packages/protocol/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
||||
14
packages/shared-types/package.json
Normal file
14
packages/shared-types/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
105
packages/shared-types/src/index.ts
Normal file
105
packages/shared-types/src/index.ts
Normal 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;
|
||||
}
|
||||
14
packages/shared-types/tsconfig.json
Normal file
14
packages/shared-types/tsconfig.json
Normal 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
2764
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
10
pnpm-workspace.yaml
Normal file
10
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
allowBuilds:
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
argon2: true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
sharp: true
|
||||
Loading…
Reference in a new issue