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:
parent
54d70735d8
commit
0f7b566626
21 changed files with 973 additions and 229 deletions
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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" }}>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
const pct = s.progressPercent ?? 0;
|
||||||
|
return (
|
||||||
<tr key={s.id}>
|
<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.node}</td>
|
||||||
<td>{s.resolution ?? "-"}</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>
|
<td>
|
||||||
<button className="danger" onClick={() => revoke(s.id)}>
|
<button className="danger" onClick={() => revoke(s.id)}>
|
||||||
Intrekken
|
Intrekken
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,22 @@ export function Nav() {
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav>
|
<nav className="topnav">
|
||||||
<span className="brand">Beheer</span>
|
<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) => (
|
{links.map((l) => (
|
||||||
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
|
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<button onClick={logout} style={{ marginLeft: "auto", background: "transparent", border: "1px solid #2a2d3a" }}>
|
</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]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -71,6 +71,7 @@ model AddonToken {
|
||||||
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
@ -340,17 +341,24 @@ 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")
|
||||||
|
addonTokenId String? @map("addon_token_id")
|
||||||
nodeId String @map("node_id")
|
nodeId String @map("node_id")
|
||||||
mediaFileId String @map("media_file_id")
|
mediaFileId String @map("media_file_id")
|
||||||
status PlaybackSessionStatus @default(ACTIVE)
|
status PlaybackSessionStatus @default(ACTIVE)
|
||||||
idleTimeoutSeconds Int @map("idle_timeout_seconds")
|
idleTimeoutSeconds Int @map("idle_timeout_seconds")
|
||||||
absoluteExpiresAt DateTime @map("absolute_expires_at")
|
absoluteExpiresAt DateTime @map("absolute_expires_at")
|
||||||
lastActivity DateTime @default(now()) @map("last_activity")
|
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)
|
revoked Boolean @default(false)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
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,
|
resolution: s.mediaFile.resolution,
|
||||||
})),
|
bytesSent: s.bytesSent.toString(),
|
||||||
|
lastByteOffset: s.lastByteOffset.toString(),
|
||||||
|
fileSizeBytes: (s.fileSizeBytes > 0n ? s.fileSizeBytes : s.mediaFile.sizeBytes).toString(),
|
||||||
|
progressPercent,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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?.(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 ./
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,16 @@ type Server struct {
|
||||||
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 {
|
||||||
|
|
@ -43,6 +53,7 @@ func New(store *database.Store, listen string, maxPerIP int) *Server {
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue