Add Plex-like live streams with viewer labels and progress, plus admin UI redesign.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-29 01:24:55 +02:00
parent 54d70735d8
commit 0f7b566626
21 changed files with 973 additions and 229 deletions

View file

@ -149,7 +149,12 @@ export default function DashboardPage() {
<>
<Nav />
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Dashboard</h1>
<div className="page-header">
<div>
<div className="page-kicker">Cluster telemetry</div>
<h1>Dashboard</h1>
</div>
</div>
{error && <div className="error">{error}</div>}
{data && (
<>
@ -165,21 +170,21 @@ export default function DashboardPage() {
<div className="card">
<h2>Films (uniek)</h2>
<div className="value">{data.availableMovies ?? data.movies ?? 0}</div>
<p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.75rem" }}>
{data.movies ?? 0} rijen totaal
</p>
</div>
<div className="card">
<h2>Series (uniek)</h2>
<div className="value">{data.availableSeries ?? data.series ?? 0}</div>
<p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.75rem" }}>
{data.series ?? 0} rijen totaal
</p>
</div>
<div className="card">
<h2>Bestanden</h2>
<div className="value">{data.availableFiles ?? 0}</div>
<p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.75rem" }}>
{data.scannedFiles ?? 0} gescand op nodes
</p>
</div>
@ -193,62 +198,67 @@ export default function DashboardPage() {
</div>
<div className="card">
<h2>Total Storage</h2>
<div className="value" style={{ fontSize: "1.5rem" }}>
<div className="value" style={{ fontSize: "1.35rem" }}>
{formatBytes(data.totalStorage)}
</div>
</div>
<div className="card">
<h2>Free Storage</h2>
<div className="value" style={{ fontSize: "1.5rem" }}>
<div className="value" style={{ fontSize: "1.35rem" }}>
{formatBytes(data.freeStorage)}
</div>
</div>
</div>
<div className="card" style={{ marginTop: "1.5rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none" }}>
<div className="card" style={{ marginTop: "0.5rem" }}>
<div className="page-kicker">Maintenance</div>
<h2 style={{ fontSize: "1.05rem", color: "var(--text-bright)", textTransform: "none", letterSpacing: 0, fontFamily: "var(--font-ui)" }}>
Herstel na pad-wijziging
</h2>
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
<p className="muted" style={{ margin: "0.75rem 0" }}>
Gebruik dit als Cinemeta geen streams meer toont na een Unraid pad-wijziging
(oude /media/movies-bestanden + dubbele titels zonder IMDb).
</p>
<button onClick={repairLibrary}>Bibliotheek herstellen</button>
{repairMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}>
<p className="muted" style={{ marginTop: "0.75rem" }}>
{repairMsg}
</p>
)}
<h2
style={{
fontSize: "1rem",
color: "#fafafa",
fontSize: "1.05rem",
color: "var(--text-bright)",
textTransform: "none",
letterSpacing: 0,
fontFamily: "var(--font-ui)",
marginTop: "1.5rem",
}}
>
Bibliotheek opruimen
</h2>
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
<p className="muted" style={{ margin: "0.75rem 0" }}>
Dubbele film-/serie-rijen samenvoegen. Daarna in Stremio de addon verversen
(of herinstalleren) zodat de volledige catalogus met paginatie geladen wordt.
</p>
<button onClick={dedupe}>Duplicaten samenvoegen</button>
{dedupeMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}>
<p className="muted" style={{ marginTop: "0.75rem" }}>
{dedupeMsg}
</p>
)}
<h2
style={{
fontSize: "1rem",
color: "#fafafa",
fontSize: "1.05rem",
color: "var(--text-bright)",
textTransform: "none",
letterSpacing: 0,
fontFamily: "var(--font-ui)",
marginTop: "1.5rem",
}}
>
Cinemeta-koppeling (IMDb)
</h2>
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
<p className="muted" style={{ margin: "0.75rem 0" }}>
Films met IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
{data.moviesMissingImdb ?? "?"}
<br />
@ -258,14 +268,14 @@ export default function DashboardPage() {
</p>
<button onClick={backfillImdb}>IMDb-IDs backfillen</button>
{imdbMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}>
<p className="muted" style={{ marginTop: "0.75rem" }}>
{imdbMsg}
</p>
)}
</div>
</>
)}
{!data && !error && <p style={{ color: "#71717a" }}>Laden...</p>}
{!data && !error && <p className="muted">Laden...</p>}
</div>
</>
);

View file

@ -1,83 +1,317 @@
:root {
--bg-void: #04060a;
--bg-deep: #070b12;
--bg-panel: #0b1019;
--bg-panel-2: #0f1622;
--bg-elevated: #121a28;
--border: rgba(56, 189, 248, 0.14);
--border-strong: rgba(56, 189, 248, 0.32);
--border-dim: rgba(148, 163, 184, 0.12);
--text: #e2e8f0;
--text-bright: #f8fafc;
--text-muted: #94a3b8;
--text-dim: #64748b;
--accent: #22d3ee;
--accent-2: #38bdf8;
--accent-dim: rgba(34, 211, 238, 0.12);
--accent-hot: #f59e0b;
--ok: #34d399;
--ok-bg: rgba(52, 211, 153, 0.12);
--warn: #fbbf24;
--danger: #f43f5e;
--danger-bg: rgba(244, 63, 94, 0.14);
--font-ui: var(--font-outfit), system-ui, sans-serif;
--font-mono: var(--font-jetbrains), ui-monospace, monospace;
--radius: 10px;
--shadow-panel: 0 0 0 1px var(--border), 0 18px 50px rgba(0, 0, 0, 0.45);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
color-scheme: dark;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f1117;
color: #e4e4e7;
font-family: var(--font-ui);
background: var(--bg-void);
color: var(--text);
min-height: 100vh;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
position: relative;
}
/* Atmosphere: grid + radial vignette */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(34, 211, 238, 0.09), transparent 55%),
radial-gradient(ellipse 40% 30% at 90% 80%, rgba(245, 158, 11, 0.04), transparent 50%),
linear-gradient(rgba(34, 211, 238, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.03) 1px, transparent 1px);
background-size: auto, auto, 48px 48px, 48px 48px;
mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, black, transparent 85%);
}
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.035;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.35) 2px,
rgba(0, 0, 0, 0.35) 4px
);
}
body > * {
position: relative;
z-index: 1;
}
a {
color: #818cf8;
color: var(--accent);
text-decoration: none;
transition: color 0.15s ease;
}
a:hover {
text-decoration: underline;
color: var(--accent-2);
}
.container {
max-width: 1200px;
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
padding: 1.75rem 1.5rem 3rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
margin-bottom: 1.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-dim);
}
.page-header h1,
.container > h1 {
font-size: 1.65rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-bright);
margin-bottom: 0;
}
.container > h1 {
margin-bottom: 1.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-dim);
}
.page-kicker {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.35rem;
}
.muted {
color: var(--text-muted);
font-size: 0.875rem;
}
.card {
background: #1a1d27;
border: 1px solid #2a2d3a;
border-radius: 12px;
padding: 1.5rem;
background:
linear-gradient(145deg, rgba(34, 211, 238, 0.04), transparent 40%),
var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.35rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-panel);
position: relative;
overflow: hidden;
}
.card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(34, 211, 238, 0.45), transparent);
opacity: 0.7;
}
.card h2 {
font-size: 0.875rem;
color: #71717a;
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 500;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
letter-spacing: 0.14em;
margin-bottom: 0.55rem;
}
.card .value {
font-size: 2rem;
font-family: var(--font-mono);
font-size: 1.85rem;
font-weight: 700;
color: #fafafa;
color: var(--text-bright);
letter-spacing: -0.03em;
line-height: 1.1;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
gap: 0.9rem;
margin-bottom: 1.5rem;
}
nav {
background: #1a1d27;
border-bottom: 1px solid #2a2d3a;
padding: 1rem 2rem;
.grid .card .value {
color: var(--accent);
text-shadow: 0 0 24px rgba(34, 211, 238, 0.25);
}
/* ——— Nav ——— */
.topnav {
position: sticky;
top: 0;
z-index: 50;
display: flex;
gap: 2rem;
align-items: center;
gap: 0.35rem;
padding: 0.7rem 1.25rem;
background: rgba(7, 11, 18, 0.82);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
nav .brand {
.topnav::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0.35;
}
.topnav .brand {
display: flex;
align-items: center;
gap: 0.65rem;
margin-right: 1rem;
font-weight: 700;
font-size: 1.25rem;
color: #818cf8;
font-size: 0.95rem;
letter-spacing: 0.04em;
color: var(--text-bright);
text-decoration: none;
}
nav a {
color: #a1a1aa;
font-size: 0.875rem;
.topnav .brand-mark {
width: 28px;
height: 28px;
border: 1px solid var(--border-strong);
border-radius: 6px;
display: grid;
place-items: center;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--accent);
background: var(--accent-dim);
box-shadow: inset 0 0 12px rgba(34, 211, 238, 0.15);
}
nav a:hover,
nav a.active {
color: #fafafa;
.topnav .brand-text {
display: flex;
flex-direction: column;
line-height: 1.05;
}
.topnav .brand-text small {
font-family: var(--font-mono);
font-size: 0.58rem;
font-weight: 500;
letter-spacing: 0.2em;
color: var(--text-dim);
text-transform: uppercase;
}
.topnav-links {
display: flex;
align-items: center;
gap: 0.15rem;
flex-wrap: wrap;
}
.topnav-links a {
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 500;
padding: 0.45rem 0.7rem;
border-radius: 6px;
border: 1px solid transparent;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.topnav-links a:hover {
color: var(--text-bright);
background: rgba(255, 255, 255, 0.03);
text-decoration: none;
}
.topnav-links a.active {
color: var(--accent);
background: var(--accent-dim);
border-color: var(--border);
text-decoration: none;
}
.topnav .btn-logout {
margin-left: auto;
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border-dim);
padding: 0.4rem 0.85rem;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.topnav .btn-logout:hover {
color: var(--text-bright);
border-color: var(--border-strong);
background: rgba(255, 255, 255, 0.03);
}
/* ——— Tables ——— */
.table-wrap,
.card > table {
width: 100%;
}
table {
@ -85,60 +319,154 @@ table {
border-collapse: collapse;
}
th, td {
th,
td {
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid #2a2d3a;
padding: 0.8rem 0.9rem;
border-bottom: 1px solid var(--border-dim);
vertical-align: middle;
}
th {
color: #71717a;
font-size: 0.75rem;
font-family: var(--font-mono);
color: var(--text-dim);
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.12em;
background: rgba(0, 0, 0, 0.2);
}
.status {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
tbody tr {
transition: background 0.12s ease;
}
.status.online { background: #166534; color: #bbf7d0; }
.status.offline { background: #374151; color: #d1d5db; }
.status.revoked { background: #991b1b; color: #fecaca; }
tbody tr:hover {
background: rgba(34, 211, 238, 0.04);
}
button, .btn {
background: #4f46e5;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
td {
font-size: 0.875rem;
}
button:hover, .btn:hover {
background: #4338ca;
/* ——— Status ——— */
.status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid transparent;
}
.status::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.status.online {
background: var(--ok-bg);
color: var(--ok);
border-color: rgba(52, 211, 153, 0.25);
}
.status.offline {
background: rgba(100, 116, 139, 0.15);
color: var(--text-muted);
border-color: rgba(100, 116, 139, 0.25);
}
.status.revoked {
background: var(--danger-bg);
color: #fda4af;
border-color: rgba(244, 63, 94, 0.3);
}
/* ——— Buttons ——— */
button,
.btn {
background: linear-gradient(180deg, rgba(34, 211, 238, 0.22), rgba(34, 211, 238, 0.08));
color: var(--text-bright);
border: 1px solid var(--border-strong);
padding: 0.5rem 1rem;
border-radius: 7px;
cursor: pointer;
font-family: var(--font-ui);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.01em;
transition: border-color 0.15s, background 0.15s, transform 0.1s;
}
button:hover,
.btn:hover {
background: linear-gradient(180deg, rgba(34, 211, 238, 0.32), rgba(34, 211, 238, 0.12));
border-color: var(--accent);
}
button:active,
.btn:active {
transform: translateY(1px);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
button.danger {
background: #dc2626;
background: linear-gradient(180deg, rgba(244, 63, 94, 0.28), rgba(244, 63, 94, 0.1));
border-color: rgba(244, 63, 94, 0.45);
color: #fecdd3;
}
button.danger:hover {
background: #b91c1c;
background: linear-gradient(180deg, rgba(244, 63, 94, 0.4), rgba(244, 63, 94, 0.16));
border-color: var(--danger);
}
input {
background: #0f1117;
border: 1px solid #2a2d3a;
color: #fafafa;
padding: 0.5rem 0.75rem;
border-radius: 8px;
button.ghost {
background: transparent;
border-color: var(--border-dim);
color: var(--text-muted);
}
/* ——— Forms ——— */
input,
select,
textarea {
background: var(--bg-deep);
border: 1px solid var(--border-dim);
color: var(--text-bright);
padding: 0.55rem 0.75rem;
border-radius: 7px;
width: 100%;
margin-bottom: 0.75rem;
font-family: var(--font-ui);
font-size: 0.9rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--border-strong);
box-shadow: 0 0 0 3px var(--accent-dim);
}
input::placeholder {
color: var(--text-dim);
}
.form-group {
@ -147,44 +475,174 @@ input {
.form-group label {
display: block;
font-size: 0.875rem;
color: #a1a1aa;
margin-bottom: 0.25rem;
font-family: var(--font-mono);
font-size: 0.68rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 0.35rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ——— Login ——— */
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1.5rem;
}
.login-box {
background: #1a1d27;
border: 1px solid #2a2d3a;
border-radius: 16px;
padding: 2rem;
background:
linear-gradient(160deg, rgba(34, 211, 238, 0.07), transparent 45%),
var(--bg-panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 2rem 2rem 1.75rem;
width: 100%;
max-width: 400px;
max-width: 420px;
box-shadow: var(--shadow-panel), 0 0 80px rgba(34, 211, 238, 0.06);
position: relative;
}
.login-box::before {
content: "";
position: absolute;
inset: -1px;
border-radius: 14px;
padding: 1px;
background: linear-gradient(135deg, rgba(34, 211, 238, 0.45), transparent 40%, rgba(245, 158, 11, 0.2));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.login-box .login-brand {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.login-box .login-brand .mark {
width: 40px;
height: 40px;
border: 1px solid var(--border-strong);
border-radius: 8px;
display: grid;
place-items: center;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
color: var(--accent);
background: var(--accent-dim);
}
.login-box h1 {
margin-bottom: 0.15rem;
font-size: 1.45rem;
letter-spacing: -0.02em;
}
.login-box .login-sub {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
.error {
color: #f87171;
font-size: 0.875rem;
color: #fda4af;
font-size: 0.85rem;
margin-bottom: 1rem;
padding: 0.65rem 0.75rem;
background: var(--danger-bg);
border: 1px solid rgba(244, 63, 94, 0.3);
border-radius: 7px;
}
.token-display {
background: #0f1117;
border: 1px solid #2a2d3a;
background: var(--bg-deep);
border: 1px solid var(--border);
padding: 1rem;
border-radius: 8px;
font-family: monospace;
font-family: var(--font-mono);
font-size: 0.78rem;
word-break: break-all;
margin: 1rem 0;
color: var(--accent);
line-height: 1.5;
}
code {
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--accent-2);
background: var(--accent-dim);
padding: 0.1em 0.35em;
border-radius: 4px;
}
pre.token-display {
white-space: pre-wrap;
}
/* Soften common inline hard-coded greys from older page markup */
.card p[style*="#a1a1aa"],
.card p[style*="#71717a"],
.container p[style*="#a1a1aa"],
.container p[style*="#71717a"] {
color: var(--text-muted) !important;
}
.card h2[style*="#fafafa"],
.card h3[style*="#e4e4e7"] {
color: var(--text-bright) !important;
font-family: var(--font-ui) !important;
letter-spacing: -0.01em !important;
}
.progress-track {
height: 6px;
border-radius: 3px;
background: rgba(148, 163, 184, 0.15);
overflow: hidden;
border: 1px solid var(--border-dim);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #67e8f9);
border-radius: 3px;
transition: width 0.4s ease;
box-shadow: 0 0 10px rgba(34, 211, 238, 0.35);
}
@media (max-width: 720px) {
.topnav {
flex-wrap: wrap;
padding: 0.65rem 0.85rem;
}
.topnav .btn-logout {
margin-left: 0;
width: 100%;
}
.topnav-links {
width: 100%;
}
.container {
padding: 1.25rem 1rem 2.5rem;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}

View file

@ -1,14 +1,27 @@
import type { Metadata } from "next";
import { JetBrains_Mono, Outfit } from "next/font/google";
import "./globals.css";
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-outfit",
display: "swap",
});
const jetbrains = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains",
display: "swap",
});
export const metadata: Metadata = {
title: "Aanmelden",
description: "",
title: "Media Cluster — Ops",
description: "Private media cluster admin console",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="nl">
<html lang="nl" className={`${outfit.variable} ${jetbrains.variable}`}>
<body>{children}</body>
</html>
);

View file

@ -190,8 +190,11 @@ export default function MoviesPage() {
<>
<Nav />
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", gap: "1rem", marginBottom: "2rem" }}>
<h1 style={{ fontSize: "1.75rem" }}>Movies</h1>
<div className="page-header">
<div>
<div className="page-kicker">Library</div>
<h1>Movies</h1>
</div>
<form
onSubmit={(e) => {
e.preventDefault();

View file

@ -258,8 +258,11 @@ export default function NodesPage() {
<>
<Nav />
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "2rem" }}>
<h1 style={{ fontSize: "1.75rem" }}>Nodes</h1>
<div className="page-header">
<div>
<div className="page-kicker">Infrastructure</div>
<h1>Nodes</h1>
</div>
<button onClick={() => setShowEnroll(!showEnroll)}>
{showEnroll ? "Annuleren" : "Node toevoegen"}
</button>

View file

@ -40,7 +40,6 @@ export default function LoginPage() {
}
throw new Error(message);
}
// Full navigation so the session cookie is definitely included
window.location.assign("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Inloggen mislukt");
@ -51,7 +50,13 @@ export default function LoginPage() {
return (
<div className="login-container">
<div className="login-box">
<h1>Aanmelden</h1>
<div className="login-brand">
<span className="mark">MC</span>
<div>
<h1>Media Cluster</h1>
</div>
</div>
<p className="login-sub">Authenticated access // ops</p>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">

View file

@ -152,8 +152,11 @@ export default function SeriesPage() {
<>
<Nav />
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", gap: "1rem", marginBottom: "2rem" }}>
<h1 style={{ fontSize: "1.75rem" }}>Series</h1>
<div className="page-header">
<div>
<div className="page-kicker">Library</div>
<h1>Series</h1>
</div>
<form
onSubmit={(e) => {
e.preventDefault();

View file

@ -281,7 +281,12 @@ export default function SettingsPage() {
<>
<Nav />
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Instellingen</h1>
<div className="page-header">
<div>
<div className="page-kicker">System</div>
<h1>Instellingen</h1>
</div>
</div>
<div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}>

View file

@ -7,11 +7,40 @@ interface Stream {
id: string;
node: string;
status: string;
viewer: string;
createdAt: string;
lastActivity: string;
absoluteExpiresAt: string;
title: string;
resolution: string | null;
bytesSent: string;
lastByteOffset: string;
fileSizeBytes: string;
progressPercent: number | null;
}
function formatBytes(bytes: string | number | null | undefined): string {
const valNum = Number(bytes ?? 0);
if (!Number.isFinite(valNum) || valNum < 0) return "0 B";
let val = valNum;
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]}`;
}
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(diff) || diff < 0) return "nu";
const sec = Math.floor(diff / 1000);
if (sec < 15) return "nu";
if (sec < 60) return `${sec}s geleden`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m geleden`;
return new Date(iso).toLocaleTimeString();
}
export default function StreamsPage() {
@ -26,7 +55,7 @@ export default function StreamsPage() {
useEffect(() => {
load();
const t = setInterval(load, 10000);
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
@ -43,36 +72,82 @@ export default function StreamsPage() {
<>
<Nav />
<div className="container">
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Actieve streams</h1>
<div className="page-header">
<div>
<div className="page-kicker">Realtime · Plex-style</div>
<h1>Actieve streams</h1>
</div>
<div className="muted" style={{ fontFamily: "var(--font-mono)", fontSize: "0.8rem" }}>
{streams.length} live
</div>
</div>
<div className="card" style={{ marginBottom: "1rem" }}>
<p className="muted" style={{ margin: 0 }}>
Alleen echte kijkers (bytes stromen). Stopt iemand → verdwijnt binnen ~3 minuten uit deze lijst.
Voortgang is een schatting op basis van range-positie in het bestand.
</p>
</div>
<div className="card">
<table>
<thead>
<tr>
<th>Kijker</th>
<th>Titel</th>
<th>Voortgang</th>
<th>Node</th>
<th>Kwaliteit</th>
<th>Laatste activiteit</th>
<th>Activiteit</th>
<th>Acties</th>
</tr>
</thead>
<tbody>
{streams.map((s) => (
{streams.map((s) => {
const pct = s.progressPercent ?? 0;
return (
<tr key={s.id}>
<td>{s.title}</td>
<td>
<strong style={{ color: "var(--accent)" }}>{s.viewer}</strong>
</td>
<td>
<div>{s.title}</div>
<div className="muted" style={{ fontSize: "0.75rem", marginTop: "0.2rem" }}>
{formatBytes(s.bytesSent)} gestreamd
</div>
</td>
<td style={{ minWidth: 140 }}>
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<div
className="muted"
style={{
fontFamily: "var(--font-mono)",
fontSize: "0.7rem",
marginTop: "0.35rem",
}}
>
{s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"}
</div>
</td>
<td>{s.node}</td>
<td>{s.resolution ?? "-"}</td>
<td>{new Date(s.lastActivity).toLocaleString()}</td>
<td style={{ fontFamily: "var(--font-mono)", fontSize: "0.75rem" }}>
{relativeTime(s.lastActivity)}
</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 colSpan={7} style={{ textAlign: "center" }} className="muted">
Niemand aan het kijken
</td>
</tr>
)}

View file

@ -23,14 +23,22 @@ export function Nav() {
];
return (
<nav>
<span className="brand">Beheer</span>
<nav className="topnav">
<Link href="/dashboard" className="brand">
<span className="brand-mark">MC</span>
<span className="brand-text">
Media Cluster
<small>ops console</small>
</span>
</Link>
<div className="topnav-links">
{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" }}>
</div>
<button type="button" className="btn-logout" onClick={logout}>
Uitloggen
</button>
</nav>
@ -41,7 +49,9 @@ export function useAuth() {
const router = useRouter();
useEffect(() => {
fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => { if (!r.ok) router.push("/"); })
.then((r) => {
if (!r.ok) router.push("/");
})
.catch(() => router.push("/"));
}, [router]);
}

View file

@ -0,0 +1,12 @@
-- AlterTable
ALTER TABLE "playback_sessions" ADD COLUMN "addon_token_id" TEXT;
ALTER TABLE "playback_sessions" ADD COLUMN "bytes_sent" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "playback_sessions" ADD COLUMN "last_byte_offset" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "playback_sessions" ADD COLUMN "file_size_bytes" BIGINT NOT NULL DEFAULT 0;
-- CreateIndex
CREATE INDEX "playback_sessions_status_last_activity_idx" ON "playback_sessions"("status", "last_activity");
CREATE INDEX "playback_sessions_addon_token_id_idx" ON "playback_sessions"("addon_token_id");
-- AddForeignKey
ALTER TABLE "playback_sessions" ADD CONSTRAINT "playback_sessions_addon_token_id_fkey" FOREIGN KEY ("addon_token_id") REFERENCES "addon_tokens"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -71,6 +71,7 @@ model AddonToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
iptvLine IptvLine?
playbackSessions PlaybackSession[]
@@map("addon_tokens")
}
@ -340,17 +341,24 @@ model PlaybackSession {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
userId String? @map("user_id")
addonTokenId String? @map("addon_token_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")
bytesSent BigInt @default(0) @map("bytes_sent")
lastByteOffset BigInt @default(0) @map("last_byte_offset")
fileSizeBytes BigInt @default(0) @map("file_size_bytes")
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])
addonToken AddonToken? @relation(fields: [addonTokenId], references: [id], onDelete: SetNull)
@@index([status, lastActivity])
@@index([addonTokenId])
@@map("playback_sessions")
}

View file

@ -37,7 +37,15 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
prisma.movie.count(),
prisma.series.count(),
prisma.episode.count(),
prisma.playbackSession.count({ where: { status: "ACTIVE", revoked: false } }),
prisma.playbackSession.count({
where: {
status: "ACTIVE",
revoked: false,
absoluteExpiresAt: { gt: new Date() },
lastActivity: { gt: new Date(Date.now() - 3 * 60 * 1000) },
bytesSent: { gt: 0 },
},
}),
prisma.node.findMany({
where: { revoked: false, status: "ONLINE" },
select: {
@ -236,7 +244,6 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
app.get("/api/v1/admin/streams", { preHandler: requireAdmin }, async () => {
const now = new Date();
// Drop expired rows so prefetch leftovers do not clutter the UI.
await prisma.playbackSession.updateMany({
where: {
status: "ACTIVE",
@ -246,16 +253,19 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
data: { status: "EXPIRED" },
});
// Only recently active + actually streamed bytes (no prefetch ghosts).
const liveSince = new Date(now.getTime() - 3 * 60 * 1000);
const sessions = await prisma.playbackSession.findMany({
where: {
status: "ACTIVE",
revoked: false,
absoluteExpiresAt: { gt: now },
// Only show recently touched sessions (prefetch often never streams bytes).
lastActivity: { gt: new Date(now.getTime() - 15 * 60 * 1000) },
lastActivity: { gt: liveSince },
bytesSent: { gt: 0 },
},
include: {
node: { select: { id: true, name: true } },
addonToken: { select: { id: true, label: true } },
mediaFile: {
include: {
movie: { select: { title: true, year: true } },
@ -270,20 +280,34 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
});
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:
streams: sessions.map((s) => {
const fileSize = Number(s.fileSizeBytes || s.mediaFile.sizeBytes || 0);
const offset = Number(s.lastByteOffset || 0);
const progressPercent =
fileSize > 0 ? Math.min(100, Math.max(0, (offset / fileSize) * 100)) : null;
const 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),
: s.mediaFile.releaseName);
const year = s.mediaFile.movie?.year ?? null;
return {
id: s.id,
node: s.node.name,
status: s.status,
viewer: s.addonToken?.label?.trim() || "Onbekend",
addonTokenId: s.addonToken?.id ?? null,
createdAt: s.createdAt,
lastActivity: s.lastActivity,
absoluteExpiresAt: s.absoluteExpiresAt,
title: year ? `${title} (${year})` : title,
resolution: s.mediaFile.resolution,
})),
bytesSent: s.bytesSent.toString(),
lastByteOffset: s.lastByteOffset.toString(),
fileSizeBytes: (s.fileSizeBytes > 0n ? s.fileSizeBytes : s.mediaFile.sizeBytes).toString(),
progressPercent,
};
}),
};
});

View file

@ -11,7 +11,7 @@ import type { Config } from "../config";
export class PlaybackService {
constructor(private readonly config: Config) {}
async createSession(mediaFileId: string, userId?: string) {
async createSession(mediaFileId: string, userId?: string, addonTokenId?: string) {
const mediaFile = await prisma.mediaFile.findUnique({
where: { id: mediaFileId },
include: { node: true },
@ -42,11 +42,13 @@ export class PlaybackService {
id: sessionId,
tokenHash,
userId,
addonTokenId: addonTokenId || null,
nodeId: mediaFile.nodeId,
mediaFileId: mediaFile.id,
idleTimeoutSeconds: this.config.PLAYBACK_IDLE_TIMEOUT_SECONDS,
absoluteExpiresAt,
lastActivity: now,
fileSizeBytes: mediaFile.sizeBytes ?? 0n,
status: "ACTIVE",
},
});

View file

@ -1,8 +1,9 @@
import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client";
import { resolveAddonToken, validateAddonToken } from "../auth/routes";
import { resolveAddonToken } from "../auth/routes";
import { PlaybackService } from "../playback/service";
import { MetadataService } from "../metadata/service";
import { nodeConnectionManager } from "../websocket/manager";
import type { Config } from "../config";
import {
IPTV_PREFIX,
@ -281,25 +282,25 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
return { streams };
}
const streams = await getStreams(type, id, auth.userId, playback, metadata, request.log);
const streams = await getStreams(type, id, auth, playback, metadata, request.log);
return { streams };
});
// Optional fallback if something still hits Master /play — redirect to node (no proxy).
// Lazy playback: session is created only when Stremio actually opens this URL.
app.route({
method: ["GET", "HEAD"],
url: "/stremio/:token/play/:mediaFileId",
handler: async (request, reply) => {
const { token, mediaFileId } = request.params as { token: string; mediaFileId: string };
const userId = await validateAddonToken(token);
if (!userId) {
const auth = await resolveAddonToken(token);
if (!auth) {
return reply.status(401).send({ error: "Invalid addon token" });
}
try {
const session = await playback.createSession(mediaFileId, userId);
const session = await playback.createSession(mediaFileId, auth.userId, auth.tokenId);
request.log.info(
{ mediaFileId, streamUrl: session.streamUrl },
{ mediaFileId, streamUrl: session.streamUrl, addonTokenId: auth.tokenId },
"playback delegate to node"
);
return reply.code(302).redirect(session.streamUrl);
@ -796,7 +797,7 @@ async function getSeriesMeta(
async function getStreams(
type: string,
id: string,
userId: string,
auth: { userId: string; rawToken: string; tokenId: string },
playback: PlaybackService,
metadata: MetadataService,
log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void }
@ -856,23 +857,20 @@ async function getStreams(
const streams = [];
for (const file of pool) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try {
const session = await playback.createSession(file.id, userId);
if (!nodeConnectionManager.isOnline(file.nodeId)) continue;
const streamUrl = playback.resolveStreamUrl(auth.rawToken, file.id);
log?.info?.(
{ mediaFileId: file.id, streamUrl: session.streamUrl },
"stream delegated to node"
{ mediaFileId: file.id, streamUrl },
"stream URL (lazy session on open)"
);
streams.push({
name: playback.formatStreamName(file),
title: playback.formatStreamTitle(file, file.node.name),
url: session.streamUrl,
url: streamUrl,
behaviorHints: {
notWebReady: true,
},
});
} catch (err) {
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
}
}
log?.info?.(
{
@ -958,23 +956,20 @@ async function getStreams(
const streams = [];
for (const file of pool) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try {
const session = await playback.createSession(file.id, userId);
if (!nodeConnectionManager.isOnline(file.nodeId)) continue;
const streamUrl = playback.resolveStreamUrl(auth.rawToken, file.id);
log?.info?.(
{ mediaFileId: file.id, streamUrl: session.streamUrl },
"stream delegated to node"
{ mediaFileId: file.id, streamUrl },
"stream URL (lazy session on open)"
);
streams.push({
name: playback.formatStreamName(file),
title: playback.formatStreamTitle(file, file.node.name),
url: session.streamUrl,
url: streamUrl,
behaviorHints: {
notWebReady: true,
},
});
} catch (err) {
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
}
}
log?.info?.(
{

View file

@ -13,6 +13,7 @@ import type {
HelloPayload,
LibraryEventPayload,
PlaybackSessionEndedPayload,
PlaybackSessionProgressPayload,
CreatePlaybackSessionAckPayload,
} from "@media-cluster/protocol";
@ -128,6 +129,9 @@ class NodeConnectionManager {
case "PLAYBACK_SESSION_ENDED":
await this.handlePlaybackEnded(message.payload as PlaybackSessionEndedPayload);
break;
case "PLAYBACK_SESSION_PROGRESS":
await this.handlePlaybackProgress(message.payload as PlaybackSessionProgressPayload);
break;
case "CREATE_PLAYBACK_SESSION_ACK":
this.handlePlaybackSessionAck(message.payload as CreatePlaybackSessionAckPayload);
break;
@ -416,6 +420,22 @@ class NodeConnectionManager {
});
}
private async handlePlaybackProgress(payload: PlaybackSessionProgressPayload): Promise<void> {
if (!payload?.sessionId) return;
const lastActivity = payload.lastActivity
? new Date(payload.lastActivity)
: new Date();
await prisma.playbackSession.updateMany({
where: { id: payload.sessionId, status: "ACTIVE", revoked: false },
data: {
lastActivity,
bytesSent: BigInt(Math.max(0, Math.floor(payload.bytesSent || 0))),
lastByteOffset: BigInt(Math.max(0, Math.floor(payload.lastByteOffset || 0))),
fileSizeBytes: BigInt(Math.max(0, Math.floor(payload.fileSizeBytes || 0))),
},
});
}
sendToNode(nodeId: string, type: ControlMessageType, payload: unknown): boolean {
const conn = this.connections.get(nodeId);
if (!conn || conn.socket.readyState !== 1) return false;

View file

@ -7,8 +7,8 @@ WORKDIR /src
RUN apk add --no-cache git ca-certificates
COPY node/media-node/ ./
RUN go mod tidy \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.2.7" -o /media-node-linux-amd64 ./cmd/media-node \
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=1.2.7" -o /media-node-linux-arm64 ./cmd/media-node
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.2.8" -o /media-node-linux-amd64 ./cmd/media-node \
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=1.2.8" -o /media-node-linux-arm64 ./cmd/media-node
FROM base AS builder
COPY package.json pnpm-workspace.yaml ./

View file

@ -131,6 +131,9 @@ func runService() {
streamer.SetSessionEndHandler(func(sessionID, reason string) {
ctrlClient.NotifySessionEnded(sessionID, reason)
})
streamer.SetSessionProgressHandler(func(sessionID string, bytesSent, lastByteOffset, fileSizeBytes int64) {
ctrlClient.NotifySessionProgress(sessionID, bytesSent, lastByteOffset, fileSizeBytes)
})
go sc.Start()
go ctrlClient.Run()

View file

@ -709,6 +709,17 @@ func (c *Client) NotifySessionEnded(sessionID, reason string) {
_ = c.write(msg)
}
func (c *Client) NotifySessionProgress(sessionID string, bytesSent, lastByteOffset, fileSizeBytes int64) {
msg := controlMessage("PLAYBACK_SESSION_PROGRESS", map[string]interface{}{
"sessionId": sessionID,
"lastActivity": time.Now().UTC().Format(time.RFC3339Nano),
"bytesSent": bytesSent,
"lastByteOffset": lastByteOffset,
"fileSizeBytes": fileSizeBytes,
})
_ = c.write(msg)
}
func (c *Client) write(msg interface{}) error {
return c.writeWithDeadline(msg, writeDeadlineShort)
}

View file

@ -30,6 +30,16 @@ type Server struct {
bytesPerSecond int64
ipConns map[string]int
onSessionEnd func(sessionID, reason string)
onSessionProgress func(sessionID string, bytesSent, lastByteOffset, fileSizeBytes int64)
progressMu sync.Mutex
progressState map[string]*progressTracker
}
type progressTracker struct {
bytesSent int64
lastOffset int64
fileSize int64
lastReport time.Time
}
func New(store *database.Store, listen string, maxPerIP int) *Server {
@ -43,6 +53,7 @@ func New(store *database.Store, listen string, maxPerIP int) *Server {
maxPerIP: maxPerIP,
lastWindow: time.Now(),
ipConns: make(map[string]int),
progressState: make(map[string]*progressTracker),
}
go s.cleanupLoop()
go s.bandwidthLoop()
@ -53,6 +64,10 @@ func (s *Server) SetSessionEndHandler(fn func(sessionID, reason string)) {
s.onSessionEnd = fn
}
func (s *Server) SetSessionProgressHandler(fn func(sessionID string, bytesSent, lastByteOffset, fileSizeBytes int64)) {
s.onSessionProgress = fn
}
func (s *Server) ListenAndServe() error {
mux := http.NewServeMux()
mux.HandleFunc("/play/", s.handlePlay)
@ -144,12 +159,17 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
if now.After(sess.AbsoluteExpiresAt) {
_ = s.store.DeleteSession(sess.SessionID)
s.clearProgress(sess.SessionID)
if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "absolute_expiry")
}
http.NotFound(w, r)
return
}
idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second)
if now.After(idleDeadline) {
_ = s.store.DeleteSession(sess.SessionID)
s.clearProgress(sess.SessionID)
if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "idle_timeout")
}
@ -180,6 +200,7 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
fileSize := stat.Size()
_ = s.store.TouchSession(sess.SessionID)
s.noteProgress(sess.SessionID, 0, fileSize, 0, true)
s.mu.Lock()
s.activeCount++
@ -203,7 +224,7 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
return
}
s.streamRange(w, f, 0, fileSize-1)
s.streamRange(w, f, 0, fileSize-1, sess.SessionID, fileSize)
return
}
@ -223,16 +244,18 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
return
}
s.streamRange(w, f, start, end)
s.noteProgress(sess.SessionID, start, fileSize, 0, true)
s.streamRange(w, f, start, end, sess.SessionID, fileSize)
}
func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64) {
func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64, sessionID string, fileSize 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)
pos := start
for remaining > 0 {
toRead := int64(len(buf))
@ -249,6 +272,8 @@ func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64
flusher.Flush()
}
remaining -= int64(n)
pos += int64(n)
s.noteProgress(sessionID, pos, fileSize, int64(n), false)
}
if err != nil {
return
@ -256,6 +281,44 @@ func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64
}
}
func (s *Server) noteProgress(sessionID string, offset, fileSize, deltaBytes int64, force bool) {
if sessionID == "" {
return
}
_ = s.store.TouchSession(sessionID)
s.progressMu.Lock()
st := s.progressState[sessionID]
if st == nil {
st = &progressTracker{}
s.progressState[sessionID] = st
}
if deltaBytes > 0 {
st.bytesSent += deltaBytes
} else if st.bytesSent == 0 && force {
// Mark as live even before first chunk (Range open).
st.bytesSent = 1
}
if offset >= st.lastOffset {
st.lastOffset = offset
}
if fileSize > 0 {
st.fileSize = fileSize
}
shouldReport := force || time.Since(st.lastReport) >= 3*time.Second
bytesSent := st.bytesSent
lastOffset := st.lastOffset
size := st.fileSize
if shouldReport {
st.lastReport = time.Now()
}
s.progressMu.Unlock()
if shouldReport && s.onSessionProgress != nil {
s.onSessionProgress(sessionID, bytesSent, lastOffset, size)
}
}
func (s *Server) ActiveStreams() int {
s.mu.RLock()
defer s.mu.RUnlock()
@ -275,6 +338,7 @@ func (s *Server) CreateSession(sess database.PlaybackSession) error {
}
func (s *Server) RevokeSession(sessionID string) error {
s.clearProgress(sessionID)
return s.store.DeleteSession(sessionID)
}
@ -314,11 +378,16 @@ func (s *Server) cleanupLoop() {
for _, sess := range sessions {
if now.After(sess.AbsoluteExpiresAt) {
_ = s.store.DeleteSession(sess.SessionID)
s.clearProgress(sess.SessionID)
if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "absolute_expiry")
}
continue
}
idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second)
if now.After(idleDeadline) {
_ = s.store.DeleteSession(sess.SessionID)
s.clearProgress(sess.SessionID)
if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "idle_timeout")
}
@ -327,6 +396,12 @@ func (s *Server) cleanupLoop() {
}
}
func (s *Server) clearProgress(sessionID string) {
s.progressMu.Lock()
delete(s.progressState, sessionID)
s.progressMu.Unlock()
}
func parseRange(rangeHeader string, size int64) (int64, int64, error) {
if !strings.HasPrefix(rangeHeader, "bytes=") {
return 0, 0, fmt.Errorf("invalid range")

View file

@ -24,6 +24,7 @@ export type ControlMessageType =
| "CONFIG_UPDATE"
| "RESTART"
| "PLAYBACK_SESSION_ENDED"
| "PLAYBACK_SESSION_PROGRESS"
| "ERROR";
export interface ControlMessage<T = unknown> {
@ -114,6 +115,14 @@ export interface PlaybackSessionEndedPayload {
reason: string;
}
export interface PlaybackSessionProgressPayload {
sessionId: string;
lastActivity: string;
bytesSent: number;
lastByteOffset: number;
fileSizeBytes: number;
}
export interface CreatePlaybackSessionAckPayload {
sessionId: string;
ok: boolean;