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 />
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Dashboard</h1>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">Cluster telemetry</div>
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{data && (
|
||||
<>
|
||||
|
|
@ -165,21 +170,21 @@ export default function DashboardPage() {
|
|||
<div className="card">
|
||||
<h2>Films (uniek)</h2>
|
||||
<div className="value">{data.availableMovies ?? data.movies ?? 0}</div>
|
||||
<p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
|
||||
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.75rem" }}>
|
||||
{data.movies ?? 0} rijen totaal
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Series (uniek)</h2>
|
||||
<div className="value">{data.availableSeries ?? data.series ?? 0}</div>
|
||||
<p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
|
||||
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.75rem" }}>
|
||||
{data.series ?? 0} rijen totaal
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Bestanden</h2>
|
||||
<div className="value">{data.availableFiles ?? 0}</div>
|
||||
<p style={{ color: "#71717a", fontSize: "0.75rem", marginTop: "0.5rem" }}>
|
||||
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.75rem" }}>
|
||||
{data.scannedFiles ?? 0} gescand op nodes
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -193,62 +198,67 @@ export default function DashboardPage() {
|
|||
</div>
|
||||
<div className="card">
|
||||
<h2>Total Storage</h2>
|
||||
<div className="value" style={{ fontSize: "1.5rem" }}>
|
||||
<div className="value" style={{ fontSize: "1.35rem" }}>
|
||||
{formatBytes(data.totalStorage)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Free Storage</h2>
|
||||
<div className="value" style={{ fontSize: "1.5rem" }}>
|
||||
<div className="value" style={{ fontSize: "1.35rem" }}>
|
||||
{formatBytes(data.freeStorage)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ marginTop: "1.5rem" }}>
|
||||
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none" }}>
|
||||
<div className="card" style={{ marginTop: "0.5rem" }}>
|
||||
<div className="page-kicker">Maintenance</div>
|
||||
<h2 style={{ fontSize: "1.05rem", color: "var(--text-bright)", textTransform: "none", letterSpacing: 0, fontFamily: "var(--font-ui)" }}>
|
||||
Herstel na pad-wijziging
|
||||
</h2>
|
||||
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
|
||||
<p className="muted" style={{ margin: "0.75rem 0" }}>
|
||||
Gebruik dit als Cinemeta geen streams meer toont na een Unraid pad-wijziging
|
||||
(oude /media/movies-bestanden + dubbele titels zonder IMDb).
|
||||
</p>
|
||||
<button onClick={repairLibrary}>Bibliotheek herstellen</button>
|
||||
{repairMsg && (
|
||||
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}>
|
||||
<p className="muted" style={{ marginTop: "0.75rem" }}>
|
||||
{repairMsg}
|
||||
</p>
|
||||
)}
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: "#fafafa",
|
||||
fontSize: "1.05rem",
|
||||
color: "var(--text-bright)",
|
||||
textTransform: "none",
|
||||
letterSpacing: 0,
|
||||
fontFamily: "var(--font-ui)",
|
||||
marginTop: "1.5rem",
|
||||
}}
|
||||
>
|
||||
Bibliotheek opruimen
|
||||
</h2>
|
||||
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
|
||||
<p className="muted" style={{ margin: "0.75rem 0" }}>
|
||||
Dubbele film-/serie-rijen samenvoegen. Daarna in Stremio de addon verversen
|
||||
(of herinstalleren) zodat de volledige catalogus met paginatie geladen wordt.
|
||||
</p>
|
||||
<button onClick={dedupe}>Duplicaten samenvoegen</button>
|
||||
{dedupeMsg && (
|
||||
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}>
|
||||
<p className="muted" style={{ marginTop: "0.75rem" }}>
|
||||
{dedupeMsg}
|
||||
</p>
|
||||
)}
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1rem",
|
||||
color: "#fafafa",
|
||||
fontSize: "1.05rem",
|
||||
color: "var(--text-bright)",
|
||||
textTransform: "none",
|
||||
letterSpacing: 0,
|
||||
fontFamily: "var(--font-ui)",
|
||||
marginTop: "1.5rem",
|
||||
}}
|
||||
>
|
||||
Cinemeta-koppeling (IMDb)
|
||||
</h2>
|
||||
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
|
||||
<p className="muted" style={{ margin: "0.75rem 0" }}>
|
||||
Films met IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
|
||||
{data.moviesMissingImdb ?? "?"}
|
||||
<br />
|
||||
|
|
@ -258,14 +268,14 @@ export default function DashboardPage() {
|
|||
</p>
|
||||
<button onClick={backfillImdb}>IMDb-IDs backfillen</button>
|
||||
{imdbMsg && (
|
||||
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}>
|
||||
<p className="muted" style={{ marginTop: "0.75rem" }}>
|
||||
{imdbMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!data && !error && <p style={{ color: "#71717a" }}>Laden...</p>}
|
||||
{!data && !error && <p className="muted">Laden...</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,83 +1,317 @@
|
|||
:root {
|
||||
--bg-void: #04060a;
|
||||
--bg-deep: #070b12;
|
||||
--bg-panel: #0b1019;
|
||||
--bg-panel-2: #0f1622;
|
||||
--bg-elevated: #121a28;
|
||||
--border: rgba(56, 189, 248, 0.14);
|
||||
--border-strong: rgba(56, 189, 248, 0.32);
|
||||
--border-dim: rgba(148, 163, 184, 0.12);
|
||||
--text: #e2e8f0;
|
||||
--text-bright: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dim: #64748b;
|
||||
--accent: #22d3ee;
|
||||
--accent-2: #38bdf8;
|
||||
--accent-dim: rgba(34, 211, 238, 0.12);
|
||||
--accent-hot: #f59e0b;
|
||||
--ok: #34d399;
|
||||
--ok-bg: rgba(52, 211, 153, 0.12);
|
||||
--warn: #fbbf24;
|
||||
--danger: #f43f5e;
|
||||
--danger-bg: rgba(244, 63, 94, 0.14);
|
||||
--font-ui: var(--font-outfit), system-ui, sans-serif;
|
||||
--font-mono: var(--font-jetbrains), ui-monospace, monospace;
|
||||
--radius: 10px;
|
||||
--shadow-panel: 0 0 0 1px var(--border), 0 18px 50px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f1117;
|
||||
color: #e4e4e7;
|
||||
font-family: var(--font-ui);
|
||||
background: var(--bg-void);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Atmosphere: grid + radial vignette */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(34, 211, 238, 0.09), transparent 55%),
|
||||
radial-gradient(ellipse 40% 30% at 90% 80%, rgba(245, 158, 11, 0.04), transparent 50%),
|
||||
linear-gradient(rgba(34, 211, 238, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(34, 211, 238, 0.03) 1px, transparent 1px);
|
||||
background-size: auto, auto, 48px 48px, 48px 48px;
|
||||
mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, black, transparent 85%);
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.035;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.35) 2px,
|
||||
rgba(0, 0, 0, 0.35) 4px
|
||||
);
|
||||
}
|
||||
|
||||
body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #818cf8;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding: 1.75rem 1.5rem 3rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.page-header h1,
|
||||
.container > h1 {
|
||||
font-size: 1.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-bright);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.container > h1 {
|
||||
margin-bottom: 1.75rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.page-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1a1d27;
|
||||
border: 1px solid #2a2d3a;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(34, 211, 238, 0.04), transparent 40%),
|
||||
var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem 1.35rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: var(--shadow-panel);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(34, 211, 238, 0.45), transparent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 0.875rem;
|
||||
color: #71717a;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.14em;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.card .value {
|
||||
font-size: 2rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.85rem;
|
||||
font-weight: 700;
|
||||
color: #fafafa;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
gap: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: #1a1d27;
|
||||
border-bottom: 1px solid #2a2d3a;
|
||||
padding: 1rem 2rem;
|
||||
.grid .card .value {
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 24px rgba(34, 211, 238, 0.25);
|
||||
}
|
||||
|
||||
/* ——— Nav ——— */
|
||||
.topnav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.7rem 1.25rem;
|
||||
background: rgba(7, 11, 18, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
nav .brand {
|
||||
.topnav::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.topnav .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin-right: 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: #818cf8;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-bright);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #a1a1aa;
|
||||
font-size: 0.875rem;
|
||||
.topnav .brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
box-shadow: inset 0 0 12px rgba(34, 211, 238, 0.15);
|
||||
}
|
||||
|
||||
nav a:hover,
|
||||
nav a.active {
|
||||
color: #fafafa;
|
||||
.topnav .brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.topnav .brand-text small {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.58rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topnav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topnav-links a {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.topnav-links a:hover {
|
||||
color: var(--text-bright);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topnav-links a.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--border);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topnav .btn-logout {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim);
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topnav .btn-logout:hover {
|
||||
color: var(--text-bright);
|
||||
border-color: var(--border-strong);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* ——— Tables ——— */
|
||||
.table-wrap,
|
||||
.card > table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
|
|
@ -85,60 +319,154 @@ table {
|
|||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #2a2d3a;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #71717a;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
tbody tr {
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.status.online { background: #166534; color: #bbf7d0; }
|
||||
.status.offline { background: #374151; color: #d1d5db; }
|
||||
.status.revoked { background: #991b1b; color: #fecaca; }
|
||||
tbody tr:hover {
|
||||
background: rgba(34, 211, 238, 0.04);
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
td {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
button:hover, .btn:hover {
|
||||
background: #4338ca;
|
||||
/* ——— Status ——— */
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
|
||||
.status.online {
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok);
|
||||
border-color: rgba(52, 211, 153, 0.25);
|
||||
}
|
||||
|
||||
.status.offline {
|
||||
background: rgba(100, 116, 139, 0.15);
|
||||
color: var(--text-muted);
|
||||
border-color: rgba(100, 116, 139, 0.25);
|
||||
}
|
||||
|
||||
.status.revoked {
|
||||
background: var(--danger-bg);
|
||||
color: #fda4af;
|
||||
border-color: rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
/* ——— Buttons ——— */
|
||||
button,
|
||||
.btn {
|
||||
background: linear-gradient(180deg, rgba(34, 211, 238, 0.22), rgba(34, 211, 238, 0.08));
|
||||
color: var(--text-bright);
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
transition: border-color 0.15s, background 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.btn:hover {
|
||||
background: linear-gradient(180deg, rgba(34, 211, 238, 0.32), rgba(34, 211, 238, 0.12));
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button:active,
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #dc2626;
|
||||
background: linear-gradient(180deg, rgba(244, 63, 94, 0.28), rgba(244, 63, 94, 0.1));
|
||||
border-color: rgba(244, 63, 94, 0.45);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #b91c1c;
|
||||
background: linear-gradient(180deg, rgba(244, 63, 94, 0.4), rgba(244, 63, 94, 0.16));
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
input {
|
||||
background: #0f1117;
|
||||
border: 1px solid #2a2d3a;
|
||||
color: #fafafa;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border-dim);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ——— Forms ——— */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-bright);
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 7px;
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
|
|
@ -147,44 +475,174 @@ input {
|
|||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: #a1a1aa;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ——— Login ——— */
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: #1a1d27;
|
||||
border: 1px solid #2a2d3a;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(34, 211, 238, 0.07), transparent 45%),
|
||||
var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 2rem 2rem 1.75rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-width: 420px;
|
||||
box-shadow: var(--shadow-panel), 0 0 80px rgba(34, 211, 238, 0.06);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-box::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: 14px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(34, 211, 238, 0.45), transparent 40%, rgba(245, 158, 11, 0.2));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-box .login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.login-box .login-brand .mark {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
margin-bottom: 0.15rem;
|
||||
font-size: 1.45rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.login-box .login-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f87171;
|
||||
font-size: 0.875rem;
|
||||
color: #fda4af;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.token-display {
|
||||
background: #0f1117;
|
||||
border: 1px solid #2a2d3a;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
word-break: break-all;
|
||||
margin: 1rem 0;
|
||||
color: var(--accent);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--accent-2);
|
||||
background: var(--accent-dim);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre.token-display {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Soften common inline hard-coded greys from older page markup */
|
||||
.card p[style*="#a1a1aa"],
|
||||
.card p[style*="#71717a"],
|
||||
.container p[style*="#a1a1aa"],
|
||||
.container p[style*="#71717a"] {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.card h2[style*="#fafafa"],
|
||||
.card h3[style*="#e4e4e7"] {
|
||||
color: var(--text-bright) !important;
|
||||
font-family: var(--font-ui) !important;
|
||||
letter-spacing: -0.01em !important;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), #67e8f9);
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.35);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topnav {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.65rem 0.85rem;
|
||||
}
|
||||
|
||||
.topnav .btn-logout {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topnav-links {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1.25rem 1rem 2.5rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
import type { Metadata } from "next";
|
||||
import { JetBrains_Mono, Outfit } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-outfit",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const jetbrains = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Aanmelden",
|
||||
description: "",
|
||||
title: "Media Cluster — Ops",
|
||||
description: "Private media cluster admin console",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="nl">
|
||||
<html lang="nl" className={`${outfit.variable} ${jetbrains.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -190,8 +190,11 @@ export default function MoviesPage() {
|
|||
<>
|
||||
<Nav />
|
||||
<div className="container">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "1rem", marginBottom: "2rem" }}>
|
||||
<h1 style={{ fontSize: "1.75rem" }}>Movies</h1>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">Library</div>
|
||||
<h1>Movies</h1>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -258,8 +258,11 @@ export default function NodesPage() {
|
|||
<>
|
||||
<Nav />
|
||||
<div className="container">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "2rem" }}>
|
||||
<h1 style={{ fontSize: "1.75rem" }}>Nodes</h1>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">Infrastructure</div>
|
||||
<h1>Nodes</h1>
|
||||
</div>
|
||||
<button onClick={() => setShowEnroll(!showEnroll)}>
|
||||
{showEnroll ? "Annuleren" : "Node toevoegen"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ export default function LoginPage() {
|
|||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
// Full navigation so the session cookie is definitely included
|
||||
window.location.assign("/dashboard");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Inloggen mislukt");
|
||||
|
|
@ -51,7 +50,13 @@ export default function LoginPage() {
|
|||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-box">
|
||||
<h1>Aanmelden</h1>
|
||||
<div className="login-brand">
|
||||
<span className="mark">MC</span>
|
||||
<div>
|
||||
<h1>Media Cluster</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p className="login-sub">Authenticated access // ops</p>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
|
|
|
|||
|
|
@ -152,8 +152,11 @@ export default function SeriesPage() {
|
|||
<>
|
||||
<Nav />
|
||||
<div className="container">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "1rem", marginBottom: "2rem" }}>
|
||||
<h1 style={{ fontSize: "1.75rem" }}>Series</h1>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">Library</div>
|
||||
<h1>Series</h1>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
|
|
|||
|
|
@ -281,7 +281,12 @@ export default function SettingsPage() {
|
|||
<>
|
||||
<Nav />
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Instellingen</h1>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">System</div>
|
||||
<h1>Instellingen</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: "2rem" }}>
|
||||
<h2 style={{ fontSize: "1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,40 @@ interface Stream {
|
|||
id: string;
|
||||
node: string;
|
||||
status: string;
|
||||
viewer: string;
|
||||
createdAt: string;
|
||||
lastActivity: string;
|
||||
absoluteExpiresAt: string;
|
||||
title: string;
|
||||
resolution: string | null;
|
||||
bytesSent: string;
|
||||
lastByteOffset: string;
|
||||
fileSizeBytes: string;
|
||||
progressPercent: number | null;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: string | number | null | undefined): string {
|
||||
const valNum = Number(bytes ?? 0);
|
||||
if (!Number.isFinite(valNum) || valNum < 0) return "0 B";
|
||||
let val = valNum;
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
while (val >= 1024 && i < units.length - 1) {
|
||||
val /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${val.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(diff) || diff < 0) return "nu";
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 15) return "nu";
|
||||
if (sec < 60) return `${sec}s geleden`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m geleden`;
|
||||
return new Date(iso).toLocaleTimeString();
|
||||
}
|
||||
|
||||
export default function StreamsPage() {
|
||||
|
|
@ -26,7 +55,7 @@ export default function StreamsPage() {
|
|||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 10000);
|
||||
const t = setInterval(load, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
|
|
@ -43,36 +72,82 @@ export default function StreamsPage() {
|
|||
<>
|
||||
<Nav />
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: "2rem", fontSize: "1.75rem" }}>Actieve streams</h1>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">Realtime · Plex-style</div>
|
||||
<h1>Actieve streams</h1>
|
||||
</div>
|
||||
<div className="muted" style={{ fontFamily: "var(--font-mono)", fontSize: "0.8rem" }}>
|
||||
{streams.length} live
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: "1rem" }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Alleen echte kijkers (bytes stromen). Stopt iemand → verdwijnt binnen ~3 minuten uit deze lijst.
|
||||
Voortgang is een schatting op basis van range-positie in het bestand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kijker</th>
|
||||
<th>Titel</th>
|
||||
<th>Voortgang</th>
|
||||
<th>Node</th>
|
||||
<th>Kwaliteit</th>
|
||||
<th>Laatste activiteit</th>
|
||||
<th>Activiteit</th>
|
||||
<th>Acties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{streams.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.title}</td>
|
||||
<td>{s.node}</td>
|
||||
<td>{s.resolution ?? "-"}</td>
|
||||
<td>{new Date(s.lastActivity).toLocaleString()}</td>
|
||||
<td>
|
||||
<button className="danger" onClick={() => revoke(s.id)}>
|
||||
Intrekken
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{streams.map((s) => {
|
||||
const pct = s.progressPercent ?? 0;
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<strong style={{ color: "var(--accent)" }}>{s.viewer}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div>{s.title}</div>
|
||||
<div className="muted" style={{ fontSize: "0.75rem", marginTop: "0.2rem" }}>
|
||||
{formatBytes(s.bytesSent)} gestreamd
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ minWidth: 140 }}>
|
||||
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div
|
||||
className="muted"
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "0.7rem",
|
||||
marginTop: "0.35rem",
|
||||
}}
|
||||
>
|
||||
{s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"}
|
||||
</div>
|
||||
</td>
|
||||
<td>{s.node}</td>
|
||||
<td>{s.resolution ?? "-"}</td>
|
||||
<td style={{ fontFamily: "var(--font-mono)", fontSize: "0.75rem" }}>
|
||||
{relativeTime(s.lastActivity)}
|
||||
</td>
|
||||
<td>
|
||||
<button className="danger" onClick={() => revoke(s.id)}>
|
||||
Intrekken
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{streams.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: "center", color: "#71717a" }}>
|
||||
Geen actieve streams
|
||||
<td colSpan={7} style={{ textAlign: "center" }} className="muted">
|
||||
Niemand aan het kijken
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,22 @@ export function Nav() {
|
|||
];
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<span className="brand">Beheer</span>
|
||||
{links.map((l) => (
|
||||
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
<button onClick={logout} style={{ marginLeft: "auto", background: "transparent", border: "1px solid #2a2d3a" }}>
|
||||
<nav className="topnav">
|
||||
<Link href="/dashboard" className="brand">
|
||||
<span className="brand-mark">MC</span>
|
||||
<span className="brand-text">
|
||||
Media Cluster
|
||||
<small>ops console</small>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="topnav-links">
|
||||
{links.map((l) => (
|
||||
<Link key={l.href} href={l.href} className={pathname === l.href ? "active" : ""}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="btn-logout" onClick={logout}>
|
||||
Uitloggen
|
||||
</button>
|
||||
</nav>
|
||||
|
|
@ -41,7 +49,9 @@ export function useAuth() {
|
|||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||
.then((r) => { if (!r.ok) router.push("/"); })
|
||||
.then((r) => {
|
||||
if (!r.ok) router.push("/");
|
||||
})
|
||||
.catch(() => router.push("/"));
|
||||
}, [router]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -69,8 +69,9 @@ model AddonToken {
|
|||
revoked Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
iptvLine IptvLine?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
iptvLine IptvLine?
|
||||
playbackSessions PlaybackSession[]
|
||||
|
||||
@@map("addon_tokens")
|
||||
}
|
||||
|
|
@ -337,20 +338,27 @@ model LibraryMatch {
|
|||
}
|
||||
|
||||
model PlaybackSession {
|
||||
id String @id @default(uuid())
|
||||
tokenHash String @unique @map("token_hash")
|
||||
userId String? @map("user_id")
|
||||
nodeId String @map("node_id")
|
||||
mediaFileId String @map("media_file_id")
|
||||
status PlaybackSessionStatus @default(ACTIVE)
|
||||
idleTimeoutSeconds Int @map("idle_timeout_seconds")
|
||||
absoluteExpiresAt DateTime @map("absolute_expires_at")
|
||||
lastActivity DateTime @default(now()) @map("last_activity")
|
||||
revoked Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id String @id @default(uuid())
|
||||
tokenHash String @unique @map("token_hash")
|
||||
userId String? @map("user_id")
|
||||
addonTokenId String? @map("addon_token_id")
|
||||
nodeId String @map("node_id")
|
||||
mediaFileId String @map("media_file_id")
|
||||
status PlaybackSessionStatus @default(ACTIVE)
|
||||
idleTimeoutSeconds Int @map("idle_timeout_seconds")
|
||||
absoluteExpiresAt DateTime @map("absolute_expires_at")
|
||||
lastActivity DateTime @default(now()) @map("last_activity")
|
||||
bytesSent BigInt @default(0) @map("bytes_sent")
|
||||
lastByteOffset BigInt @default(0) @map("last_byte_offset")
|
||||
fileSizeBytes BigInt @default(0) @map("file_size_bytes")
|
||||
revoked Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
node Node @relation(fields: [nodeId], references: [id])
|
||||
mediaFile MediaFile @relation(fields: [mediaFileId], references: [id])
|
||||
node Node @relation(fields: [nodeId], references: [id])
|
||||
mediaFile MediaFile @relation(fields: [mediaFileId], references: [id])
|
||||
addonToken AddonToken? @relation(fields: [addonTokenId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([status, lastActivity])
|
||||
@@index([addonTokenId])
|
||||
@@map("playback_sessions")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,15 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
prisma.movie.count(),
|
||||
prisma.series.count(),
|
||||
prisma.episode.count(),
|
||||
prisma.playbackSession.count({ where: { status: "ACTIVE", revoked: false } }),
|
||||
prisma.playbackSession.count({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
revoked: false,
|
||||
absoluteExpiresAt: { gt: new Date() },
|
||||
lastActivity: { gt: new Date(Date.now() - 3 * 60 * 1000) },
|
||||
bytesSent: { gt: 0 },
|
||||
},
|
||||
}),
|
||||
prisma.node.findMany({
|
||||
where: { revoked: false, status: "ONLINE" },
|
||||
select: {
|
||||
|
|
@ -236,7 +244,6 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
|
||||
app.get("/api/v1/admin/streams", { preHandler: requireAdmin }, async () => {
|
||||
const now = new Date();
|
||||
// Drop expired rows so prefetch leftovers do not clutter the UI.
|
||||
await prisma.playbackSession.updateMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
|
|
@ -246,16 +253,19 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
data: { status: "EXPIRED" },
|
||||
});
|
||||
|
||||
// Only recently active + actually streamed bytes (no prefetch ghosts).
|
||||
const liveSince = new Date(now.getTime() - 3 * 60 * 1000);
|
||||
const sessions = await prisma.playbackSession.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
revoked: false,
|
||||
absoluteExpiresAt: { gt: now },
|
||||
// Only show recently touched sessions (prefetch often never streams bytes).
|
||||
lastActivity: { gt: new Date(now.getTime() - 15 * 60 * 1000) },
|
||||
lastActivity: { gt: liveSince },
|
||||
bytesSent: { gt: 0 },
|
||||
},
|
||||
include: {
|
||||
node: { select: { id: true, name: true } },
|
||||
addonToken: { select: { id: true, label: true } },
|
||||
mediaFile: {
|
||||
include: {
|
||||
movie: { select: { title: true, year: true } },
|
||||
|
|
@ -270,20 +280,34 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
});
|
||||
|
||||
return {
|
||||
streams: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
node: s.node.name,
|
||||
status: s.status,
|
||||
createdAt: s.createdAt,
|
||||
lastActivity: s.lastActivity,
|
||||
absoluteExpiresAt: s.absoluteExpiresAt,
|
||||
title:
|
||||
streams: sessions.map((s) => {
|
||||
const fileSize = Number(s.fileSizeBytes || s.mediaFile.sizeBytes || 0);
|
||||
const offset = Number(s.lastByteOffset || 0);
|
||||
const progressPercent =
|
||||
fileSize > 0 ? Math.min(100, Math.max(0, (offset / fileSize) * 100)) : null;
|
||||
const title =
|
||||
s.mediaFile.movie?.title ??
|
||||
(s.mediaFile.episode
|
||||
? `${s.mediaFile.episode.series?.title} S${String(s.mediaFile.episode.seasonNumber).padStart(2, "0")}E${String(s.mediaFile.episode.episodeNumber).padStart(2, "0")}`
|
||||
: s.mediaFile.releaseName),
|
||||
resolution: s.mediaFile.resolution,
|
||||
})),
|
||||
: s.mediaFile.releaseName);
|
||||
const year = s.mediaFile.movie?.year ?? null;
|
||||
return {
|
||||
id: s.id,
|
||||
node: s.node.name,
|
||||
status: s.status,
|
||||
viewer: s.addonToken?.label?.trim() || "Onbekend",
|
||||
addonTokenId: s.addonToken?.id ?? null,
|
||||
createdAt: s.createdAt,
|
||||
lastActivity: s.lastActivity,
|
||||
absoluteExpiresAt: s.absoluteExpiresAt,
|
||||
title: year ? `${title} (${year})` : title,
|
||||
resolution: s.mediaFile.resolution,
|
||||
bytesSent: s.bytesSent.toString(),
|
||||
lastByteOffset: s.lastByteOffset.toString(),
|
||||
fileSizeBytes: (s.fileSizeBytes > 0n ? s.fileSizeBytes : s.mediaFile.sizeBytes).toString(),
|
||||
progressPercent,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { Config } from "../config";
|
|||
export class PlaybackService {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
async createSession(mediaFileId: string, userId?: string) {
|
||||
async createSession(mediaFileId: string, userId?: string, addonTokenId?: string) {
|
||||
const mediaFile = await prisma.mediaFile.findUnique({
|
||||
where: { id: mediaFileId },
|
||||
include: { node: true },
|
||||
|
|
@ -42,11 +42,13 @@ export class PlaybackService {
|
|||
id: sessionId,
|
||||
tokenHash,
|
||||
userId,
|
||||
addonTokenId: addonTokenId || null,
|
||||
nodeId: mediaFile.nodeId,
|
||||
mediaFileId: mediaFile.id,
|
||||
idleTimeoutSeconds: this.config.PLAYBACK_IDLE_TIMEOUT_SECONDS,
|
||||
absoluteExpiresAt,
|
||||
lastActivity: now,
|
||||
fileSizeBytes: mediaFile.sizeBytes ?? 0n,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../database/client";
|
||||
import { resolveAddonToken, validateAddonToken } from "../auth/routes";
|
||||
import { resolveAddonToken } from "../auth/routes";
|
||||
import { PlaybackService } from "../playback/service";
|
||||
import { MetadataService } from "../metadata/service";
|
||||
import { nodeConnectionManager } from "../websocket/manager";
|
||||
import type { Config } from "../config";
|
||||
import {
|
||||
IPTV_PREFIX,
|
||||
|
|
@ -281,25 +282,25 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
|
|||
return { streams };
|
||||
}
|
||||
|
||||
const streams = await getStreams(type, id, auth.userId, playback, metadata, request.log);
|
||||
const streams = await getStreams(type, id, auth, playback, metadata, request.log);
|
||||
return { streams };
|
||||
});
|
||||
|
||||
// Optional fallback if something still hits Master /play — redirect to node (no proxy).
|
||||
// Lazy playback: session is created only when Stremio actually opens this URL.
|
||||
app.route({
|
||||
method: ["GET", "HEAD"],
|
||||
url: "/stremio/:token/play/:mediaFileId",
|
||||
handler: async (request, reply) => {
|
||||
const { token, mediaFileId } = request.params as { token: string; mediaFileId: string };
|
||||
const userId = await validateAddonToken(token);
|
||||
if (!userId) {
|
||||
const auth = await resolveAddonToken(token);
|
||||
if (!auth) {
|
||||
return reply.status(401).send({ error: "Invalid addon token" });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await playback.createSession(mediaFileId, userId);
|
||||
const session = await playback.createSession(mediaFileId, auth.userId, auth.tokenId);
|
||||
request.log.info(
|
||||
{ mediaFileId, streamUrl: session.streamUrl },
|
||||
{ mediaFileId, streamUrl: session.streamUrl, addonTokenId: auth.tokenId },
|
||||
"playback delegate to node"
|
||||
);
|
||||
return reply.code(302).redirect(session.streamUrl);
|
||||
|
|
@ -796,7 +797,7 @@ async function getSeriesMeta(
|
|||
async function getStreams(
|
||||
type: string,
|
||||
id: string,
|
||||
userId: string,
|
||||
auth: { userId: string; rawToken: string; tokenId: string },
|
||||
playback: PlaybackService,
|
||||
metadata: MetadataService,
|
||||
log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void }
|
||||
|
|
@ -856,23 +857,20 @@ async function getStreams(
|
|||
const streams = [];
|
||||
for (const file of pool) {
|
||||
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
|
||||
try {
|
||||
const session = await playback.createSession(file.id, userId);
|
||||
log?.info?.(
|
||||
{ mediaFileId: file.id, streamUrl: session.streamUrl },
|
||||
"stream delegated to node"
|
||||
);
|
||||
streams.push({
|
||||
name: playback.formatStreamName(file),
|
||||
title: playback.formatStreamTitle(file, file.node.name),
|
||||
url: session.streamUrl,
|
||||
behaviorHints: {
|
||||
notWebReady: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
|
||||
}
|
||||
if (!nodeConnectionManager.isOnline(file.nodeId)) continue;
|
||||
const streamUrl = playback.resolveStreamUrl(auth.rawToken, file.id);
|
||||
log?.info?.(
|
||||
{ mediaFileId: file.id, streamUrl },
|
||||
"stream URL (lazy session on open)"
|
||||
);
|
||||
streams.push({
|
||||
name: playback.formatStreamName(file),
|
||||
title: playback.formatStreamTitle(file, file.node.name),
|
||||
url: streamUrl,
|
||||
behaviorHints: {
|
||||
notWebReady: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
log?.info?.(
|
||||
{
|
||||
|
|
@ -958,23 +956,20 @@ async function getStreams(
|
|||
const streams = [];
|
||||
for (const file of pool) {
|
||||
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
|
||||
try {
|
||||
const session = await playback.createSession(file.id, userId);
|
||||
log?.info?.(
|
||||
{ mediaFileId: file.id, streamUrl: session.streamUrl },
|
||||
"stream delegated to node"
|
||||
);
|
||||
streams.push({
|
||||
name: playback.formatStreamName(file),
|
||||
title: playback.formatStreamTitle(file, file.node.name),
|
||||
url: session.streamUrl,
|
||||
behaviorHints: {
|
||||
notWebReady: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
|
||||
}
|
||||
if (!nodeConnectionManager.isOnline(file.nodeId)) continue;
|
||||
const streamUrl = playback.resolveStreamUrl(auth.rawToken, file.id);
|
||||
log?.info?.(
|
||||
{ mediaFileId: file.id, streamUrl },
|
||||
"stream URL (lazy session on open)"
|
||||
);
|
||||
streams.push({
|
||||
name: playback.formatStreamName(file),
|
||||
title: playback.formatStreamTitle(file, file.node.name),
|
||||
url: streamUrl,
|
||||
behaviorHints: {
|
||||
notWebReady: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
log?.info?.(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
HelloPayload,
|
||||
LibraryEventPayload,
|
||||
PlaybackSessionEndedPayload,
|
||||
PlaybackSessionProgressPayload,
|
||||
CreatePlaybackSessionAckPayload,
|
||||
} from "@media-cluster/protocol";
|
||||
|
||||
|
|
@ -128,6 +129,9 @@ class NodeConnectionManager {
|
|||
case "PLAYBACK_SESSION_ENDED":
|
||||
await this.handlePlaybackEnded(message.payload as PlaybackSessionEndedPayload);
|
||||
break;
|
||||
case "PLAYBACK_SESSION_PROGRESS":
|
||||
await this.handlePlaybackProgress(message.payload as PlaybackSessionProgressPayload);
|
||||
break;
|
||||
case "CREATE_PLAYBACK_SESSION_ACK":
|
||||
this.handlePlaybackSessionAck(message.payload as CreatePlaybackSessionAckPayload);
|
||||
break;
|
||||
|
|
@ -416,6 +420,22 @@ class NodeConnectionManager {
|
|||
});
|
||||
}
|
||||
|
||||
private async handlePlaybackProgress(payload: PlaybackSessionProgressPayload): Promise<void> {
|
||||
if (!payload?.sessionId) return;
|
||||
const lastActivity = payload.lastActivity
|
||||
? new Date(payload.lastActivity)
|
||||
: new Date();
|
||||
await prisma.playbackSession.updateMany({
|
||||
where: { id: payload.sessionId, status: "ACTIVE", revoked: false },
|
||||
data: {
|
||||
lastActivity,
|
||||
bytesSent: BigInt(Math.max(0, Math.floor(payload.bytesSent || 0))),
|
||||
lastByteOffset: BigInt(Math.max(0, Math.floor(payload.lastByteOffset || 0))),
|
||||
fileSizeBytes: BigInt(Math.max(0, Math.floor(payload.fileSizeBytes || 0))),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sendToNode(nodeId: string, type: ControlMessageType, payload: unknown): boolean {
|
||||
const conn = this.connections.get(nodeId);
|
||||
if (!conn || conn.socket.readyState !== 1) return false;
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ WORKDIR /src
|
|||
RUN apk add --no-cache git ca-certificates
|
||||
COPY node/media-node/ ./
|
||||
RUN go mod tidy \
|
||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.2.7" -o /media-node-linux-amd64 ./cmd/media-node \
|
||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=1.2.7" -o /media-node-linux-arm64 ./cmd/media-node
|
||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=1.2.8" -o /media-node-linux-amd64 ./cmd/media-node \
|
||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=1.2.8" -o /media-node-linux-arm64 ./cmd/media-node
|
||||
|
||||
FROM base AS builder
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
|
|
|
|||
|
|
@ -131,6 +131,9 @@ func runService() {
|
|||
streamer.SetSessionEndHandler(func(sessionID, reason string) {
|
||||
ctrlClient.NotifySessionEnded(sessionID, reason)
|
||||
})
|
||||
streamer.SetSessionProgressHandler(func(sessionID string, bytesSent, lastByteOffset, fileSizeBytes int64) {
|
||||
ctrlClient.NotifySessionProgress(sessionID, bytesSent, lastByteOffset, fileSizeBytes)
|
||||
})
|
||||
|
||||
go sc.Start()
|
||||
go ctrlClient.Run()
|
||||
|
|
|
|||
|
|
@ -709,6 +709,17 @@ func (c *Client) NotifySessionEnded(sessionID, reason string) {
|
|||
_ = c.write(msg)
|
||||
}
|
||||
|
||||
func (c *Client) NotifySessionProgress(sessionID string, bytesSent, lastByteOffset, fileSizeBytes int64) {
|
||||
msg := controlMessage("PLAYBACK_SESSION_PROGRESS", map[string]interface{}{
|
||||
"sessionId": sessionID,
|
||||
"lastActivity": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"bytesSent": bytesSent,
|
||||
"lastByteOffset": lastByteOffset,
|
||||
"fileSizeBytes": fileSizeBytes,
|
||||
})
|
||||
_ = c.write(msg)
|
||||
}
|
||||
|
||||
func (c *Client) write(msg interface{}) error {
|
||||
return c.writeWithDeadline(msg, writeDeadlineShort)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,16 +20,26 @@ import (
|
|||
)
|
||||
|
||||
type Server struct {
|
||||
store *database.Store
|
||||
listen string
|
||||
maxPerIP int
|
||||
mu sync.RWMutex
|
||||
activeCount int
|
||||
bytesWindow int64
|
||||
lastWindow time.Time
|
||||
bytesPerSecond int64
|
||||
ipConns map[string]int
|
||||
onSessionEnd func(sessionID, reason string)
|
||||
store *database.Store
|
||||
listen string
|
||||
maxPerIP int
|
||||
mu sync.RWMutex
|
||||
activeCount int
|
||||
bytesWindow int64
|
||||
lastWindow time.Time
|
||||
bytesPerSecond int64
|
||||
ipConns map[string]int
|
||||
onSessionEnd func(sessionID, reason string)
|
||||
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 {
|
||||
|
|
@ -38,11 +48,12 @@ func New(store *database.Store, listen string, maxPerIP int) *Server {
|
|||
maxPerIP = 64
|
||||
}
|
||||
s := &Server{
|
||||
store: store,
|
||||
listen: listen,
|
||||
maxPerIP: maxPerIP,
|
||||
lastWindow: time.Now(),
|
||||
ipConns: make(map[string]int),
|
||||
store: store,
|
||||
listen: listen,
|
||||
maxPerIP: maxPerIP,
|
||||
lastWindow: time.Now(),
|
||||
ipConns: make(map[string]int),
|
||||
progressState: make(map[string]*progressTracker),
|
||||
}
|
||||
go s.cleanupLoop()
|
||||
go s.bandwidthLoop()
|
||||
|
|
@ -53,6 +64,10 @@ func (s *Server) SetSessionEndHandler(fn func(sessionID, reason string)) {
|
|||
s.onSessionEnd = fn
|
||||
}
|
||||
|
||||
func (s *Server) SetSessionProgressHandler(fn func(sessionID string, bytesSent, lastByteOffset, fileSizeBytes int64)) {
|
||||
s.onSessionProgress = fn
|
||||
}
|
||||
|
||||
func (s *Server) ListenAndServe() error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/play/", s.handlePlay)
|
||||
|
|
@ -144,12 +159,17 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
|
|||
now := time.Now().UTC()
|
||||
if now.After(sess.AbsoluteExpiresAt) {
|
||||
_ = s.store.DeleteSession(sess.SessionID)
|
||||
s.clearProgress(sess.SessionID)
|
||||
if s.onSessionEnd != nil {
|
||||
s.onSessionEnd(sess.SessionID, "absolute_expiry")
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second)
|
||||
if now.After(idleDeadline) {
|
||||
_ = s.store.DeleteSession(sess.SessionID)
|
||||
s.clearProgress(sess.SessionID)
|
||||
if s.onSessionEnd != nil {
|
||||
s.onSessionEnd(sess.SessionID, "idle_timeout")
|
||||
}
|
||||
|
|
@ -180,6 +200,7 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
fileSize := stat.Size()
|
||||
_ = s.store.TouchSession(sess.SessionID)
|
||||
s.noteProgress(sess.SessionID, 0, fileSize, 0, true)
|
||||
|
||||
s.mu.Lock()
|
||||
s.activeCount++
|
||||
|
|
@ -203,7 +224,7 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
s.streamRange(w, f, 0, fileSize-1)
|
||||
s.streamRange(w, f, 0, fileSize-1, sess.SessionID, fileSize)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -223,16 +244,18 @@ func (s *Server) handlePlay(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
s.streamRange(w, f, start, end)
|
||||
s.noteProgress(sess.SessionID, start, fileSize, 0, true)
|
||||
s.streamRange(w, f, start, end, sess.SessionID, fileSize)
|
||||
}
|
||||
|
||||
func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64) {
|
||||
func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64, sessionID string, fileSize int64) {
|
||||
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
||||
return
|
||||
}
|
||||
remaining := end - start + 1
|
||||
buf := make([]byte, 256*1024)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
pos := start
|
||||
|
||||
for remaining > 0 {
|
||||
toRead := int64(len(buf))
|
||||
|
|
@ -249,6 +272,8 @@ func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64
|
|||
flusher.Flush()
|
||||
}
|
||||
remaining -= int64(n)
|
||||
pos += int64(n)
|
||||
s.noteProgress(sessionID, pos, fileSize, int64(n), false)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
|
|
@ -256,6 +281,44 @@ func (s *Server) streamRange(w http.ResponseWriter, f *os.File, start, end int64
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Server) noteProgress(sessionID string, offset, fileSize, deltaBytes int64, force bool) {
|
||||
if sessionID == "" {
|
||||
return
|
||||
}
|
||||
_ = s.store.TouchSession(sessionID)
|
||||
|
||||
s.progressMu.Lock()
|
||||
st := s.progressState[sessionID]
|
||||
if st == nil {
|
||||
st = &progressTracker{}
|
||||
s.progressState[sessionID] = st
|
||||
}
|
||||
if deltaBytes > 0 {
|
||||
st.bytesSent += deltaBytes
|
||||
} else if st.bytesSent == 0 && force {
|
||||
// Mark as live even before first chunk (Range open).
|
||||
st.bytesSent = 1
|
||||
}
|
||||
if offset >= st.lastOffset {
|
||||
st.lastOffset = offset
|
||||
}
|
||||
if fileSize > 0 {
|
||||
st.fileSize = fileSize
|
||||
}
|
||||
shouldReport := force || time.Since(st.lastReport) >= 3*time.Second
|
||||
bytesSent := st.bytesSent
|
||||
lastOffset := st.lastOffset
|
||||
size := st.fileSize
|
||||
if shouldReport {
|
||||
st.lastReport = time.Now()
|
||||
}
|
||||
s.progressMu.Unlock()
|
||||
|
||||
if shouldReport && s.onSessionProgress != nil {
|
||||
s.onSessionProgress(sessionID, bytesSent, lastOffset, size)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ActiveStreams() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
@ -275,6 +338,7 @@ func (s *Server) CreateSession(sess database.PlaybackSession) error {
|
|||
}
|
||||
|
||||
func (s *Server) RevokeSession(sessionID string) error {
|
||||
s.clearProgress(sessionID)
|
||||
return s.store.DeleteSession(sessionID)
|
||||
}
|
||||
|
||||
|
|
@ -314,11 +378,16 @@ func (s *Server) cleanupLoop() {
|
|||
for _, sess := range sessions {
|
||||
if now.After(sess.AbsoluteExpiresAt) {
|
||||
_ = s.store.DeleteSession(sess.SessionID)
|
||||
s.clearProgress(sess.SessionID)
|
||||
if s.onSessionEnd != nil {
|
||||
s.onSessionEnd(sess.SessionID, "absolute_expiry")
|
||||
}
|
||||
continue
|
||||
}
|
||||
idleDeadline := sess.LastActivity.Add(time.Duration(sess.IdleTimeoutSeconds) * time.Second)
|
||||
if now.After(idleDeadline) {
|
||||
_ = s.store.DeleteSession(sess.SessionID)
|
||||
s.clearProgress(sess.SessionID)
|
||||
if s.onSessionEnd != nil {
|
||||
s.onSessionEnd(sess.SessionID, "idle_timeout")
|
||||
}
|
||||
|
|
@ -327,6 +396,12 @@ func (s *Server) cleanupLoop() {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Server) clearProgress(sessionID string) {
|
||||
s.progressMu.Lock()
|
||||
delete(s.progressState, sessionID)
|
||||
s.progressMu.Unlock()
|
||||
}
|
||||
|
||||
func parseRange(rangeHeader string, size int64) (int64, int64, error) {
|
||||
if !strings.HasPrefix(rangeHeader, "bytes=") {
|
||||
return 0, 0, fmt.Errorf("invalid range")
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export type ControlMessageType =
|
|||
| "CONFIG_UPDATE"
|
||||
| "RESTART"
|
||||
| "PLAYBACK_SESSION_ENDED"
|
||||
| "PLAYBACK_SESSION_PROGRESS"
|
||||
| "ERROR";
|
||||
|
||||
export interface ControlMessage<T = unknown> {
|
||||
|
|
@ -114,6 +115,14 @@ export interface PlaybackSessionEndedPayload {
|
|||
reason: string;
|
||||
}
|
||||
|
||||
export interface PlaybackSessionProgressPayload {
|
||||
sessionId: string;
|
||||
lastActivity: string;
|
||||
bytesSent: number;
|
||||
lastByteOffset: number;
|
||||
fileSizeBytes: number;
|
||||
}
|
||||
|
||||
export interface CreatePlaybackSessionAckPayload {
|
||||
sessionId: string;
|
||||
ok: boolean;
|
||||
|
|
|
|||
Loading…
Reference in a new issue