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 /> <Nav />
<div className="container"> <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>} {error && <div className="error">{error}</div>}
{data && ( {data && (
<> <>
@ -165,21 +170,21 @@ export default function DashboardPage() {
<div className="card"> <div className="card">
<h2>Films (uniek)</h2> <h2>Films (uniek)</h2>
<div className="value">{data.availableMovies ?? data.movies ?? 0}</div> <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 {data.movies ?? 0} rijen totaal
</p> </p>
</div> </div>
<div className="card"> <div className="card">
<h2>Series (uniek)</h2> <h2>Series (uniek)</h2>
<div className="value">{data.availableSeries ?? data.series ?? 0}</div> <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 {data.series ?? 0} rijen totaal
</p> </p>
</div> </div>
<div className="card"> <div className="card">
<h2>Bestanden</h2> <h2>Bestanden</h2>
<div className="value">{data.availableFiles ?? 0}</div> <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 {data.scannedFiles ?? 0} gescand op nodes
</p> </p>
</div> </div>
@ -193,62 +198,67 @@ export default function DashboardPage() {
</div> </div>
<div className="card"> <div className="card">
<h2>Total Storage</h2> <h2>Total Storage</h2>
<div className="value" style={{ fontSize: "1.5rem" }}> <div className="value" style={{ fontSize: "1.35rem" }}>
{formatBytes(data.totalStorage)} {formatBytes(data.totalStorage)}
</div> </div>
</div> </div>
<div className="card"> <div className="card">
<h2>Free Storage</h2> <h2>Free Storage</h2>
<div className="value" style={{ fontSize: "1.5rem" }}> <div className="value" style={{ fontSize: "1.35rem" }}>
{formatBytes(data.freeStorage)} {formatBytes(data.freeStorage)}
</div> </div>
</div> </div>
</div> </div>
<div className="card" style={{ marginTop: "1.5rem" }}> <div className="card" style={{ marginTop: "0.5rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none" }}> <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 Herstel na pad-wijziging
</h2> </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 Gebruik dit als Cinemeta geen streams meer toont na een Unraid pad-wijziging
(oude /media/movies-bestanden + dubbele titels zonder IMDb). (oude /media/movies-bestanden + dubbele titels zonder IMDb).
</p> </p>
<button onClick={repairLibrary}>Bibliotheek herstellen</button> <button onClick={repairLibrary}>Bibliotheek herstellen</button>
{repairMsg && ( {repairMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}> <p className="muted" style={{ marginTop: "0.75rem" }}>
{repairMsg} {repairMsg}
</p> </p>
)} )}
<h2 <h2
style={{ style={{
fontSize: "1rem", fontSize: "1.05rem",
color: "#fafafa", color: "var(--text-bright)",
textTransform: "none", textTransform: "none",
letterSpacing: 0,
fontFamily: "var(--font-ui)",
marginTop: "1.5rem", marginTop: "1.5rem",
}} }}
> >
Bibliotheek opruimen Bibliotheek opruimen
</h2> </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 Dubbele film-/serie-rijen samenvoegen. Daarna in Stremio de addon verversen
(of herinstalleren) zodat de volledige catalogus met paginatie geladen wordt. (of herinstalleren) zodat de volledige catalogus met paginatie geladen wordt.
</p> </p>
<button onClick={dedupe}>Duplicaten samenvoegen</button> <button onClick={dedupe}>Duplicaten samenvoegen</button>
{dedupeMsg && ( {dedupeMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}> <p className="muted" style={{ marginTop: "0.75rem" }}>
{dedupeMsg} {dedupeMsg}
</p> </p>
)} )}
<h2 <h2
style={{ style={{
fontSize: "1rem", fontSize: "1.05rem",
color: "#fafafa", color: "var(--text-bright)",
textTransform: "none", textTransform: "none",
letterSpacing: 0,
fontFamily: "var(--font-ui)",
marginTop: "1.5rem", marginTop: "1.5rem",
}} }}
> >
Cinemeta-koppeling (IMDb) Cinemeta-koppeling (IMDb)
</h2> </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:{" "} Films met IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
{data.moviesMissingImdb ?? "?"} {data.moviesMissingImdb ?? "?"}
<br /> <br />
@ -258,14 +268,14 @@ export default function DashboardPage() {
</p> </p>
<button onClick={backfillImdb}>IMDb-IDs backfillen</button> <button onClick={backfillImdb}>IMDb-IDs backfillen</button>
{imdbMsg && ( {imdbMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}> <p className="muted" style={{ marginTop: "0.75rem" }}>
{imdbMsg} {imdbMsg}
</p> </p>
)} )}
</div> </div>
</> </>
)} )}
{!data && !error && <p style={{ color: "#71717a" }}>Laden...</p>} {!data && !error && <p className="muted">Laden...</p>}
</div> </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; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
html {
color-scheme: dark;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: var(--font-ui);
background: #0f1117; background: var(--bg-void);
color: #e4e4e7; color: var(--text);
min-height: 100vh; 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 { a {
color: #818cf8; color: var(--accent);
text-decoration: none; text-decoration: none;
transition: color 0.15s ease;
} }
a:hover { a:hover {
text-decoration: underline; color: var(--accent-2);
} }
.container { .container {
max-width: 1200px; max-width: 1280px;
margin: 0 auto; 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 { .card {
background: #1a1d27; background:
border: 1px solid #2a2d3a; linear-gradient(145deg, rgba(34, 211, 238, 0.04), transparent 40%),
border-radius: 12px; var(--bg-panel);
padding: 1.5rem; border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.35rem;
margin-bottom: 1rem; 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 { .card h2 {
font-size: 0.875rem; font-family: var(--font-mono);
color: #71717a; font-size: 0.68rem;
font-weight: 500;
color: var(--text-dim);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.14em;
margin-bottom: 0.5rem; margin-bottom: 0.55rem;
} }
.card .value { .card .value {
font-size: 2rem; font-family: var(--font-mono);
font-size: 1.85rem;
font-weight: 700; font-weight: 700;
color: #fafafa; color: var(--text-bright);
letter-spacing: -0.03em;
line-height: 1.1;
} }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem; gap: 0.9rem;
margin-bottom: 2rem; margin-bottom: 1.5rem;
} }
nav { .grid .card .value {
background: #1a1d27; color: var(--accent);
border-bottom: 1px solid #2a2d3a; text-shadow: 0 0 24px rgba(34, 211, 238, 0.25);
padding: 1rem 2rem; }
/* ——— Nav ——— */
.topnav {
position: sticky;
top: 0;
z-index: 50;
display: flex; display: flex;
gap: 2rem;
align-items: center; 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-weight: 700;
font-size: 1.25rem; font-size: 0.95rem;
color: #818cf8; letter-spacing: 0.04em;
color: var(--text-bright);
text-decoration: none;
} }
nav a { .topnav .brand-mark {
color: #a1a1aa; width: 28px;
font-size: 0.875rem; 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, .topnav .brand-text {
nav a.active { display: flex;
color: #fafafa; 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 { table {
@ -85,60 +319,154 @@ table {
border-collapse: collapse; border-collapse: collapse;
} }
th, td { th,
td {
text-align: left; text-align: left;
padding: 0.75rem 1rem; padding: 0.8rem 0.9rem;
border-bottom: 1px solid #2a2d3a; border-bottom: 1px solid var(--border-dim);
vertical-align: middle;
} }
th { th {
color: #71717a; font-family: var(--font-mono);
font-size: 0.75rem; color: var(--text-dim);
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.12em;
background: rgba(0, 0, 0, 0.2);
} }
.status { tbody tr {
display: inline-block; transition: background 0.12s ease;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
} }
.status.online { background: #166534; color: #bbf7d0; } tbody tr:hover {
.status.offline { background: #374151; color: #d1d5db; } background: rgba(34, 211, 238, 0.04);
.status.revoked { background: #991b1b; color: #fecaca; } }
button, .btn { td {
background: #4f46e5;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
} }
button:hover, .btn:hover { /* ——— Status ——— */
background: #4338ca; .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 { 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 { 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 { button.ghost {
background: #0f1117; background: transparent;
border: 1px solid #2a2d3a; border-color: var(--border-dim);
color: #fafafa; color: var(--text-muted);
padding: 0.5rem 0.75rem; }
border-radius: 8px;
/* ——— 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%; width: 100%;
margin-bottom: 0.75rem; 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 { .form-group {
@ -147,44 +475,174 @@ input {
.form-group label { .form-group label {
display: block; display: block;
font-size: 0.875rem; font-family: var(--font-mono);
color: #a1a1aa; font-size: 0.68rem;
margin-bottom: 0.25rem; font-weight: 500;
color: var(--text-muted);
margin-bottom: 0.35rem;
letter-spacing: 0.08em;
text-transform: uppercase;
} }
/* ——— Login ——— */
.login-container { .login-container {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; min-height: 100vh;
padding: 1.5rem;
} }
.login-box { .login-box {
background: #1a1d27; background:
border: 1px solid #2a2d3a; linear-gradient(160deg, rgba(34, 211, 238, 0.07), transparent 45%),
border-radius: 16px; var(--bg-panel);
padding: 2rem; border: 1px solid var(--border);
border-radius: 14px;
padding: 2rem 2rem 1.75rem;
width: 100%; 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 { .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; margin-bottom: 1.5rem;
font-size: 1.5rem;
} }
.error { .error {
color: #f87171; color: #fda4af;
font-size: 0.875rem; font-size: 0.85rem;
margin-bottom: 1rem; 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 { .token-display {
background: #0f1117; background: var(--bg-deep);
border: 1px solid #2a2d3a; border: 1px solid var(--border);
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: 8px;
font-family: monospace; font-family: var(--font-mono);
font-size: 0.78rem;
word-break: break-all; word-break: break-all;
margin: 1rem 0; 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 type { Metadata } from "next";
import { JetBrains_Mono, Outfit } from "next/font/google";
import "./globals.css"; 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 = { export const metadata: Metadata = {
title: "Aanmelden", title: "Media Cluster — Ops",
description: "", description: "Private media cluster admin console",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="nl"> <html lang="nl" className={`${outfit.variable} ${jetbrains.variable}`}>
<body>{children}</body> <body>{children}</body>
</html> </html>
); );

View file

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

View file

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

View file

@ -40,7 +40,6 @@ export default function LoginPage() {
} }
throw new Error(message); throw new Error(message);
} }
// Full navigation so the session cookie is definitely included
window.location.assign("/dashboard"); window.location.assign("/dashboard");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Inloggen mislukt"); setError(err instanceof Error ? err.message : "Inloggen mislukt");
@ -51,7 +50,13 @@ export default function LoginPage() {
return ( return (
<div className="login-container"> <div className="login-container">
<div className="login-box"> <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>} {error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">

View file

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

View file

@ -281,7 +281,12 @@ export default function SettingsPage() {
<> <>
<Nav /> <Nav />
<div className="container"> <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" }}> <div className="card" style={{ marginBottom: "2rem" }}>
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}> <h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}>

View file

@ -7,11 +7,40 @@ interface Stream {
id: string; id: string;
node: string; node: string;
status: string; status: string;
viewer: string;
createdAt: string; createdAt: string;
lastActivity: string; lastActivity: string;
absoluteExpiresAt: string; absoluteExpiresAt: string;
title: string; title: string;
resolution: string | null; 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() { export default function StreamsPage() {
@ -26,7 +55,7 @@ export default function StreamsPage() {
useEffect(() => { useEffect(() => {
load(); load();
const t = setInterval(load, 10000); const t = setInterval(load, 5000);
return () => clearInterval(t); return () => clearInterval(t);
}, []); }, []);
@ -43,36 +72,82 @@ export default function StreamsPage() {
<> <>
<Nav /> <Nav />
<div className="container"> <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"> <div className="card">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Kijker</th>
<th>Titel</th> <th>Titel</th>
<th>Voortgang</th>
<th>Node</th> <th>Node</th>
<th>Kwaliteit</th> <th>Kwaliteit</th>
<th>Laatste activiteit</th> <th>Activiteit</th>
<th>Acties</th> <th>Acties</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{streams.map((s) => ( {streams.map((s) => {
<tr key={s.id}> const pct = s.progressPercent ?? 0;
<td>{s.title}</td> return (
<td>{s.node}</td> <tr key={s.id}>
<td>{s.resolution ?? "-"}</td> <td>
<td>{new Date(s.lastActivity).toLocaleString()}</td> <strong style={{ color: "var(--accent)" }}>{s.viewer}</strong>
<td> </td>
<button className="danger" onClick={() => revoke(s.id)}> <td>
Intrekken <div>{s.title}</div>
</button> <div className="muted" style={{ fontSize: "0.75rem", marginTop: "0.2rem" }}>
</td> {formatBytes(s.bytesSent)} gestreamd
</tr> </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 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 && ( {streams.length === 0 && (
<tr> <tr>
<td colSpan={5} style={{ textAlign: "center", color: "#71717a" }}> <td colSpan={7} style={{ textAlign: "center" }} className="muted">
Geen actieve streams Niemand aan het kijken
</td> </td>
</tr> </tr>
)} )}

View file

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

@ -69,8 +69,9 @@ model AddonToken {
revoked Boolean @default(false) revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
iptvLine IptvLine? iptvLine IptvLine?
playbackSessions PlaybackSession[]
@@map("addon_tokens") @@map("addon_tokens")
} }
@ -337,20 +338,27 @@ model LibraryMatch {
} }
model PlaybackSession { model PlaybackSession {
id String @id @default(uuid()) id String @id @default(uuid())
tokenHash String @unique @map("token_hash") tokenHash String @unique @map("token_hash")
userId String? @map("user_id") userId String? @map("user_id")
nodeId String @map("node_id") addonTokenId String? @map("addon_token_id")
mediaFileId String @map("media_file_id") nodeId String @map("node_id")
status PlaybackSessionStatus @default(ACTIVE) mediaFileId String @map("media_file_id")
idleTimeoutSeconds Int @map("idle_timeout_seconds") status PlaybackSessionStatus @default(ACTIVE)
absoluteExpiresAt DateTime @map("absolute_expires_at") idleTimeoutSeconds Int @map("idle_timeout_seconds")
lastActivity DateTime @default(now()) @map("last_activity") absoluteExpiresAt DateTime @map("absolute_expires_at")
revoked Boolean @default(false) lastActivity DateTime @default(now()) @map("last_activity")
createdAt DateTime @default(now()) @map("created_at") 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]) node Node @relation(fields: [nodeId], references: [id])
mediaFile MediaFile @relation(fields: [mediaFileId], 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") @@map("playback_sessions")
} }

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import type {
HelloPayload, HelloPayload,
LibraryEventPayload, LibraryEventPayload,
PlaybackSessionEndedPayload, PlaybackSessionEndedPayload,
PlaybackSessionProgressPayload,
CreatePlaybackSessionAckPayload, CreatePlaybackSessionAckPayload,
} from "@media-cluster/protocol"; } from "@media-cluster/protocol";
@ -128,6 +129,9 @@ class NodeConnectionManager {
case "PLAYBACK_SESSION_ENDED": case "PLAYBACK_SESSION_ENDED":
await this.handlePlaybackEnded(message.payload as PlaybackSessionEndedPayload); await this.handlePlaybackEnded(message.payload as PlaybackSessionEndedPayload);
break; break;
case "PLAYBACK_SESSION_PROGRESS":
await this.handlePlaybackProgress(message.payload as PlaybackSessionProgressPayload);
break;
case "CREATE_PLAYBACK_SESSION_ACK": case "CREATE_PLAYBACK_SESSION_ACK":
this.handlePlaybackSessionAck(message.payload as CreatePlaybackSessionAckPayload); this.handlePlaybackSessionAck(message.payload as CreatePlaybackSessionAckPayload);
break; 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 { sendToNode(nodeId: string, type: ControlMessageType, payload: unknown): boolean {
const conn = this.connections.get(nodeId); const conn = this.connections.get(nodeId);
if (!conn || conn.socket.readyState !== 1) return false; if (!conn || conn.socket.readyState !== 1) return false;

View file

@ -7,8 +7,8 @@ WORKDIR /src
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
COPY node/media-node/ ./ COPY node/media-node/ ./
RUN go mod tidy \ 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=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.7" -o /media-node-linux-arm64 ./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 FROM base AS builder
COPY package.json pnpm-workspace.yaml ./ COPY package.json pnpm-workspace.yaml ./

View file

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

View file

@ -709,6 +709,17 @@ func (c *Client) NotifySessionEnded(sessionID, reason string) {
_ = c.write(msg) _ = 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 { func (c *Client) write(msg interface{}) error {
return c.writeWithDeadline(msg, writeDeadlineShort) return c.writeWithDeadline(msg, writeDeadlineShort)
} }

View file

@ -20,16 +20,26 @@ import (
) )
type Server struct { type Server struct {
store *database.Store store *database.Store
listen string listen string
maxPerIP int maxPerIP int
mu sync.RWMutex mu sync.RWMutex
activeCount int activeCount int
bytesWindow int64 bytesWindow int64
lastWindow time.Time lastWindow time.Time
bytesPerSecond int64 bytesPerSecond int64
ipConns map[string]int ipConns map[string]int
onSessionEnd func(sessionID, reason string) 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 { func New(store *database.Store, listen string, maxPerIP int) *Server {
@ -38,11 +48,12 @@ func New(store *database.Store, listen string, maxPerIP int) *Server {
maxPerIP = 64 maxPerIP = 64
} }
s := &Server{ s := &Server{
store: store, store: store,
listen: listen, listen: listen,
maxPerIP: maxPerIP, maxPerIP: maxPerIP,
lastWindow: time.Now(), lastWindow: time.Now(),
ipConns: make(map[string]int), ipConns: make(map[string]int),
progressState: make(map[string]*progressTracker),
} }
go s.cleanupLoop() go s.cleanupLoop()
go s.bandwidthLoop() go s.bandwidthLoop()
@ -53,6 +64,10 @@ func (s *Server) SetSessionEndHandler(fn func(sessionID, reason string)) {
s.onSessionEnd = fn s.onSessionEnd = fn
} }
func (s *Server) SetSessionProgressHandler(fn func(sessionID string, bytesSent, lastByteOffset, fileSizeBytes int64)) {
s.onSessionProgress = fn
}
func (s *Server) ListenAndServe() error { func (s *Server) ListenAndServe() error {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/play/", s.handlePlay) mux.HandleFunc("/play/", s.handlePlay)
@ -144,12 +159,17 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC() now := time.Now().UTC()
if now.After(sess.AbsoluteExpiresAt) { if now.After(sess.AbsoluteExpiresAt) {
_ = s.store.DeleteSession(sess.SessionID) _ = s.store.DeleteSession(sess.SessionID)
s.clearProgress(sess.SessionID)
if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "absolute_expiry")
}
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second) idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second)
if now.After(idleDeadline) { if now.After(idleDeadline) {
_ = s.store.DeleteSession(sess.SessionID) _ = s.store.DeleteSession(sess.SessionID)
s.clearProgress(sess.SessionID)
if s.onSessionEnd != nil { if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "idle_timeout") s.onSessionEnd(sess.SessionID, "idle_timeout")
} }
@ -180,6 +200,7 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
fileSize := stat.Size() fileSize := stat.Size()
_ = s.store.TouchSession(sess.SessionID) _ = s.store.TouchSession(sess.SessionID)
s.noteProgress(sess.SessionID, 0, fileSize, 0, true)
s.mu.Lock() s.mu.Lock()
s.activeCount++ s.activeCount++
@ -203,7 +224,7 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
s.streamRange(w, f, 0, fileSize-1) s.streamRange(w, f, 0, fileSize-1, sess.SessionID, fileSize)
return return
} }
@ -223,16 +244,18 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
return 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 { if _, err := f.Seek(start, io.SeekStart); err != nil {
return return
} }
remaining := end - start + 1 remaining := end - start + 1
buf := make([]byte, 256*1024) buf := make([]byte, 256*1024)
flusher, canFlush := w.(http.Flusher) flusher, canFlush := w.(http.Flusher)
pos := start
for remaining > 0 { for remaining > 0 {
toRead := int64(len(buf)) toRead := int64(len(buf))
@ -249,6 +272,8 @@ func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64
flusher.Flush() flusher.Flush()
} }
remaining -= int64(n) remaining -= int64(n)
pos += int64(n)
s.noteProgress(sessionID, pos, fileSize, int64(n), false)
} }
if err != nil { if err != nil {
return 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 { func (s *Server) ActiveStreams() int {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@ -275,6 +338,7 @@ func (s *Server) CreateSession(sess database.PlaybackSession) error {
} }
func (s *Server) RevokeSession(sessionID string) error { func (s *Server) RevokeSession(sessionID string) error {
s.clearProgress(sessionID)
return s.store.DeleteSession(sessionID) return s.store.DeleteSession(sessionID)
} }
@ -314,11 +378,16 @@ func (s *Server) cleanupLoop() {
for _, sess := range sessions { for _, sess := range sessions {
if now.After(sess.AbsoluteExpiresAt) { if now.After(sess.AbsoluteExpiresAt) {
_ = s.store.DeleteSession(sess.SessionID) _ = s.store.DeleteSession(sess.SessionID)
s.clearProgress(sess.SessionID)
if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "absolute_expiry")
}
continue continue
} }
idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second) idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second)
if now.After(idleDeadline) { if now.After(idleDeadline) {
_ = s.store.DeleteSession(sess.SessionID) _ = s.store.DeleteSession(sess.SessionID)
s.clearProgress(sess.SessionID)
if s.onSessionEnd != nil { if s.onSessionEnd != nil {
s.onSessionEnd(sess.SessionID, "idle_timeout") 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) { func parseRange(rangeHeader string, size int64) (int64, int64, error) {
if !strings.HasPrefix(rangeHeader, "bytes=") { if !strings.HasPrefix(rangeHeader, "bytes=") {
return 0, 0, fmt.Errorf("invalid range") return 0, 0, fmt.Errorf("invalid range")

View file

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