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